Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Infrastructure / Support
* Speed up sensor stats, especially potent when viewing sensors stats over a large sensor history [`PR #2173 <https://www.github.com/FlexMeasures/flexmeasures/pull/2173>`_]
* Various smaller fixes in documenting scheduling endpoints and flex-model fields [`PR #2122 <https://www.github.com/FlexMeasures/flexmeasures/pull/2122>`_]
* Add cross-cutting Copilot instruction files to ``.github/instructions/`` covering atomic commits, changelog format, docstrings, error handling, Marshmallow schemas, pre-commit hooks, testing, timezone awareness, and UI terminology [see `PR #2198 <https://www.github.com/FlexMeasures/flexmeasures/pull/2198>`_]
* Ignore stale asset references in ``sensors_to_show`` so chart endpoints and the graph editor remain usable, and automatically prune those references when deleting assets [see `PR #2210 <https://www.github.com/FlexMeasures/flexmeasures/pull/2210>`_]

Bugfixes
-----------
Expand Down
56 changes: 55 additions & 1 deletion flexmeasures/api/v3_0/tests/test_assets_api_fresh_db.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import json

import pytest
from flask import url_for
from sqlalchemy import select

from flexmeasures.api.tests.utils import AccountContext
from flexmeasures.data.models.generic_assets import GenericAsset
from flexmeasures.api.v3_0.tests.utils import get_asset_post_data
from flexmeasures.api.v3_0.tests.utils import get_asset_post_data, check_audit_log_event


@pytest.mark.parametrize(
Expand Down Expand Up @@ -100,3 +102,55 @@ def test_patch_asset_accepts_flex_context_object(
).scalar_one_or_none()
assert updated_asset is not None
assert updated_asset.flex_context["site-power-capacity"] == "1000 kW"


@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True)
def test_delete_asset_cleans_stale_asset_references_in_sensors_to_show(
client, setup_api_fresh_test_data, requesting_user, fresh_db
):
"""Verify that deleting an asset cleans up stale asset references in other assets' sensors_to_show."""
db = fresh_db
deleted_asset = setup_api_fresh_test_data["some gas sensor"].generic_asset
deleted_asset_id = deleted_asset.id
deleted_asset_name = deleted_asset.name
referenced_sensor = setup_api_fresh_test_data["some temperature sensor"]

# Use a dedicated asset as the reference holder so deleting `deleted_asset`
# does not remove the object we assert on later.
referencing_asset = GenericAsset(
name="stale-asset-ref-holder",
generic_asset_type_id=deleted_asset.generic_asset_type_id,
account_id=requesting_user.account_id,
)
db.session.add(referencing_asset)
db.session.flush()

referencing_asset.sensors_to_show = [
{
"title": "Mixed graph",
"plots": [
{"sensor": referenced_sensor.id},
{"asset": deleted_asset_id, "flex-model": "soc-min"},
],
}
]
db.session.add(referencing_asset)
db.session.commit()

delete_asset_response = client.delete(
url_for("AssetAPI:delete", id=deleted_asset_id),
)
assert delete_asset_response.status_code == 204

updated_referencing_asset = db.session.get(GenericAsset, referencing_asset.id)
assert updated_referencing_asset is not None
assert str(deleted_asset_id) not in json.dumps(
updated_referencing_asset.sensors_to_show
)

check_audit_log_event(
db=db,
event=f"Removed asset reference '{deleted_asset_name}': {deleted_asset_id} from sensors-to-show (because asset has been deleted).",
user=requesting_user,
asset=updated_referencing_asset,
)
90 changes: 70 additions & 20 deletions flexmeasures/data/schemas/generic_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from http import HTTPStatus
from typing import Any, TYPE_CHECKING

from flask import current_app
from flask import abort
from marshmallow import validates, ValidationError, fields, validates_schema
from marshmallow.validate import OneOf
Expand Down Expand Up @@ -196,7 +197,12 @@ def _validate_flex_config_field_is_valid_choice(
if field_name in plot_config:
value = plot_config[field_name]
asset_id = plot_config.get("asset")
asset = GenericAssetIdField().deserialize(asset_id)
try:
asset = GenericAssetIdField().deserialize(asset_id)
except ValidationError:
if self.allow_missing_asset_flex_field:
Comment thread
nhoening marked this conversation as resolved.
return
raise

if asset is None:
raise ValidationError(f"Asset with ID {asset_id} does not exist.")
Expand Down Expand Up @@ -251,28 +257,72 @@ def flatten(cls, nested_list: list) -> list[int] | list[Sensor]:
:param nested_list: A list containing sensor IDs, or dictionaries with `sensors` or `sensor` keys.
:returns: A unique list of sensor IDs, or a unique list of Sensors
"""
all_objects = []
all_objects: list[Any] = []
for s in nested_list:
if isinstance(s, list):
all_objects.extend(s)
elif isinstance(s, int):
all_objects.append(s)
elif isinstance(s, dict):
if "plots" in s:
for plot in s["plots"]:
if "sensors" in plot:
all_objects.extend(plot["sensors"])
if "sensor" in plot:
all_objects.append(plot["sensor"])
if "asset" in plot:
sensors, _ = extract_sensors_from_flex_config(plot)
all_objects.extend(sensors)
elif "sensors" in s:
all_objects.extend(s["sensors"])
elif "sensor" in s:
all_objects.append(s["sensor"])
all_objects.extend(cls._flatten_entry(s))
return list(dict.fromkeys(all_objects).keys())

@classmethod
def _flatten_entry(cls, entry: Any) -> list[Any]:
"""Flatten one sensors_to_show entry into sensor IDs or Sensor objects."""
if isinstance(entry, list):
return entry
if isinstance(entry, int):
return [entry]
if isinstance(entry, dict):
return cls._flatten_dict_entry(entry)
return []

@classmethod
def _flatten_dict_entry(cls, entry: dict) -> list[Any]:
"""Flatten one dictionary-form sensors_to_show entry."""
if "plots" in entry:
return cls._flatten_plots(entry["plots"])
if "sensors" in entry:
return entry["sensors"]
if "sensor" in entry:
return [entry["sensor"]]
return []

@classmethod
def _flatten_plots(cls, plots: list[dict]) -> list[Any]:
"""Flatten a list of plot dictionaries to sensor IDs or Sensor objects."""
flattened: list[Any] = []
for plot in plots:
flattened.extend(cls._flatten_plot_entry(plot))
return flattened

@classmethod
def _flatten_plot_entry(cls, plot: dict) -> list[Any]:
"""Flatten one plot dictionary to sensor IDs or Sensor objects."""
flattened: list[Any] = []

if "sensors" in plot:
flattened.extend(plot["sensors"])
if "sensor" in plot:
flattened.append(plot["sensor"])
if "asset" in plot:
flattened.extend(cls._flatten_asset_plot_entry(plot))

return flattened

@classmethod
def _flatten_asset_plot_entry(cls, plot: dict) -> list[Sensor]:
"""Resolve flex-config sensor references for one asset plot.

Invalid stale references are skipped to keep chart rendering robust.
"""
try:
sensors, _ = extract_sensors_from_flex_config(plot)
except ValidationError as err:
current_app.logger.warning(
"Skipping invalid asset plot reference %s while flattening sensors_to_show: %s",
plot,
err,
)
return []
return sensors


class SensorKPIFieldSchema(ma.SQLAlchemySchema):
title = fields.Str(required=True)
Expand Down
Loading
Loading