From 0a1816e52fd5053bed564511bf5b6e1d7b5ffd24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Wed, 30 Sep 2015 15:58:38 +0200 Subject: [PATCH 1/8] Better error reporting in PipUtil for errors during pip setup detection --- src/octoprint/util/pip.py | 43 +++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/src/octoprint/util/pip.py b/src/octoprint/util/pip.py index 1a590b1c..4b5b19b9 100644 --- a/src/octoprint/util/pip.py +++ b/src/octoprint/util/pip.py @@ -198,7 +198,7 @@ class PipCaller(CommandlineCaller): ok, pip_user, pip_virtual_env, pip_install_dir = self._check_pip_setup(pip_command) if not ok: - self._logger.error("Pip install directory {} is not writable and is part of a virtual environment, can't use this constellation".format(pip_install_dir)) + self._logger.error("Cannot use pip at {}".format(pip_command)) return self._logger.info("pip at {} installs to {}, --user flag needed => {}, virtual env => {}".format(pip_command, pip_install_dir, "yes" if pip_user else "no", "yes" if pip_virtual_env else "no")) @@ -299,26 +299,29 @@ class PipCaller(CommandlineCaller): cwd=testballoon) output = p.stdout.text - self._logger.debug("Got output from {}: {}".format(" ".join(sarge_command), output)) - - install_dir_match = self.__class__.pip_install_dir_regex.search(output) - virtual_env_match = self.__class__.pip_virtual_env_regex.search(output) - writable_match = self.__class__.pip_writable_regex.search(output) - - if install_dir_match and virtual_env_match and writable_match: - install_dir = install_dir_match.group(1) - virtual_env = virtual_env_match.group(1) == "True" - writable = writable_match.group(1) == "True" - - # ok, enable user flag, virtual env yes/no, installation dir - result = writable or not virtual_env, \ - not writable and not virtual_env and site.ENABLE_USER_SITE, \ - virtual_env, \ - install_dir - _cache["setup"][pip_command] = result - return result - + except: + self._logger.exception("Error while trying to install testballoon to figure out pip setup") + return False, False, False, None finally: sarge_command = [pip_command, "uninstall", "-y", "OctoPrint-PipTestBalloon"] sarge.run(sarge_command, stdout=sarge.Capture(), stderr=sarge.Capture()) + install_dir_match = self.__class__.pip_install_dir_regex.search(output) + virtual_env_match = self.__class__.pip_virtual_env_regex.search(output) + writable_match = self.__class__.pip_writable_regex.search(output) + + if install_dir_match and virtual_env_match and writable_match: + install_dir = install_dir_match.group(1) + virtual_env = virtual_env_match.group(1) == "True" + writable = writable_match.group(1) == "True" + + # ok, enable user flag, virtual env yes/no, installation dir + result = writable or not virtual_env, \ + not writable and not virtual_env and site.ENABLE_USER_SITE, \ + virtual_env, \ + install_dir + _cache["setup"][pip_command] = result + return result + else: + self._logger.debug("Could not detect desired output from testballoon install, got this instead: {}".format(" ".join(sarge_command), output)) + return False, False, False, None From 982f24fe3125586e733134aad74bc3f861fc8d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Wed, 30 Sep 2015 15:59:08 +0200 Subject: [PATCH 2/8] PMGR: Better wording of "pip unavailable" message --- .../pluginmanager/templates/pluginmanager_settings.jinja2 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 b/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 index 72cbc7ca..ae137976 100644 --- a/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 +++ b/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 @@ -6,8 +6,9 @@ {% macro pluginmanager_nopip() %}
{% trans %} - The pip command could not be found. - Please configure it manually. No installation and uninstallation of plugin + The pip command could not be found or does not work correctly + for this installation of OctoPrint - please consult the log file for details + and if necessary configure it manually. No installation and uninstallation of plugin packages is possible while pip is unavailable. {% endtrans %}
{% endmacro %} From 5cc8ec5cc39913a7ed11a5a156c91a3014ce6757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Wed, 30 Sep 2015 16:00:32 +0200 Subject: [PATCH 3/8] Better wording for plugin system startup & sorted plugin list of ALL plugins --- src/octoprint/plugin/core.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/octoprint/plugin/core.py b/src/octoprint/plugin/core.py index 913289de..bcb28ce0 100644 --- a/src/octoprint/plugin/core.py +++ b/src/octoprint/plugin/core.py @@ -861,7 +861,7 @@ class PluginManager(object): additional_pre_inits=additional_pre_inits, additional_post_inits=additional_post_inits) - self.logger.info("Initialized {count} plugin(s)".format(count=len(self.plugin_implementations))) + self.logger.info("Initialized {count} plugin implementation(s)".format(count=len(self.plugin_implementations))) def initialize_implementation_of_plugin(self, name, plugin, additional_injects=None, additional_inject_factories=None, additional_pre_inits=None, additional_post_inits=None): if plugin.implementation is None: @@ -955,15 +955,13 @@ class PluginManager(object): self.logger.info("No plugins available") else: self.logger.info("{count} plugin(s) registered with the system:\n{plugins}".format(count=len(all_plugins), plugins="\n".join( - sorted( - map(lambda x: "| " + x.long_str(show_bundled=show_bundled, - bundled_strs=bundled_str, - show_location=show_location, - location_str=location_str, - show_enabled=show_enabled, - enabled_strs=enabled_str), - self.enabled_plugins.values()) - ) + map(lambda x: "| " + x.long_str(show_bundled=show_bundled, + bundled_strs=bundled_str, + show_location=show_location, + location_str=location_str, + show_enabled=show_enabled, + enabled_strs=enabled_str), + sorted(self.plugins.values(), key=lambda x: str(x).lower())) ))) def get_plugin(self, identifier, require_enabled=True): From 31bc7c1f3e543a6a765d96662311696ab62c4ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Thu, 1 Oct 2015 10:08:24 +0200 Subject: [PATCH 4/8] Helper method for creating notifications with only one confirm button PNotify always merges the default buttons and the ones provided. Just settings the default to containing no buttons was no option since plugins might already depend on the so far obligatory Cancel button being present. The helper allows to create one buttoned notifications without touching the defaults. --- src/octoprint/static/js/app/main.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/octoprint/static/js/app/main.js b/src/octoprint/static/js/app/main.js index f2a7b11d..30b3d5d1 100644 --- a/src/octoprint/static/js/app/main.js +++ b/src/octoprint/static/js/app/main.js @@ -62,6 +62,22 @@ $(function() { PNotify.prototype.options.styling = "bootstrap2"; PNotify.prototype.options.mouse_reset = false; + PNotify.singleButtonNotify = function(options) { + var autoDisplay = options.auto_display != false; + + var params = $.extend(true, {}, options); + params.auto_display = false; + + var notify = new PNotify(params); + notify.options.confirm.buttons = [notify.options.confirm.buttons[0]]; + notify.modules.confirm.makeDialog(notify, notify.options.confirm); + + if (autoDisplay) { + notify.open(); + } + return notify; + }; + //~~ Initialize view models // the view model map is our basic look up table for dependencies that may be injected into other view models From 6ab44849cdf2161c2a6dd6b7a83ce34457b6bdd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Thu, 1 Oct 2015 14:05:23 +0200 Subject: [PATCH 5/8] Extracted system commands into their own proper API & ViewModel --- src/octoprint/server/api/__init__.py | 35 +--- src/octoprint/server/api/system.py | 176 ++++++++++++++++++ src/octoprint/server/util/flask.py | 1 + .../static/js/app/viewmodels/navigation.js | 35 +--- .../static/js/app/viewmodels/system.js | 96 ++++++++++ .../templates/navbar/systemmenu.jinja2 | 4 +- 6 files changed, 278 insertions(+), 69 deletions(-) create mode 100644 src/octoprint/server/api/system.py create mode 100644 src/octoprint/static/js/app/viewmodels/system.js diff --git a/src/octoprint/server/api/__init__.py b/src/octoprint/server/api/__init__.py index 7bb87bc4..6b924236 100644 --- a/src/octoprint/server/api/__init__.py +++ b/src/octoprint/server/api/__init__.py @@ -38,6 +38,7 @@ from . import log as api_logs from . import slicing as api_slicing from . import printer_profiles as api_printer_profiles from . import languages as api_languages +from . import system as api_system VERSION = "0.1" @@ -177,40 +178,6 @@ def apiVersion(): "api": VERSION }) -#~~ system control - - -@api.route("/system", methods=["POST"]) -@restricted_access -@admin_permission.require(403) -def performSystemAction(): - logger = logging.getLogger(__name__) - if "action" in request.values.keys(): - action = request.values["action"] - available_actions = s().get(["system", "actions"]) - for availableAction in available_actions: - if availableAction["action"] == action: - async = availableAction["async"] if "async" in availableAction else False - ignore = availableAction["ignore"] if "ignore" in availableAction else False - logger.info("Performing command: %s" % availableAction["command"]) - try: - # we run this with shell=True since we have to trust whatever - # our admin configured as command and since we want to allow - # shell-alike handling here... - p = sarge.run(availableAction["command"], stderr=sarge.Capture(), shell=True, async=async) - if not async: - if not ignore and p.returncode != 0: - returncode = p.returncode - stderr_text = p.stderr.text - logger.warn("Command failed with return code %i: %s" % (returncode, stderr_text)) - return make_response(("Command failed with return code %i: %s" % (returncode, stderr_text), 500, [])) - except Exception, e: - if not ignore: - logger.warn("Command failed: %s" % e) - return make_response(("Command failed: %s" % e, 500, [])) - break - return NO_CONTENT - #~~ Login/user handling diff --git a/src/octoprint/server/api/system.py b/src/octoprint/server/api/system.py new file mode 100644 index 00000000..fba71f43 --- /dev/null +++ b/src/octoprint/server/api/system.py @@ -0,0 +1,176 @@ +# coding=utf-8 +from __future__ import absolute_import + +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import collections +import logging +import sarge + +from flask import request, make_response, jsonify, url_for +from flask.ext.babel import gettext + +from octoprint.settings import settings as s + +from octoprint.server import admin_permission, NO_CONTENT +from octoprint.server.api import api +from octoprint.server.util.flask import restricted_access, get_remote_address + + +@api.route("/system", methods=["POST"]) +@restricted_access +@admin_permission.require(403) +def performSystemAction(): + logging.getLogger(__name__).warn("Deprecated API call to /api/system made by {}, should be migrated to use /system/commands/custom/".format(get_remote_address(request))) + + data = request.values + if hasattr(request, "json") and request.json: + data = request.json + + if not "action" in data: + return make_response("action for perform is not defined", 400) + + return executeSystemCommand("custom", data["action"]) + + +@api.route("/system/commands", methods=["GET"]) +@restricted_access +@admin_permission.require(403) +def retrieveSystemCommands(): + return jsonify(core=_to_client_specs(_get_core_command_specs()), + custom=_to_client_specs(_get_custom_command_specs())) + + +@api.route("/system/commands/", methods=["GET"]) +@restricted_access +@admin_permission.require(403) +def retrieveSystemCommandsForSource(source): + if source == "core": + specs = _get_core_command_specs() + elif source == "custom": + specs = _get_custom_command_specs() + else: + return make_response("Unknown system command source: {}".format(source), 400) + + return jsonify(_to_client_specs(specs)) + + +@api.route("/system/commands//", methods=["POST"]) +@restricted_access +@admin_permission.require(403) +def executeSystemCommand(source, command): + logger = logging.getLogger(__name__) + + command_spec = _get_command_spec(source, command) + if not command_spec: + return make_response("Command {}:{} not found".format(source, command), 404) + + if not "command" in command_spec: + return make_response("Command {}:{} does not define a command to execute, can't proceed".format(source, command), 500) + + async = command_spec["async"] if "async" in command_spec else False + ignore = command_spec["ignore"] if "ignore" in command_spec else False + logger.info("Performing command for {}:{}: {}".format(source, command, command_spec["command"])) + try: + # we run this with shell=True since we have to trust whatever + # our admin configured as command and since we want to allow + # shell-alike handling here... + p = sarge.run(command_spec["command"], + stdout=sarge.Capture(), + stderr=sarge.Capture(), + shell=True, + async=async) + if not async: + if not ignore and p.returncode != 0: + returncode = p.returncode + stdout_text = p.stdout.text + stderr_text = p.stderr.text + + error = "Command failed with return code {}:\nSTDOUT: {}\nSTDERR: {}".format(returncode, stdout_text, stderr_text) + logger.warn(error) + return make_response(error, 500) + except Exception, e: + if not ignore: + error = "Command failed: {}".format(str(e)) + logger.warn(error) + return make_response(error, 500) + + return NO_CONTENT + + +def _to_client_specs(specs): + result = list() + for spec in specs.values(): + if not "action" in spec or not "source" in spec: + continue + copied = dict((k, v) for k, v in spec.items() if k in ("source", "action", "name", "confirm")) + copied["resource"] = url_for(".executeSystemCommand", + source=spec["source"], + command=spec["action"], + _external=True) + result.append(copied) + return result + + +def _get_command_spec(source, action): + if source == "core": + return _get_core_command_spec(action) + elif source == "custom": + return _get_custom_command_spec(action) + else: + return None + + +def _get_core_command_specs(): + commands = collections.OrderedDict( + shutdown=dict( + command=s().get(["server", "commands", "systemShutdownCommand"]), + name=gettext("Shutdown"), + confirm=gettext("You are about to shutdown the system.")), + reboot=dict( + command=s().get(["server", "commands", "systemRestartCommand"]), + name=gettext("Reboot"), + confirm=gettext("You are about to reboot the system.")), + restart=dict( + command=s().get(["server", "commands", "serverRestartCommand"]), + name="Restart OctoPrint", + confirm="You are about to restart the OctoPrint server.") + ) + + available_commands = dict() + for action, spec in commands.items(): + if not spec["command"]: + continue + spec.update(dict(action=action, source="core", async=True, ignore=True)) + available_commands[action] = spec + return available_commands + + +def _get_core_command_spec(action): + available_actions = _get_core_command_specs() + if not action in available_actions: + logging.getLogger(__name__).warn("Command for core action {} is not configured, you need to configure the command before it can be used".format(action)) + return None + + return available_actions[action] + + +def _get_custom_command_specs(): + specs = collections.OrderedDict() + for spec in s().get(["system", "actions"]): + if not "action" in spec: + continue + copied = dict(spec) + copied["source"] = "custom" + specs[spec["action"]] = copied + return specs + + +def _get_custom_command_spec(action): + available_actions = _get_custom_command_specs() + if not action in available_actions: + return None + + return available_actions[action] + diff --git a/src/octoprint/server/util/flask.py b/src/octoprint/server/util/flask.py index 4fb4d6cb..b1c386af 100644 --- a/src/octoprint/server/util/flask.py +++ b/src/octoprint/server/util/flask.py @@ -631,6 +631,7 @@ def collect_plugin_assets(enable_gcodeviewer=True, preferred_stylesheet="css"): 'js/app/viewmodels/printerprofiles.js', 'js/app/viewmodels/settings.js', 'js/app/viewmodels/slicing.js', + 'js/app/viewmodels/system.js', 'js/app/viewmodels/temperature.js', 'js/app/viewmodels/terminal.js', 'js/app/viewmodels/timelapse.js', diff --git a/src/octoprint/static/js/app/viewmodels/navigation.js b/src/octoprint/static/js/app/viewmodels/navigation.js index 736fc949..37bebbe8 100644 --- a/src/octoprint/static/js/app/viewmodels/navigation.js +++ b/src/octoprint/static/js/app/viewmodels/navigation.js @@ -6,8 +6,7 @@ $(function() { self.appearance = parameters[1]; self.settings = parameters[2]; self.usersettings = parameters[3]; - - self.systemActions = self.settings.system_actions; + self.system = parameters[4]; self.appearanceClasses = ko.computed(function() { var classes = self.appearance.color(); @@ -17,41 +16,11 @@ $(function() { return classes; }); - self.triggerAction = function(action) { - var callback = function() { - $.ajax({ - url: API_BASEURL + "system", - type: "POST", - dataType: "json", - data: "action=" + action.action, - success: function() { - new PNotify({title: "Success", text: _.sprintf(gettext("The command \"%(command)s\" executed successfully"), {command: action.name}), type: "success"}); - }, - error: function(jqXHR, textStatus, errorThrown) { - if (!action.hasOwnProperty("ignore") || !action.ignore) { - var error = "

" + _.sprintf(gettext("The command \"%(command)s\" could not be executed."), {command: action.name}) + "

