From edea3789b2f00b4769dea9d8704851fbbc842ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Mon, 1 Apr 2013 17:20:25 +0200 Subject: [PATCH 01/19] Made Webcam omnipresent --- octoprint/templates/index.html | 636 +++++++++++++++++---------------- 1 file changed, 324 insertions(+), 312 deletions(-) diff --git a/octoprint/templates/index.html b/octoprint/templates/index.html index 98ead09..bc57d61 100644 --- a/octoprint/templates/index.html +++ b/octoprint/templates/index.html @@ -161,364 +161,376 @@ -
- - -
-
-
-
+
+ {% if webcamStream %} +
+
+ -
-
-

Temperature

- - - - - - -
- - °C -
-
- - - -
-
-
-

Bed Temperature

- - - - - - -
- - °C -
-
- - - +
+
+
+
-
-
- +
+ {% endif %} +
+ + +
+
+
+
+
+
+
+

Temperature

+ + + + + + +
+ + °C +
+
+ + + +
+
+
+

Bed Temperature

+ + + + + + +
+ + °C +
+
+ + + +
+
+
+
+
-

X/Y

-
- + +
+

X/Y

+
+ +
+
+ + + +
+
+ +
-
- - - + +
+

Z

+
+ +
+
+ +
+
+ +
-
- + +
+
+ + + + +
- +
-

Z

+

E

- +
+ + mm +
+ +
+
+ +
+

General

- -
-
- + + +
- -
-
- - - - + + +
+ + + + + + + +
+
+
+ +
+ + % + +
+ + +
+ + % + +
+ + +
+ + % + +
+ + +
+ + % +
- -
-

E

-
-
- - mm -
- - -
-
- -
-

General

-
- - - -
-
+
+ +
+
- -
- - - - - - - -
-
-
- -
- - % - -
- - -
- - % - -
- - -
- - % - -
- - -
- - % - -
-
-
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
+
+
+ +
+
+
+
+
+
+
+
+
-
-
- -
-
-

+
+ +
+
+

+
-
-
- -
-
-

+
+ +
+
+

+
-
-
- -
-
- Show non-extrusion moves
- Show retracts and restarts
- Move model to the center of the grid
- Show different speeds with different colors
- Emulate extrusion width
- Width modifier:
- Show +1 layer
+
+ +
+
+ Show non-extrusion moves
+ Show retracts and restarts
+ Move model to the center of the grid
+ Show different speeds with different colors
+ Emulate extrusion width
+ Width modifier:
+ Show +1 layer
+
-
-
- -
-
- These require re-analyzing file:
- Sort layers by Z
- Hide empty layers
- Show GCode in GCode tab (memory intensive!)
+
+ +
+
+ These require re-analyzing file:
+ Sort layers by Z
+ Hide empty layers
+ Show GCode in GCode tab (memory intensive!)
+
+
+
+

+                                
 
-                        
-
-

-                            
-
-                            
- - -
-
- {% if webcamStream %} -
-
- -
- - {% if enableTimelapse %} -

Timelapse Configuration

- - - - -
-
- - sec + +
+ {% if enableTimelapse %} +
+

Timelapse Configuration

-
- -
+ + -

Finished Timelapses

+
+ +
+ + sec +
+
- - - - - - - - - - - - - - - - -
NameSizeAction
 | 
- {% endif %}
- {% endif %}
From 656aeb018965f6136d500a9743faf2044e3d07d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Sun, 7 Apr 2013 22:06:17 +0200 Subject: [PATCH 02/19] Fixed evaluation of boolean argument for loadAndPrint from UI Closes #96. --- octoprint/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/octoprint/server.py b/octoprint/server.py index 865d792..aab4393 100644 --- a/octoprint/server.py +++ b/octoprint/server.py @@ -12,7 +12,7 @@ import logging, logging.config import subprocess from octoprint.printer import Printer, getConnectionOptions -from octoprint.settings import settings +from octoprint.settings import settings, valid_boolean_trues import octoprint.timelapse as timelapse import octoprint.gcodefiles as gcodefiles import octoprint.util as util @@ -262,7 +262,7 @@ def uploadGcodeFile(): def loadGcodeFile(): if "filename" in request.values.keys(): printAfterLoading = False - if "print" in request.values.keys() and request.values["print"]: + if "print" in request.values.keys() and request.values["print"] in valid_boolean_trues: printAfterLoading = True filename = gcodeManager.getAbsolutePath(request.values["filename"]) if filename is not None: From 31745590728984802b1fce1c5544c9ad90cac2d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Sun, 7 Apr 2013 22:15:04 +0200 Subject: [PATCH 03/19] Moved webcam view to Control tab --- octoprint/static/css/octoprint.less | 14 +- octoprint/static/js/ui.js | 65 +-- octoprint/templates/index.html | 654 ++++++++++++++-------------- 3 files changed, 357 insertions(+), 376 deletions(-) diff --git a/octoprint/static/css/octoprint.less b/octoprint/static/css/octoprint.less index b66ef6f..2822bde 100644 --- a/octoprint/static/css/octoprint.less +++ b/octoprint/static/css/octoprint.less @@ -181,6 +181,16 @@ table { &.timelapse_files_action { text-align: center; width: 45px; + + a { + text-decoration: none; + color: #000; + + &.disabled { + color: #ccc; + cursor: default; + } + } } } } @@ -274,9 +284,9 @@ ul.dropdown-menu li a { } } -/** Controls */ +/** Control tab */ -#controls { +#control { overflow: hidden; .jog-panel { diff --git a/octoprint/static/js/ui.js b/octoprint/static/js/ui.js index bf0831c..9009ce0 100644 --- a/octoprint/static/js/ui.js +++ b/octoprint/static/js/ui.js @@ -376,7 +376,7 @@ function TemperatureViewModel(settingsViewModel) { } } -function ControlsViewModel() { +function ControlViewModel() { var self = this; self.isErrorOrClosed = ko.observable(undefined); @@ -788,7 +788,7 @@ function GcodeFilesViewModel() { } -function WebcamViewModel() { +function TimelapseViewModel() { var self = this; self.timelapseType = ko.observable(undefined); @@ -847,7 +847,6 @@ function WebcamViewModel() { dataType: "json", success: self.fromResponse }); - $("#webcam_image").attr("src", CONFIG_WEBCAM_STREAM + "?" + new Date().getTime()); } self.fromResponse = function(response) { @@ -1131,24 +1130,25 @@ function NavigationViewModel(appearanceViewModel, settingsViewModel) { } } -function DataUpdater(connectionViewModel, printerStateViewModel, temperatureViewModel, controlsViewModel, speedViewModel, terminalViewModel, gcodeFilesViewModel, webcamViewModel, gcodeViewModel) { +function DataUpdater(connectionViewModel, printerStateViewModel, temperatureViewModel, controlViewModel, speedViewModel, terminalViewModel, gcodeFilesViewModel, timelapseViewModel, gcodeViewModel) { var self = this; self.connectionViewModel = connectionViewModel; self.printerStateViewModel = printerStateViewModel; self.temperatureViewModel = temperatureViewModel; - self.controlsViewModel = controlsViewModel; + self.controlViewModel = controlViewModel; self.terminalViewModel = terminalViewModel; self.speedViewModel = speedViewModel; self.gcodeFilesViewModel = gcodeFilesViewModel; - self.webcamViewModel = webcamViewModel; + self.timelapseViewModel = timelapseViewModel; self.gcodeViewModel = gcodeViewModel; self._socket = io.connect(); self._socket.on("connect", function() { if ($("#offline_overlay").is(":visible")) { $("#offline_overlay").hide(); - self.webcamViewModel.requestData(); + self.timelapseViewModel.requestData(); + $("#webcam_image").attr("src", CONFIG_WEBCAM_STREAM + "?" + new Date().getTime()); } }) self._socket.on("disconnect", function() { @@ -1170,9 +1170,9 @@ function DataUpdater(connectionViewModel, printerStateViewModel, temperatureView self.connectionViewModel.fromHistoryData(data); self.printerStateViewModel.fromHistoryData(data); self.temperatureViewModel.fromHistoryData(data); - self.controlsViewModel.fromHistoryData(data); + self.controlViewModel.fromHistoryData(data); self.terminalViewModel.fromHistoryData(data); - self.webcamViewModel.fromHistoryData(data); + self.timelapseViewModel.fromHistoryData(data); self.gcodeViewModel.fromHistoryData(data); self.gcodeFilesViewModel.fromCurrentData(data); }) @@ -1180,9 +1180,9 @@ function DataUpdater(connectionViewModel, printerStateViewModel, temperatureView self.connectionViewModel.fromCurrentData(data); self.printerStateViewModel.fromCurrentData(data); self.temperatureViewModel.fromCurrentData(data); - self.controlsViewModel.fromCurrentData(data); + self.controlViewModel.fromCurrentData(data); self.terminalViewModel.fromCurrentData(data); - self.webcamViewModel.fromCurrentData(data); + self.timelapseViewModel.fromCurrentData(data); self.gcodeViewModel.fromCurrentData(data); self.gcodeFilesViewModel.fromCurrentData(data); }) @@ -1449,11 +1449,11 @@ $(function() { var settingsViewModel = new SettingsViewModel(); var appearanceViewModel = new AppearanceViewModel(settingsViewModel); var temperatureViewModel = new TemperatureViewModel(settingsViewModel); - var controlsViewModel = new ControlsViewModel(); + var controlViewModel = new ControlViewModel(); var speedViewModel = new SpeedViewModel(); var terminalViewModel = new TerminalViewModel(); var gcodeFilesViewModel = new GcodeFilesViewModel(); - var webcamViewModel = new WebcamViewModel(); + var timelapseViewModel = new TimelapseViewModel(); var gcodeViewModel = new GcodeViewModel(); var navigationViewModel = new NavigationViewModel(appearanceViewModel, settingsViewModel); @@ -1461,19 +1461,19 @@ $(function() { connectionViewModel, printerStateViewModel, temperatureViewModel, - controlsViewModel, + controlViewModel, speedViewModel, terminalViewModel, gcodeFilesViewModel, - webcamViewModel, + timelapseViewModel, gcodeViewModel ); //work around a stupid iOS6 bug where ajax requests get cached and only work once, as described at http://stackoverflow.com/questions/12506897/is-safari-on-ios-6-caching-ajax-results $.ajaxSetup({ - type: 'POST', - headers: { "cache-control": "no-cache" } - }); + type: 'POST', + headers: { "cache-control": "no-cache" } + }); //~~ Show settings - to ensure centered $('#navbar_show_settings').click(function() { @@ -1606,23 +1606,6 @@ $(function() { //~~ Offline overlay $("#offline_overlay_reconnect").click(function() {dataUpdater.reconnect()}); - //~~ Alert - - /* - function displayAlert(text, timeout, type) { - var placeholder = $("#alert_placeholder"); - - var alertType = ""; - if (type == "success" || type == "error" || type == "info") { - alertType = " alert-" + type; - } - - placeholder.append($("

