Repository: FlexMeasures/flexmeasures
Pull request #1072 (2024-05-27 08:39:19)

Asset audit log
## Description

Start adding audit log records for asset related actions.

## Look & Feel

New router assets/<id>/auditlog shows account audit log records

## How to test

Create an asset, edit it, add / remove sensors, go to auditlog and these actions should be displayed there

## Further Improvements

-

## Related Items

-

---

- [x] I agree to contribute to the project under Apache 2 License. 
- [x] To the best of my knowledge, the proposed patch is not based on code under GPL or other license that is incompatible with FlexMeasures



Patch:
diff --git a/documentation/changelog.rst b/documentation/changelog.rst
index 56a8e376..916e4fd5 100644
--- a/documentation/changelog.rst
+++ b/documentation/changelog.rst
@@ -8,6 +8,8 @@ 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>`_]
+* On the asset page, facilitate comparison by showing the two default sensors together if they record the same unit [see `PR #1066 <https://github.com/FlexMeasures/flexmeasures/pull/1066>`_]
 * Flex-context (price sensors and inflexible device sensors) can now be set on the asset page (and are part of GenericAsset model) [see `PR #1059 <https://github.com/FlexMeasures/flexmeasures/pull/1059/>`_]
 * On the asset page's default view, facilitate comparison by showing the two default sensors together if they record the same unit [see `PR #1066 <https://github.com/FlexMeasures/flexmeasures/pull/1066>`_]
 
diff --git a/flexmeasures/__init__.py b/flexmeasures/__init__.py
index 335b3f27..6633d95d 100644
--- a/flexmeasures/__init__.py
+++ b/flexmeasures/__init__.py
@@ -1,6 +1,7 @@
 from importlib_metadata import version, PackageNotFoundError
 
 from flexmeasures.data.models.annotations import Annotation
+from flexmeasures.data.models.audit_log import AssetAuditLog
 from flexmeasures.data.models.user import (
     Account,
     AccountRole,
diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py
index 5067c5d5..89cf3afb 100644
--- a/flexmeasures/api/v3_0/assets.py
+++ b/flexmeasures/api/v3_0/assets.py
@@ -11,6 +11,7 @@ from sqlalchemy import select, delete
 from flexmeasures.auth.decorators import permission_required_for_context
 from flexmeasures.data import db
 from flexmeasures.data.models.user import Account
+from flexmeasures.data.models.audit_log import AssetAuditLog
 from flexmeasures.data.models.generic_assets import GenericAsset
 from flexmeasures.data.schemas import AwareDateTimeField
 from flexmeasures.data.schemas.generic_assets import GenericAssetSchema as AssetSchema
@@ -148,6 +149,9 @@ class AssetAPI(FlaskView):
 
         asset.set_inflexible_sensors(inflexible_sensor_ids)
         db.session.commit()
+
+        AssetAuditLog.add_record(asset, f"Created asset '{asset.name}': {asset.id}")
+
         return asset_schema.dump(asset), 201
 
     @route("/<id>", methods=["GET"])
@@ -239,6 +243,24 @@ class AssetAPI(FlaskView):
         inflexible_sensor_ids = asset_data.pop("inflexible_device_sensor_ids", [])
         db_asset.set_inflexible_sensors(inflexible_sensor_ids)
 
+        audit_log_data = list()
+        for k, v in asset_data.items():
+            if getattr(db_asset, k) == v:
+                continue
+            if k == "attributes":
+                current_attributes = getattr(db_asset, k)
+                for attr_key, attr_value in v.items():
+                    if current_attributes.get(attr_key) != attr_value:
+                        audit_log_data.append(
+                            f"Attribute name: {attr_key}, Old value: {current_attributes.get(attr_key)}, New value: {attr_value}"
+                        )
+                continue
+            audit_log_data.append(
+                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)}"
+        AssetAuditLog.add_record(db_asset, audit_log_event)
+
         for k, v in asset_data.items():
             setattr(db_asset, k, v)
         db.session.add(db_asset)