"; - error += pnotifyAdditionalInfo("
" + jqXHR.responseText + "
"); - new PNotify({title: gettext("Error"), text: error, type: "error", hide: false}); - } - } - }) - }; - if (action.confirm) { - showConfirmationDialog({ - message: action.confirm, - onproceed: function(e) { - callback(); - } - }); - } else { - callback(); - } - } } OCTOPRINT_VIEWMODELS.push([ NavigationViewModel, - ["loginStateViewModel", "appearanceViewModel", "settingsViewModel", "userSettingsViewModel"], + ["loginStateViewModel", "appearanceViewModel", "settingsViewModel", "userSettingsViewModel", "systemViewModel"], "#navbar" ]); }); diff --git a/src/octoprint/static/js/app/viewmodels/system.js b/src/octoprint/static/js/app/viewmodels/system.js new file mode 100644 index 00000000..fd2307e0 --- /dev/null +++ b/src/octoprint/static/js/app/viewmodels/system.js @@ -0,0 +1,96 @@ +$(function() { + function SystemViewModel(parameters) { + var self = this; + + self.loginState = parameters[0]; + + self.lastCommandResponse = undefined; + self.systemActions = ko.observableArray([]); + + self.requestData = function() { + self.requestCommandData(); + }; + + self.requestCommandData = function() { + if (!self.loginState.isAdmin()) { + return; + } + + $.ajax({ + url: API_BASEURL + "system/commands", + type: "GET", + dataType: "json", + success: self.fromCommandResponse + }); + }; + + self.fromCommandResponse = function(response) { + var actions = []; + _.each(response.core, function(data) { + var action = _.extend({}, data); + action.actionSource = "core"; + actions.push(action); + }); + actions.push({action: "divider"}); + _.each(response.custom, function(data) { + var action = _.extend({}, data); + action.actionSource = "custom"; + actions.push(action); + }); + self.lastCommandResponse = response; + self.systemActions(actions); + }; + + self.triggerCommand = function(commandSpec) { + var callback = function() { + $.ajax({ + url: commandSpec.resource, + type: "POST", + dataType: "json", + data: "{}", + contentType: "application/json; charset=UTF-8", + success: function() { + new PNotify({title: "Success", text: _.sprintf(gettext("The command \"%(command)s\" executed successfully"), {command: commandSpec.name}), type: "success"}); + }, + error: function(jqXHR, textStatus, errorThrown) { + if (!commandSpec.hasOwnProperty("ignore") || !commandSpec.ignore) { + var error = "

