This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 732248e4755 fix(logging): record dashboard_id and slice_id for REST
mutation endpoints (#44553)
732248e4755 is described below
commit 732248e4755b972ef0a1c04b8d7698b707777ff5
Author: Evan Rusackas <[email protected]>
AuthorDate: Thu Sep 24 11:24:11 2026 -0700
fix(logging): record dashboard_id and slice_id for REST mutation endpoints
(#44553)
Co-authored-by: vikash kumar <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
---
superset/charts/api.py | 11 +-
superset/dashboards/api.py | 9 +-
superset/utils/log.py | 101 +++++++++++++-
tests/integration_tests/base_tests.py | 14 +-
tests/integration_tests/charts/api_tests.py | 11 ++
tests/integration_tests/dashboards/api_tests.py | 27 +++-
.../dashboards/soft_delete_tests.py | 7 +
tests/unit_tests/utils/log_tests.py | 150 ++++++++++++++++++++-
8 files changed, 320 insertions(+), 10 deletions(-)
diff --git a/superset/charts/api.py b/superset/charts/api.py
index fdcc0d6a2d8..0501420b0b3 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -19,7 +19,7 @@ import logging
from contextvars import ContextVar
from datetime import datetime
from io import BytesIO
-from typing import Any, cast, ClassVar, Optional
+from typing import Any, Callable, cast, ClassVar, Optional
from zipfile import is_zipfile, ZipFile
from flask import current_app, redirect, request, Response, url_for
@@ -692,9 +692,13 @@ class ChartRestApi(SoftDeleteApiMixin,
BaseSupersetModelRestApi):
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
log_to_statsd=False,
+ allow_extra_payload=True,
)
@requires_json
- def post(self) -> Response:
+ def post(
+ self,
+ add_extra_log_payload: Callable[..., None] = lambda **kwargs: None,
+ ) -> Response:
"""Create a new chart.
---
post:
@@ -736,6 +740,9 @@ class ChartRestApi(SoftDeleteApiMixin,
BaseSupersetModelRestApi):
return self.response_400(message=error.messages)
try:
new_model = CreateChartCommand(item).run()
+ # The id only exists once the command has run, so the event
+ # logger cannot derive it from the route.
+ add_extra_log_payload(slice_id=new_model.id)
return self.response(201, id=new_model.id, result=item,
uuid=new_model.uuid)
except DashboardsForbiddenError as ex:
return self.response(ex.status, message=ex.message)
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index c12ed00661c..e63739cd05a 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -983,9 +983,13 @@ class DashboardRestApi(
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
log_to_statsd=False,
+ allow_extra_payload=True,
)
@requires_json
- def post(self) -> Response:
+ def post(
+ self,
+ add_extra_log_payload: Callable[..., None] = lambda **kwargs: None,
+ ) -> Response:
"""Create a new dashboard.
---
post:
@@ -1025,6 +1029,9 @@ class DashboardRestApi(
return self.response_400(message=error.messages)
try:
new_model = CreateDashboardCommand(item).run()
+ # The id only exists once the command has run, so the event
+ # logger cannot derive it from the route.
+ add_extra_log_payload(dashboard_id=new_model.id)
return self.response(201, id=new_model.id, result=item,
uuid=new_model.uuid)
except DashboardInvalidError as ex:
return self.response_422(message=ex.normalized_messages())
diff --git a/superset/utils/log.py b/superset/utils/log.py
index 58051600fbe..e1cece42e29 100644
--- a/superset/utils/log.py
+++ b/superset/utils/log.py
@@ -20,6 +20,7 @@ import functools
import inspect
import logging
import textwrap
+import uuid
from abc import ABC, abstractmethod
from collections.abc import Iterator
from contextlib import contextmanager
@@ -31,12 +32,98 @@ from flask_appbuilder.const import API_URI_RIS_KEY
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.exc import SQLAlchemyError
+from superset.constants import SKIP_VISIBILITY_FILTER_CLASSES
from superset.extensions import stats_logger_manager
from superset.utils import json
from superset.utils.core import get_user_id, LoggerLevel, to_int
logger = logging.getLogger(__name__)
+# The ``logs`` table has an integer column for the dashboard or chart a request
+# touched. This maps the model behind a REST API's ``datamodel`` to that column
+# so every route on the matching API populates it without per-endpoint
plumbing.
+LOG_OBJECT_ID_COLUMNS: dict[str, str] = {
+ "Dashboard": "dashboard_id",
+ "Slice": "slice_id",
+}
+
+# Route parameters that identify the single object a REST API route acts on.
+OBJECT_ID_VIEW_ARGS: tuple[str, ...] = (
+ "pk",
+ "id_or_slug",
+ "id_or_uuid",
+ "uuid",
+ "uuid_str",
+)
+
+
+def _resolve_object_id(model: Any, identifier: Any) -> int | None:
+ """
+ Turn a route identifier (id, UUID or slug) into the model's integer id.
+
+ Slugs and UUIDs are looked up bypassing the soft-delete visibility filter
so
+ that restore and purge routes can still identify the archived row they act
+ on. Lookup failures never propagate: an unlogged id must not fail a
request.
+ """
+ # pylint: disable=import-outside-toplevel
+ from superset import db
+
+ try:
+ return int(identifier)
+ except (TypeError, ValueError):
+ pass
+
+ try:
+ criterion = model.uuid == uuid.UUID(str(identifier))
+ except ValueError:
+ if not hasattr(model, "slug"):
+ return None
+ criterion = model.slug == str(identifier)
+
+ try:
+ return (
+ db.session.query(model.id)
+ .filter(criterion)
+ .execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {model}})
+ .scalar()
+ )
+ except SQLAlchemyError:
+ logger.debug(
+ "Could not resolve %s %r for event logging", model.__name__,
identifier
+ )
+ return None
+
+
+def get_object_ids_from_view_args(
+ view: Any, view_args: dict[str, Any]
+) -> dict[str, Any]:
+ """
+ Derive the ``dashboard_id`` / ``slice_id`` log fields for a REST API route.
+
+ ``view`` is the API instance the logged route was called on and
+ ``view_args`` are the keyword arguments Flask passed to it. The result is
+ empty unless the API is backed by a model that ``logs`` has a column for.
+
+ A single-object route (``/<pk>``, ``/<id_or_slug>``, ``/<uuid>``, ...)
+ yields e.g. ``{"dashboard_id": 42}``. A bulk route identified by a rison
+ list of ids yields ``{"dashboard_ids": [...]}`` for the JSON payload
+ instead, since the integer column can only hold one id.
+ """
+ model = getattr(getattr(view, "datamodel", None), "obj", None)
+ column = LOG_OBJECT_ID_COLUMNS.get(getattr(model, "__name__", ""))
+ if column is None:
+ return {}
+
+ for key in OBJECT_ID_VIEW_ARGS:
+ if key in view_args:
+ object_id = _resolve_object_id(model, view_args[key])
+ return {column: object_id} if object_id is not None else {}
+
+ ids = view_args.get("rison")
+ if isinstance(ids, list) and ids and all(isinstance(i, int) for i in ids):
+ return {f"{column}s": ids}
+ return {}
+
def collect_request_payload(include_request_data: bool = True) -> dict[str,
Any]:
"""Collect log payload identifiable from request context"""
@@ -321,7 +408,19 @@ class AbstractEventLogger(ABC):
with self.log_context(
action=action_str, object_ref=object_ref_str, **wrapper_kwargs
) as log:
- log(**kwargs)
+ # Resolve the object's id before the route runs so that delete
+ # and purge can still identify the row they are about to
remove.
+ # Read the URL's own view args (Flask fills request.view_args
+ # from the route regardless of what a decorator above this one
+ # does to the wrapped function's signature) rather than only
+ # this wrapper's own kwargs, which a decorator like
+ # with_dashboard can empty out by calling the wrapped function
+ # positionally (e.g. f(self, dash)) after resolving the id.
+ view = args[0] if args else None
+ route_args = dict(kwargs)
+ if has_request_context() and request:
+ route_args.update(request.view_args or {})
+ log(**kwargs, **get_object_ids_from_view_args(view,
route_args))
if allow_extra_payload:
# add a payload updater to the decorated function
value = f(*args, add_extra_log_payload=log, **kwargs)
diff --git a/tests/integration_tests/base_tests.py
b/tests/integration_tests/base_tests.py
index 8bef7da4dff..4dae108ad1e 100644
--- a/tests/integration_tests/base_tests.py
+++ b/tests/integration_tests/base_tests.py
@@ -39,7 +39,7 @@ from superset import db, security_manager
from superset.connectors.sqla.models import BaseDatasource, SqlaTable
from superset.constants import SKIP_VISIBILITY_FILTER_CLASSES
from superset.models import core as models
-from superset.models.core import Database
+from superset.models.core import Database, Log
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.sql.parse import CTASMethod
@@ -311,6 +311,18 @@ class SupersetTestCase(TestCase):
username, first_name, last_name, email, role_admin, password
)
+ @staticmethod
+ def get_latest_log(action: str) -> Log:
+ """Return the newest ``logs`` row recorded for ``action``."""
+ log = (
+ db.session.query(Log)
+ .filter_by(action=action)
+ .order_by(Log.id.desc())
+ .first()
+ )
+ assert log is not None, f"no log row recorded for {action}"
+ return log
+
@staticmethod
def get_user(username: str) -> ab_models.User:
user = (
diff --git a/tests/integration_tests/charts/api_tests.py
b/tests/integration_tests/charts/api_tests.py
index b9501de7ddd..1bfcab0b5d0 100644
--- a/tests/integration_tests/charts/api_tests.py
+++ b/tests/integration_tests/charts/api_tests.py
@@ -336,6 +336,8 @@ class TestChartApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCase):
assert rv.status_code == 200
model = db.session.query(Slice).get(chart_id)
assert model is None
+ log = self.get_latest_log("ChartRestApi.delete")
+ assert log.slice_id == chart_id
def test_delete_bulk_charts(self):
"""
@@ -359,6 +361,11 @@ class TestChartApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCase):
for chart_id in chart_ids:
model = db.session.query(Slice).get(chart_id)
assert model is None
+ # a single integer column cannot hold every id, so the full list is
+ # recorded in the JSON payload instead
+ log = self.get_latest_log("ChartRestApi.bulk_delete")
+ assert log.slice_id is None
+ assert json.loads(log.json)["slice_ids"] == chart_ids
def test_delete_bulk_chart_bad_request(self):
"""
@@ -579,6 +586,8 @@ class TestChartApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCase):
# uuid should be returned in the response
assert "uuid" in data
assert str(model.uuid) == str(data["uuid"])
+ log = self.get_latest_log("ChartRestApi.post")
+ assert log.slice_id == model.id
db.session.delete(model)
db.session.commit()
@@ -867,6 +876,8 @@ class TestChartApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCase):
uri = f"api/v1/chart/{chart_id}"
rv = self.put_assert_metric(uri, chart_data, "put")
assert rv.status_code == 200
+ log = self.get_latest_log("ChartRestApi.put")
+ assert log.slice_id == chart_id
model = db.session.query(Slice).get(chart_id)
related_dashboard =
db.session.query(Dashboard).filter_by(slug="births").first()
assert model.created_by == admin
diff --git a/tests/integration_tests/dashboards/api_tests.py
b/tests/integration_tests/dashboards/api_tests.py
index 8cbe5763029..00d4853b082 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -1663,6 +1663,8 @@ class TestDashboardApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCa
assert rv.status_code == 200
model = db.session.query(Dashboard).get(dashboard_id)
assert model is None
+ log = self.get_latest_log("DashboardRestApi.delete")
+ assert log.dashboard_id == dashboard_id
def test_delete_bulk_dashboards(self):
"""
@@ -1690,6 +1692,11 @@ class TestDashboardApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCa
for dashboard_id in dashboard_ids:
model = db.session.query(Dashboard).get(dashboard_id)
assert model is None
+ # a single integer column cannot hold every id, so the full list is
+ # recorded in the JSON payload instead
+ log = self.get_latest_log("DashboardRestApi.bulk_delete")
+ assert log.dashboard_id is None
+ assert json.loads(log.json)["dashboard_ids"] == dashboard_ids
def test_delete_bulk_embedded_dashboards(self):
"""
@@ -1967,6 +1974,8 @@ class TestDashboardApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCa
# uuid should be returned in the response
assert "uuid" in data
assert str(model.uuid) == str(data["uuid"])
+ log = self.get_latest_log("DashboardRestApi.post")
+ assert log.dashboard_id == model.id
db.session.delete(model)
db.session.commit()
@@ -2330,6 +2339,8 @@ class TestDashboardApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCa
uri = f"api/v1/dashboard/{dashboard_id}"
rv = self.put_assert_metric(uri, self.dashboard_data, "put")
assert rv.status_code == 200
+ log = self.get_latest_log("DashboardRestApi.put")
+ assert log.dashboard_id == dashboard_id
model = db.session.query(Dashboard).get(dashboard_id)
assert model.dashboard_title == self.dashboard_data["dashboard_title"]
assert model.slug == self.dashboard_data["slug"]
@@ -2449,6 +2460,8 @@ class TestDashboardApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCa
uri = f"api/v1/dashboard/{dashboard_id}/filters"
rv = self.put_assert_metric(uri, self.dashboard_put_filters_data,
"put_filters")
assert rv.status_code == 200
+ log = self.get_latest_log("DashboardRestApi.put_filters")
+ assert log.dashboard_id == dashboard_id
model = db.session.query(Dashboard).get(dashboard_id)
json_metadata = model.json_metadata
native_filter_config =
json.loads(json_metadata)["native_filter_configuration"]
@@ -2622,6 +2635,8 @@ class TestDashboardApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCa
}
rv = self.put_assert_metric(uri, put_data, "put_chart_customizations")
assert rv.status_code == 200
+ log = self.get_latest_log("DashboardRestApi.put_chart_customizations")
+ assert log.dashboard_id == dashboard_id
model = db.session.query(Dashboard).get(dashboard_id)
json_metadata = model.json_metadata
chart_customization_config = json.loads(json_metadata)[
@@ -4419,6 +4434,8 @@ class TestDashboardApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCa
assert rv.status_code == 200
response = json.loads(rv.data.decode("utf-8"))
assert response == {"result": {"id": ANY, "last_modified_time": ANY}}
+ log = self.get_latest_log("DashboardRestApi.copy_dash")
+ assert log.dashboard_id == pk
dash = (
db.session.query(Dashboard)
@@ -4849,8 +4866,8 @@ class TestDashboardApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCa
mock_get_from_cache_key.side_effect = lambda cache_key, **_kwargs:
payloads.get(
cache_key
)
- mock_store_cache_payload.side_effect = (
- lambda cache_key, payload: payloads.__setitem__(cache_key, payload)
+ mock_store_cache_payload.side_effect = lambda cache_key, payload: (
+ payloads.__setitem__(cache_key, payload)
)
def publish(_request_key, cache_key, _scope, _previous_cache_key):
@@ -5074,8 +5091,8 @@ class TestDashboardApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCa
mock_get_from_cache_key.side_effect = lambda cache_key, **_kwargs:
payloads.get(
cache_key
)
- mock_store_cache_payload.side_effect = (
- lambda cache_key, payload: payloads.__setitem__(cache_key, payload)
+ mock_store_cache_payload.side_effect = lambda cache_key, payload: (
+ payloads.__setitem__(cache_key, payload)
)
def publish(_request_key, cache_key, _scope, previous_cache_key):
@@ -6236,6 +6253,8 @@ class TestDashboardApi(ApiEditorsTestCaseMixin,
InsertChartMixin, SupersetTestCa
uri = f"api/v1/dashboard/{dashboard.id}/colors"
rv = self.client.put(uri, json=colors)
assert rv.status_code == 200
+ log = self.get_latest_log("DashboardRestApi.put_colors")
+ assert log.dashboard_id == dashboard.id
updated_dashboard = db.session.query(Dashboard).get(dashboard.id)
updated_label_colors = json.loads(updated_dashboard.json_metadata).get(
diff --git a/tests/integration_tests/dashboards/soft_delete_tests.py
b/tests/integration_tests/dashboards/soft_delete_tests.py
index 9174a6d59f0..4869137a12e 100644
--- a/tests/integration_tests/dashboards/soft_delete_tests.py
+++ b/tests/integration_tests/dashboards/soft_delete_tests.py
@@ -389,6 +389,9 @@ class TestDashboardRestore(SupersetTestCase):
self.client.delete(f"/api/v1/dashboard/{dashboard_id}")
rv = self.client.post(f"/api/v1/dashboard/{dashboard_uuid}/restore")
assert rv.status_code == 200
+ # the UUID route still resolves to the archived row's integer id
+ log = self.get_latest_log("DashboardRestApi.restore")
+ assert log.dashboard_id == dashboard_id
rv = self.client.get(f"/api/v1/dashboard/{dashboard_id}")
assert rv.status_code == 200
@@ -876,6 +879,10 @@ class TestDashboardArchiveListing(SupersetTestCase):
rv = self.client.post(f"/api/v1/dashboard/{dashboard_uuid}/purge")
assert rv.status_code == 200, rv.data
+ # the UUID resolves to the row's integer id before it's gone for good
+ log = self.get_latest_log("DashboardRestApi.purge")
+ assert log.dashboard_id == dashboard_id
+
row = (
db.session.query(Dashboard)
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {Dashboard}})
diff --git a/tests/unit_tests/utils/log_tests.py
b/tests/unit_tests/utils/log_tests.py
index 5b031b57788..121b26552ae 100644
--- a/tests/unit_tests/utils/log_tests.py
+++ b/tests/unit_tests/utils/log_tests.py
@@ -15,8 +15,21 @@
# specific language governing permissions and limitations
# under the License.
+import uuid
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from typing import Any
-from superset.utils.log import get_logger_from_status
+import pytest
+from flask import current_app
+from pytest_mock import MockerFixture
+from sqlalchemy.orm.session import Session
+
+from superset.utils.log import (
+ DBEventLogger,
+ get_logger_from_status,
+ get_object_ids_from_view_args,
+)
def test_log_from_status_exception() -> None:
@@ -35,3 +48,138 @@ def test_log_from_status_info() -> None:
(func, log_level) = get_logger_from_status(300)
assert func.__name__ == "info"
assert log_level == "info"
+
+
+# Stand-ins for the models behind ``DashboardRestApi`` / ``ChartRestApi``
+# ``datamodel``: the helper only inspects the class name, so no ORM is needed.
+_Dashboard = type("Dashboard", (), {})
+_Slice = type("Slice", (), {})
+# A model that ``logs`` has no id column for.
+_Database = type("Database", (), {})
+
+
+def _view_for(model: type) -> SimpleNamespace:
+ """Build the minimal REST API shape the event logger inspects."""
+ return SimpleNamespace(datamodel=SimpleNamespace(obj=model))
+
+
[email protected](
+ "model,view_args,expected",
+ [
+ (_Dashboard, {"pk": 42}, {"dashboard_id": 42}),
+ (_Dashboard, {"pk": "42"}, {"dashboard_id": 42}),
+ (_Dashboard, {"id_or_slug": "7"}, {"dashboard_id": 7}),
+ (_Slice, {"pk": "3"}, {"slice_id": 3}),
+ (_Slice, {"id_or_uuid": 3}, {"slice_id": 3}),
+ (_Dashboard, {"rison": [1, 2, 3]}, {"dashboard_ids": [1, 2, 3]}),
+ (_Slice, {"rison": [5]}, {"slice_ids": [5]}),
+ # rison payloads that are not a list of ids (list endpoints,
thumbnails)
+ (_Dashboard, {"rison": {"columns": ["id"]}}, {}),
+ (_Dashboard, {"rison": []}, {}),
+ (_Dashboard, {"rison": [1, "a"]}, {}),
+ # routes with no object identifier at all (create, import, list)
+ (_Dashboard, {}, {}),
+ # a route parameter takes precedence over a rison list
+ (_Dashboard, {"pk": 9, "rison": [1, 2]}, {"dashboard_id": 9}),
+ # models without a ``logs`` column never contribute ids
+ (_Database, {"pk": 1}, {}),
+ (_Database, {"rison": [1, 2]}, {}),
+ ],
+)
+def test_get_object_ids_from_view_args(
+ model: type, view_args: dict[str, Any], expected: dict[str, Any]
+) -> None:
+ assert get_object_ids_from_view_args(_view_for(model), view_args) ==
expected
+
+
+def test_get_object_ids_from_view_args_without_datamodel() -> None:
+ """Plain views and free functions decorated with the logger are ignored."""
+ assert get_object_ids_from_view_args(None, {"pk": 1}) == {}
+ assert get_object_ids_from_view_args(object(), {"pk": 1}) == {}
+
+
+def test_get_object_ids_from_view_args_resolves_slug_and_uuid(
+ session: Session,
+) -> None:
+ """Slug and UUID routes resolve to the integer id, even when archived."""
+ from superset.models.core import FavStar # noqa: F401
+ from superset.models.dashboard import Dashboard
+
+ Dashboard.metadata.create_all(session.get_bind()) # pylint:
disable=no-member
+ dashboard = Dashboard(
+ id=100,
+ dashboard_title="audited",
+ slug="audited-slug",
+ uuid=uuid.uuid4(),
+ deleted_at=datetime.now(timezone.utc),
+ )
+ session.add(dashboard)
+ session.commit()
+
+ view = _view_for(Dashboard)
+ assert get_object_ids_from_view_args(view, {"id_or_slug": "audited-slug"})
== {
+ "dashboard_id": 100
+ }
+ assert get_object_ids_from_view_args(view, {"uuid": str(dashboard.uuid)})
== {
+ "dashboard_id": 100
+ }
+ assert get_object_ids_from_view_args(view, {"uuid_str":
str(uuid.uuid4())}) == {}
+ assert get_object_ids_from_view_args(view, {"id_or_slug": "missing"}) == {}
+
+
+def test_log_this_with_context_derives_object_id_from_route(
+ app_context: None, mocker: MockerFixture
+) -> None:
+ """``log_this_with_context`` fills ``dashboard_id`` from the route's pk."""
+ mock_log = mocker.patch.object(DBEventLogger, "log")
+ logger = DBEventLogger()
+
+ class FakeDashboardRestApi: # pylint: disable=too-few-public-methods
+ datamodel = SimpleNamespace(obj=_Dashboard)
+
+ @logger.log_this_with_context(action="DashboardRestApi.delete")
+ def delete(self, pk: int) -> str:
+ return f"deleted {pk}"
+
+ with current_app.test_request_context("/api/v1/dashboard/42",
method="DELETE"):
+ assert FakeDashboardRestApi().delete(pk="42") == "deleted 42"
+
+ payload = mock_log.call_args[1]
+ assert payload["dashboard_id"] == 42
+ # ``to_int`` turns a missing id into 0; the DB logger stores that as NULL.
+ assert not payload["slice_id"]
+ assert payload["records"][0]["dashboard_id"] == 42
+ assert payload["records"][0]["pk"] == "42"
+
+
+def test_log_this_with_context_derives_object_id_despite_outer_decorator(
+ app_context: None, mocker: MockerFixture
+) -> None:
+ """``DashboardRestApi.get`` sits behind ``with_dashboard``, which calls the
+ logged view positionally (``f(self, dash)``) after resolving the id,
+ leaving the logger's own ``kwargs`` empty. The id must still come from
+ ``request.view_args``, with no ``add_extra_log_payload`` call in the view.
+ """
+ mock_log = mocker.patch.object(DBEventLogger, "log")
+ logger = DBEventLogger()
+
+ class FakeDashboardRestApi: # pylint: disable=too-few-public-methods
+ datamodel = SimpleNamespace(obj=_Dashboard)
+
+ @logger.log_this_with_context(action="DashboardRestApi.get")
+ def get(self, dash_id: int) -> str:
+ return f"got {dash_id}"
+
+ def with_dashboard(f: Any) -> Any:
+ def wraps(self: Any, id_or_slug: str) -> Any:
+ return f(self, int(id_or_slug))
+
+ return wraps
+
+ FakeDashboardRestApi.get = with_dashboard(FakeDashboardRestApi.get) #
type: ignore[method-assign]
+
+ with current_app.test_request_context("/api/v1/dashboard/42",
method="GET"):
+ assert FakeDashboardRestApi().get("42") == "got 42"
+
+ payload = mock_log.call_args[1]
+ assert payload["dashboard_id"] == 42