" + text + "

")); - placeholder.fadeIn(); - $("#activeAlert").delay(timeout).fadeOut("slow", function() {$(this).remove(); $("#alert_placeholder").hide();}); - } - */ - //~~ knockout.js bindings ko.bindingHandlers.popover = { @@ -1647,7 +1630,7 @@ $(function() { ko.applyBindings(gcodeFilesViewModel, document.getElementById("files")); ko.applyBindings(gcodeFilesViewModel, document.getElementById("files-heading")); ko.applyBindings(temperatureViewModel, document.getElementById("temp")); - ko.applyBindings(controlsViewModel, document.getElementById("controls")); + ko.applyBindings(controlViewModel, document.getElementById("control")); ko.applyBindings(terminalViewModel, document.getElementById("term")); ko.applyBindings(speedViewModel, document.getElementById("speed")); ko.applyBindings(gcodeViewModel, document.getElementById("gcode")); @@ -1655,9 +1638,9 @@ $(function() { ko.applyBindings(navigationViewModel, document.getElementById("navbar")); ko.applyBindings(appearanceViewModel, document.getElementsByTagName("head")[0]); - var webcamElement = document.getElementById("webcam"); - if (webcamElement) { - ko.applyBindings(webcamViewModel, document.getElementById("webcam")); + var timelapseElement = document.getElementById("timelapse"); + if (timelapseElement) { + ko.applyBindings(timelapseViewModel, document.getElementById("timelapse")); } var gCodeVisualizerElement = document.getElementById("gcode"); if(gCodeVisualizerElement){ @@ -1666,9 +1649,9 @@ $(function() { //~~ startup commands connectionViewModel.requestData(); - controlsViewModel.requestData(); + controlViewModel.requestData(); gcodeFilesViewModel.requestData(); - webcamViewModel.requestData(); + timelapseViewModel.requestData(); settingsViewModel.requestData(); //~~ UI stuff diff --git a/octoprint/templates/index.html b/octoprint/templates/index.html index bc57d61..da35a03 100644 --- a/octoprint/templates/index.html +++ b/octoprint/templates/index.html @@ -161,376 +161,364 @@
-
- {% if webcamStream %} -
-
-
- Webcam +
+ + +
+
+
+
-
-
-
- +
+
+

Temperature

+ + + + + + +
+ + °C +
+
+ + + +
+
+
+

Bed Temperature

+ + + + + + +
+ + °C +
+
+ + +
-
- {% endif %} -
- - -
-
-
-
-
-
-
-

Temperature

- - - - - - -
- - °C -
-
- - - -
-
-
-

Bed Temperature

- - - - - - -
- - °C -
-
- - - -
-
-
+
+ {% if webcamStream %} +
+
-
+ {% endif %} + +
+
- -
-

X/Y

-
- -
-
- - - -
-
- -
-
- -
-

Z

-
- -
-
- -
-
- -
-
- -
-
- - - - -
-
-
- -
-

E

+

X/Y

-
- - mm -
- - +
-
- -
-

General

- - - + + + +
+
+
- - -
- - - - - - - -
-
-
- -
- - % - + +
+

Z

+
+
- - -
- - % - +
+
- - -
- - % - +
+
- - -
- - % - +
+ +
+
+ + + +
-
- -
-
+ +
+

E

+
+
+ + mm +
+ + +
+
+ +
+

General

+
+ + + +
+
-
-
- -
-
-
-
-
-
-
-
-
+ +
+ + + + + + + +
+
+
+ +
+ + % + +
+ + +
+ + % + +
+ + +
+ + % + +
+ + +
+ + % + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
-
- -
-
-

-
+
+
+ +
+
+

-
- -
-
-

-
+
+
+ +
+
+

-
- -
-
- Show non-extrusion moves
- Show retracts and restarts
- Move model to the center of the grid
- Show different speeds with different colors
- Emulate extrusion width
- Width modifier:
- Show +1 layer
-
+
+
+ +
+
+ Show non-extrusion moves
+ Show retracts and restarts
+ Move model to the center of the grid
+ Show different speeds with different colors
+ Emulate extrusion width
+ Width modifier:
+ Show +1 layer
+
-
- -
-
- These require re-analyzing file:
- Sort layers by Z
- Hide empty layers
- Show GCode in GCode tab (memory intensive!)
-
+
+ +
+
+ These require re-analyzing file:
+ Sort layers by Z
+ Hide empty layers
+ Show GCode in GCode tab (memory intensive!)
-
-
-

-                                
 
-                                
- - -
-
- {% if enableTimelapse %} -
-

Timelapse Configuration

- - - - -
- -
- - sec -
-
- -
- -
- -

Finished Timelapses

- - - - - - - - - - - - - - - - - -
NameSizeAction
 | 
- -
- {% endif %}
+
+

+                            
+
+                            
+ + +
+
+ {% if enableTimelapse %} +
+

Timelapse Configuration

+ + + + +
+ +
+ + sec +
+
+ +
+ +
+ +

Finished Timelapses

+ + + + + + + + + + + + + + + + + +
NameSizeAction
 | 
