Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions documentation/api/change_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ API change log

.. note:: The FlexMeasures API follows its own versioning scheme. This is also reflected in the URL (e.g. `/api/v3_0`), allowing developers to upgrade at their own pace.

v3.0-36 | September 11, 2026
""""""""""""""""""""""""""""
- ``GET /api/v3_0/assets`` now applies the ``root`` and ``depth`` constraints to ``num-records``, too, so a listing scoped to an asset subtree reports how many assets that subtree holds. Previously, ``num-records`` counted every asset in the account and asset-type scope, which made a paginated client report the assets outside the subtree as having been filtered out by the search term.

v3.0-35 | September 9, 2026
"""""""""""""""""""""""""""
- The ``resolution`` field is now rejected with a ``422 (Unprocessable Entity)`` response unless it spans a positive amount of time. This applies wherever the API accepts one: as a query parameter on ``GET /api/v3_0/sensors/<id>/data`` and on the ``chart_data`` endpoints under ``api/dev``, and in the request body of the ``POST`` schedule trigger endpoints. Previously, a zero resolution (such as ``PT0S``) either crashed the request with a ``500`` or was silently ignored, and a negative resolution returned an empty set of values.
Expand Down
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ New features
* Both tabs of an asset's status page now name the asset each row belongs to, and the jobs tab also lists the jobs of the asset's sub-assets, so a site asset shows what happened anywhere below it, which you can switch off per session [see `PR #2500 <https://www.github.com/FlexMeasures/flexmeasures/pull/2500>`_]
* Changing the selected time range on an asset or sensor chart now only loads the data that is actually new, instead of reloading the whole range, which makes stepping through or extending a long period much faster; reloading the page, or leaving it open for five minutes, still fetches everything afresh [see `PR #2433 <https://www.github.com/FlexMeasures/flexmeasures/pull/2433>`_]
* The statistics table on a sensor page now shows all data sources together by default, as the graph does [see `PR #2462 <https://www.github.com/FlexMeasures/flexmeasures/pull/2462>`_]
* The asset lists on an organisation's page and on the asset overview now show only top-level assets per default, so the sites you are looking for are no longer buried among their sub-assets; untick *Top-level only* to see the whole tree again [see `PR #2523 <https://www.github.com/FlexMeasures/flexmeasures/pull/2523>`_]

