From 52633d0433f3ef0cabcb17102dd3df3748c3e960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 31 Mar 2017 16:52:48 +0200 Subject: [PATCH] PMGR: Support for plugin notices Evaluates json data file as e.g. published on plugins.octoprint.org/notices.json and displays notices for plugins installed and matching (optional) version lists. --- .../plugins/pluginmanager/__init__.py | 146 ++++++++- .../pluginmanager/static/js/pluginmanager.js | 295 +++++++++++++++++- .../templates/pluginmanager_settings.jinja2 | 39 ++- 3 files changed, 452 insertions(+), 28 deletions(-) diff --git a/src/octoprint/plugins/pluginmanager/__init__.py b/src/octoprint/plugins/pluginmanager/__init__.py index d9065bd4..bf3ef562 100644 --- a/src/octoprint/plugins/pluginmanager/__init__.py +++ b/src/octoprint/plugins/pluginmanager/__init__.py @@ -16,6 +16,7 @@ from octoprint.util.pip import LocalPipCaller, UnknownPip from flask import jsonify, make_response from flask.ext.babel import gettext +from collections import OrderedDict import logging import sarge @@ -24,6 +25,9 @@ import requests import re import os import pkg_resources +import copy +import dateutil.parser +import time class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, octoprint.plugin.TemplatePlugin, @@ -53,12 +57,19 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, self._repository_cache_path = None self._repository_cache_ttl = 0 + self._notices = dict() + self._notices_available = False + self._notices_cache_path = None + self._notices_cache_ttl = 0 + self._console_logger = None def initialize(self): self._console_logger = logging.getLogger("octoprint.plugins.pluginmanager.console") self._repository_cache_path = os.path.join(self.get_plugin_data_folder(), "plugins.json") self._repository_cache_ttl = self._settings.get_int(["repository_ttl"]) * 60 + self._notices_cache_path = os.path.join(self.get_plugin_data_folder(), "notices.json") + self._notices_cache_ttl = self._settings.get_int(["notices_ttl"]) * 60 self._pip_caller = LocalPipCaller(force_user=self._settings.get_boolean(["pip_force_user"])) self._pip_caller.on_log_call = self._log_call @@ -84,6 +95,7 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, self._console_logger.propagate = False self._repository_available = self._fetch_repository_from_disk() + self._notices_available = self._fetch_notices_from_disk() ##~~ SettingsPlugin @@ -91,6 +103,8 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, return dict( repository="http://plugins.octoprint.org/plugins.json", repository_ttl=24*60, + notices="http://plugins.octoprint.org/notices.json", + notices_ttl=6*60, pip_args=None, pip_force_user=False, dependency_links=False, @@ -101,6 +115,7 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, octoprint.plugin.SettingsPlugin.on_settings_save(self, data) self._repository_cache_ttl = self._settings.get_int(["repository_ttl"]) * 60 + self._notices_cache_ttl = self._settings.get_int(["notices_ttl"]) * 60 self._pip_caller.force_user = self._settings.get_boolean(["pip_force_user"]) ##~~ AssetPlugin @@ -192,6 +207,10 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, if refresh_repository: self._repository_available = self._refresh_repository() + refresh_notices = request.values.get("refresh_notices", "false") in valid_boolean_trues + if refresh_notices: + self._notices_available = self._refresh_notices() + def view(): return jsonify(plugins=self._get_plugins(), repository=dict( @@ -217,6 +236,8 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, hash.update(repr(self._get_plugins())) hash.update(str(self._repository_available)) hash.update(repr(self._repository_plugins)) + hash.update(str(self._notices_available)) + hash.update(repr(self._notices)) hash.update(repr(safe_mode)) return hash.hexdigest() @@ -225,7 +246,7 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, return with_revalidation_checking(etag_factory=lambda *args, **kwargs: etag(), condition=lambda *args, **kwargs: condition(), - unless=lambda: refresh_repository)(view)() + unless=lambda: refresh_repository or refresh_notices)(view)() def on_api_command(self, command, data): if not admin_permission.can(): @@ -368,7 +389,12 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, self._plugin_manager.log_all_plugins() - result = dict(result=True, url=url, needs_restart=needs_restart, needs_refresh=needs_refresh, was_reinstalled=new_plugin_key in all_plugins_before or reinstall is not None, plugin=self._to_external_representation(new_plugin)) + result = dict(result=True, + url=url, + needs_restart=needs_restart, + needs_refresh=needs_refresh, + was_reinstalled=new_plugin_key in all_plugins_before or reinstall is not None, + plugin=self._to_external_plugin(new_plugin)) self._send_result_notification("install", result) return jsonify(result) @@ -448,7 +474,7 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, self._plugin_manager.reload_plugins() - result = dict(result=True, needs_restart=needs_restart, needs_refresh=needs_refresh, plugin=self._to_external_representation(plugin)) + result = dict(result=True, needs_restart=needs_restart, needs_refresh=needs_refresh, plugin=self._to_external_plugin(plugin)) self._send_result_notification("uninstall", result) return jsonify(result) @@ -473,9 +499,15 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, self._logger.exception(u"Problem toggling enabled state of {name}: {reason}".format(name=plugin.key, reason=e.reason)) result = dict(result=False, reason=e.reason) except octoprint.plugin.core.PluginNeedsRestart: - result = dict(result=True, needs_restart=True, needs_refresh=True, plugin=self._to_external_representation(plugin)) + result = dict(result=True, + needs_restart=True, + needs_refresh=True, + plugin=self._to_external_plugin(plugin)) else: - result = dict(result=True, needs_restart=needs_restart_api, needs_refresh=needs_refresh_api, plugin=self._to_external_representation(plugin)) + result = dict(result=True, + needs_restart=needs_restart_api, + needs_refresh=needs_refresh_api, + plugin=self._to_external_plugin(plugin)) self._send_result_notification(command, result) return jsonify(result) @@ -572,7 +604,6 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, return self._refresh_repository(repo_data=repo_data) def _fetch_repository_from_url(self): - import requests repository_url = self._settings.get(["repository"]) try: r = requests.get(repository_url) @@ -602,7 +633,7 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, octoprint_version = self._get_octoprint_version(base=True) def map_repository_entry(entry): - result = dict(entry) + result = copy.deepcopy(entry) if not "follow_dependency_links" in result: result["follow_dependency_links"] = False @@ -624,6 +655,69 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, self._repository_plugins = map(map_repository_entry, repo_data) return True + def _fetch_notices_from_disk(self): + notice_data = None + if os.path.isfile(self._notices_cache_path): + import time + mtime = os.path.getmtime(self._notices_cache_path) + if mtime + self._notices_cache_ttl >= time.time() > mtime: + try: + import json + with open(self._notices_cache_path) as f: + notice_data = json.load(f) + self._logger.info("Loaded notices from disk, was still valid") + except: + self._logger.exception("Error while loading notices from {}".format(self._notices_cache_path)) + + return self._refresh_notices(notice_data=notice_data) + + def _fetch_notices_from_url(self): + notices_url = self._settings.get(["notices"]) + try: + r = requests.get(notices_url) + self._logger.info("Loaded plugin notices data from {}".format(notices_url)) + except Exception as e: + self._logger.exception("Could not fetch notices from {notices_url}: {message}".format(notices_url=notices_url, message=str(e))) + return None + + notice_data = r.json() + + try: + import json + with octoprint.util.atomic_write(self._notices_cache_path, "wb") as f: + json.dump(notice_data, f) + except Exception as e: + self._logger.exception("Error while saving notices to {}: {}".format(self._notices_cache_path, str(e))) + return notice_data + + def _refresh_notices(self, notice_data=None): + if notice_data is None: + notice_data = self._fetch_notices_from_url() + if notice_data is None: + return False + + notices = dict() + for notice in notice_data: + if not "plugin" in notice or not "text" in notice or not "date" in notice: + continue + + key = notice["plugin"] + + try: + parsed_date = dateutil.parser.parse(notice["date"]) + notice["timestamp"] = parsed_date.timetuple() + except Exception as e: + self._logger.warn("Error while parsing date {!r} for plugin notice " + "of plugin {}, ignoring notice: {}".format(notice["date"], key, str(e))) + continue + + if not key in notices: + notices[key] = [] + notices[key].append(notice) + + self._notices = notices + return True + def _is_octoprint_compatible(self, octoprint_version, compatibility_entries): """ Tests if the current ``octoprint_version`` is compatible to any of the provided ``compatibility_entries``. @@ -694,14 +788,14 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, hidden = self._settings.get(["hidden"]) result = [] - for name, plugin in plugins.items(): - if name in hidden: + for key, plugin in plugins.items(): + if key in hidden: continue - result.append(self._to_external_representation(plugin)) + result.append(self._to_external_plugin(plugin)) return result - def _to_external_representation(self, plugin): + def _to_external_plugin(self, plugin): return dict( key=plugin.key, name=plugin.name, @@ -720,9 +814,37 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin, pending_disable=((plugin.enabled or getattr(plugin, "safe_mode_enabled", False)) and plugin.key in self._pending_disable), pending_install=(self._plugin_manager.is_plugin_marked(plugin.key, "installed")), pending_uninstall=(self._plugin_manager.is_plugin_marked(plugin.key, "uninstalled")), - origin=plugin.origin.type + origin=plugin.origin.type, + notifications = self._get_notifications(plugin) ) + def _get_notifications(self, plugin): + key = plugin.key + if key not in self._notices: + return + + plugin_notifications = self._notices.get(key, []) + + def filter_versions(notification): + return "text" in notification and "date" in notification and \ + ("versions" not in notification or plugin.version in notification["versions"]) + + def map_notification(notification): + return self._to_external_notification(key, notification) + + return filter(lambda x: x is not None, + map(map_notification, + filter(filter_versions, + plugin_notifications))) + + def _to_external_notification(self, key, notification): + return dict(key=key, + date=time.mktime(notification["timestamp"]), + text=notification["text"], + link=notification.get("link"), + versions=notification.get("versions", []), + important=notification.get("important", False)) + __plugin_name__ = "Plugin Manager" __plugin_author__ = "Gina Häußge" __plugin_url__ = "http://docs.octoprint.org/en/master/bundledplugins/pluginmanager.html" diff --git a/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js b/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js index 2ca5b1f3..8475bbaa 100644 --- a/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js +++ b/src/octoprint/plugins/pluginmanager/static/js/pluginmanager.js @@ -10,7 +10,20 @@ }; OctoPrintPluginManagerClient.prototype.get = function(refresh, opts) { - return this.base.get(this.base.getSimpleApiUrl("pluginmanager") + ((refresh) ? "?refresh_repository=true" : ""), opts); + var refresh_repo, refresh_notices; + if (_.isPlainObject(refresh)) { + refresh_repo = refresh.repo || false; + refresh_notices = refresh.notices || false; + } else { + refresh_repo = refresh; + refresh_notices = false; + } + + var query = []; + if (refresh_repo) query.push("refresh_repository=true"); + if (refresh_notices) query.push("refresh_notices=true"); + + return this.base.get(this.base.getSimpleApiUrl("pluginmanager") + ((query.length) ? "?" + query.join("&") : ""), opts); }; OctoPrintPluginManagerClient.prototype.getWithRefresh = function(opts) { @@ -79,6 +92,8 @@ $(function() { self.config_repositoryUrl = ko.observable(); self.config_repositoryTtl = ko.observable(); + self.config_noticesUrl = ko.observable(); + self.config_noticesTtl = ko.observable(); self.config_pipAdditionalArgs = ko.observable(); self.config_pipForceUser = ko.observable(); @@ -185,6 +200,20 @@ $(function() { }); self.notifications = []; + self.noticeNotifications = []; + self.hiddenNoticeNotifications = {}; + self.noticeCount = ko.observable(0); + + self.noticeCountText = ko.pureComputed(function() { + var count = self.noticeCount(); + if (count == 0) { + return gettext("There are no plugin notices. Great!"); + } else if (count == 1) { + return gettext("There is a plugin notice for one of your installed plugins."); + } else { + return _.sprintf(gettext("There are %(count)d plugin notices for one or more of your installed plugins."), {count: count}); + } + }); self.enableManagement = ko.pureComputed(function() { return !self.printerState.isPrinting(); @@ -280,19 +309,36 @@ $(function() { return false; }; - self.fromResponse = function(data) { - self._fromPluginsResponse(data.plugins); - self._fromRepositoryResponse(data.repository); - self._fromPipResponse(data.pip); + self.fromResponse = function(data, options) { + self._fromPluginsResponse(data.plugins, options); + self._fromRepositoryResponse(data.repository, options); + self._fromPipResponse(data.pip, options); self.safeMode(data.safe_mode || false); }; - self._fromPluginsResponse = function(data) { + self._fromPluginsResponse = function(data, options) { + var evalNotices = options.eval_notices || false; + var ignoreNoticeHidden = options.ignore_notice_hidden || false; + var ignoreNoticeIgnored = options.ignore_notice_ignored || false; + + if (evalNotices) self._removeAllNoticeNotifications(); + var installedPlugins = []; + var noticeCount = 0; _.each(data, function(plugin) { installedPlugins.push(plugin.key); + + if (evalNotices && plugin.notifications && plugin.notifications.length) { + _.each(plugin.notifications, function(notification) { + noticeCount++; + if (!ignoreNoticeIgnored && self._isNoticeNotificationIgnored(plugin.key, notification.date)) return; + if (!ignoreNoticeHidden && self._isNoticeNotificationHidden(plugin.key, notification.date)) return; + self._showPluginNotification(plugin, notification); + }); + } }); + if (evalNotices) self.noticeCount(noticeCount); self.installedPlugins(installedPlugins); self.plugins.updateItems(data); }; @@ -324,13 +370,28 @@ $(function() { } }; - self.requestData = function(includeRepo) { + self.requestData = function(options) { if (!self.loginState.isAdmin()) { return; } - OctoPrint.plugins.pluginmanager.get(includeRepo) - .done(self.fromResponse); + if (!_.isPlainObject(options)) { + options = { + refresh_repo: options, + refresh_notices: false, + eval_notices: false + }; + + } + + options.refresh_repo = options.refresh_repo || false; + options.refresh_notices = options.refresh_notices || false; + options.eval_notices = options.eval_notices || false; + + OctoPrint.plugins.pluginmanager.get({repo: options.refresh_repo, notices: options.refresh_notices}) + .done(function(data) { + self.fromResponse(data, options); + }); }; self.togglePlugin = function(data) { @@ -344,7 +405,9 @@ $(function() { if (data.key == "pluginmanager") return; - var onSuccess = self.requestData, + var onSuccess = function() { + self.requestData(); + }, onError = function() { new PNotify({ title: gettext("Something went wrong"), @@ -479,7 +542,9 @@ $(function() { self._markWorking(gettext("Uninstalling plugin..."), _.sprintf(gettext("Uninstalling plugin \"%(name)s\""), {name: data.name})); OctoPrint.plugins.pluginmanager.uninstall(data.key) - .done(self.requestData) + .done(function() { + self.requestData(); + }) .fail(function() { new PNotify({ title: gettext("Something went wrong"), @@ -497,8 +562,23 @@ $(function() { if (!self.loginState.isAdmin()) { return; } + self.requestData({refresh_repo: true}); + }; - self.requestData(true); + self.refreshNotices = function() { + if (!self.loginState.isAdmin()) { + return; + } + + self.requestData({refresh_notices: true, eval_notices: true, ignore_notice_hidden: true, ignore_notice_ignored: true}); + }; + + self.reshowNotices = function() { + if (!self.loginState.isAdmin()) { + return; + } + + self.requestData({eval_notices: true, ignore_notice_hidden: true, ignore_notice_ignored: true}); }; self.showPluginSettings = function() { @@ -519,6 +599,18 @@ $(function() { repositoryTtl = null; } + var notices = self.config_noticesUrl(); + if (notices != undefined && notices.trim() == "") { + notices = null; + } + + var noticesTtl; + try { + noticesTtl = parseInt(self.config_noticesTtl()); + } catch (ex) { + noticesTtl = null; + } + var pipArgs = self.config_pipAdditionalArgs(); if (pipArgs != undefined && pipArgs.trim() == "") { pipArgs = null; @@ -529,6 +621,8 @@ $(function() { pluginmanager: { repository: repository, repository_ttl: repositoryTtl, + notices: notices, + notices_ttl: noticesTtl, pip_args: pipArgs, pip_force_user: self.config_pipForceUser() } @@ -537,13 +631,15 @@ $(function() { self.settingsViewModel.saveData(data, function() { self.configurationDialog.modal("hide"); self._copyConfig(); - self.refreshRepository(); + self.requestData({refresh_repo: true, refresh_notices: true, eval_notices: true}); }); }; self._copyConfig = function() { self.config_repositoryUrl(self.settingsViewModel.settings.plugins.pluginmanager.repository()); self.config_repositoryTtl(self.settingsViewModel.settings.plugins.pluginmanager.repository_ttl()); + self.config_noticesUrl(self.settingsViewModel.settings.plugins.pluginmanager.notices()); + self.config_noticesTtl(self.settingsViewModel.settings.plugins.pluginmanager.notices_ttl()); self.config_pipAdditionalArgs(self.settingsViewModel.settings.plugins.pluginmanager.pip_args()); self.config_pipForceUser(self.settingsViewModel.settings.plugins.pluginmanager.pip_force_user()); }; @@ -706,13 +802,184 @@ $(function() { } }; + self.showPluginNotifications = function(plugin) { + if (!plugin.notifications || plugin.notifications.length == 0) return; + + self._removeAllNoticeNotificationsForPlugin(plugin.key); + _.each(plugin.notifications, function(notification) { + self._showPluginNotification(plugin, notification); + }); + }; + + self.showPluginNotificationsLinkText = function(plugins) { + if (!plugins.notifications || plugins.notifications.length == 0) return; + + var count = plugins.notifications.length; + var importantCount = _.filter(plugins.notifications, function(notification) { return notification.important }).length; + if (count > 1) { + if (importantCount) { + return _.sprintf(gettext("There are %(count)d notices (%(important)d marked as important) available regarding this plugin - click to show!"), {count: count, important: importantCount}); + } else { + return _.sprintf(gettext("There are %(count)d notices available regarding this plugin - click to show!"), {count: count}); + } + } else { + if (importantCount) { + return gettext("There is an important notice available regarding this plugin - click to show!"); + } else { + return gettext("There is a notice available regarding this plugin - click to show!"); + } + } + }; + + self._showPluginNotification = function(plugin, notification) { + var name = plugin.name; + var version = plugin.version; + + var important = notification.important; + var link = notification.link; + + var title; + if (important) { + title = _.sprintf(gettext("Important notice regarding plugin \"%(name)s\""), {name: name}); + } else { + title = _.sprintf(gettext("Notice regarding plugin \"%(name)s\""), {name: name}); + } + + var text = ""; + + if (notification.versions && notification.versions.length > 0) { + var versions = _.map(notification.versions, function(v) { return (v == version) ? "" + v + "" : v; }).join(", "); + text += "" + _.sprintf(gettext("Affected versions: %(versions)s"), {versions: versions}) + ""; + } else { + text += "" + gettext("Affected versions: all") + ""; + } + + text += "