+ +
+ {% endif %}
From 34a860efda3898b34654b080d633f2ac21b9ffe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Wed, 10 Apr 2013 16:37:12 +0200 Subject: [PATCH 04/19] Added confirmation dialog and different logo for print job restart Closes #98 --- octoprint/static/js/ui.js | 60 ++++++++++++++++++++-------------- octoprint/templates/index.html | 6 ++-- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/octoprint/static/js/ui.js b/octoprint/static/js/ui.js index 9009ce0..2164006 100644 --- a/octoprint/static/js/ui.js +++ b/octoprint/static/js/ui.js @@ -200,6 +200,42 @@ function PrinterStateViewModel() { self._processZData = function(data) { self.currentHeight(data); } + + self.print = function() { + var printAction = function() { + $.ajax({ + url: AJAX_BASEURL + "control/print", + type: "POST", + dataType: "json" + }); + } + + if (self.isPaused()) { + $("#confirmation_dialog .confirmation_dialog_message").text("This will restart the print job from the beginning."); + $("#confirmation_dialog .confirmation_dialog_acknowledge").click(function(e) {e.preventDefault(); $("#confirmation_dialog").modal("hide"); printAction(); }); + $("#confirmation_dialog").modal("show"); + } else { + printAction(); + } + + } + + self.pause = function() { + $("#job_pause").button("toggle"); + $.ajax({ + url: AJAX_BASEURL + "control/pause", + type: "POST", + dataType: "json" + }); + } + + self.cancel = function() { + $.ajax({ + url: AJAX_BASEURL + "control/cancel", + type: "POST", + dataType: "json" + }); + } } function TemperatureViewModel(settingsViewModel) { @@ -1487,30 +1523,6 @@ $(function() { //~~ Print job control - $("#job_print").click(function() { - $.ajax({ - url: AJAX_BASEURL + "control/print", - type: "POST", - dataType: "json", - success: function(){} - }) - }) - $("#job_pause").click(function() { - $("#job_pause").button("toggle"); - $.ajax({ - url: AJAX_BASEURL + "control/pause", - type: "POST", - dataType: "json" - }) - }) - $("#job_cancel").click(function() { - $.ajax({ - url: AJAX_BASEURL + "control/cancel", - type: "POST", - dataType: "json" - }) - }) - //~~ Temperature control (should really move to knockout click binding) $("#temp_newTemp_set").click(function() { diff --git a/octoprint/templates/index.html b/octoprint/templates/index.html index 2279c9c..dc370ee 100644 --- a/octoprint/templates/index.html +++ b/octoprint/templates/index.html @@ -90,9 +90,9 @@
From 150d6cb53d62ad4cd8aee9834ab6fa749c500167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Sun, 17 Mar 2013 22:28:08 +0100 Subject: [PATCH 05/19] First work on login and user management --- octoprint/server.py | 26 ++++++ octoprint/static/js/ui.js | 5 + octoprint/templates/index.html | 21 +++++ octoprint/users.py | 161 +++++++++++++++++++++++++++++++++ requirements.txt | 3 +- 5 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 octoprint/users.py diff --git a/octoprint/server.py b/octoprint/server.py index aab4393..cdc059a 100644 --- a/octoprint/server.py +++ b/octoprint/server.py @@ -5,25 +5,31 @@ __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agp from flask import Flask, request, render_template, jsonify, send_from_directory, abort, url_for from werkzeug.utils import secure_filename import tornadio2 +from flask.ext.login import LoginManager import os import threading import logging, logging.config import subprocess +import hashlib + from octoprint.printer import Printer, getConnectionOptions from octoprint.settings import settings, valid_boolean_trues import octoprint.timelapse as timelapse import octoprint.gcodefiles as gcodefiles import octoprint.util as util +import octoprint.users as users SUCCESS = {} BASEURL = "/ajax/" + app = Flask("octoprint") # Only instantiated by the Server().run() method # In order that threads don't start too early when running as a Daemon printer = None gcodeManager = None +userManager = None #~~ Printer state @@ -442,6 +448,21 @@ def performSystemAction(): return app.make_response(("Command failed: %r" % ex, 500, [])) return jsonify(SUCCESS) +#~~ Login/user handling + +@app.route(BASEURL + "login", methods=["POST"]) +def login(): + if "user" in request.values.keys() and "pass" in request.values.keys(): + username = request.values["user"] + password = request.values["pass"] + + passwordHash = users.createPasswordHash(password) + + pass + +def load_user(userid): + pass + #~~ startup code class Server(): def __init__(self, configfile=None, basedir=None, host="0.0.0.0", port=5000, debug=False): @@ -470,6 +491,11 @@ class Server(): gcodeManager = gcodefiles.GcodeManager() printer = Printer(gcodeManager) + app.secret_key = "k3PuVYgtxNm8DXKKTw2nWmFQQun9qceV" + login_manager = LoginManager() + login_manager.session_protection = "strong" + login_manager.init_app(app) + if self._host is None: self._host = settings().get(["server", "host"]) if self._port is None: diff --git a/octoprint/static/js/ui.js b/octoprint/static/js/ui.js index 2164006..9835867 100644 --- a/octoprint/static/js/ui.js +++ b/octoprint/static/js/ui.js @@ -1680,6 +1680,11 @@ $(function() { $.pnotify.defaults.history = false; + // Fix input element click problem + $('.dropdown input, .dropdown label').click(function(e) { + e.stopPropagation(); + }); + } ); diff --git a/octoprint/templates/index.html b/octoprint/templates/index.html index dc370ee..0a39ae9 100644 --- a/octoprint/templates/index.html +++ b/octoprint/templates/index.html @@ -45,6 +45,27 @@ {% endif %} +
diff --git a/octoprint/users.py b/octoprint/users.py new file mode 100644 index 0000000..1a8ce1c --- /dev/null +++ b/octoprint/users.py @@ -0,0 +1,161 @@ +# coding=utf-8 +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' + +from flask.ext.login import UserMixin +import hashlib +import os +import yaml + +from octoprint.settings import settings + +class UserManager: + valid_roles=["user", "admin"] + + @staticmethod + def createPasswordHash(password): + return hashlib.sha512(password + "mvBUTvwzBzD3yPwvnJ4E4tXNf3CGJvvW").hexdigest() + + def addUser(self, username, password): + pass + + def addRoleToUser(self, username, role): + pass + + def removeRoleFromUser(self, username, role): + pass + + def updateUser(self, username, password): + pass + + def removeUser(self, username): + pass + + def findUser(self, username=None): + return None + +##~~ FilebasedUserManager, takes available users from users.yaml file + +class FilebasedUserManager(UserManager): + def __init__(self, userfile=None): + UserManager.__init__(self) + + if userfile is None: + userfile = os.path.join(settings().settings_dir, "users.yaml") + self._userfile = userfile + self._users = None + self._dirty = False + + self._load() + + def _load(self): + self._users = {} + if os.path.exists(self._userfile) and os.path.isfile(self._userfile): + with open(self._userfile, "r") as f: + data = yaml.safe_load(f) + for name in data.keys(): + attributes = data[name] + self._users[name] = User(name, attributes.password, attributes.active, attributes.roles) + + def _save(self, force=False): + if not self._dirty and not force: + return + + data = {} + for name in self._users.keys(): + user = self._users[name] + data[name] = { + "password": user.passwordHash, + "active": user.active, + "roles": user.roles + } + + with open(self._userfile, "wb") as f: + yaml.safe_dump(data, f, default_flow_style=False, indent=" ", allow_unicode=True) + self._dirty = False + self._load() + + def addUser(self, username, password): + if username in self._users.keys(): + raise UserAlreadyExists(username) + + self._users[username] = User(username, UserManager.createPasswordHash(password), False, ["user"]) + self._dirty = True + self._save() + + def addRoleToUser(self, username, role): + if not username in self._users.keys(): + raise UnknownUser(username) + + user = self._users[username] + if not role in user.roles: + user.roles.append(role) + self._dirty = True + self._save() + + def removeRoleFromUser(self, username, role): + if not username in self._users.keys(): + raise UnknownUser(username) + + user = self._users[username] + if role in user.roles: + user.roles.remove(role) + self._dirty = True + self._save() + + def updateUser(self, username, password): + if not username in self._users.keys(): + raise UnknownUser(username) + + passwordHash = UserManager.createPasswordHash(password) + user = self._users[username] + if user.passwordHash != passwordHash: + user.passwordHash = passwordHash + self._dirty = True + self._save() + + def removeUser(self, username): + if not username in self._users.keys(): + raise UnknownUser(username) + + del self._users[username] + self._dirty = True + self._save() + + def findUser(self, username=None): + if username is None: + return None + + if username not in self._users.keys(): + return None + + return self._users[username] + +##~~ Exceptions + +class UserAlreadyExists(Exception): + def __init__(self, username): + Exception.__init__(self, "User %s already exists" % username) + +class UnknownUser(Exception): + def __init__(self, username): + Exception.__init__(self, "Unknown user: %s" % username) + +class UnknownRole(Exception): + def _init_(self, role): + Exception.__init__(self, "Unknown role: %s" % role) + +##~~ User object + +class User(UserMixin): + def __init__(self, username, passwordHash, active, roles): + self.username = username + self.passwordHash = passwordHash + self.active = active + self.roles = roles + + def get_id(self): + return self.username + + def is_active(self): + return self.active \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2599a61..4114ee7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ numpy>=1.6.2 pyserial>=2.6 tornado>=2.4.1 tornadio2>=0.0.4 -PyYAML>=3.10 \ No newline at end of file +PyYAML>=3.10 +Flask-Login>=0.1.3 \ No newline at end of file From 874a7421e92a80f1a09e6d8f2371360e1acf2629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Mon, 18 Mar 2013 22:27:23 +0100 Subject: [PATCH 06/19] Login and logout working for the first time --- octoprint/server.py | 61 ++++++++++++++++++++++++------- octoprint/settings.py | 5 +++ octoprint/static/js/ui.js | 65 ++++++++++++++++++++++++++++++++++ octoprint/templates/index.html | 4 ++- octoprint/timelapse.py | 2 +- octoprint/users.py | 20 ++++++++--- octoprint/util/__init__.py | 11 ++++++ 7 files changed, 149 insertions(+), 19 deletions(-) diff --git a/octoprint/server.py b/octoprint/server.py index cdc059a..c2384cd 100644 --- a/octoprint/server.py +++ b/octoprint/server.py @@ -5,15 +5,13 @@ __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agp from flask import Flask, request, render_template, jsonify, send_from_directory, abort, url_for from werkzeug.utils import secure_filename import tornadio2 -from flask.ext.login import LoginManager +from flask.ext.login import LoginManager, login_user, logout_user, login_required, current_user, AnonymousUser import os import threading import logging, logging.config import subprocess -import hashlib - from octoprint.printer import Printer, getConnectionOptions from octoprint.settings import settings, valid_boolean_trues import octoprint.timelapse as timelapse @@ -109,7 +107,8 @@ def index(): webcamStream=settings().get(["webcam", "stream"]), enableTimelapse=(settings().get(["webcam", "snapshot"]) is not None and settings().get(["webcam", "ffmpeg"]) is not None), enableGCodeVisualizer=settings().get(["feature", "gCodeVisualizer"]), - enableSystemMenu=settings().get(["system"]) is not None and settings().get(["system", "actions"]) is not None and len(settings().get(["system", "actions"])) > 0 + enableSystemMenu=settings().get(["system"]) is not None and settings().get(["system", "actions"]) is not None and len(settings().get(["system", "actions"])) > 0, + enableAccessControl=userManager is not None ) #~~ Printer control @@ -119,6 +118,7 @@ def connectionOptions(): return jsonify(getConnectionOptions()) @app.route(BASEURL + "control/connect", methods=["POST"]) +@login_required def connect(): port = None baudrate = None @@ -456,12 +456,36 @@ def login(): username = request.values["user"] password = request.values["pass"] - passwordHash = users.createPasswordHash(password) + if "remember" in request.values.keys() and request.values["remember"]: + remember = True + else: + remember = False - pass + user = userManager.findUser(username) + if user is not None: + passwordHash = users.UserManager.createPasswordHash(password) + if passwordHash == user.passwordHash: + login_user(user, remember=remember) + return jsonify({"name": user.username, "roles": user.roles}) + return app.make_response(("User unknown or password incorrect", 401, [])) + elif "passive" in request.values.keys(): + user = current_user + if user is not None and not user.is_anonymous(): + return jsonify({"name": user.username, "roles": user.roles}) + else: + return jsonify(SUCCESS) -def load_user(userid): - pass +@app.route(BASEURL + "logout", methods=["POST"]) +@login_required +def logout(): + logout_user() + return jsonify(SUCCESS) + +def load_user(id): + if userManager is not None: + return userManager.findUser(id) + else: + return users.DummyUser() #~~ startup code class Server(): @@ -476,6 +500,7 @@ class Server(): # Global as I can't work out a way to get it into PrinterStateConnection global printer global gcodeManager + global userManager from tornado.wsgi import WSGIContainer from tornado.httpserver import HTTPServer @@ -487,13 +512,25 @@ class Server(): # then initialize logging self._initLogging(self._debug) + logger = logging.getLogger(__name__) gcodeManager = gcodefiles.GcodeManager() printer = Printer(gcodeManager) + if settings().getBoolean(["accessControl", "enabled"]): + userManagerName = settings().get(["accessControl", "userManager"]) + try: + clazz = util.getClass(userManagerName) + userManager = clazz() + except AttributeError, e: + logger.exception("Could not instantiate user manager %s, will run with accessControl disabled!" % userManagerName) + app.secret_key = "k3PuVYgtxNm8DXKKTw2nWmFQQun9qceV" login_manager = LoginManager() login_manager.session_protection = "strong" + login_manager.user_callback = load_user + if userManager is None: + login_manager.anonymous_user = users.DummyUser login_manager.init_app(app) if self._host is None: @@ -501,7 +538,7 @@ class Server(): if self._port is None: self._port = settings().getInt(["server", "port"]) - logging.getLogger(__name__).info("Listening on http://%s:%d" % (self._host, self._port)) + logger.info("Listening on http://%s:%d" % (self._host, self._port)) app.debug = self._debug self._router = tornadio2.TornadioRouter(PrinterStateConnection) @@ -517,7 +554,7 @@ class Server(): s = settings(init=True, basedir=basedir, configfile=configfile) def _initLogging(self, debug): - self._config = { + config = { "version": 1, "formatters": { "simple": { @@ -556,13 +593,13 @@ class Server(): } if debug: - self._config["loggers"]["SERIAL"] = { + config["loggers"]["SERIAL"] = { "level": "DEBUG", "handlers": ["serialFile"], "propagate": False } - logging.config.dictConfig(self._config) + logging.config.dictConfig(config) if __name__ == "__main__": octoprint = Server() diff --git a/octoprint/settings.py b/octoprint/settings.py index 0c7dfad..7c385f8 100644 --- a/octoprint/settings.py +++ b/octoprint/settings.py @@ -69,6 +69,11 @@ default_settings = { "controls": [], "system": { "actions": [] + }, + "accessControl": { + "enabled": False, + "userManager": "octoprint.users.FilebasedUserManager", + "userfile": None } } diff --git a/octoprint/static/js/ui.js b/octoprint/static/js/ui.js index 9835867..9cf01a3 100644 --- a/octoprint/static/js/ui.js +++ b/octoprint/static/js/ui.js @@ -1618,6 +1618,71 @@ $(function() { //~~ Offline overlay $("#offline_overlay_reconnect").click(function() {dataUpdater.reconnect()}); + //~~ Alert + + /* + function displayAlert(text, timeout, type) { + var placeholder = $("#alert_placeholder"); + + var alertType = ""; + if (type == "success" || type == "error" || type == "info") { + alertType = " alert-" + type; + } + + placeholder.append($("

