Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Asset audit log #1072

Merged
merged 13 commits into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 1 addition & 2 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ v0.22.0 | June XX, 2024

New features
-------------

* Add `asset/<id>/auditlog` to view asset related actions [see `PR #1067 <https://github.com/FlexMeasures/flexmeasures/pull/1067>`_]

Infrastructure / Support
----------------------
Expand All @@ -17,7 +17,6 @@ Infrastructure / Support
* Add option ``droplevels`` to the ``PandasReporter`` to drop all the levels except the ``event_start`` and ``event_value`` [see `PR #1043 <https://github.com/FlexMeasures/flexmeasures/pull/1043/>`_]
* ``PandasReporter`` accepts the parameter ``use_latest_version_only`` to filter input data [see `PR #1045 <https://github.com/FlexMeasures/flexmeasures/pull/1045/>`_]
* ``flexmeasures show beliefs`` uses the entity path (`<Account>/../<Sensor>`) in case of duplicated sensors [see `PR #1026 <https://github.com/FlexMeasures/flexmeasures/pull/1026/>`_]
* Add `asset/<id>/auditlog` to view asset related actions [see `PR #1067 <https://github.com/FlexMeasures/flexmeasures/pull/1067>`_]


v0.21.0 | May 16, 2024
Expand Down
28 changes: 3 additions & 25 deletions flexmeasures/api/v3_0/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,7 @@ def post(self, asset_data: dict):
db.session.add(asset)
db.session.commit()

audit_log = AssetAuditLog(
event_datetime=server_now(),
event=f"Created asset '{asset.name}': {asset.id}",
active_user_id=current_user.id,
active_user_name=current_user.username,
affected_asset_id=asset.id,
)
db.session.add(audit_log)
AssetAuditLog.add_record(asset, f"Created asset '{asset.name}': {asset.id}")

return asset_schema.dump(asset), 201

Expand Down Expand Up @@ -260,15 +253,7 @@ def patch(self, asset_data: dict, id: int, db_asset: GenericAsset):
f"Field name: {k}, Old value: {getattr(db_asset, k)}, New value: {v}"
)
audit_log_event = f"Updated asset '{db_asset.name}': {db_asset.id} fields: {'; '.join(audit_log_data)}"

audit_log = AssetAuditLog(
event_datetime=server_now(),
event=audit_log_event,
active_user_id=current_user.id,
active_user_name=current_user.username,
affected_asset_id=db_asset.id,
)
db.session.add(audit_log)
AssetAuditLog.add_record(db_asset, audit_log_event)

for k, v in asset_data.items():
setattr(db_asset, k, v)
Expand Down Expand Up @@ -297,14 +282,7 @@ def delete(self, id: int, asset: GenericAsset):
:status 422: UNPROCESSABLE_ENTITY
"""
asset_name, asset_id = asset.name, asset.id
audit_log = AssetAuditLog(
event_datetime=server_now(),
event=f"Deleted asset '{asset_name}': {asset_id}",
active_user_id=current_user.id,
active_user_name=current_user.username,
affected_asset_id=asset_id,
)
db.session.add(audit_log)
AssetAuditLog.add_record(asset, f"Deleted asset '{asset_name}': {asset_id}")

db.session.execute(delete(GenericAsset).filter_by(id=asset.id))
db.session.commit()
Expand Down
37 changes: 7 additions & 30 deletions flexmeasures/api/v3_0/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -660,14 +660,7 @@ def post(self, sensor_data: dict):
db.session.commit()

asset = sensor_schema.context["generic_asset"]
audit_log = AssetAuditLog(
event_datetime=server_now(),
event=f"Created sensor '{sensor.name}': {sensor.id}",
active_user_id=current_user.id,
active_user_name=current_user.username,
affected_asset_id=asset.id,
)
db.session.add(audit_log)
AssetAuditLog.add_record(asset, f"Created sensor '{sensor.name}': {sensor.id}")

return sensor_schema.dump(sensor), 201

Expand Down Expand Up @@ -726,21 +719,13 @@ def patch(self, sensor_data: dict, id: int, sensor: Sensor):
"""
audit_log_data = list()
for k, v in sensor_data.items():
if getattr(sensor, k) == v:
continue
audit_log_data.append(
f"Field name: {k}, Old value: {getattr(sensor, k)}, New value: {v}"
)
if getattr(sensor, k) != v:
audit_log_data.append(
f"Field name: {k}, Old value: {getattr(sensor, k)}, New value: {v}"
)
audit_log_event = f"Updated sensor '{sensor.name}': {sensor.id}. Updated fields: {'; '.join(audit_log_data)}"