" + notification.text + "

"; + if (link) { + text += "

" + gettext("Read more...") + "

"; + } + + var beforeClose = function(notification) { + if (!self.noticeNotifications[plugin.key]) return; + self.noticeNotifications[plugin.key] = _.without(self.noticeNotifications[plugin.key], notification); + }; + + var options = { + title: title, + text: text, + type: (important) ? "error" : "notice", + before_close: beforeClose, + hide: false, + confirm: { + confirm: true, + buttons: [{ + text: gettext("Later"), + click: function(notice) { + self._hideNoticeNotification(plugin.key, notification.date); + notice.remove(); + notice.get().trigger("pnotify.cancel", notice); + } + }, { + text: gettext("Mark read"), + click: function(notice) { + self._ignoreNoticeNotification(plugin.key, notification.date); + notice.remove(); + notice.get().trigger("pnotify.cancel", notice); + } + }] + }, + buttons: { + sticker: false, + closer: false + } + }; + + if (!self.noticeNotifications[plugin.key]) { + self.noticeNotifications[plugin.key] = []; + } + self.noticeNotifications[plugin.key].push(new PNotify(options)); + }; + + self._removeAllNoticeNotifications = function() { + _.each(_.keys(self.noticeNotifications), function(key) { + self._removeAllNoticeNotificationsForPlugin(key); + }); + }; + + self._removeAllNoticeNotificationsForPlugin = function(key) { + if (!self.noticeNotifications[key] || !self.noticeNotifications[key].length) return; + _.each(self.noticeNotifications[key], function(notification) { + notification.remove(); + }); + }; + + self._hideNoticeNotification = function(key, date) { + if (!self.hiddenNoticeNotifications[key]) { + self.hiddenNoticeNotifications[key] = []; + } + if (!_.contains(self.hiddenNoticeNotifications[key], date)) { + self.hiddenNoticeNotifications[key].push(date); + } + }; + + self._isNoticeNotificationHidden = function(key, date) { + if (!self.hiddenNoticeNotifications[key]) return false; + return _.any(_.map(self.hiddenNoticeNotifications[key], function(d) { return date == d; })); + }; + + var noticeLocalStorageKey = "plugin.pluginmanager.seen_notices"; + self._ignoreNoticeNotification = function(key, date) { + if (!Modernizr.localstorage) + return false; + if (!self.loginState.isAdmin()) + return false; + + var currentString = localStorage[noticeLocalStorageKey]; + var current; + if (currentString === undefined) { + current = {}; + } else { + current = JSON.parse(currentString); + } + if (!current[self.loginState.username()]) { + current[self.loginState.username()] = {}; + } + if (!current[self.loginState.username()][key]) { + current[self.loginState.username()][key] = []; + } + + if (!_.contains(current[self.loginState.username()][key], date)) { + current[self.loginState.username()][key].push(date); + localStorage[noticeLocalStorageKey] = JSON.stringify(current); + } + }; + + self._isNoticeNotificationIgnored = function(key, date) { + if (!Modernizr.localstorage) + return false; + + if (localStorage[noticeLocalStorageKey] == undefined) + return false; + + var knownData = JSON.parse(localStorage[noticeLocalStorageKey]); + + if (!self.loginState.isAdmin()) + return true; + + var userData = knownData[self.loginState.username()]; + if (userData === undefined) + return false; + + return userData[key] && _.contains(userData[key], date); + }; + self.onBeforeBinding = function() { self.settings = self.settingsViewModel.settings; }; self.onUserLoggedIn = function(user) { if (user.admin) { - self.requestData(); + self.requestData({eval_notices: true}); } else { self.onUserLoggedOut(); } diff --git a/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 b/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 index 8f01183c..d29d33a1 100644 --- a/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 +++ b/src/octoprint/plugins/pluginmanager/templates/pluginmanager_settings.jinja2 @@ -40,7 +40,8 @@ -
()
+
()
+
 
{{ _('Homepage') }} @@ -72,7 +73,7 @@
- + Using pip of "", Version @@ -86,6 +87,22 @@
+ + + {{ _('Notices configuration') }} + +
+ +
+ +
+
+
+ +
+
+ + min +
+
+
+ {{ _('pip configuration') }}