@@ -265,7 +287,9 @@ class AssetAPI(FlaskView):
         :status 403: INVALID_SENDER
         :status 422: UNPROCESSABLE_ENTITY
         """
-        asset_name = asset.name
+        asset_name, asset_id = asset.name, asset.id
+        AssetAuditLog.add_record(asset, f"Deleted asset '{asset_name}': {asset_id}")
+
         db.session.execute(delete(GenericAsset).filter_by(id=asset.id))
         db.session.commit()
         current_app.logger.info("Deleted asset '%s'." % asset_name)
@@ -326,3 +350,49 @@ class AssetAPI(FlaskView):
         """
         sensors = flatten_unique(asset.sensors_to_show)
         return asset.search_beliefs(sensors=sensors, as_json=True, **kwargs)
+
+    @route("/<id>/auditlog")
+    @use_kwargs(
+        {"asset": AssetIdField(data_key="id")},
+        location="path",
+    )
+    @permission_required_for_context("read", ctx_arg_name="asset")
+    @as_json
+    def auditlog(self, id: int, asset: GenericAsset):
+        """API endpoint to get history of asset related actions.
+        **Example response**
+
+        .. sourcecode:: json
+            [
+                {
+                    'event': 'Asset test asset deleted',
+                    'event_datetime': '2021-01-01T00:00:00',
+                    'active_user_name': 'Test user',
+                }
+            ]
+
+        :reqheader Authorization: The authentication token
+        :reqheader Content-Type: application/json
+        :resheader Content-Type: application/json
+        :status 200: PROCESSED
+        :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
+        :status 401: UNAUTHORIZED
+        :status 403: INVALID_SENDER
+        :status 422: UNPROCESSABLE_ENTITY
+        """
+        audit_logs = (
+            db.session.query(AssetAuditLog).filter_by(affected_asset_id=asset.id).all()
+        )
+        audit_logs = [
+            {
+                k: getattr(log, k)
+                for k in (
+                    "event",
+                    "event_datetime",
+                    "active_user_name",
+                    "active_user_id",
+                )
+            }
+            for log in audit_logs
+        ]
+        return audit_logs, 200
diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py
index a9094970..63b912c2 100644
--- a/flexmeasures/api/v3_0/sensors.py
+++ b/flexmeasures/api/v3_0/sensors.py
@@ -31,6 +31,7 @@ from flexmeasures.api.common.schemas.users import AccountIdField
 from flexmeasures.api.common.utils.api_utils import save_and_enqueue
 from flexmeasures.auth.decorators import permission_required_for_context
 from flexmeasures.data import db
+from flexmeasures.data.models.audit_log import AssetAuditLog
 from flexmeasures.data.models.user import Account
 from flexmeasures.data.models.generic_assets import GenericAsset
 from flexmeasures.data.models.time_series import Sensor, TimedBelief
@@ -615,7 +616,7 @@ class SensorAPI(FlaskView):
         pass_ctx_to_loader=True,
     )
     def post(self, sensor_data: dict):
-        """Create new asset.
+        """Create new sensor.
 
         .. :quickref: Sensor; Create a new Sensor
 
@@ -660,6 +661,10 @@ class SensorAPI(FlaskView):
         sensor = Sensor(**sensor_data)
         db.session.add(sensor)
         db.session.commit()
+
+        asset = sensor_schema.context["generic_asset"]
+        AssetAuditLog.add_record(asset, f"Created sensor '{sensor.name}': {sensor.id}")
+
         return sensor_schema.dump(sensor), 201
 
     @route("/<id>", methods=["PATCH"])
@@ -715,6 +720,16 @@ class SensorAPI(FlaskView):
         :status 403: INVALID_SENDER
         :status 422: UNPROCESSABLE_ENTITY
         """
+        audit_log_data = list()
+        for k, v in sensor_data.items():
+            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)}"
+
+        AssetAuditLog.add_record(sensor.generic_asset, audit_log_event)
+
         for k, v in sensor_data.items():
             setattr(sensor, k, v)
         db.session.add(sensor)
@@ -745,6 +760,10 @@ class SensorAPI(FlaskView):
         """Delete time series data."""
         db.session.execute(delete(TimedBelief).filter_by(sensor_id=sensor.id))
 
+        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))
         db.session.commit()
diff --git a/flexmeasures/cli/data_edit.py b/flexmeasures/cli/data_edit.py
index ad219518..8e7c7280 100644
--- a/flexmeasures/cli/data_edit.py
+++ b/flexmeasures/cli/data_edit.py
@@ -21,6 +21,7 @@ from flexmeasures.data.schemas.attributes import validate_special_attributes
 from flexmeasures.data.schemas.generic_assets import GenericAssetIdField
 from flexmeasures.data.schemas.sensors import SensorIdField
 from flexmeasures.data.models.generic_assets import GenericAsset
+from flexmeasures.data.models.audit_log import AssetAuditLog
 from flexmeasures.data.models.time_series import TimedBelief
 from flexmeasures.data.utils import save_to_db
 from flexmeasures.cli.utils import MsgStyle, DeprecatedOption, DeprecatedOptionsCommand
@@ -146,9 +147,15 @@ def edit_attribute(
 
     # Set attribute
     for asset in assets:
+        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:
+        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()
@@ -228,6 +235,11 @@ def resample_sensor_data(
                 abort=True,
             )
 
+        AssetAuditLog.add_record(
+            sensor.generic_asset,
+            f"Resampled sensor data for sensor '{sensor.name}': {sensor.id} to {event_resolution} from {sensor.event_resolution}",
+        )
+
         # Update sensor
         sensor.event_resolution = event_resolution
         db.session.add(sensor)
@@ -268,6 +280,11 @@ def transfer_ownership(asset: Asset, new_owner: Account):
     """
 
     def transfer_ownership_recursive(asset: Asset, account: Account):