audit_log = AssetAuditLog(
event_datetime=server_now(),
event=audit_log_event,
active_user_id=current_user.id,
active_user_name=current_user.username,
affected_asset_id=sensor.generic_asset_id,
)
db.session.add(audit_log)
AssetAuditLog.add_record(sensor.generic_asset, audit_log_event)

for k, v in sensor_data.items():
setattr(sensor, k, v)
Expand Down Expand Up @@ -772,15 +757,7 @@ def delete(self, id: int, sensor: Sensor):
"""Delete time series data."""
db.session.execute(delete(TimedBelief).filter_by(sensor_id=sensor.id))

asset = sensor.generic_asset
audit_log = AssetAuditLog(
event_datetime=server_now(),
event=f"Deleted sensor '{sensor.name}': {sensor.id}",
active_user_id=current_user.id,
active_user_name=current_user.username,
affected_asset_id=asset.id,
)
db.session.add(audit_log)
AssetAuditLog.add_record(sensor.generic_asset, f"Deleted sensor '{sensor.name}': {sensor.id}")

sensor_name = sensor.name
db.session.execute(delete(Sensor).filter_by(id=sensor.id))
Expand Down
20 changes: 10 additions & 10 deletions flexmeasures/api/v3_0/tests/test_assets_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,16 +179,6 @@ def test_alter_an_asset(
print(f"Editing Response: {asset_edit_response.json}")
assert asset_edit_response.status_code == 200

audit_log_event = f"Updated asset '{prosumer_asset.name}': {prosumer_asset.id} fields: Field name: name, Old value: {name}, New value: other; Field name: latitude, Old value: {latitude}, New value: 11.1"
assert db.session.execute(
select(AssetAuditLog).filter_by(
event=audit_log_event,
active_user_id=requesting_user.id,
active_user_name=requesting_user.username,
affected_asset_id=prosumer_asset.id,
)
).scalar_one_or_none()

# Resetting changes to keep other tests clean here
nrozanov marked this conversation as resolved.
Show resolved Hide resolved
asset_edit_response = client.patch(
url_for("AssetAPI:patch", id=prosumer_asset.id),
Expand All @@ -200,6 +190,16 @@ def test_alter_an_asset(
print(f"Editing Response: {asset_edit_response.json}")
assert asset_edit_response.status_code == 200

audit_log_event = f"Updated asset '{prosumer_asset.name}': {prosumer_asset.id} fields: Field name: name, Old value: {name}, New value: other; Field name: latitude, Old value: {latitude}, New value: 11.1"
assert db.session.execute(
select(AssetAuditLog).filter_by(
event=audit_log_event,
active_user_id=requesting_user.id,
active_user_name=requesting_user.username,
affected_asset_id=prosumer_asset.id,
)
).scalar_one_or_none()


@pytest.mark.parametrize(
"bad_json_str, error_msg",
Expand Down
61 changes: 8 additions & 53 deletions flexmeasures/cli/data_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,11 @@ def edit_attribute(

# Set attribute
for asset in assets:
write_audit_log_for_attribute_update(
attribute_key, attribute_value, "asset", asset
)
AssetAuditLog.add_record_for_attribute_update(attribute_key, attribute_value, "asset", asset)
asset.attributes[attribute_key] = attribute_value
db.session.add(asset)
for sensor in sensors:
write_audit_log_for_attribute_update(
attribute_key, attribute_value, "sensor", sensor
)
AssetAuditLog.add_record_for_attribute_update(attribute_key, attribute_value, "sensor", sensor)
sensor.attributes[attribute_key] = attribute_value
db.session.add(sensor)
db.session.commit()
Expand Down Expand Up @@ -214,9 +210,6 @@ def resample_sensor_data(
event_resolution = timedelta(minutes=event_resolution_in_minutes)
event_starts_after = pd.Timestamp(start_str) # note that "" or None becomes NaT
event_ends_before = pd.Timestamp(end_str)
current_user_id, current_user_name = None, None
if current_user.is_authenticated:
current_user_id, current_user_name = current_user.id, current_user.username
for sensor_id in sensor_ids:
sensor = db.session.get(Sensor, sensor_id)
if sensor.event_resolution == event_resolution:
Expand All @@ -240,14 +233,10 @@ def resample_sensor_data(
abort=True,
)

audit_log = AssetAuditLog(
event_datetime=server_now(),
event=f"Resampled sensor data for sensor '{sensor.name}': {sensor.id} to {event_resolution} from {sensor.event_resolution}",
active_user_id=current_user_id,
active_user_name=current_user_name,
affected_asset_id=sensor.generic_asset_id,
AssetAuditLog.add_record(
sensor.generic_asset,
f"Resampled sensor data for sensor '{sensor.name}': {sensor.id} to {event_resolution} from {sensor.event_resolution}"
)
db.session.add(audit_log)

# Update sensor
sensor.event_resolution = event_resolution
Expand Down Expand Up @@ -293,14 +282,10 @@ def transfer_ownership(asset: Asset, new_owner: Account):
current_user_id, current_user_name = current_user.id, current_user.username

def transfer_ownership_recursive(asset: Asset, account: Account):
audit_log = AssetAuditLog(
event_datetime=server_now(),
event=f"Transfered ownership for asset '{asset.name}': {asset.id} from {asset.owner.id} to {account.id}",
active_user_id=current_user_id,
active_user_name=current_user_name,
affected_asset_id=asset.id,
AssetAuditLog.add_record(
asset,
f"Transfered ownership for asset '{asset.name}': {asset.id} from '{asset.owner.name}': {asset.owner.id} to '{account.name}': {account.id}"
)
db.session.add(audit_log)

asset.owner = account
for child in asset.child_assets:
Expand Down Expand Up @@ -373,33 +358,3 @@ def parse_attribute_value( # noqa: C901
def single_true(iterable) -> bool:
i = iter(iterable)
return any(i) and not any(i)


def write_audit_log_for_attribute_update(
attribute_key, attribute_value, entity_type, asset_or_sensor
):
current_user_id, current_user_name = None, None
if (
current_user
and hasattr(current_user, "is_authenticated")
and current_user.is_authenticated
):
current_user_id, current_user_name = current_user.id, current_user.username

old_value = asset_or_sensor.attributes.get(attribute_key)
if entity_type == "sensor":
event = f"Updated sensor '{asset_or_sensor.name}': {asset_or_sensor.id} "
affected_asset_id = (asset_or_sensor.generic_asset_id,)
else:
event = f"Updated asset '{asset_or_sensor.name}': {asset_or_sensor.id} "
affected_asset_id = asset_or_sensor.id
event += f"attribute '{attribute_key}' to {attribute_value} from {old_value}"

audit_log = AssetAuditLog(
event_datetime=server_now(),
event=event,
active_user_id=current_user_id,
active_user_name=current_user_name,
affected_asset_id=affected_asset_id,
)
db.session.add(audit_log)
2 changes: 1 addition & 1 deletion flexmeasures/cli/tests/test_data_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ def test_transfer_ownership(app, db, add_asset_with_children, add_alternative_ac
assert child.owner == new_account

for child_asset in (parent, *parent.child_assets):
event = f"Transfered ownership for asset '{child_asset.name}': {child_asset.id} from {old_account.id} to {new_account.id}"
event = f"Transfered ownership for asset '{child_asset.name}': {child_asset.id} from '{old_account.name}': {old_account.id} to '{new_account.name}': {new_account.id}"
assert db.session.execute(
select(AssetAuditLog).filter_by(
affected_asset_id=child_asset.id,
Expand Down
73 changes: 72 additions & 1 deletion flexmeasures/data/models/audit_log.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
from __future__ import annotations

from flask_security import current_user
from sqlalchemy import DateTime, Column, Integer, String, ForeignKey

from flexmeasures.auth.policy import AuthModelMixin
from flexmeasures.data import db
from flexmeasures.data.models.generic_assets import GenericAsset
from flexmeasures.data.models.time_series import Sensor
from flexmeasures.data.models.user import User, Account
from flexmeasures.auth.policy import AuthModelMixin
from flexmeasures.utils.time_utils import server_now


def get_current_user_id_name():
current_user_id, current_user_name = None, None
if (
current_user
and hasattr(current_user, "is_authenticated")
and current_user.is_authenticated
):
current_user_id, current_user_name = current_user.id, current_user.username
return current_user_id, current_user_name


class AuditLog(db.Model, AuthModelMixin):
Expand Down Expand Up @@ -107,3 +122,59 @@ class AssetAuditLog(db.Model, AuthModelMixin):
Integer(),
ForeignKey("generic_asset.id", ondelete="SET NULL"),
)

@classmethod
def add_record_for_attribute_update(
cls,
attribute_key: str,
attribute_value: float | int | bool | str | list | dict | None,
entity_type: str,
asset_or_sensor: GenericAsset | Sensor,
) -> None:
"""Add audit log record about asset or sensor attribute update.

:param attribute_key: attribute key to update
:param attribute_value: new attribute value
:param entity_type: 'asset' or 'sensor'
:param asset_or_sensor: asset or sensor object
"""
current_user_id, current_user_name = get_current_user_id_name()

old_value = asset_or_sensor.attributes.get(attribute_key)
if entity_type == "sensor":
event = f"Updated sensor '{asset_or_sensor.name}': {asset_or_sensor.id} "
affected_asset_id = (asset_or_sensor.generic_asset_id,)
else:
event = f"Updated asset '{asset_or_sensor.name}': {asset_or_sensor.id} "
affected_asset_id = asset_or_sensor.id
event += f"attribute '{attribute_key}' to {attribute_value} from {old_value}"

audit_log = cls(
event_datetime=server_now(),
event=event,
active_user_id=current_user_id,
active_user_name=current_user_name,
affected_asset_id=affected_asset_id,
)
db.session.add(audit_log)

@classmethod
def add_record(
cls,
asset: GenericAsset | Sensor,
event: str,
) -> None:
"""Add audit log record about asset related crud actions.

:param asset: asset or sensor object
:param event: event to log
"""
current_user_id, current_user_name = get_current_user_id_name()
audit_log = AssetAuditLog(
event_datetime=server_now(),
event=event,
active_user_id=current_user_id,
active_user_name=current_user_name,
affected_asset_id=asset.id,
)
db.session.add(audit_log)
22 changes: 13 additions & 9 deletions flexmeasures/ui/templates/crud/asset.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,6 @@
</div>
<div class="row">
<div class="col-sm-2 on-top-md">
<div>
<form action="/assets/auditlog/{{ asset.id }}" method="get">
<button class="btn btn-sm btn-responsive btn-info delete-button" type="submit"
title="View history of asset related actions.">Audit log</button>
</form>
</div>
<div class="header-action-button">
{% if user_can_create_assets %}
<div class="">
Expand All @@ -66,9 +60,19 @@
</div>
{% endif %}
</div>
<form action="/assets/{{ asset.id }}/status" method="get">
<button id="asset-status-button" class="btn btn-sm btn-responsive" type="submit">Asset status</button>
</form>
<div class="header-action-button">
<div class="">
<form action="/assets/{{ asset.id }}/status" method="get">
<button id="asset-status-button" class="btn btn-sm btn-responsive" type="submit">Asset status</button>
</form>
</div>
<div class="">
nrozanov marked this conversation as resolved.
Show resolved Hide resolved
<form action="/assets/auditlog/{{ asset.id }}" method="get">
nrozanov marked this conversation as resolved.
Show resolved Hide resolved
<button class="btn btn-sm btn-responsive btn-info delete-button" type="submit"
title="View history of asset related actions.">Audit log</button>
</form>
</div>
</div>
<div class="sidepanel-container">
<div class="left-sidepanel-label">Select dates</div>
<div class="sidepanel left-sidepanel">
Expand Down