diff --git a/docs/plugins/mixins.rst b/docs/plugins/mixins.rst index b57c012e..791d2aa3 100644 --- a/docs/plugins/mixins.rst +++ b/docs/plugins/mixins.rst @@ -18,6 +18,7 @@ StartupPlugin .. autoclass:: octoprint.plugin.StartupPlugin :members: + :show-inheritance: .. _sec-plugins-mixins-shutdownplugin: @@ -26,6 +27,7 @@ ShutdownPlugin .. autoclass:: octoprint.plugin.ShutdownPlugin :members: + :show-inheritance: .. _sec-plugins-mixins-settingsplugin: @@ -34,6 +36,7 @@ SettingsPlugin .. autoclass:: octoprint.plugin.SettingsPlugin :members: + :show-inheritance: .. _sec-plugins-mixins-assetplugin: @@ -42,6 +45,7 @@ AssetPlugin .. autoclass:: octoprint.plugin.AssetPlugin :members: + :show-inheritance: .. _sec-plugins-mixins-templateplugin: @@ -50,6 +54,7 @@ TemplatePlugin .. autoclass:: octoprint.plugin.TemplatePlugin :members: + :show-inheritance: .. _sec-plugins-mixins-wizardplugin: @@ -58,6 +63,16 @@ WizardPlugin .. autoclass:: octoprint.plugin.WizardPlugin :members: + :show-inheritance: + +.. _sec-plugins-mixins-uiplugin: + +UiPlugin +-------- + +.. autoclass:: octoprint.plugin.UiPlugin + :members: + :show-inheritance: .. _sec-plugins-mixins-simpleapiplugin: @@ -66,6 +81,7 @@ SimpleApiPlugin .. autoclass:: octoprint.plugin.SimpleApiPlugin :members: + :show-inheritance: .. _sec-plugins-mixins-blueprintplugin: @@ -74,6 +90,7 @@ BlueprintPlugin .. autoclass:: octoprint.plugin.BlueprintPlugin :members: + :show-inheritance: .. _sec-plugins-mixins-eventhandlerplugin: @@ -82,6 +99,7 @@ EventHandlerPlugin .. autoclass:: octoprint.plugin.EventHandlerPlugin :members: + :show-inheritance: .. _sec-plugins-mixins-progressplugin: @@ -90,6 +108,7 @@ ProgressPlugin .. autoclass:: octoprint.plugin.ProgressPlugin :members: + :show-inheritance: .. _sec-plugins-mixins-slicerplugin: @@ -98,4 +117,5 @@ SlicerPlugin .. autoclass:: octoprint.plugin.SlicerPlugin :members: + :show-inheritance: diff --git a/src/octoprint/plugin/__init__.py b/src/octoprint/plugin/__init__.py index 1df93a9b..686c7836 100644 --- a/src/octoprint/plugin/__init__.py +++ b/src/octoprint/plugin/__init__.py @@ -104,7 +104,8 @@ def plugin_manager(init=False, plugin_folders=None, plugin_types=None, plugin_en SlicerPlugin, AppPlugin, ProgressPlugin, - WizardPlugin] + WizardPlugin, + UiPlugin] if plugin_entry_points is None: plugin_entry_points = "octoprint.plugin" if plugin_disabled_list is None: diff --git a/src/octoprint/plugin/core.py b/src/octoprint/plugin/core.py index ce202600..30577575 100644 --- a/src/octoprint/plugin/core.py +++ b/src/octoprint/plugin/core.py @@ -12,6 +12,12 @@ way and could be extracted into a separate Python module in the future. .. autoclass:: Plugin :members: +.. autoclass:: RestartNeedingPlugin + :members: + +.. autoclass:: SortablePlugin + :members: + """ from __future__ import absolute_import @@ -1280,10 +1286,34 @@ class Plugin(object): pass class RestartNeedingPlugin(Plugin): - pass + """ + Mixin for plugin types that need a restart in order to be enabled. + """ class SortablePlugin(Plugin): + """ + Mixin for plugin types that are sortable. + """ + def get_sorting_key(self, context=None): + """ + Returns the sorting key to use for the implementation in the specified ``context``. + + May return ``None`` if order is irrelevant. + + Implementations returning None will be ordered by plugin identifier + after all implementations which did return a sorting key value that was + not None sorted by that. + + Arguments: + context (str): The sorting context for which to provide the + sorting key value. + + Returns: + int or None: An integer signifying the sorting key value of the plugin + (sorting will be done ascending), or None if the implementation + doesn't care about calling order. + """ return None class PluginNeedsRestart(Exception): diff --git a/src/octoprint/plugin/types.py b/src/octoprint/plugin/types.py index 9fdbe024..bbd06f54 100644 --- a/src/octoprint/plugin/types.py +++ b/src/octoprint/plugin/types.py @@ -9,6 +9,9 @@ Please note that the plugin implementation types are documented in the section .. autoclass:: OctoPrintPlugin :show-inheritance: +.. autoclass:: ReloadNeedingPlugin + :show-inheritance: + """ from __future__ import absolute_import @@ -97,12 +100,18 @@ class OctoPrintPlugin(Plugin): class ReloadNeedingPlugin(Plugin): - pass + """ + Mixin for plugin types that need a reload of the UI in order to become usable. + """ class StartupPlugin(OctoPrintPlugin, SortablePlugin): """ The ``StartupPlugin`` allows hooking into the startup of OctoPrint. It can be used to start up additional services on or just after the startup of the server. + + ``StartupPlugin`` is a :class:`~octoprint.plugin.core.SortablePlugin`. The + relevant sorting context for :meth:`on_startup` is ``StartupPlugin.on_startup``, + the one for :meth:`on_after_startup` will be ``StartupPlugin.on_after_startup``. """ def on_startup(self, host, port): @@ -132,6 +141,9 @@ class ShutdownPlugin(OctoPrintPlugin, SortablePlugin): The ``ShutdownPlugin`` allows hooking into the shutdown of OctoPrint. It's usually used in conjunction with the :class:`StartupPlugin` mixin, to cleanly shut down additional services again that where started by the :class:`StartupPlugin` part of the plugin. + + ``ShutdownPlugin`` is a :class:`~octoprint.plugin.core.SortablePlugin`. + The relevant sorting context will be ``ShutdownPlugin.on_shutdown``. """ def on_shutdown(self): @@ -149,6 +161,8 @@ class AssetPlugin(OctoPrintPlugin, RestartNeedingPlugin): A typical usage of the ``AssetPlugin`` functionality is to embed a custom view model to be used by templates injected through a :class:`TemplatePlugin`. + + ``AssetPlugin`` is a :class:`~octoprint.plugins.core.RestartNeedingPlugin`. """ def get_asset_folder(self): @@ -297,6 +311,8 @@ class TemplatePlugin(OctoPrintPlugin, ReloadNeedingPlugin): responsibility to ensure that all core functionality is still maintained. Plugins can also add additional template types by implementing the :ref:`octoprint.ui.web.templatetypes ` hook. + + ``TemplatePlugin`` is a :class:`~octoprint.plugin.core.ReloadNeedingPlugin`. """ def get_template_configs(self): @@ -485,6 +501,167 @@ class TemplatePlugin(OctoPrintPlugin, ReloadNeedingPlugin): return os.path.join(self._basefolder, "templates") +class UiPlugin(OctoPrintPlugin, SortablePlugin): + """ + The ``UiPlugin`` mixin allows plugins to completely replace the UI served + by OctoPrint when requesting the main page hosted at `/`. + + OctoPrint will query whether your mixin implementation will handle a + provided request by calling :meth:`~octoprint.plugin.UiPlugin.will_handle_ui` with the Flask + `Request `_ object as + parameter. If you plugin returns `True` here, OctoPrint will next call + :meth:`~octoprint.plugin.UiPlugin.on_ui_render` with a couple of parameters like + - again - the Flask Request object and the render keyword arguments as + used by the default OctoPrint web interface. For more information see below. + + There are two methods used in order to allow for caching of the actual + response sent to the client. Whatever a plugin implementation returns + from the call to its :meth:`~octoprint.plugin.UiPlugin.on_ui_render` method + will be cached server side. The cache will be emptied in case of explicit + no-cache headers sent by the client, or if the ``_refresh`` query parameter + on the request exists and is set to ``true``. To prevent caching of the + response altogether, a plugin may set no-cache headers on the returned + response as well. + + ``UiPlugin`` is a :class:`~octoprint.plugin.core.SortablePlugin`. The + relevant sorting context when acting as a UiPlugin is ``UiPlugin.will_handle_ui``. + The first plugin to return ``True`` will be the one whose ui will be used, + no further calls to :meth:`~octoprint.plugin.UiPlugin.on_ui_render` will be performed. + + If implementations want to serve custom templates in the :meth:`~octoprint.plugin.UiPlugin.on_ui_render` + method it is recommended to also implement the :class:`~octoprint.plugin.TemplatePlugin` + mixin. + + **Example** + + What follows is a very simple example that renders a different (non functional and + only exemplary) UI if the requesting client has a UserAgent string hinting + at it being a mobile device: + + .. onlineinclude:: https://raw.githubusercontent.com/OctoPrint/Plugin-Examples/master/dummy_mobile_ui/__init__.py + :linenos: + :tab-width: 4 + :caption: `dummy_mobile_ui/__init__.py `_ + + .. onlineinclude:: https://raw.githubusercontent.com/OctoPrint/Plugin-Examples/master/dummy_mobile_ui/templates/dummy_mobile_ui_index.jinja2 + :linenos: + :tab-width: 4 + :caption: `dummy_mobile_ui/templates/dummy_mobile_ui_index.jinja2 `_ + + Try installing the above plugin ``dummy_mobile_ui`` (also available in the + `plugin examples repository `_) + into your OctoPrint instance. If you access it from a regular desktop browser, + you should still see the default UI. However if you access it from a mobile + device (make sure to not have that request the desktop version of pages!) + you should see the very simple dummy page defined above. + """ + + def will_handle_ui(self, request): + """ + Called by OctoPrint to determine if the mixin implementation will be + able to handle the ``request`` provided as a parameter. + + Return ``True`` here to signal that your implementation will handle + the request and that the result of its :meth:`~octoprint.plugin.UiPlugin.on_ui_render` method + is what should be served to the user. + + Arguments: + request (flask.Request): A Flask `Request `_ + object. + + Returns: + bool: ``True`` if the the implementation will serve the request, + ``False`` otherwise. + """ + return False + + def on_ui_render(self, now, request, render_kwargs): + """ + Called by OctoPrint to retrieve the response to send to the client + for the ``request`` to ``/``. Only called if :meth:`~octoprint.plugin.UiPlugin.will_handle_ui` + returned ``True``. + + ``render_kwargs`` will be a dictionary (whose contents are cached) which + will contain the following key and value pairs (note that not all + key value pairs contained in the dictionary are listed here, only + those you should depend on as a plugin developer at the current time): + + .. list-table:: + :widths: 5 95 + + * - debug + - ``True`` if debug mode is enabled, ``False`` otherwise. + * - firstRun + - ``True`` if the server is being run for the first time (not + configured yet), ``False`` otherwise. + * - version + - OctoPrint's version information. This is a ``dict`` with the + following keys: + + .. list-table:: + :widths: 5 95 + + * - number + - The version number (e.g. ``x.y.z``) + * - branch + - The GIT branch from which the OctoPrint instance was built + (e.g. ``master``) + * - display + - The full human readable version string, including the + branch information (e.g. ``x.y.z (master branch)`` + + * - uiApiKey + - The UI API key to use for unauthorized API requests. This is + freshly generated on every server restart. + * - templates + - Template data to render in the UI. Will be a ``dict`` containing entries + for all known template types. + + The sub structure for each key will be as follows: + + .. list-table:: + :widths: 5 95 + + * - order + - A list of template names in the order they should appear + in the final rendered page + * - entries + - The template entry definitions to render. Depending on the + template type those are either 2-tuples of a name and a ``dict`` + or directly ``dicts`` with information regarding the + template to render. + + For the possible contents of the data ``dicts`` see the + :class:`~octoprint.plugin.TemplatePlugin` mixin. + + * - pluginNames + - A list of names of :class:`~octoprint.plugin.TemplatePlugin` + implementation that were enabled when creating the ``templates`` + value. + * - locales + - The locales for which there are translations available. + + On top of that all additional template variables as provided by :meth:`~octoprint.plugin.TemplatePlugin.get_template_vars` + will be contained in the dictionary as well. + + Arguments: + now (datetime.datetime): The datetime instance representing "now" + for this request, in case your plugin implementation needs this + information. + request (flask.Request): A Flask `Request `_ object. + render_kwargs (dict): The (cached) render keyword arguments that + would usually be provided to the core UI render function. + + Returns: + flask.Response: Should return a Flask `Response `_ + object that can be served to the requesting client directly. May be + created with ``flask.make_response`` combined with something like + ``flask.render_template``. + """ + + return None + + class WizardPlugin(OctoPrintPlugin, ReloadNeedingPlugin): """ The ``WizardPlugin`` mixin allows plugins to report to OctoPrint whether @@ -546,6 +723,8 @@ class WizardPlugin(OctoPrintPlugin, ReloadNeedingPlugin): def get_wizard_version(self): return 1 + + ``WizardPlugin`` is a :class:`~octoprint.plugin.core.ReloadNeedingPlugin`. """ def is_wizard_required(self): @@ -850,6 +1029,8 @@ class BlueprintPlugin(OctoPrintPlugin, RestartNeedingPlugin): flask.url_for("plugin.myblueprintplugin.myEcho") # will return "/plugin/myblueprintplugin/echo" + + ``BlueprintPlugin`` implements :class:`~octoprint.plugins.core.RestartNeedingPlugin`. """ @staticmethod diff --git a/src/octoprint/server/util/flask.py b/src/octoprint/server/util/flask.py index 4dfa3200..cf561a29 100644 --- a/src/octoprint/server/util/flask.py +++ b/src/octoprint/server/util/flask.py @@ -283,11 +283,11 @@ def cached(timeout=5 * 60, key=lambda: "view/%s" % flask.request.path, unless=No if not callable(refreshif) or not refreshif(): rv = _cache.get(cache_key) if rv is not None: - logger.debug("Serving entry for {path} from cache".format(path=flask.request.path)) + logger.debug("Serving entry for {path} from cache (key: {key})".format(path=flask.request.path, key=cache_key)) return rv # get value from wrapped function - logger.debug("No cache entry or refreshing cache for {path}, calling wrapped function".format(path=flask.request.path)) + logger.debug("No cache entry or refreshing cache for {path} (key: {key}), calling wrapped function".format(path=flask.request.path, key=cache_key)) rv = f(*args, **kwargs) # do not store if the "unless_response" condition is true diff --git a/src/octoprint/server/views.py b/src/octoprint/server/views.py index be79b626..961ba445 100644 --- a/src/octoprint/server/views.py +++ b/src/octoprint/server/views.py @@ -22,21 +22,95 @@ from . import util import logging _logger = logging.getLogger(__name__) -@app.route("/") -@util.flask.cached(refreshif=lambda: util.flask.cache_check_headers() or "_refresh" in request.values, - key=lambda: "view/%s/%s" % (request.path, g.locale), - unless_response=util.flask.cache_check_response_headers) -def index(): +_templates = None +_plugin_names = None +_plugin_vars = None +@app.route("/") +def index(): + force_refresh = util.flask.cache_check_headers() or "_refresh" in request.values + + global _templates, _plugin_names, _plugin_vars + + if force_refresh or _templates is None or _plugin_names is None or _plugin_vars is None: + _templates, _plugin_names, _plugin_vars = _process_templates() + + now = datetime.datetime.utcnow() + render_kwargs = _get_render_kwargs(_templates, _plugin_names, _plugin_vars, now) + + def get_cached_view(key, view): + return util.flask.cached(refreshif=lambda: force_refresh, + key=lambda: "ui:{}:{}".format(key, g.locale), + unless_response=util.flask.cache_check_response_headers)(view) + + ui_plugins = pluginManager.get_implementations(octoprint.plugin.UiPlugin, sorting_context="UiPlugin.on_ui_render") + for plugin in ui_plugins: + if plugin.will_handle_ui(request): + # plugin claims responsibility, let it render the UI + cached = get_cached_view(plugin._identifier, plugin.on_ui_render) + response = cached(now, request, render_kwargs) + if response is not None: + break + + else: + wizard = bool(_templates["wizard"]["order"]) + enable_accesscontrol = userManager is not None + + render_kwargs.update(dict( + webcamStream=settings().get(["webcam", "stream"]), + enableTemperatureGraph=settings().get(["feature", "temperatureGraph"]), + enableAccessControl=enable_accesscontrol, + enableSdSupport=settings().get(["feature", "sdSupport"]), + gcodeMobileThreshold=settings().get(["gcodeViewer", "mobileSizeThreshold"]), + gcodeThreshold=settings().get(["gcodeViewer", "sizeThreshold"]), + wizard=wizard, + now=now, + )) + + # no plugin took an interest, we'll use the default UI + def make_default_ui(): + r = make_response(render_template("index.jinja2", **render_kwargs)) + if bool(render_kwargs["templates"]["wizard"]["order"]): + r = util.flask.add_non_caching_response_headers(r) + return r + + cached = get_cached_view("_default", make_default_ui) + response = cached() + + response.headers["Last-Modified"] = now + + return response + + +def _get_render_kwargs(templates, plugin_names, plugin_vars, now): #~~ a bunch of settings + first_run = settings().getBoolean(["server", "firstRun"]) + locales = dict((l.language, dict(language=l.language, display=l.display_name, english=l.english_name)) for l in LOCALES) + + #~~ prepare full set of template vars for rendering + + render_kwargs = dict( + debug=debug, + firstRun=first_run, + version=dict(number=VERSION, display=DISPLAY_VERSION, branch=BRANCH), + uiApiKey=UI_API_KEY, + templates=templates, + pluginNames=plugin_names, + locales=locales, + ) + render_kwargs.update(plugin_vars) + + return render_kwargs + + +def _process_templates(): + enable_accesscontrol = userManager is not None first_run = settings().getBoolean(["server", "firstRun"]) enable_gcodeviewer = settings().getBoolean(["gcodeViewer", "enabled"]) enable_timelapse = (settings().get(["webcam", "snapshot"]) and settings().get(["webcam", "ffmpeg"])) enable_systemmenu = settings().get(["system"]) is not None and settings().get(["system", "actions"]) is not None and len(settings().get(["system", "actions"])) > 0 - enable_accesscontrol = userManager is not None preferred_stylesheet = settings().get(["devel", "stylesheet"]) - locales = dict((l.language, dict(language=l.language, display=l.display_name, english=l.english_name)) for l in LOCALES) ##~~ prepare templates @@ -327,43 +401,7 @@ def index(): templates[t]["entries"].update(template_sorting[t]["custom_insert_entries"](sorted_missing)) templates[t]["order"] = template_sorting[t]["custom_insert_order"](templates[t]["order"], sorted_missing) - #~~ prepare full set of template vars for rendering - - wizard = bool(templates["wizard"]["order"]) - now = datetime.datetime.utcnow() - render_kwargs = dict( - webcamStream=settings().get(["webcam", "stream"]), - enableTemperatureGraph=settings().get(["feature", "temperatureGraph"]), - enableAccessControl=enable_accesscontrol, - enableSdSupport=settings().get(["feature", "sdSupport"]), - firstRun=first_run, - debug=debug, - version=VERSION, - display_version=DISPLAY_VERSION, - branch=BRANCH, - gcodeMobileThreshold=settings().get(["gcodeViewer", "mobileSizeThreshold"]), - gcodeThreshold=settings().get(["gcodeViewer", "sizeThreshold"]), - uiApiKey=UI_API_KEY, - templates=templates, - pluginNames=plugin_names, - locales=locales, - wizard=wizard, - now=now - ) - render_kwargs.update(plugin_vars) - - #~~ render! - - response = make_response(render_template( - "index.jinja2", - **render_kwargs - )) - response.headers["Last-Modified"] = now - - if wizard: - response = util.flask.add_non_caching_response_headers(response) - - return response + return templates, plugin_names, plugin_vars def _process_template_configs(name, implementation, configs, rules): @@ -454,7 +492,8 @@ def robotsTxt(): @app.route("/i18n//.js") -@util.flask.cached(refreshif=lambda: util.flask.cache_check_headers() or "_refresh" in request.values, key=lambda: "view/%s/%s" % (request.path, g.locale)) +@util.flask.cached(refreshif=lambda: util.flask.cache_check_headers() or "_refresh" in request.values, + key=lambda: "{}:{}".format(request.path, g.locale)) def localeJs(locale, domain): messages = dict() plural_expr = None diff --git a/src/octoprint/templates/initscript.jinja2 b/src/octoprint/templates/initscript.jinja2 index 28bf8eb5..e239de95 100644 --- a/src/octoprint/templates/initscript.jinja2 +++ b/src/octoprint/templates/initscript.jinja2 @@ -25,9 +25,9 @@ var SOCKJS_CLOSE_NORMAL = 1000; var UI_API_KEY = "{{ uiApiKey }}"; - var VERSION = "{{ version }}"; - var DISPLAY_VERSION = "{{ display_version }}"; - var BRANCH = "{{ branch }}"; + var VERSION = "{{ version.number }}"; + var DISPLAY_VERSION = "{{ version.display }}"; + var BRANCH = "{{ version.branch }}"; var LOCALE = "{{ g.locale }}"; var AVAILABLE_LOCALES = {{ locales|tojson }};