" + _.sprintf(gettext("The command \"%(command)s\" could not be executed."), {command: commandSpec.name}) + "

"; + error += pnotifyAdditionalInfo("
" + jqXHR.responseText + "
"); + new PNotify({title: gettext("Error"), text: error, type: "error", hide: false}); + } + } + }) + }; + if (commandSpec.confirm) { + showConfirmationDialog({ + message: commandSpec.confirm, + onproceed: function(e) { + callback(); + } + }); + } else { + callback(); + } + }; + + self.onUserLoggedIn = function(user) { + if (user.admin) { + self.requestData(); + } else { + self.onUserLoggedOut(); + } + }; + + self.onUserLoggedOut = function() { + self.lastCommandResponse = undefined; + self.systemActions([]); + } + } + + // view model class, parameters for constructor, container to bind to + ADDITIONAL_VIEWMODELS.push([ + SystemViewModel, + ["loginStateViewModel"], + [] + ]); +}); diff --git a/src/octoprint/templates/navbar/systemmenu.jinja2 b/src/octoprint/templates/navbar/systemmenu.jinja2 index a2ee3f3b..603d2a09 100644 --- a/src/octoprint/templates/navbar/systemmenu.jinja2 +++ b/src/octoprint/templates/navbar/systemmenu.jinja2 @@ -2,11 +2,11 @@ {{ _('System') }} -