From 7e95feb0d80e3a17b608830670a6902c49fd376a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 31 Jan 2024 11:14:32 -0800 Subject: [PATCH] Documentation for event plugin hooks, refs #2240 --- datasette/__init__.py | 1 + datasette/app.py | 7 +++-- datasette/events.py | 7 ++++- datasette/hookspecs.py | 4 +-- docs/plugin_hooks.rst | 71 ++++++++++++++++++++++++++++++++++++++++++ docs/plugins.rst | 9 ++++++ 6 files changed, 93 insertions(+), 6 deletions(-) diff --git a/datasette/__init__.py b/datasette/__init__.py index 271e09ada0..47d2b4f6db 100644 --- a/datasette/__init__.py +++ b/datasette/__init__.py @@ -1,5 +1,6 @@ from datasette.permissions import Permission # noqa from datasette.version import __version_info__, __version__ # noqa +from datasette.events import Event # noqa from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa from datasette.utils import actor_matches_allow # noqa from datasette.views import Context # noqa diff --git a/datasette/app.py b/datasette/app.py index 112fb85ed3..34324fa279 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -885,10 +885,11 @@ async def track_event(self, event: Event): assert isinstance(event, self.event_classes), "Invalid event type: {}".format( type(event) ) - properties = dataclasses.asdict(event) - actor = properties.pop("actor") for hook in pm.hook.track_event( - datasette=self, name=event.name, actor=actor, properties=properties + datasette=self, + name=event.name, + actor=event.actor, + properties=event.properties(), ): await await_me_maybe(hook) diff --git a/datasette/events.py b/datasette/events.py index fbba064b58..7d563cbe38 100644 --- a/datasette/events.py +++ b/datasette/events.py @@ -1,5 +1,5 @@ from abc import ABC, abstractproperty -from dataclasses import dataclass +from dataclasses import asdict, dataclass from datasette.hookspecs import hookimpl @@ -11,6 +11,11 @@ def name(self): actor: dict + def properties(self): + properties = asdict(self) + properties.pop("actor", None) + return properties + @dataclass class LogoutEvent(Event): diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index c297e35264..b473f39801 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -161,8 +161,8 @@ def handle_exception(datasette, request, exception): @hookspec -def track_event(datasette, name, actor, properties): - """Respond to a named event tracked by Datasette""" +def track_event(datasette, event): + """Respond to an event tracked by Datasette""" @hookspec diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index da69c6c9b6..c82619ce34 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1759,3 +1759,74 @@ top_canned_query(datasette, request, database, query_name) The name of the canned query. Returns HTML to be displayed at the top of the canned query page. + +.. _plugin_event_tracking: + +Event tracking +-------------- + +Datasette includes an internal mechanism for tracking analytical events. This can be used for analytics, but can also be used by plugins that want to listen out for when key events occur (such as a table being created) and take action in response. + +Plugins can register to receive events using the ``track_event`` plugin hook. + +They can also define their own events for other plugins to receive using the ``register_events`` plugin hook, combined with calls to the ``datasette.track_event(...)`` internal method. + +.. _plugin_hook_track_event: + +track_event(datasette, name, event) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +``event`` - ``Event`` + Information about the event, represented as an instance of a subclass of the ``Event`` base class. + +This hook will be called any time an event is tracked through code calling the ``datasette.track_event(...)`` internal method. + +The ``event`` object will always have the following properties: + +- ``name``: a string representing the name of the event, for example ``logout`` or ``create-table``. +- ``actor``: a dictionary representing the actor that triggered the event, or ``None`` if the event was not triggered by an actor. + +Other properties on the event will be available depending on the type of event. TODO: Link to documentation of these. + +.. _plugin_hook_register_events: + +register_events(datasette) +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. + +This hook should return a list of ``Event`` subclasses that represent custom events that the plugin might send to the ``datasette.track_event()`` method. + +This example registers event subclasses for ``ban-user`` and ``unban-user`` events: + +.. code-block:: python + + from dataclasses import dataclass + from datasette import hookimpl, Event + + + @dataclass + class BanUserEvent(Event): + name = "ban-user" + user: dict + + + @dataclass + class UnbanUserEvent(Event): + name = "unban-user" + user: dict + + + @hookimpl + def register_events(): + return [BanUserEvent, UnbanUserEvent] + +The plugin can then call ``datasette.track_event(...)`` to send a ``ban-user`` event: + +.. code-block:: python + + await datasette.track_event(BanUserEvent(user={"id": 1, "username": "cleverbot"})) diff --git a/docs/plugins.rst b/docs/plugins.rst index 2ec03701c3..1a72af9540 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -228,6 +228,15 @@ If you run ``datasette plugins --all`` it will include default plugins that ship "skip_csrf" ] }, + { + "name": "datasette.events", + "static": false, + "templates": false, + "version": null, + "hooks": [ + "register_events" + ] + }, { "name": "datasette.facets", "static": false,