+        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}",
+        )
+
         asset.owner = account
         for child in asset.child_assets:
             transfer_ownership_recursive(child, account)
diff --git a/flexmeasures/data/migrations/versions/126d65cbe6b4_asset_audit_log.py b/flexmeasures/data/migrations/versions/126d65cbe6b4_asset_audit_log.py
new file mode 100644
index 00000000..6f20cd24
--- /dev/null
+++ b/flexmeasures/data/migrations/versions/126d65cbe6b4_asset_audit_log.py
@@ -0,0 +1,50 @@
+"""asset_audit_log
+
+Revision ID: 126d65cbe6b4
+Revises: 202505c5cb06
+Create Date: 2024-05-27 15:48:07.399594
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = "126d65cbe6b4"
+down_revision = "202505c5cb06"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table(
+        "asset_audit_log",
+        sa.Column("id", sa.Integer(), nullable=False),
+        sa.Column("event_datetime", sa.DateTime(), nullable=True),
+        sa.Column("event", sa.String(length=255), nullable=True),
+        sa.Column("active_user_name", sa.String(length=255), nullable=True),
+        sa.Column("active_user_id", sa.Integer(), nullable=True),
+        sa.Column("affected_asset_id", sa.Integer(), nullable=True),
+        sa.ForeignKeyConstraint(
+            ["active_user_id"],
+            ["fm_user.id"],
+            name=op.f("asset_audit_log_active_user_id_fm_user_fkey"),
+            ondelete="SET NULL",
+        ),
+        sa.ForeignKeyConstraint(
+            ["affected_asset_id"],
+            ["generic_asset.id"],
+            name=op.f("asset_audit_log_affected_asset_id_generic_asset_fkey"),
+            ondelete="SET NULL",
+        ),
+        sa.PrimaryKeyConstraint("id", name=op.f("asset_audit_log_pkey")),
+    )
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+
+    op.drop_table("asset_audit_log")
+    # ### end Alembic commands ###
diff --git a/flexmeasures/data/models/audit_log.py b/flexmeasures/data/models/audit_log.py
index e0b6addd..41768f7b 100644
--- a/flexmeasures/data/models/audit_log.py
+++ b/flexmeasures/data/models/audit_log.py
@@ -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):
@@ -86,3 +101,80 @@ class AuditLog(db.Model, AuthModelMixin):
                 }
 
         return AuditLogAccess(account)
+
+
+class AssetAuditLog(db.Model, AuthModelMixin):
+    """
+    Model for storing actions that happen to an asset.
+    E.g asset creation, editing etc.
+    """
+
+    __tablename__ = "asset_audit_log"
+    id = Column(Integer, primary_key=True)
+    event_datetime = Column(DateTime())
+    event = Column(String(255))
+    active_user_name = Column(String(255))
+    active_user_id = Column(
+        "active_user_id", Integer(), ForeignKey("fm_user.id", ondelete="SET NULL")
+    )
+    affected_asset_id = Column(
+        "affected_asset_id",
+        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)
diff --git a/flexmeasures/data/schemas/sensors.py b/flexmeasures/data/schemas/sensors.py
index d344beb3..0c093b6e 100644
--- a/flexmeasures/data/schemas/sensors.py
+++ b/flexmeasures/data/schemas/sensors.py
@@ -158,6 +158,8 @@ class SensorSchema(SensorSchemaMixin, ma.SQLAlchemySchema):
             raise ValidationError(
                 f"Generic asset with id {generic_asset_id} doesn't exist."
             )
+        # Add it to context to use it for AssetAuditLog record
+        self.context["generic_asset"] = generic_asset
 
     class Meta:
         model = Sensor
diff --git a/flexmeasures/ui/crud/assets/views.py b/flexmeasures/ui/crud/assets/views.py
index b4cef73d..cfbaa087 100644
--- a/flexmeasures/ui/crud/assets/views.py
+++ b/flexmeasures/ui/crud/assets/views.py
@@ -306,3 +306,18 @@ class AssetCrudUI(FlaskView):
         return self.index(
             msg=f"Asset {id} and assorted meter readings / forecasts have been deleted."
         )
+
+    @login_required
+    def auditlog(self, id: str):
+        """/assets/auditlog/<id>"""
+        get_asset_response = InternalApi().get(url_for("AssetAPI:fetch_one", id=id))
+        asset_dict = get_asset_response.json()
+        asset = process_internal_api_response(asset_dict, int(id), make_obj=True)
+
+        audit_log_response = InternalApi().get(url_for("AssetAPI:auditlog", id=id))
+        audit_logs_response = audit_log_response.json()
+        return render_flexmeasures_template(
+            "crud/asset_audit_log.html",
+            audit_logs=audit_logs_response,
+            asset=asset,
+        )
diff --git a/flexmeasures/ui/static/css/flexmeasures.css b/flexmeasures/ui/static/css/flexmeasures.css
index bc993d3e..0dfffbb6 100644
--- a/flexmeasures/ui/static/css/flexmeasures.css
+++ b/flexmeasures/ui/static/css/flexmeasures.css
@@ -1375,13 +1375,18 @@ body .dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
     box-shadow: none;
     border-radius: 0px;
     text-align: center;
+    width: 100%;
     background: var(--primary-color);
+    border-top-left-radius: 0;
+    border-top-right-radius: 0;
+    border-bottom-left-radius: 0;
+    border-bottom-right-radius: 0;
     border-right: 1px solid var(--primary-border-color) !important;
-    transition: .4s;
     -webkit-border-radius: 0px;
     -moz-border-radius: 0px;
     -ms-border-radius: 0px;
     -o-border-radius: 0px;
+    transition: .4s;
     -webkit-transition: .4s;
     -moz-transition: .4s;
     -ms-transition: .4s;
@@ -1395,13 +1400,6 @@ body .dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
     background: var(--primary-hover-color) !important;
 }
 
-.header-action-button>div button {
-    border-top-left-radius: 0;
-    border-top-right-radius: 0;
-    border-bottom-left-radius: 0;
-    border-bottom-right-radius: 0;
-}
-
 .header-action-button>div:first-child button {
     border-top-left-radius: 15px;
     border-bottom-left-radius: 15px;
@@ -1934,6 +1932,7 @@ div.heading-group {
   content: " > ";
   color: var(--primary-color);
 }
+
 .table-responsive{
     padding: 20px;
-}
\ No newline at end of file
+}
diff --git a/flexmeasures/ui/templates/crud/asset.html b/flexmeasures/ui/templates/crud/asset.html
index 7a199397..a5bfde35 100644
--- a/flexmeasures/ui/templates/crud/asset.html
+++ b/flexmeasures/ui/templates/crud/asset.html
@@ -41,14 +41,14 @@
         <div class="col-md-2 on-top-md">
             <div class="header-action-button">
                 {% if user_can_create_assets %}
-                <div class="">
+                <div>
                     <form action="/assets/new" method="get">
                         <button class="btn btn-sm btn-responsive btn-success create-button mb-3" type="submit">Create new asset</button>
                     </form>
                 </div>
                 {% endif %}
                 {% if user_can_delete_asset %}
-                <div class="">
+                <div>
                     <form action="/assets/delete_with_data/{{ asset.id }}" method="get">
                         <button id="delete-asset-button" class="btn btn-sm btn-responsive btn-danger delete-button" type="submit">Delete this asset</button>
                     </form>
@@ -65,9 +65,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>
+                    <form action="/assets/{{ asset.id }}/status" method="get">
+                        <button id="asset-status-button" class="btn btn-sm btn-responsive btn-info" type="submit">Asset status</button>
+                    </form>
+                </div>
+                <div>
+                    <form action="/assets/auditlog/{{ asset.id }}" method="get">
+                        <button class="btn btn-sm btn-responsive btn-info" 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">
diff --git a/flexmeasures/ui/templates/crud/asset_audit_log.html b/flexmeasures/ui/templates/crud/asset_audit_log.html
new file mode 100644
index 00000000..8a804f71
--- /dev/null
+++ b/flexmeasures/ui/templates/crud/asset_audit_log.html
@@ -0,0 +1,47 @@
+{% extends "base.html" %}
+
+{% set active_page = "assets" %}
+
+{% block title %} {{asset.name}} {% endblock %}
+
+{% block divs %}
+
+<div class="container-fluid">
+    <div class="row">
+        <div class="asset auditlog card">
+            <h3>History of actions for asset <a href="/assets/{{ asset.id }}">{{ asset.name }}</a></h3>
+            <table id="asset_audit_log" class="table table-striped table-responsive paginate nav-on-click" title="View data">
+                <thead>
+                    <tr>
+                        <th>Event Datetime</th>
+                        <th>Event Name</th>
+                        <th>Acting user</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for audit_log in audit_logs: %}
+                    <tr>
+                        <td title="{{  audit_log.event_datetime }}">
+                            {{ audit_log.event_datetime | naturalized_datetime }}
+                        </td>
+                        <td>
+                            {{ audit_log.event }}
+                        </td>
+                        <td>
+                            {{ audit_log.active_user_name }} (Id: {{ audit_log.active_user_id }})
+                        </td>
+                    </tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+
+<script>
+    $(document).ready(function() {
+        $('#asset_audit_log').DataTable({"order": [[ 0, "desc" ]]});
+    });
+</script>
+
+{% endblock %}
\ No newline at end of file


Test patch:
diff --git a/flexmeasures/api/v3_0/tests/test_assets_api.py b/flexmeasures/api/v3_0/tests/test_assets_api.py
index 13192545..c6292a81 100644
--- a/flexmeasures/api/v3_0/tests/test_assets_api.py
+++ b/flexmeasures/api/v3_0/tests/test_assets_api.py
@@ -4,6 +4,7 @@ from flask import url_for
 import pytest
 from sqlalchemy import select, func
 
+from flexmeasures.data.models.audit_log import AssetAuditLog
 from flexmeasures.data.models.generic_assets import GenericAsset
 from flexmeasures.data.services.users import find_user_by_email
 from flexmeasures.api.tests.utils import get_auth_token, UserContext, AccountContext
@@ -147,7 +148,9 @@ def test_get_public_assets(
 @pytest.mark.parametrize(
     "requesting_user", ["test_prosumer_user@seita.nl"], indirect=True
 )
-def test_alter_an_asset(client, setup_api_test_data, setup_accounts, requesting_user):
+def test_alter_an_asset(
+    client, setup_api_test_data, setup_accounts, requesting_user, db
+):
     # without being an account-admin, no asset can be created ...
     with AccountContext("Test Prosumer Account") as prosumer:
         prosumer_asset = prosumer.generic_assets[0]
@@ -165,15 +168,38 @@ def test_alter_an_asset(client, setup_api_test_data, setup_accounts, requesting_
     print(f"Deletion Response: {asset_delete_response.json}")
     assert asset_delete_response.status_code == 403
     # ... but editing is allowed.
+    latitude, name = prosumer_asset.latitude, prosumer_asset.name
     asset_edit_response = client.patch(
         url_for("AssetAPI:patch", id=prosumer_asset.id),
         json={
-            "latitude": prosumer_asset.latitude,
-        },  # we're not changing values to keep other tests clean here
+            "latitude": 11.1,
+            "name": "other",
+        },
     )
     print(f"Editing Response: {asset_edit_response.json}")
     assert asset_edit_response.status_code == 200
 
+    # Resetting changes to keep other tests clean here
+    asset_edit_response = client.patch(
+        url_for("AssetAPI:patch", id=prosumer_asset.id),
+        json={
+            "latitude": latitude,
+            "name": name,
+        },
+    )
+    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",
@@ -353,10 +379,20 @@ def test_post_an_asset(client, setup_api_test_data, requesting_user, db):
     assert asset is not None
     assert asset.latitude == 30.1
 
+    assert db.session.execute(
+        select(AssetAuditLog).filter_by(
+            affected_asset_id=asset.id,
+            event=f"Created asset '{asset.name}': {asset.id}",
+            active_user_id=requesting_user.id,
+            active_user_name=requesting_user.username,
+        )
+    ).scalar_one_or_none()
+
 
 @pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True)
 def test_delete_an_asset(client, setup_api_test_data, requesting_user, db):
-    existing_asset_id = setup_api_test_data["some gas sensor"].generic_asset.id
+    existing_asset = setup_api_test_data["some gas sensor"].generic_asset
+    existing_asset_id, existing_asset_name = existing_asset.id, existing_asset.name
 
     delete_asset_response = client.delete(
         url_for("AssetAPI:delete", id=existing_asset_id),
@@ -367,6 +403,15 @@ def test_delete_an_asset(client, setup_api_test_data, requesting_user, db):
     ).scalar_one_or_none()
     assert deleted_asset is None
 
+    audit_log = db.session.execute(
+        select(AssetAuditLog).filter_by(
+            event=f"Deleted asset '{existing_asset_name}': {existing_asset_id}",
+            active_user_id=requesting_user.id,
+            active_user_name=requesting_user.username,
+        )
+    ).scalar_one_or_none()
+    assert audit_log.affected_asset_id is None
+
 
 @pytest.mark.parametrize(
     "requesting_user",
diff --git a/flexmeasures/api/v3_0/tests/test_sensors_api.py b/flexmeasures/api/v3_0/tests/test_sensors_api.py
index d6df1498..0dcb7548 100644
--- a/flexmeasures/api/v3_0/tests/test_sensors_api.py
+++ b/flexmeasures/api/v3_0/tests/test_sensors_api.py
@@ -8,6 +8,7 @@ from sqlalchemy import select, func
 from flexmeasures.data.models.time_series import TimedBelief
 from flexmeasures import Sensor
 from flexmeasures.api.v3_0.tests.utils import get_sensor_post_data
+from flexmeasures.data.models.audit_log import AssetAuditLog
 from flexmeasures.data.schemas.sensors import SensorSchema
 from flexmeasures.data.models.generic_assets import GenericAsset
 
@@ -84,6 +85,15 @@ def test_post_a_sensor(client, setup_api_test_data, requesting_user, db):
     assert sensor.unit == "kWh"
     assert sensor.attributes["capacity_in_mw"] == 0.0074
 
+    assert db.session.execute(
+        select(AssetAuditLog).filter_by(
+            affected_asset_id=post_data["generic_asset_id"],
+            event=f"Created sensor '{sensor.name}': {sensor.id}",
+            active_user_id=requesting_user.id,
+            active_user_name=requesting_user.username,
+        )
+    ).scalar_one_or_none()
+
 
 @pytest.mark.parametrize(
     "requesting_user", ["test_supplier_user_4@seita.nl"], indirect=True
@@ -131,6 +141,19 @@ def test_patch_sensor(client, setup_api_test_data, requesting_user, db):
     )
     assert new_sensor.attributes["test_attribute"] == "test_attribute_value"
 
+    audit_log_event = (
+        f"Updated sensor 'some gas sensor': {sensor.id}. Updated fields: Field name: name, Old value: some gas sensor, New value: Changed name; Field name: attributes, "
+        + "Old value: {}, New value: {'test_attribute': 'test_attribute_value'}"
+    )
+    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=sensor.generic_asset_id,
+        )
+    ).scalar_one_or_none()
+
 
 @pytest.mark.parametrize(
     "attribute, value",
@@ -184,7 +207,8 @@ def test_patch_sensor_non_admin(client, setup_api_test_data, requesting_user, db
 
 @pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True)
 def test_delete_a_sensor(client, setup_api_test_data, requesting_user, db):
-    existing_sensor_id = setup_api_test_data["some temperature sensor"].id
+    existing_sensor = setup_api_test_data["some temperature sensor"]
+    existing_sensor_id = existing_sensor.id
     sensor_data = db.session.scalars(
         select(TimedBelief).filter(TimedBelief.sensor_id == existing_sensor_id)
     ).all()
@@ -207,3 +231,12 @@ def test_delete_a_sensor(client, setup_api_test_data, requesting_user, db):
     assert (
         db.session.scalar(select(func.count()).select_from(Sensor)) == sensor_count - 1
     )
+
+    assert db.session.execute(
+        select(AssetAuditLog).filter_by(
+            affected_asset_id=existing_sensor.generic_asset_id,
+            event=f"Deleted sensor '{existing_sensor.name}': {existing_sensor.id}",
+            active_user_id=requesting_user.id,
+            active_user_name=requesting_user.username,
+        )
+    ).scalar_one_or_none()
diff --git a/flexmeasures/cli/tests/test_data_edit.py b/flexmeasures/cli/tests/test_data_edit.py
index 40be53bb..d08c2735 100644
--- a/flexmeasures/cli/tests/test_data_edit.py
+++ b/flexmeasures/cli/tests/test_data_edit.py
@@ -5,12 +5,13 @@ import timely_beliefs as tb
 from sqlalchemy import select
 
 from flexmeasures.cli.tests.utils import to_flags
+from flexmeasures.data.models.audit_log import AssetAuditLog
 from flexmeasures.data.models.time_series import TimedBelief
 from flexmeasures.cli.tests.utils import get_click_commands
 from flexmeasures.tests.utils import get_test_sensor
 
 
-@pytest.mark.skip
+@pytest.mark.skip_github
 def test_add_one_sensor_attribute(app, db, setup_markets):
     from flexmeasures.cli.data_edit import edit_attribute
 
@@ -27,6 +28,16 @@ def test_add_one_sensor_attribute(app, db, setup_markets):
     result = runner.invoke(edit_attribute, to_flags(cli_input))
     assert result.exit_code == 0 and "Success" in result.output, result.exception
 
+    event = f"Updated sensor '{sensor.name}': {sensor.id} attribute 'some new attribute' to 3.0 from None"
+    assert db.session.execute(
+        select(AssetAuditLog).filter_by(
+            affected_asset_id=sensor.generic_asset_id,
+            event=event,
+            active_user_id=None,
+            active_user_name=None,
+        )
+    ).scalar_one_or_none()
+
     # Reload sensor from database and count attributes
     sensor = get_test_sensor(db)
     n_attributes_after = len(sensor.attributes)
@@ -34,6 +45,33 @@ def test_add_one_sensor_attribute(app, db, setup_markets):
     assert n_attributes_after == n_attributes_before + 1
 
 
+@pytest.mark.skip_github
+def test_update_one_asset_attribute(app, db, setup_generic_assets):
+    from flexmeasures.cli.data_edit import edit_attribute
+
+    db.session.flush()
+    asset = setup_generic_assets["test_battery"]
+    cli_input = {
+        "asset": asset.id,
+        "attribute": "some-attribute",
+        "str": "some-new-value",
+    }
+
+    runner = app.test_cli_runner()
+    result = runner.invoke(edit_attribute, to_flags(cli_input))
+    assert result.exit_code == 0 and "Success" in result.output, result.exception
+
+    event = f"Updated asset '{asset.name}': {asset.id} attribute 'some-attribute' to some-new-value from some-value"
+    assert db.session.execute(
+        select(AssetAuditLog).filter_by(
+            affected_asset_id=asset.id,
+            event=event,
+            active_user_id=None,
+            active_user_name=None,
+        )
+    ).scalar_one_or_none()
+
+
 @pytest.mark.skip_github
 @pytest.mark.parametrize(
     "event_starts_after, event_ends_before",
@@ -98,6 +136,15 @@ def test_resample_sensor_data(
     # Checksum
     assert beliefs_after["event_value"].sum() == 2 * beliefs_before["event_value"].sum()
 
+    assert db.session.execute(
+        select(AssetAuditLog).filter_by(
+            affected_asset_id=sensor.generic_asset_id,
+            event=f"Resampled sensor data for sensor '{sensor.name}': {sensor.id} to 0:30:00 from 1:00:00",
+            active_user_id=None,
+            active_user_name=None,
+        )
+    ).scalar_one_or_none()
+
     # Resample back to original resolution (on behalf of the next test case)
     cli_input["event-resolution"] = sensor.event_resolution.seconds / 60
     result = runner.invoke(
@@ -149,3 +196,14 @@ def test_transfer_ownership(app, db, add_asset_with_children, add_alternative_ac
     assert parent.owner == new_account
     for child in parent.child_assets:
         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.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,
+                event=event,
+                active_user_id=None,
+                active_user_name=None,
+            )
+        ).scalar_one_or_none()