" + text + "

")); + placeholder.fadeIn(); + $("#activeAlert").delay(timeout).fadeOut("slow", function() {$(this).remove(); $("#alert_placeholder").hide();}); + } + */ + + //~~ Login/logout + + $("#login_button").click(function() { + var username = $("#login_user").val(); + var password = $("#login_pass").val(); + var remember = $("#login_remember").is(":checked"); + + $.ajax({ + url: AJAX_BASEURL + "login", + type: "POST", + data: {"user": username, "pass": password, "remember": remember}, + success: function(response) { + $.pnotify({title: "Login successful", text: "You are now logged in", type: "success"}); + $("#login_dropdown_text").text("\"" + response.name + "\""); + $("#login_dropdown_loggedout").removeClass("dropdown-menu").addClass("hide"); + $("#login_dropdown_loggedin").removeClass("hide").addClass("dropdown-menu"); + }, + error: function(jqXHR, textStatus, errorThrown) { + $.pnotify({title: "Login failed", text: "User unknown or wrong password", type: "error"}); + } + }) + }); + $("#logout_button").click(function(){ + $.ajax({ + url: AJAX_BASEURL + "logout", + type: "POST", + success: function(response) { + $.pnotify({title: "Logout successful", text: "You are now logged out", type: "success"}); + $("#login_dropdown_text").text("Login"); + $("#login_dropdown_loggedin").removeClass("dropdown-menu").addClass("hide"); + $("#login_dropdown_loggedout").removeClass("hide").addClass("dropdown-menu"); + } + }) + }) + + $.ajax({ + url: AJAX_BASEURL + "login", + type: "POST", + data: {"passive": true}, + success: function(response) { + if (response["name"]) { + $("#login_dropdown_text").text("\"" + response.name + "\""); + $("#login_dropdown_loggedout").removeClass("dropdown-menu").addClass("hide"); + $("#login_dropdown_loggedin").removeClass("hide").addClass("dropdown-menu"); + } + } + }) + //~~ knockout.js bindings ko.bindingHandlers.popover = { diff --git a/octoprint/templates/index.html b/octoprint/templates/index.html index 0a39ae9..998e345 100644 --- a/octoprint/templates/index.html +++ b/octoprint/templates/index.html @@ -45,6 +45,7 @@ {% endif %} + {% if enableAccessControl %} + {% endif %}
diff --git a/octoprint/timelapse.py b/octoprint/timelapse.py index 4283323..2db8984 100644 --- a/octoprint/timelapse.py +++ b/octoprint/timelapse.py @@ -83,7 +83,7 @@ class Timelapse(object): with self._captureMutex: filename = os.path.join(self._captureDir, "tmp_%05d.jpg" % (self._imageNumber)) - self._imageNumber += 1; + self._imageNumber += 1 self._logger.debug("Capturing image to %s" % filename) captureThread = threading.Thread(target=self._captureWorker, kwargs={"filename": filename}) diff --git a/octoprint/users.py b/octoprint/users.py index 1a8ce1c..d502da7 100644 --- a/octoprint/users.py +++ b/octoprint/users.py @@ -9,8 +9,8 @@ import yaml from octoprint.settings import settings -class UserManager: - valid_roles=["user", "admin"] +class UserManager(object): + valid_roles = ["user", "admin"] @staticmethod def createPasswordHash(password): @@ -37,9 +37,10 @@ class UserManager: ##~~ FilebasedUserManager, takes available users from users.yaml file class FilebasedUserManager(UserManager): - def __init__(self, userfile=None): + def __init__(self): UserManager.__init__(self) + userfile = settings().get(["accessControl", "userfile"]) if userfile is None: userfile = os.path.join(settings().settings_dir, "users.yaml") self._userfile = userfile @@ -49,7 +50,7 @@ class FilebasedUserManager(UserManager): self._load() def _load(self): - self._users = {} + self._users = {"admin": User("admin", "7557160613d5258f883014a7c3c0428de53040fc152b1791f1cc04a62b428c0c2a9c46ed330cdce9689353ab7a5352ba2b2ceb459b96e9c8ed7d0cb0b2c0c076", True, UserManager.valid_roles)} if os.path.exists(self._userfile) and os.path.isfile(self._userfile): with open(self._userfile, "r") as f: data = yaml.safe_load(f) @@ -158,4 +159,13 @@ class User(UserMixin): return self.username def is_active(self): - return self.active \ No newline at end of file + return self.active + +##~~ DummyUser object to use when accessControl is disabled + +class DummyUser(UserMixin): + def __init__(self): + self.roles = UserManager.valid_roles + + def get_id(self): + return "dummy" \ No newline at end of file diff --git a/octoprint/util/__init__.py b/octoprint/util/__init__.py index 52d8361..f762414 100644 --- a/octoprint/util/__init__.py +++ b/octoprint/util/__init__.py @@ -28,3 +28,14 @@ def getFormattedDateTime(d): return None return d.strftime("%Y-%m-%d %H:%M") + +def getClass(name): + """ + Taken from http://stackoverflow.com/a/452981/2028598 + """ + parts = name.split(".") + module = ".".join(parts[:-1]) + m = __import__(module) + for comp in parts[1:]: + m = getattr(m, comp) + return m \ No newline at end of file From b27e1ce15e9eb027699f1b31992af05d913da301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Tue, 19 Mar 2013 21:07:47 +0100 Subject: [PATCH 07/19] User factory method for creating SocketConnection object for Tornadio2 --- octoprint/server.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/octoprint/server.py b/octoprint/server.py index c2384cd..d59226e 100644 --- a/octoprint/server.py +++ b/octoprint/server.py @@ -32,7 +32,7 @@ userManager = None #~~ Printer state class PrinterStateConnection(tornadio2.SocketConnection): - def __init__(self, session, endpoint=None): + def __init__(self, printer, gcodeManager, userManager, session, endpoint=None): tornadio2.SocketConnection.__init__(self, session, endpoint) self._logger = logging.getLogger(__name__) @@ -44,6 +44,10 @@ class PrinterStateConnection(tornadio2.SocketConnection): self._messageBacklog = [] self._messageBacklogMutex = threading.Lock() + self._printer = printer + self._gcodeManager = gcodeManager + self._userManager = userManager + def on_open(self, info): self._logger.info("New connection from client") # Use of global here is smelly @@ -541,7 +545,7 @@ class Server(): logger.info("Listening on http://%s:%d" % (self._host, self._port)) app.debug = self._debug - self._router = tornadio2.TornadioRouter(PrinterStateConnection) + self._router = tornadio2.TornadioRouter(self._createSocketConnection) self._tornado_app = Application(self._router.urls + [ (".*", FallbackHandler, {"fallback": WSGIContainer(app)}) @@ -550,6 +554,10 @@ class Server(): self._server.listen(self._port, address=self._host) IOLoop.instance().start() + def _createSocketConnection(self, session, endpoint=None): + global printer, gcodeManager, userManager + return PrinterStateConnection(printer, gcodeManager, userManager, session, endpoint) + def _initSettings(self, configfile, basedir): s = settings(init=True, basedir=basedir, configfile=configfile) From 1febcd671ab7e81e1243c065053589b8a53e6da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Tue, 19 Mar 2013 23:06:48 +0100 Subject: [PATCH 08/19] "Writing" backend methods now protected (need logged in user (or dummy user if access control is disabled) to perform action), most "writing" frontend controls disabled if no logged in (or dummy) user returned from backend. --- octoprint/server.py | 25 +++- octoprint/static/js/ui.js | 218 +++++++++++++++++++-------------- octoprint/templates/index.html | 112 ++++++++--------- octoprint/users.py | 32 +++-- 4 files changed, 224 insertions(+), 163 deletions(-) diff --git a/octoprint/server.py b/octoprint/server.py index d59226e..c900f1f 100644 --- a/octoprint/server.py +++ b/octoprint/server.py @@ -138,11 +138,13 @@ def connect(): return jsonify(state="Connecting") @app.route(BASEURL + "control/disconnect", methods=["POST"]) +@login_required def disconnect(): printer.disconnect() return jsonify(state="Offline") @app.route(BASEURL + "control/command", methods=["POST"]) +@login_required def printerCommand(): if "application/json" in request.headers["Content-Type"]: data = request.json @@ -166,21 +168,25 @@ def printerCommand(): return jsonify(SUCCESS) @app.route(BASEURL + "control/print", methods=["POST"]) +@login_required def printGcode(): printer.startPrint() return jsonify(SUCCESS) @app.route(BASEURL + "control/pause", methods=["POST"]) +@login_required def pausePrint(): printer.togglePausePrint() return jsonify(SUCCESS) @app.route(BASEURL + "control/cancel", methods=["POST"]) +@login_required def cancelPrint(): printer.cancelPrint() return jsonify(SUCCESS) @app.route(BASEURL + "control/temperature", methods=["POST"]) +@login_required def setTargetTemperature(): if not printer.isOperational(): return jsonify(SUCCESS) @@ -198,6 +204,7 @@ def setTargetTemperature(): return jsonify(SUCCESS) @app.route(BASEURL + "control/jog", methods=["POST"]) +@login_required def jog(): if not printer.isOperational() or printer.isPrinting(): # do not jog when a print job is running or we don't have a connection @@ -234,6 +241,7 @@ def getSpeedValues(): return jsonify(feedrate=printer.feedrateState()) @app.route(BASEURL + "control/speed", methods=["POST"]) +@login_required def speed(): if not printer.isOperational(): return jsonify(SUCCESS) @@ -261,6 +269,7 @@ def readGcodeFile(filename): return send_from_directory(settings().getBaseFolder("uploads"), filename, as_attachment=True) @app.route(BASEURL + "gcodefiles/upload", methods=["POST"]) +@login_required def uploadGcodeFile(): filename = None if "gcode_file" in request.files.keys(): @@ -269,6 +278,7 @@ def uploadGcodeFile(): return jsonify(files=gcodeManager.getAllFileData(), filename=filename) @app.route(BASEURL + "gcodefiles/load", methods=["POST"]) +@login_required def loadGcodeFile(): if "filename" in request.values.keys(): printAfterLoading = False @@ -280,6 +290,7 @@ def loadGcodeFile(): return jsonify(SUCCESS) @app.route(BASEURL + "gcodefiles/delete", methods=["POST"]) +@login_required def deleteGcodeFile(): if "filename" in request.values.keys(): filename = request.values["filename"] @@ -318,6 +329,7 @@ def downloadTimelapse(filename): return send_from_directory(settings().getBaseFolder("timelapse"), filename, as_attachment=True) @app.route(BASEURL + "timelapse/", methods=["DELETE"]) +@login_required def deleteTimelapse(filename): if util.isAllowedFile(filename, set(["mpg"])): secure = os.path.join(settings().getBaseFolder("timelapse"), secure_filename(filename)) @@ -326,6 +338,7 @@ def deleteTimelapse(filename): return getTimelapseData() @app.route(BASEURL + "timelapse/config", methods=["POST"]) +@login_required def setTimelapseConfig(): if request.values.has_key("type"): type = request.values["type"] @@ -389,6 +402,7 @@ def getSettings(): }) @app.route(BASEURL + "settings", methods=["POST"]) +@login_required def setSettings(): if "application/json" in request.headers["Content-Type"]: data = request.json @@ -434,6 +448,7 @@ def setSettings(): #~~ system control @app.route(BASEURL + "system", methods=["POST"]) +@login_required def performSystemAction(): logger = logging.getLogger(__name__) if request.values.has_key("action"): @@ -467,15 +482,14 @@ def login(): user = userManager.findUser(username) if user is not None: - passwordHash = users.UserManager.createPasswordHash(password) - if passwordHash == user.passwordHash: + if user.check_password(users.UserManager.createPasswordHash(password)): login_user(user, remember=remember) - return jsonify({"name": user.username, "roles": user.roles}) + return jsonify({"name": user.get_name(), "user": user.is_user(), "admin": user.is_admin()}) return app.make_response(("User unknown or password incorrect", 401, [])) elif "passive" in request.values.keys(): user = current_user if user is not None and not user.is_anonymous(): - return jsonify({"name": user.username, "roles": user.roles}) + return jsonify({"name": user.get_name(), "user": user.is_user(), "admin": user.is_admin()}) else: return jsonify(SUCCESS) @@ -488,8 +502,7 @@ def logout(): def load_user(id): if userManager is not None: return userManager.findUser(id) - else: - return users.DummyUser() + return None #~~ startup code class Server(): diff --git a/octoprint/static/js/ui.js b/octoprint/static/js/ui.js index 9cf01a3..6bef7b8 100644 --- a/octoprint/static/js/ui.js +++ b/octoprint/static/js/ui.js @@ -1,8 +1,80 @@ //~~ View models -function ConnectionViewModel() { +function LoginStateViewModel() { var self = this; + self.loggedIn = ko.observable(false); + self.username = ko.observable(undefined); + self.isAdmin = ko.observable(false); + self.isUser = ko.observable(false); + + self.userMenuText = ko.computed(function() { + if (self.loggedIn()) { + return "\"" + self.username() + "\""; + } else { + return "Login"; + } + }) + + self.requestData = function() { + $.ajax({ + url: AJAX_BASEURL + "login", + type: "POST", + data: {"passive": true}, + success: self.fromResponse + }) + } + + self.fromResponse = function(response) { + if (response && response.name) { + self.loggedIn(true); + self.username(response.name); + self.isUser(response.user); + self.isAdmin(response.admin); + } else { + self.loggedIn(false); + self.username(undefined); + self.isUser(false); + self.isAdmin(false); + } + } + + self.login = function() { + var username = $("#login_user").val(); + var password = $("#login_pass").val(); + var remember = $("#login_remember").is(":checked"); + + $.ajax({ + url: AJAX_BASEURL + "login", + type: "POST", + data: {"user": username, "pass": password, "remember": remember}, + success: function(response) { + $.pnotify({title: "Login successful", text: "You are now logged in", type: "success"}); + self.fromResponse(response); + }, + error: function(jqXHR, textStatus, errorThrown) { + $.pnotify({title: "Login failed", text: "User unknown or wrong password", type: "error"}); + } + }) + } + + self.logout = function() { + $.ajax({ + url: AJAX_BASEURL + "logout", + type: "POST", + success: function(response) { + $.pnotify({title: "Logout successful", text: "You are now logged out", type: "success"}); + self.fromResponse(response); + } + }) + } +} + +function ConnectionViewModel(loginStateViewModel) { + var self = this; + + self.loginState = loginStateViewModel; + self.portOptions = ko.observableArray(undefined); self.baudrateOptions = ko.observableArray(undefined); self.selectedPort = ko.observable(undefined); @@ -107,9 +179,11 @@ function ConnectionViewModel() { } } -function PrinterStateViewModel() { +function PrinterStateViewModel(loginStateViewModel) { var self = this; + self.loginState = loginStateViewModel; + self.stateString = ko.observable(undefined); self.isErrorOrClosed = ko.observable(undefined); self.isOperational = ko.observable(undefined); @@ -238,9 +312,11 @@ function PrinterStateViewModel() { } } -function TemperatureViewModel(settingsViewModel) { +function TemperatureViewModel(loginStateViewModel, settingsViewModel) { var self = this; + self.loginState = loginStateViewModel; + self.temp = ko.observable(undefined); self.bedTemp = ko.observable(undefined); self.targetTemp = ko.observable(undefined); @@ -412,9 +488,11 @@ function TemperatureViewModel(settingsViewModel) { } } -function ControlViewModel() { +function ControlViewModel(loginStateViewModel) { var self = this; + self.loginState = loginStateViewModel; + self.isErrorOrClosed = ko.observable(undefined); self.isOperational = ko.observable(undefined); self.isPrinting = ko.observable(undefined); @@ -567,9 +645,11 @@ function ControlViewModel() { } -function SpeedViewModel() { +function SpeedViewModel(loginStateViewModel) { var self = this; + self.loginState = loginStateViewModel; + self.outerWall = ko.observable(undefined); self.innerWall = ko.observable(undefined); self.fill = ko.observable(undefined); @@ -625,9 +705,11 @@ function SpeedViewModel() { } } -function TerminalViewModel() { +function TerminalViewModel(loginStateViewModel) { var self = this; + self.loginState = loginStateViewModel; + self.log = []; self.isErrorOrClosed = ko.observable(undefined); @@ -654,6 +736,7 @@ function TerminalViewModel() { if (!self.log) self.log = [] self.log = self.log.concat(data) + self.log = self.log.slice(-300) self.updateOutput(); } @@ -690,9 +773,11 @@ function TerminalViewModel() { } } -function GcodeFilesViewModel() { +function GcodeFilesViewModel(loginStateViewModel) { var self = this; + self.loginState = loginStateViewModel; + self.isErrorOrClosed = ko.observable(undefined); self.isOperational = ko.observable(undefined); self.isPrinting = ko.observable(undefined); @@ -824,9 +909,11 @@ function GcodeFilesViewModel() { } -function TimelapseViewModel() { +function TimelapseViewModel(loginStateViewModel) { var self = this; + self.loginState = loginStateViewModel; + self.timelapseType = ko.observable(undefined); self.timelapseTimedInterval = ko.observable(undefined); @@ -943,9 +1030,11 @@ function TimelapseViewModel() { } } -function GcodeViewModel() { +function GcodeViewModel(loginStateViewModel) { var self = this; + self.loginState = loginStateViewModel; + self.loadedFilename = undefined; self.status = 'idle'; self.enabled = false; @@ -1006,9 +1095,11 @@ function GcodeViewModel() { } -function SettingsViewModel() { +function SettingsViewModel(loginStateViewModel) { var self = this; + self.loginState = loginStateViewModel; + self.appearance_name = ko.observable(undefined); self.appearance_color = ko.observable(undefined); @@ -1135,9 +1226,10 @@ function SettingsViewModel() { } -function NavigationViewModel(appearanceViewModel, settingsViewModel) { +function NavigationViewModel(loginStateViewModel, appearanceViewModel, settingsViewModel) { var self = this; + self.loginState = loginStateViewModel; self.appearance = appearanceViewModel; self.systemActions = settingsViewModel.system_actions; @@ -1166,9 +1258,10 @@ function NavigationViewModel(appearanceViewModel, settingsViewModel) { } } -function DataUpdater(connectionViewModel, printerStateViewModel, temperatureViewModel, controlViewModel, speedViewModel, terminalViewModel, gcodeFilesViewModel, timelapseViewModel, gcodeViewModel) { +function DataUpdater(loginStateViewModel, connectionViewModel, printerStateViewModel, temperatureViewModel, controlViewModel, speedViewModel, terminalViewModel, gcodeFilesViewModel, timelapseViewModel, gcodeViewModel) { var self = this; + self.loginStateViewModel = loginStateViewModel; self.connectionViewModel = connectionViewModel; self.printerStateViewModel = printerStateViewModel; self.temperatureViewModel = temperatureViewModel; @@ -1185,6 +1278,7 @@ function DataUpdater(connectionViewModel, printerStateViewModel, temperatureView $("#offline_overlay").hide(); self.timelapseViewModel.requestData(); $("#webcam_image").attr("src", CONFIG_WEBCAM_STREAM + "?" + new Date().getTime()); + self.loginStateViewModel.requestData(); } }) self._socket.on("disconnect", function() { @@ -1480,20 +1574,22 @@ function AppearanceViewModel(settingsViewModel) { $(function() { //~~ View models - var connectionViewModel = new ConnectionViewModel(); - var printerStateViewModel = new PrinterStateViewModel(); - var settingsViewModel = new SettingsViewModel(); + var loginStateViewModel = new LoginStateViewModel(); + var connectionViewModel = new ConnectionViewModel(loginStateViewModel); + var printerStateViewModel = new PrinterStateViewModel(loginStateViewModel); + var settingsViewModel = new SettingsViewModel(loginStateViewModel); var appearanceViewModel = new AppearanceViewModel(settingsViewModel); - var temperatureViewModel = new TemperatureViewModel(settingsViewModel); - var controlViewModel = new ControlViewModel(); - var speedViewModel = new SpeedViewModel(); - var terminalViewModel = new TerminalViewModel(); - var gcodeFilesViewModel = new GcodeFilesViewModel(); - var timelapseViewModel = new TimelapseViewModel(); - var gcodeViewModel = new GcodeViewModel(); - var navigationViewModel = new NavigationViewModel(appearanceViewModel, settingsViewModel); + var temperatureViewModel = new TemperatureViewModel(loginStateViewModel, settingsViewModel); + var controlViewModel = new ControlsViewModel(loginStateViewModel); + var speedViewModel = new SpeedViewModel(loginStateViewModel); + var terminalViewModel = new TerminalViewModel(loginStateViewModel); + var gcodeFilesViewModel = new GcodeFilesViewModel(loginStateViewModel); + var timelapseViewModel = new TimelapseViewModel(loginStateViewModel); + var gcodeViewModel = new GcodeViewModel(loginStateViewModel); + var navigationViewModel = new NavigationViewModel(loginStateViewModel, appearanceViewModel, settingsViewModel); var dataUpdater = new DataUpdater( + loginStateViewModel, connectionViewModel, printerStateViewModel, temperatureViewModel, @@ -1505,7 +1601,8 @@ $(function() { gcodeViewModel ); - //work around a stupid iOS6 bug where ajax requests get cached and only work once, as described at http://stackoverflow.com/questions/12506897/is-safari-on-ios-6-caching-ajax-results + // work around a stupid iOS6 bug where ajax requests get cached and only work once, as described at + // http://stackoverflow.com/questions/12506897/is-safari-on-ios-6-caching-ajax-results $.ajaxSetup({ type: 'POST', headers: { "cache-control": "no-cache" } @@ -1547,6 +1644,7 @@ $(function() { }) $('#tabs a[data-toggle="tab"]').on('shown', function (e) { temperatureViewModel.updatePlot(); + terminalViewModel.updateOutput(); }); //~~ Speed controls @@ -1618,71 +1716,6 @@ $(function() { //~~ Offline overlay $("#offline_overlay_reconnect").click(function() {dataUpdater.reconnect()}); - //~~ Alert - - /* - function displayAlert(text, timeout, type) { - var placeholder = $("#alert_placeholder"); - - var alertType = ""; - if (type == "success" || type == "error" || type == "info") { - alertType = " alert-" + type; - } - - placeholder.append($("