Infrastructure / Support
-------------------------
Expand Down
11 changes: 9 additions & 2 deletions flexmeasures/api/v3_0/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,9 +631,16 @@ def index(
select_pagination: SelectPagination = db.paginate(
query, per_page=per_page, page=page
)
num_records = db.session.scalar(
select(func.count(GenericAsset.id)).filter(filter_statement)
# `num-records` reports the size of the scope the search filter was applied to,
# so it must respect the same subtree constraint as the paginated query itself.
num_records_query = select(func.count(GenericAsset.id)).filter(
filter_statement
)
if root_asset is not None or max_depth is not None:
num_records_query = filter_assets_under_root(
query=num_records_query, root_asset=root_asset, max_depth=max_depth
)
num_records = db.session.scalar(num_records_query)
response = {
"data": response_schema.dump(select_pagination.items, many=True),
"num-records": num_records,
Expand Down
52 changes: 52 additions & 0 deletions flexmeasures/api/v3_0/tests/test_assets_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,58 @@ def test_get_asset_with_children(client, add_asset_with_children, requesting_use
assert len(get_assets_response.json["child_assets"]) == 2


@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True)
def test_get_assets_top_level_only(client, add_asset_with_children, requesting_user):
"""
Listing assets with `depth=0` returns only assets without a parent asset.
The unfiltered listing is checked as well, to show that the children would otherwise be included.
"""
parent = add_asset_with_children["parent"]
child_ids = {add_asset_with_children[f"child_{i}"].id for i in (1, 2)}

full_response = client.get(
url_for("AssetAPI:index"),
query_string={"all_accessible": "true"},
)
assert full_response.status_code == 200
full_asset_ids = {asset["id"] for asset in full_response.json}
assert parent.id in full_asset_ids
assert child_ids <= full_asset_ids

top_level_response = client.get(
url_for("AssetAPI:index"),
query_string={"all_accessible": "true", "depth": 0},
)
print("Server responded with:\n%s" % top_level_response.json)
assert top_level_response.status_code == 200
top_level_asset_ids = {asset["id"] for asset in top_level_response.json}
assert parent.id in top_level_asset_ids
assert not child_ids & top_level_asset_ids


@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True)
def test_get_assets_top_level_only_record_counts(
client, add_asset_with_children, requesting_user
):
"""
`num-records` reports the size of the scope that the search filter is applied to, so it respects `depth` just like the paginated query does.
Without that, a client showing the listing would report the descendants as having been filtered out by the search.
"""
response = client.get(
url_for("AssetAPI:index"),
query_string={
"all_accessible": "true",
"depth": 0,
"page": 1,
"per_page": 100,
},
)
print("Server responded with:\n%s" % response.json)
assert response.status_code == 200
assert response.json["num-records"] == len(response.json["data"])
assert response.json["num-records"] == response.json["filtered-records"]


@pytest.mark.parametrize("requesting_user", [None], indirect=True)
def test_get_public_assets_noauth(
client, setup_api_test_data, setup_accounts, requesting_user
Expand Down
23 changes: 23 additions & 0 deletions flexmeasures/ui/templates/accounts/account.html
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,17 @@ <h3>Assets
</a>
{% endif %}
</h3>
<div class="form-check form-check-inline">
<label class="form-check-label">
<input
id="topLevelAssetsOnlyCheckbox"
name="top_level_assets_only"
type="checkbox"
checked
/>
Top-level only
</label>
</div>
<div class="form-check form-check-inline">
<label class="form-check-label">
<input
Expand Down Expand Up @@ -718,6 +729,7 @@ <h5 class="modal-title pe-2">Create user</h5>

$(document).ready(function () {
let includePublicAssets = false;
let topLevelAssetsOnly = $("#topLevelAssetsOnlyCheckbox").is(":checked");
const assetsTableTitle = $("#assetsTableTitle");
const assetsTable = $("#assetTable").dataTable({
order: [[1, "asc"]],
Expand Down Expand Up @@ -746,6 +758,11 @@ <h5 class="modal-title pe-2">Create user</h5>

url += "&fields=id|name|generic_asset_type|owner|latitude|longitude|sensors";

if (topLevelAssetsOnly) {
// depth=0 keeps the descendants of top-level assets out of the list
url += "&depth=0";
}

if (filter.length > 0) {
url = `${url}&filter=${filter}`;
}
Expand Down Expand Up @@ -785,6 +802,12 @@ <h5 class="modal-title pe-2">Create user</h5>
},
});

// Event listener for the checkbox to toggle the topLevelAssetsOnly state
$("#topLevelAssetsOnlyCheckbox").change(function () {
topLevelAssetsOnly = this.checked;
assetsTable.api().ajax.reload();
});

// Event listener for the checkbox to toggle includePublicAssets state
$("#includePublicAssetsCheckbox").change(function () {
includePublicAssets = this.checked;
Expand Down
27 changes: 25 additions & 2 deletions flexmeasures/ui/templates/assets/assets.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@
if (latitude != null && longitude != null) this.location = `LAT: ${latitude}, LONG: ${longitude}`;
};

$(document).ready(function() {
$("#assetTable").dataTable({
$(document).ready(function() {
let topLevelAssetsOnly = $("#topLevelAssetsOnlyCheckbox").is(":checked");
const assetsTable = $("#assetTable").dataTable({
order: [[0, "asc"]],
serverSide: true,
columns: [
Expand All @@ -65,6 +66,11 @@

url += "&fields=id|name|generic_asset_type|owner|latitude|longitude|sensors";

if (topLevelAssetsOnly) {
// depth=0 keeps the descendants of top-level assets out of the list
url += "&depth=0";
}

{% if account %}
url += "&account_id={{ account.id }}";
{% else %}
Expand Down Expand Up @@ -95,6 +101,12 @@
});
}
});

// Event listener for the checkbox to toggle the topLevelAssetsOnly state
$("#topLevelAssetsOnlyCheckbox").change(function () {
topLevelAssetsOnly = this.checked;
assetsTable.api().ajax.reload();
});
})

</script>
Expand All @@ -117,6 +129,17 @@ <h3>Asset overview
for account {{ account.name }}
{% endif %}
</h3>
<div class="form-check form-check-inline">
<label class="form-check-label">
<input
id="topLevelAssetsOnlyCheckbox"
name="top_level_assets_only"
type="checkbox"
checked
/>
Top-level only
</label>
</div>
<div class="table-responsive">
<table class="table table-striped paginate nav-on-click" title="View this asset" id="assetTable"></table>
</div>
Expand Down
16 changes: 16 additions & 0 deletions flexmeasures/ui/tests/test_account_crud.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import re

import pytest

from flask import url_for
Expand Down Expand Up @@ -34,6 +36,20 @@ def test_account_page(db, client, as_prosumer_user1):
assert str(current_user.username) in str(account_page.data)


def test_account_page_defaults_to_top_level_assets(db, client, as_prosumer_user1):
"""The account's asset listing offers a 'Top-level only' checkbox, which starts out checked."""
account_page = client.get(
url_for("AccountCrudUI:get", account_id=current_user.account_id),
follow_redirects=True,
)
assert account_page.status_code == 200
checkbox = re.search(
rb"<input\s[^>]*id=\"topLevelAssetsOnlyCheckbox\"[^>]*>", account_page.data
)
assert checkbox is not None
assert b"checked" in checkbox.group(0)


def test_account_page_breadcrumb(db, client, as_prosumer_user1):
"""Account page should show the account name in a breadcrumb."""
account_page = client.get(
Expand Down
12 changes: 12 additions & 0 deletions flexmeasures/ui/tests/test_asset_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ def test_assets_page_empty(db, client, as_prosumer_user1):
assert asset_index.status_code == 200


def test_assets_page_defaults_to_top_level_assets(db, client, as_prosumer_user1):
"""The asset listing offers a 'Top-level only' checkbox, which starts out checked."""
asset_index = client.get(url_for("AssetCrudUI:index"), follow_redirects=True)
assert asset_index.status_code == 200
assert b'id="topLevelAssetsOnlyCheckbox"' in asset_index.data
checkbox = re.search(
rb"<input[^>]*id=\"topLevelAssetsOnlyCheckbox\"[^>]*>", asset_index.data
)
assert checkbox is not None
assert b"checked" in checkbox.group(0)


def test_new_asset_page(client, setup_assets, as_admin):
asset_page = client.get(url_for("AssetCrudUI:get", id="new"), follow_redirects=True)
assert asset_page.status_code == 200
Expand Down
Loading