" + text + "

")); - placeholder.fadeIn(); - $("#activeAlert").delay(timeout).fadeOut("slow", function() {$(this).remove(); $("#alert_placeholder").hide();}); - } - */ - - //~~ Login/logout - - $("#login_button").click(function() { - var username = $("#login_user").val(); - var password = $("#login_pass").val(); - var remember = $("#login_remember").is(":checked"); - - $.ajax({ - url: AJAX_BASEURL + "login", - type: "POST", - data: {"user": username, "pass": password, "remember": remember}, - success: function(response) { - $.pnotify({title: "Login successful", text: "You are now logged in", type: "success"}); - $("#login_dropdown_text").text("\"" + response.name + "\""); - $("#login_dropdown_loggedout").removeClass("dropdown-menu").addClass("hide"); - $("#login_dropdown_loggedin").removeClass("hide").addClass("dropdown-menu"); - }, - error: function(jqXHR, textStatus, errorThrown) { - $.pnotify({title: "Login failed", text: "User unknown or wrong password", type: "error"}); - } - }) - }); - $("#logout_button").click(function(){ - $.ajax({ - url: AJAX_BASEURL + "logout", - type: "POST", - success: function(response) { - $.pnotify({title: "Logout successful", text: "You are now logged out", type: "success"}); - $("#login_dropdown_text").text("Login"); - $("#login_dropdown_loggedin").removeClass("dropdown-menu").addClass("hide"); - $("#login_dropdown_loggedout").removeClass("hide").addClass("dropdown-menu"); - } - }) - }) - - $.ajax({ - url: AJAX_BASEURL + "login", - type: "POST", - data: {"passive": true}, - success: function(response) { - if (response["name"]) { - $("#login_dropdown_text").text("\"" + response.name + "\""); - $("#login_dropdown_loggedout").removeClass("dropdown-menu").addClass("hide"); - $("#login_dropdown_loggedin").removeClass("hide").addClass("dropdown-menu"); - } - } - }) - //~~ knockout.js bindings ko.bindingHandlers.popover = { @@ -1717,14 +1750,15 @@ $(function() { var timelapseElement = document.getElementById("timelapse"); if (timelapseElement) { - ko.applyBindings(timelapseViewModel, document.getElementById("timelapse")); + ko.applyBindings(timelapseViewModel, timelapseElement); } var gCodeVisualizerElement = document.getElementById("gcode"); - if(gCodeVisualizerElement){ + if (gCodeVisualizerElement) { gcodeViewModel.initialize(); } //~~ startup commands + loginStateViewModel.requestData(); connectionViewModel.requestData(); controlViewModel.requestData(); gcodeFilesViewModel.requestData(); @@ -1745,7 +1779,7 @@ $(function() { $.pnotify.defaults.history = false; - // Fix input element click problem + // Fix input element click problem on login dialog $('.dropdown input, .dropdown label').click(function(e) { e.stopPropagation(); }); diff --git a/octoprint/templates/index.html b/octoprint/templates/index.html index 998e345..632ed60 100644 --- a/octoprint/templates/index.html +++ b/octoprint/templates/index.html @@ -33,9 +33,13 @@ OctoPrint @@ -113,9 +115,9 @@
@@ -209,12 +211,12 @@
- + °C
- - +

General

- - - + + +
@@ -341,7 +343,7 @@ @@ -360,30 +362,30 @@
- + % - +
- + % - +
- + % - +
- + % - +
@@ -482,7 +484,7 @@
- +
{% if enableTimelapse %} @@ -490,7 +492,7 @@

Timelapse Configuration

- @@ -505,7 +507,7 @@
- +

Finished Timelapses

diff --git a/octoprint/users.py b/octoprint/users.py index d502da7..f8268f1 100644 --- a/octoprint/users.py +++ b/octoprint/users.py @@ -150,22 +150,34 @@ class UnknownRole(Exception): class User(UserMixin): def __init__(self, username, passwordHash, active, roles): - self.username = username - self.passwordHash = passwordHash - self.active = active - self.roles = roles + self._username = username + self._passwordHash = passwordHash + self._active = active + self._roles = roles + + def check_password(self, passwordHash): + return self._passwordHash == passwordHash def get_id(self): - return self.username + return self._username + + def get_name(self): + return self._username def is_active(self): - return self.active + return self._active + + def is_user(self): + return "user" in self._roles + + def is_admin(self): + return "admin" in self._roles ##~~ DummyUser object to use when accessControl is disabled -class DummyUser(UserMixin): +class DummyUser(User): def __init__(self): - self.roles = UserManager.valid_roles + User.__init__(self, "dummy", "", True, UserManager.valid_roles) - def get_id(self): - return "dummy" \ No newline at end of file + def check_password(self, passwordHash): + return True \ No newline at end of file From 93a73a0ad81093d5a96bb7c58b3fd6bdbfd75278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Mon, 1 Apr 2013 14:24:47 +0200 Subject: [PATCH 09/19] Added roles (user and admin) and according requirements --- octoprint/server.py | 30 ++++++++++++++++++++++++++++-- octoprint/users.py | 5 ++++- requirements.txt | 3 ++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/octoprint/server.py b/octoprint/server.py index c900f1f..9f33c21 100644 --- a/octoprint/server.py +++ b/octoprint/server.py @@ -2,10 +2,11 @@ __author__ = "Gina Häußge " __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' -from flask import Flask, request, render_template, jsonify, send_from_directory, abort, url_for from werkzeug.utils import secure_filename import tornadio2 -from flask.ext.login import LoginManager, login_user, logout_user, login_required, current_user, AnonymousUser +from flask import Flask, request, render_template, jsonify, send_from_directory, url_for, current_app, session +from flask.ext.login import LoginManager, login_user, logout_user, login_required, current_user +from flask.ext.principal import Principal, Permission, RoleNeed, Identity, identity_changed, AnonymousIdentity, identity_loaded, UserNeed import os import threading @@ -29,6 +30,10 @@ printer = None gcodeManager = None userManager = None +principals = Principal(app) +admin_permission = Permission(RoleNeed("admin")) +user_permission = Permission(RoleNeed("user")) + #~~ Printer state class PrinterStateConnection(tornadio2.SocketConnection): @@ -403,6 +408,7 @@ def getSettings(): @app.route(BASEURL + "settings", methods=["POST"]) @login_required +@admin_permission.require() def setSettings(): if "application/json" in request.headers["Content-Type"]: data = request.json @@ -449,6 +455,7 @@ def setSettings(): @app.route(BASEURL + "system", methods=["POST"]) @login_required +@admin_permission.require() def performSystemAction(): logger = logging.getLogger(__name__) if request.values.has_key("action"): @@ -484,6 +491,7 @@ def login(): if user is not None: if user.check_password(users.UserManager.createPasswordHash(password)): login_user(user, remember=remember) + identity_changed.send(current_app._get_current_object(), identity=Identity(user.get_id())) return jsonify({"name": user.get_name(), "user": user.is_user(), "admin": user.is_admin()}) return app.make_response(("User unknown or password incorrect", 401, [])) elif "passive" in request.values.keys(): @@ -496,9 +504,27 @@ def login(): @app.route(BASEURL + "logout", methods=["POST"]) @login_required def logout(): + # Remove session keys set by Flask-Principal + for key in ('identity.name', 'identity.auth_type'): + del session[key] + identity_changed.send(current_app._get_current_object(), identity=AnonymousIdentity()) + logout_user() + return jsonify(SUCCESS) +@identity_loaded.connect_via(app) +def on_identity_loaded(sender, identity): + user = load_user(identity.name) + if user is None: + return + + identity.provides.add(UserNeed(user.get_name())) + if user.is_user(): + identity.provides.add(RoleNeed("user")) + if user.is_admin(): + identity.provides.add(RoleNeed("admin")) + def load_user(id): if userManager is not None: return userManager.findUser(id) diff --git a/octoprint/users.py b/octoprint/users.py index f8268f1..2b9de83 100644 --- a/octoprint/users.py +++ b/octoprint/users.py @@ -50,7 +50,10 @@ class FilebasedUserManager(UserManager): self._load() def _load(self): - self._users = {"admin": User("admin", "7557160613d5258f883014a7c3c0428de53040fc152b1791f1cc04a62b428c0c2a9c46ed330cdce9689353ab7a5352ba2b2ceb459b96e9c8ed7d0cb0b2c0c076", True, UserManager.valid_roles)} + self._users = { + "admin": User("admin", "7557160613d5258f883014a7c3c0428de53040fc152b1791f1cc04a62b428c0c2a9c46ed330cdce9689353ab7a5352ba2b2ceb459b96e9c8ed7d0cb0b2c0c076", True, ["user", "admin"]), + "user": User("user", "ced28770ae4457f420e322a5c7b8abc5f31432aef2552871909d6f4f372d1e0d6e0e7be14114656971eeba88e6462d5ea596b656d521c847047a496fecc431a5", True, ["user"]) + } if os.path.exists(self._userfile) and os.path.isfile(self._userfile): with open(self._userfile, "r") as f: data = yaml.safe_load(f) diff --git a/requirements.txt b/requirements.txt index 4114ee7..e1a1510 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ pyserial>=2.6 tornado>=2.4.1 tornadio2>=0.0.4 PyYAML>=3.10 -Flask-Login>=0.1.3 \ No newline at end of file +Flask-Login>=0.1.3 +Flask-Principal>=0.3.4 \ No newline at end of file From 3e5a6d3679674aa5024a5411edeb8e86a45a95b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 12 Apr 2013 23:08:14 +0200 Subject: [PATCH 10/19] Furhter work on user management --- octoprint/server.py | 155 ++++++++++++++++------ octoprint/static/css/octoprint.less | 29 +++- octoprint/static/js/ui.js | 197 ++++++++++++++++++++++++++-- octoprint/templates/index.html | 26 ++-- octoprint/templates/settings.html | 149 ++++++++++++++++++++- octoprint/users.py | 94 +++++++++---- 6 files changed, 556 insertions(+), 94 deletions(-) diff --git a/octoprint/server.py b/octoprint/server.py index 9f33c21..289f8e2 100644 --- a/octoprint/server.py +++ b/octoprint/server.py @@ -122,31 +122,29 @@ def index(): #~~ Printer control -@app.route(BASEURL + "control/connectionOptions", methods=["GET"]) +@app.route(BASEURL + "control/connection/options", methods=["GET"]) def connectionOptions(): return jsonify(getConnectionOptions()) -@app.route(BASEURL + "control/connect", methods=["POST"]) +@app.route(BASEURL + "control/connection", methods=["POST"]) @login_required def connect(): - port = None - baudrate = None - if "port" in request.values.keys(): - port = request.values["port"] - if "baudrate" in request.values.keys(): - baudrate = request.values["baudrate"] - if "save" in request.values.keys(): - settings().set(["serial", "port"], port) - settings().setInt(["serial", "baudrate"], baudrate) - settings().save() - printer.connect(port=port, baudrate=baudrate) - return jsonify(state="Connecting") + if "command" in request.values.keys() and request.values["command"] == "connect": + port = None + baudrate = None + if "port" in request.values.keys(): + port = request.values["port"] + if "baudrate" in request.values.keys(): + baudrate = request.values["baudrate"] + if "save" in request.values.keys(): + settings().set(["serial", "port"], port) + settings().setInt(["serial", "baudrate"], baudrate) + settings().save() + printer.connect(port=port, baudrate=baudrate) + elif "command" in request.values.keys() and request.values["command"] == "disconnect": + printer.disconnect() -@app.route(BASEURL + "control/disconnect", methods=["POST"]) -@login_required -def disconnect(): - printer.disconnect() - return jsonify(state="Offline") + return jsonify(SUCCESS) @app.route(BASEURL + "control/command", methods=["POST"]) @login_required @@ -172,36 +170,27 @@ def printerCommand(): return jsonify(SUCCESS) -@app.route(BASEURL + "control/print", methods=["POST"]) +@app.route(BASEURL + "control/job", methods=["POST"]) @login_required -def printGcode(): - printer.startPrint() - return jsonify(SUCCESS) - -@app.route(BASEURL + "control/pause", methods=["POST"]) -@login_required -def pausePrint(): - printer.togglePausePrint() - return jsonify(SUCCESS) - -@app.route(BASEURL + "control/cancel", methods=["POST"]) -@login_required -def cancelPrint(): - printer.cancelPrint() +def printJobControl(): + if "command" in request.values.keys(): + if request.values["command"] == "start": + printer.startPrint() + elif request.values["command"] == "pause": + printer.togglePausePrint() + elif request.values["command"] == "cancel": + printer.cancelPrint() return jsonify(SUCCESS) @app.route(BASEURL + "control/temperature", methods=["POST"]) @login_required def setTargetTemperature(): - if not printer.isOperational(): - return jsonify(SUCCESS) - - elif request.values.has_key("temp"): - # set target temperature + if "temp" in request.values.keys(): + # set target temperature temp = request.values["temp"] printer.command("M104 S" + temp) - elif request.values.has_key("bedTemp"): + if "bedTemp" in request.values.keys(): # set target bed temperature bedTemp = request.values["bedTemp"] printer.command("M140 S" + bedTemp) @@ -342,7 +331,7 @@ def deleteTimelapse(filename): os.remove(secure) return getTimelapseData() -@app.route(BASEURL + "timelapse/config", methods=["POST"]) +@app.route(BASEURL + "timelapse", methods=["POST"]) @login_required def setTimelapseConfig(): if request.values.has_key("type"): @@ -451,6 +440,90 @@ def setSettings(): return getSettings() +#~~ user settings + +@app.route(BASEURL + "users", methods=["GET"]) +@login_required +@admin_permission.require() +def getUsers(): + return jsonify({"users": userManager.getAllUsers()}) + +@app.route(BASEURL + "users", methods=["POST"]) +@login_required +@admin_permission.require() +def addUser(): + if "application/json" in request.headers["Content-Type"]: + data = request.json + + name = data["name"] + password = data["password"] + active = data["active"] + + roles = ["user"] + if "admin" in data.keys() and data["admin"]: + roles.append("admin") + + try: + userManager.addUser(name, password, active, roles) + except users.UserAlreadyExists: + return app.make_response(("User already exists: " % name, 409, [])) + return getUsers() + +@app.route(BASEURL + "users/", methods=["GET"]) +@login_required +@admin_permission.require() +def getUser(username): + user = userManager.findUser(username) + if user is not None: + return jsonify(user.asDict()) + else: + return app.make_response(("Unknown user: " % username, 404, [])) + +@app.route(BASEURL + "users/", methods=["PUT"]) +@login_required +@admin_permission.require() +def updateUser(username): + user = userManager.findUser(username) + if user is not None: + if "application/json" in request.headers["Content-Type"]: + data = request.json + + # change roles + roles = ["user"] + if "admin" in data.keys() and data["admin"]: + roles.append("admin") + userManager.changeUserRoles(username, roles) + + # change activation + if "active" in data.keys(): + userManager.changeUserActivation(username, data["active"]) + return getUsers() + else: + return app.make_response(("Unknown user: " % username, 404, [])) + +@app.route(BASEURL + "users/", methods=["DELETE"]) +@login_required +@admin_permission.require() +def removeUser(username): + try: + userManager.removeUser(username) + return getUsers() + except users.UnknownUser: + return app.make_response(("Unknown user: " % username, 404, [])) + +@app.route(BASEURL + "users//password", methods=["PUT"]) +@login_required +@admin_permission.require() +def changePasswordForUser(username): + if "application/json" in request.headers["Content-Type"]: + data = request.json + if "password" in data.keys() and data["password"]: + try: + userManager.changeUserPassword(username, data["password"]) + except users.UnknownUser: + return app.make_response(("Unknown user: " % username, 404, [])) + return jsonify(SUCCESS) + #~~ system control @app.route(BASEURL + "system", methods=["POST"]) diff --git a/octoprint/static/css/octoprint.less b/octoprint/static/css/octoprint.less index 2822bde..cace470 100644 --- a/octoprint/static/css/octoprint.less +++ b/octoprint/static/css/octoprint.less @@ -112,7 +112,7 @@ body { .octoprint-container { .accordion-heading { .settings-trigger { - float: right; + //float: right; padding: 0px 15px; } @@ -192,6 +192,32 @@ table { } } } + + // user settings + &.settings_users_name { + text-overflow: ellipsis; + text-align: left; + } + + &.settings_users_active, &.settings_users_admin { + text-align: center; + width: 55px; + } + + &.settings_users_actions { + text-align: center; + width: 60px; + + a { + text-decoration: none; + color: #000; + + &.disabled { + color: #ccc; + cursor: default; + } + } + } } } @@ -334,7 +360,6 @@ ul.dropdown-menu li a { /** Settings dialog */ #settings_dialog { - width: 650px; } /** Footer */ diff --git a/octoprint/static/js/ui.js b/octoprint/static/js/ui.js index 6bef7b8..d35ebdc 100644 --- a/octoprint/static/js/ui.js +++ b/octoprint/static/js/ui.js @@ -100,7 +100,7 @@ function ConnectionViewModel(loginStateViewModel) { self.requestData = function() { $.ajax({ - url: AJAX_BASEURL + "control/connectionOptions", + url: AJAX_BASEURL + "control/connection/options", method: "GET", dataType: "json", success: function(response) { @@ -155,6 +155,7 @@ function ConnectionViewModel(loginStateViewModel) { self.connect = function() { if (self.isErrorOrClosed()) { var data = { + "command": "connect", "port": self.selectedPort(), "baudrate": self.selectedBaudrate() }; @@ -163,7 +164,7 @@ function ConnectionViewModel(loginStateViewModel) { data["save"] = true; $.ajax({ - url: AJAX_BASEURL + "control/connect", + url: AJAX_BASEURL + "control/connection", type: "POST", dataType: "json", data: data @@ -171,9 +172,10 @@ function ConnectionViewModel(loginStateViewModel) { } else { self.requestData(); $.ajax({ - url: AJAX_BASEURL + "control/disconnect", + url: AJAX_BASEURL + "control/connection", type: "POST", - dataType: "json" + dataType: "json", + data: {"command": "disconnect"} }) } } @@ -1095,11 +1097,181 @@ function GcodeViewModel(loginStateViewModel) { } -function SettingsViewModel(loginStateViewModel) { +function UsersViewModel(loginStateViewModel) { var self = this; self.loginState = loginStateViewModel; + // initialize list helper + self.listHelper = new ItemListHelper( + "users", + { + "name": function(a, b) { + // sorts ascending + if (a["name"].toLocaleLowerCase() < b["name"].toLocaleLowerCase()) return -1; + if (a["name"].toLocaleLowerCase() > b["name"].toLocaleLowerCase()) return 1; + return 0; + } + }, + {}, + "name", + [], + CONFIG_USERSPERPAGE + ) + + self.emptyUser = {name: "", admin: false, active: false}; + + self.currentUser = ko.observable(self.emptyUser); + + self.editorUsername = ko.observable(undefined); + self.editorPassword = ko.observable(undefined); + self.editorRepeatedPassword = ko.observable(undefined); + self.editorAdmin = ko.observable(undefined); + self.editorActive = ko.observable(undefined); + + self.currentUser.subscribe(function(newValue) { + if (newValue === undefined) { + self.editorUsername(undefined); + self.editorAdmin(undefined); + self.editorActive(undefined); + } else { + self.editorUsername(newValue.name); + self.editorAdmin(newValue.admin); + self.editorActive(newValue.active); + } + self.editorPassword(undefined); + self.editorRepeatedPassword(undefined); + }); + + self.editorPasswordMismatch = ko.computed(function() { + return self.editorPassword() != self.editorRepeatedPassword(); + }); + + self.requestData = function() { + $.ajax({ + url: AJAX_BASEURL + "users", + type: "GET", + dataType: "json", + success: self.fromResponse + }); + } + + self.fromResponse = function(response) { + self.listHelper.updateItems(response.users); + } + + self.showAddUserDialog = function() { + self.currentUser(undefined); + $("#settings-usersDialogAddUser").modal("show"); + } + + self.confirmAddUser = function() { + var user = {name: self.editorUsername(), password: self.editorPassword(), admin: self.editorAdmin(), active: self.editorActive()}; + self.addUser(user, function() { + // close dialog + self.currentUser(undefined); + $("#settings-usersDialogAddUser").modal("hide"); + }); + } + + self.showEditUserDialog = function(user) { + self.currentUser(user); + $("#settings-usersDialogEditUser").modal("show"); + } + + self.confirmEditUser = function() { + var user = self.currentUser(); + user.active = self.editorActive(); + user.admin = self.editorAdmin(); + + // make AJAX call + self.updateUser(user, function() { + // close dialog + self.currentUser(undefined); + $("#settings-usersDialogEditUser").modal("hide"); + }); + } + + self.showChangePasswordDialog = function(user) { + self.currentUser(user); + $("#settings-usersDialogChangePassword").modal("show"); + } + + self.confirmChangePassword = function() { + self.updatePassword(self.currentUser().name, self.editorPassword(), function() { + // close dialog + self.currentUser(undefined); + $("#settings-usersDialogChangePassword").modal("hide"); + }); + } + + //~~ AJAX calls + + self.addUser = function(user, callback) { + if (user === undefined) return; + + $.ajax({ + url: AJAX_BASEURL + "users", + type: "POST", + contentType: "application/json; charset=UTF-8", + data: JSON.stringify(user), + success: function(response) { + self.fromResponse(response); + callback(); + } + }); + } + + self.removeUser = function(user, callback) { + if (user === undefined) return; + if (user.name == loginStateViewModel.username()) { + // we do not allow to delete ourself + $.pnotify({title: "Not possible", text: "You may not delete your own account.", type: "error"}); + return; + } + + $.ajax({ + url: AJAX_BASEURL + "users/" + user.name, + type: "DELETE", + success: function(response) { + self.fromResponse(response); + callback(); + } + }); + } + + self.updateUser = function(user, callback) { + if (user === undefined) return; + + $.ajax({ + url: AJAX_BASEURL + "users/" + user.name, + type: "PUT", + contentType: "application/json; charset=UTF-8", + data: JSON.stringify(user), + success: function(response) { + self.fromResponse(response); + callback(); + } + }); + } + + self.updatePassword = function(username, password, callback) { + $.ajax({ + url: AJAX_BASEURL + "users/" + username + "/password", + type: "PUT", + contentType: "application/json; charset=UTF-8", + data: JSON.stringify({password: password}), + success: callback + }); + } +} + +function SettingsViewModel(loginStateViewModel, usersViewModel) { + var self = this; + + self.loginState = loginStateViewModel; + self.users = usersViewModel; + self.appearance_name = ko.observable(undefined); self.appearance_color = ko.observable(undefined); @@ -1143,7 +1315,8 @@ function SettingsViewModel(loginStateViewModel) { type: "GET", dataType: "json", success: self.fromResponse - }) + }); + self.users.requestData(); } self.fromResponse = function(response) { @@ -1574,10 +1747,11 @@ function AppearanceViewModel(settingsViewModel) { $(function() { //~~ View models - var loginStateViewModel = new LoginStateViewModel(); + var loginStateViewModel = new LoginStateViewModel(loginStateViewModel); + var usersViewModel = new UsersViewModel(loginStateViewModel); var connectionViewModel = new ConnectionViewModel(loginStateViewModel); var printerStateViewModel = new PrinterStateViewModel(loginStateViewModel); - var settingsViewModel = new SettingsViewModel(loginStateViewModel); + var settingsViewModel = new SettingsViewModel(loginStateViewModel, usersViewModel); var appearanceViewModel = new AppearanceViewModel(settingsViewModel); var temperatureViewModel = new TemperatureViewModel(loginStateViewModel, settingsViewModel); var controlViewModel = new ControlsViewModel(loginStateViewModel); @@ -1618,7 +1792,7 @@ $(function() { return false; }) - //~~ Print job control + //~~ Print job control (should move to PrinterStateViewModel) //~~ Temperature control (should really move to knockout click binding) @@ -1779,6 +1953,11 @@ $(function() { $.pnotify.defaults.history = false; + $.fn.modal.defaults.maxHeight = function(){ + // subtract the height of the modal header and footer + return $(window).height() - 165; + } + // Fix input element click problem on login dialog $('.dropdown input, .dropdown label').click(function(e) { e.stopPropagation(); diff --git a/octoprint/templates/index.html b/octoprint/templates/index.html index 632ed60..64f47f8 100644 --- a/octoprint/templates/index.html +++ b/octoprint/templates/index.html @@ -8,6 +8,7 @@ + @@ -19,6 +20,7 @@ var AJAX_BASEURL = "/ajax/"; var CONFIG_GCODEFILESPERPAGE = 5; var CONFIG_TIMELAPSEFILESPERPAGE = 10; + var CONFIG_USERSPERPAGE = 10; var CONFIG_WEBCAM_STREAM = "{{ webcamStream }}"; var WEB_SOCKET_SWF_LOCATION = "{{ url_for('static', filename='js/WebSocketMain.swf') }}"; @@ -128,7 +130,7 @@ - - - - - + + + + + - - - - - + + + + +
NameSizeAction
NameSizeAction
 | 
 |