Merge branch 'devel' of github.com:foosel/OctoPrint into dev/folderSupport

This commit is contained in:
Salandora 2015-10-08 15:29:36 +02:00
commit 070fd4a47f
29 changed files with 344 additions and 65 deletions

View file

@ -56,6 +56,10 @@
* Documentation improvements
* Test buttons for webcam snapshot & stream URL, ffmpeg path and some other settings
(see also [#183](https://github.com/foosel/OctoPrint/issues/183)).
* Temperature graph automatically adjusts its Y axis range if necessary to
accomodate the plotted data (see also [#632](https://github.com/foosel/OctoPrint/issues/632)).
* "Fan on" command now always sends `S255` parameter for better compatibility
across firmwares.
### Bug Fixes

View file

@ -18,6 +18,11 @@ from .analysis import QueueEntry, AnalysisQueue
from .storage import LocalFileStorage
from .util import AbstractFileWrapper, StreamWrapper, DiskFileWrapper
from collections import namedtuple
ContentTypeMapping = namedtuple("ContentTypeMapping", "extensions, content_type")
ContentTypeDetector = namedtuple("ContentTypeDetector", "extensions, detector")
extensions = dict(
)
@ -25,11 +30,11 @@ def full_extension_tree():
result = dict(
# extensions for 3d model files
model=dict(
stl=["stl"]
stl=ContentTypeMapping(["stl"], "application/sla")
),
# extensions for printable machine code
machinecode=dict(
gcode=["gcode", "gco", "g"]
gcode=ContentTypeMapping(["gcode", "gco", "g"], "text/plain")
)
)
@ -68,8 +73,12 @@ def get_all_extensions(subtree=None):
for key, value in subtree.items():
if isinstance(value, dict):
result += get_all_extensions(value)
elif isinstance(value, (ContentTypeMapping, ContentTypeDetector)):
result += value.extensions
elif isinstance(value, (list, tuple)):
result += value
elif isinstance(subtree, (ContentTypeMapping, ContentTypeDetector)):
result = subtree.extensions
elif isinstance(subtree, (list, tuple)):
result = subtree
return result
@ -79,7 +88,9 @@ def get_path_for_extension(extension, subtree=None):
subtree = full_extension_tree()
for key, value in subtree.items():
if isinstance(value, (list, tuple)) and extension in value:
if isinstance(value, (ContentTypeMapping, ContentTypeDetector)) and extension in value.extensions:
return [key]
elif isinstance(value, (list, tuple)) and extension in value:
return [key]
elif isinstance(value, dict):
path = get_path_for_extension(extension, subtree=value)
@ -88,6 +99,23 @@ def get_path_for_extension(extension, subtree=None):
return None
def get_content_type_mapping_for_extension(extension, subtree=None):
if not subtree:
subtree = full_extension_tree()
for key, value in subtree.items():
content_extension_matches = isinstance(value, (ContentTypeMapping, ContentTypeDetector)) and extension in value. extensions
list_extension_matches = isinstance(value, (list, tuple)) and extension in value
if content_extension_matches or list_extension_matches:
return value
elif isinstance(value, dict):
result = get_content_type_mapping_for_extension(extension, subtree=value)
if result is not None:
return result
return None
def valid_extension(extension, type=None):
if not type:
return extension in get_all_extensions()
@ -106,6 +134,19 @@ def get_file_type(filename):
extension = extension[1:].lower()
return get_path_for_extension(extension)
def get_mime_type(filename):
_, extension = os.path.splitext(filename)
extension = extension[1:].lower()
mapping = get_content_type_mapping_for_extension(extension)
if mapping:
if isinstance(mapping, ContentTypeMapping) and mapping.content_type is not None:
return mapping.content_type
elif isinstance(mapping, ContentTypeDetector) and callable(mapping.detector):
result = mapping.detector(filename)
if result is not None:
return result
return "application/octet-stream"
class NoSuchStorage(Exception):
pass

View file

@ -7,6 +7,8 @@ __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms
import io
from octoprint.util import atomic_write
class AbstractFileWrapper(object):
"""
Wrapper for file representations to save to storages.
@ -85,7 +87,7 @@ class StreamWrapper(AbstractFileWrapper):
"""
import shutil
with open(path, "wb") as dest:
with atomic_write(path, "wb") as dest:
with self.stream() as source:
shutil.copyfileobj(source, dest)

View file

@ -84,13 +84,17 @@ class CoreWizardPlugin(octoprint.plugin.AssetPlugin,
from flask import request
from octoprint.server.api import valid_boolean_trues, NO_CONTENT
if "ac" in request.values and request.values["ac"] in valid_boolean_trues and \
"user" in request.values.keys() and "pass1" in request.values.keys() and \
"pass2" in request.values.keys() and request.values["pass1"] == request.values["pass2"]:
data = request.values
if hasattr(request, "json") and request.json:
data = request.json
if "ac" in data and data["ac"] in valid_boolean_trues and \
"user" in data.keys() and "pass1" in data.keys() and \
"pass2" in data.keys() and data["pass1"] == data["pass2"]:
# configure access control
self._settings.global_set_boolean(["accessControl", "enabled"], True)
octoprint.server.userManager.addUser(request.values["user"], request.values["pass1"], True, ["user", "admin"], overwrite=True)
elif "ac" in request.values.keys() and not request.values["ac"] in valid_boolean_trues:
octoprint.server.userManager.addUser(data["user"], data["pass1"], True, ["user", "admin"], overwrite=True)
elif "ac" in data.keys() and not data["ac"] in valid_boolean_trues:
# disable access control
self._settings.global_set_boolean(["accessControl", "enabled"], False)

View file

@ -456,7 +456,7 @@ class CuraPlugin(octoprint.plugin.SlicerPlugin,
def _save_profile(self, path, profile, allow_overwrite=True):
import yaml
with open(path, "wb") as f:
with octoprint.util.atomic_write(path, "wb") as f:
yaml.safe_dump(profile, f, default_flow_style=False, indent=" ", allow_unicode=True)
def _convert_to_engine(self, profile, new_engine):

View file

@ -557,7 +557,7 @@ class PluginManagerPlugin(octoprint.plugin.SimpleApiPlugin,
try:
import json
with open(self._repository_cache_path, "w+b") as f:
with octoprint.util.atomic_write(self._repository_cache_path, "wb") as f:
json.dump(repo_data, f)
except Exception as e:
self._logger.exception("Error while saving repository data to {}: {}".format(self._repository_cache_path, str(e)))

View file

@ -691,7 +691,7 @@ class Printer(PrinterInterface, comm.MachineComPrintCallback):
# Use a string for mtime because it could be float and the
# javascript needs to exact match
if not sd:
date = int(os.stat(path_on_disk).st_ctime)
date = int(os.stat(path_on_disk).st_mtime)
try:
fileData = self._fileManager.get_metadata(FileDestinations.SDCARD if sd else FileDestinations.LOCAL, path_on_disk)

View file

@ -332,13 +332,34 @@ class Server(object):
upload_suffixes = dict(name=s.get(["server", "uploads", "nameSuffix"]), path=s.get(["server", "uploads", "pathSuffix"]))
def mime_type_guesser(path):
from octoprint.filemanager import get_mime_type
return get_mime_type(path)
download_handler_kwargs = dict(
as_attachment=True,
allow_client_caching=False
)
additional_mime_types=dict(mime_type_guesser=mime_type_guesser)
admin_validator = dict(access_validation=util.tornado.access_validation_factory(app, loginManager, util.flask.user_validator))
no_hidden_files_validator = dict(path_validation=util.tornado.path_validation_factory(lambda path: not os.path.basename(path).startswith("."), status_code=404))
def joined_dict(*dicts):
if not len(dicts):
return dict()
joined = dict()
for d in dicts:
joined.update(d)
return joined
server_routes = self._router.urls + [
# various downloads
(r"/downloads/timelapse/([^/]*\.mpg)", util.tornado.LargeResponseHandler, dict(path=s.getBaseFolder("timelapse"), as_attachment=True)),
(r"/downloads/files/local/(.*)", util.tornado.LargeResponseHandler, dict(path=s.getBaseFolder("uploads"), as_attachment=True, path_validation=util.tornado.path_validation_factory(lambda path: not os.path.basename(path).startswith("."), status_code=404))),
(r"/downloads/logs/([^/]*)", util.tornado.LargeResponseHandler, dict(path=s.getBaseFolder("logs"), as_attachment=True, access_validation=util.tornado.access_validation_factory(app, loginManager, util.flask.admin_validator))),
(r"/downloads/timelapse/([^/]*\.mpg)", util.tornado.LargeResponseHandler, joined_dict(dict(path=s.getBaseFolder("timelapse")), download_handler_kwargs, no_hidden_files_validator)),
(r"/downloads/files/local/(.*)", util.tornado.LargeResponseHandler, joined_dict(dict(path=s.getBaseFolder("uploads")), download_handler_kwargs, no_hidden_files_validator, additional_mime_types)),
(r"/downloads/logs/([^/]*)", util.tornado.LargeResponseHandler, joined_dict(dict(path=s.getBaseFolder("logs")), download_handler_kwargs, admin_validator)),
# camera snapshot
(r"/downloads/camera/current", util.tornado.UrlForwardHandler, dict(url=s.get(["webcam", "snapshot"]), as_attachment=True, access_validation=util.tornado.access_validation_factory(app, loginManager, util.flask.user_validator))),
(r"/downloads/camera/current", util.tornado.UrlProxyHandler, dict(url=s.get(["webcam", "snapshot"]), as_attachment=True, access_validation=util.tornado.access_validation_factory(app, loginManager, util.flask.user_validator))),
# generated webassets
(r"/static/webassets/(.*)", util.tornado.LargeResponseHandler, dict(path=os.path.join(s.getBaseFolder("generated"), "webassets")))
]

View file

@ -768,6 +768,8 @@ class LargeResponseHandler(tornado.web.StaticFileHandler):
:class:``~tornado.web.StaticFileHandler`` as the ``default_filename`` keyword parameter). Defaults to ``None``.
as_attachment (bool): Whether to serve requested files with ``Content-Disposition: attachment`` header (``True``)
or not. Defaults to ``False``.
allow_client_caching (bool): Whether to allow the client to cache (by not setting any ``Cache-Control`` or
``Expires`` headers on the response) or not.
access_validation (function): Callback to call in the ``get`` method to validate access to the resource. Will
be called with ``self.request`` as parameter which contains the full tornado request object. Should raise
a ``tornado.web.HTTPError`` if access is not allowed in which case the request will not be further processed.
@ -776,13 +778,22 @@ class LargeResponseHandler(tornado.web.StaticFileHandler):
with the requested path as parameter. Should raise a ``tornado.web.HTTPError`` (e.g. an 404) if the requested
path does not pass validation in which case the request will not be further processed.
Defaults to ``None`` and hence no path validation being performed.
etag_generator (function): Callback to call for generating the value of the ETag response header. Will be
called with the response handler as parameter. May return ``None`` to prevent the ETag response header
from being set. If not provided the last modified time of the file in question will be used as returned
by ``get_content_version``.
"""
def initialize(self, path, default_filename=None, as_attachment=False, access_validation=None, path_validation=None):
def initialize(self, path, default_filename=None, as_attachment=False, allow_client_caching=True,
access_validation=None, path_validation=None, etag_generator=None,
mime_type_guesser=None):
tornado.web.StaticFileHandler.initialize(self, os.path.abspath(path), default_filename)
self._as_attachment = as_attachment
self._allow_client_caching = allow_client_caching
self._access_validation = access_validation
self._path_validation = path_validation
self._etag_generator = etag_generator
self._mime_type_guesser = mime_type_guesser
def get(self, path, include_body=True):
if self._access_validation is not None:
@ -800,6 +811,24 @@ class LargeResponseHandler(tornado.web.StaticFileHandler):
if self._as_attachment:
self.set_header("Content-Disposition", "attachment; filename=%s" % os.path.basename(path))
if not self._allow_client_caching:
self.set_header("Cache-Control", "max-age=0, must-revalidate, private")
self.set_header("Expires", "-1")
def compute_etag(self):
if self._etag_generator is not None:
return self._etag_generator(self)
else:
return self.get_content_version(self.absolute_path)
def get_content_type(self):
if self._mime_type_guesser is not None:
type = self._mime_type_guesser(self.absolute_path)
if type is not None:
return type
return tornado.web.StaticFileHandler.get_content_type(self)
@classmethod
def get_content_version(cls, abspath):
import os
@ -809,7 +838,7 @@ class LargeResponseHandler(tornado.web.StaticFileHandler):
##~~ URL Forward Handler for forwarding requests to a preconfigured static URL
class UrlForwardHandler(tornado.web.RequestHandler):
class UrlProxyHandler(tornado.web.RequestHandler):
"""
`tornado.web.RequestHandler <http://tornado.readthedocs.org/en/branch4.0/web.html#request-handlers>`_ that proxies
requests to a preconfigured url and returns the response. Allows delivery of the requested content as attachment
@ -819,7 +848,7 @@ class UrlForwardHandler(tornado.web.RequestHandler):
for making the request to the configured endpoint and return the body of the client response with the status code
from the client response and the following headers:
* ``Date``, ``Cache-Control``, ``Server``, ``Content-Type`` and ``Location`` will be copied over.
* ``Date``, ``Cache-Control``, ``Expires``, ``ETag``, ``Server``, ``Content-Type`` and ``Location`` will be copied over.
* If ``as_attachment`` is set to True, ``Content-Disposition`` will be set to ``attachment``. If ``basename`` is
set including the attachement's ``filename`` attribute will be set to the base name followed by the extension
guessed based on the MIME type from the ``Content-Type`` header of the response. If no extension can be guessed
@ -869,7 +898,7 @@ class UrlForwardHandler(tornado.web.RequestHandler):
filename = None
self.set_status(response.code)
for name in ("Date", "Cache-Control", "Server", "Content-Type", "Location"):
for name in ("Date", "Cache-Control", "Server", "Content-Type", "Location", "Expires", "ETag"):
value = response.headers.get(name)
if value:
self.set_header(name, value)

View file

@ -17,6 +17,8 @@ from octoprint.server import app, userManager, pluginManager, gettext, \
debug, LOCALES, VERSION, DISPLAY_VERSION, UI_API_KEY, BRANCH
from octoprint.settings import settings
import re
from . import util
import logging
@ -26,12 +28,21 @@ _templates = None
_plugin_names = None
_plugin_vars = None
_valid_id_re = re.compile("[a-z_]+")
_valid_div_re = re.compile("[a-zA-Z_-]+")
@app.route("/")
def index():
force_refresh = util.flask.cache_check_headers() or "_refresh" in request.values
global _templates, _plugin_names, _plugin_vars
# helper to check if wizards are active
def wizard_active(templates):
return templates is not None and bool(templates["wizard"]["order"])
# we force a refresh if the client forces one or if we have wizards cached
force_refresh = util.flask.cache_check_headers() or "_refresh" in request.values or wizard_active(_templates)
# if we need to refresh our template cache or it's not yet set, process it
if force_refresh or _templates is None or _plugin_names is None or _plugin_vars is None:
_templates, _plugin_names, _plugin_vars = _process_templates()
@ -53,9 +64,8 @@ def index():
break
else:
wizard = bool(_templates["wizard"]["order"])
wizard = wizard_active(_templates)
enable_accesscontrol = userManager is not None
render_kwargs.update(dict(
webcamStream=settings().get(["webcam", "stream"]),
enableTemperatureGraph=settings().get(["feature", "temperatureGraph"]),
@ -70,7 +80,8 @@ def index():
# no plugin took an interest, we'll use the default UI
def make_default_ui():
r = make_response(render_template("index.jinja2", **render_kwargs))
if bool(render_kwargs["templates"]["wizard"]["order"]):
if wizard:
# if we have active wizard dialogs, set non caching headers
r = util.flask.add_non_caching_response_headers(r)
return r
@ -466,6 +477,9 @@ def _process_template_config(name, implementation, rule, config=None, counter=1)
data["_div"] = rule["div"](name)
if "suffix" in data:
data["_div"] = data["_div"] + data["suffix"]
if not _valid_div_re.match(data["_div"]):
_logger.warn("Template config {} contains invalid div identifier {}, skipping it".format(name, data["_div"]))
return None
if not "template" in data:
data["template"] = rule["template"](name)
@ -477,6 +491,7 @@ def _process_template_config(name, implementation, rule, config=None, counter=1)
data_bind = "allowBindings: true"
if "data_bind" in data:
data_bind = data_bind + ", " + data["data_bind"]
data_bind = data_bind.replace("\"", "\\\"")
data["data_bind"] = data_bind
data["_key"] = "plugin_" + name

View file

@ -30,6 +30,8 @@ import logging
import re
import uuid
from octoprint.util import atomic_write
_APPNAME = "OctoPrint"
_instance = None
@ -1079,7 +1081,7 @@ class Settings(object):
path, _ = os.path.split(filename)
if not os.path.exists(path):
os.makedirs(path)
with open(filename, "w+") as f:
with atomic_write(filename, "wb") as f:
f.write(script)
def _default_basedir(applicationName):

File diff suppressed because one or more lines are too long

View file

@ -7,7 +7,6 @@ function DataUpdater(allViewModels) {
self._configHash = undefined;
self.reloadOverlay = $("#reloadui_overlay");
$("#reloadui_overlay_reload").click(function() { location.reload(true); });
self.connect = function() {
OctoPrint.socket.connect({debug: !!SOCKJS_DEBUG});

View file

@ -705,7 +705,7 @@ $(function() {
data = getOnlyChangedData(self.getLocalData(), self.lastReceivedSettings);
}
OctoPrint.settings.save(data)
return OctoPrint.settings.save(data)
.done(function(data, status, xhr) {
self.receiving(true);
self.sending(false);

View file

@ -232,6 +232,8 @@ $(function() {
var heaterOptions = self.heaterOptions();
if (!heaterOptions) return;
var maxTemps = [310/1.1];
_.each(_.keys(heaterOptions), function(type) {
if (type == "bed" && !self.hasBed()) {
return;
@ -258,12 +260,31 @@ $(function() {
color: pusher.color(heaterOptions[type].color).tint(0.5).html(),
data: targets
});
maxTemps.push(self.getMaxTemp(actuals, targets));
});
self.plotOptions.yaxis.max = Math.max.apply(null, maxTemps) * 1.1;
$.plot(graph, data, self.plotOptions);
}
};
self.getMaxTemp = function(actuals, targets) {
var pair;
var maxTemp = 0;
actuals.forEach(function(pair) {
if (pair[1] > maxTemp){
maxTemp = pair[1];
}
});
targets.forEach(function(pair) {
if (pair[1] > maxTemp){
maxTemp = pair[1];
}
});
return maxTemp;
}
self.setTarget = function(item) {
var value = item.newTarget();
if (!value) return;

View file

@ -6,6 +6,7 @@ $(function() {
self.settingsViewModel = parameters[1];
self.wizardDialog = undefined;
self.reloadOverlay = undefined;
self.allViewModels = undefined;
@ -40,6 +41,7 @@ $(function() {
self.onStartup = function() {
self.wizardDialog = $("#wizard_dialog");
self.reloadOverlay = $("#reloadui_overlay");
};
self.onUserLoggedIn = function() {
@ -126,8 +128,7 @@ $(function() {
.done(function() {
self.closeDialog();
if (reload) {
log.info("Wizard requested reloading");
location.reload(true);
self.reloadOverlay.show();
}
});
}
@ -144,12 +145,27 @@ $(function() {
};
self.finishWizard = function() {
var deferred = $.Deferred();
self.finishing = true;
self.settingsViewModel.saveData();
return OctoPrint.wizard.finish(self.wizards)
.always(function() {
self.finishing = false;
self.settingsViewModel.saveData()
.done(function() {
OctoPrint.wizard.finish(self.wizards)
.done(function() {
deferred.resolve(arguments);
})
.fail(function() {
deferred.reject(arguments);
})
.always(function() {
self.finishing = false;
});
})
.fail(function() {
deferred.reject(arguments);
});
return deferred;
};
self.onSettingsPreventRefresh = function() {

View file

@ -20,7 +20,7 @@
class="{% if mark_active %}active{% set mark_active = False %}{% endif %} {% if "classes_link" in data %}{{ data.classes_link|join(' ') }}{% elif "classes" in data %}{{ data.classes|join(' ') }}{% endif %}"
{% if "styles_link" in data %} style="{{ data.styles_link|join(', ') }}" {% elif "styles" in data %} style="{{ data.styles|join(', ') }}" {% endif %}
>
<a href="#{{ data._div }}" data-toggle="tab">{{ entry }}</a>
<a href="#{{ data._div }}" data-toggle="tab">{{ entry|e }}</a>
</li>
{% if "custom_bindings" not in data or data["custom_bindings"] %}<!-- /ko -->{% endif %}
{% endif %}

View file

@ -17,7 +17,7 @@
class="{% if mark_active %}active{% set mark_active = False %}{% endif %} {% if "classes_link" in data %}{{ data.classes_link|join(' ') }}{% elif "classes" in data %}{{ data.classes|join(' ') }}{% endif %}"
{% if "styles_link" in data %} style="{{ data.styles_link|join(', ') }}" {% elif "styles" in data %} style="{{ data.styles|join(', ') }}" {% endif %}
>
<a href="#{{ data._div }}" data-toggle="tab">{{ entry }}</a>
<a href="#{{ data._div }}" data-toggle="tab">{{ entry|e }}</a>
</li>
{% if "custom_bindings" not in data or data["custom_bindings"] %}<!-- /ko -->{% endif %}
{% endif %}

View file

@ -55,7 +55,7 @@
>
<div class="accordion-heading">
<a class="accordion-toggle" data-toggle="collapse" data-target="#{{ data._div }}">
{% if "icon" in data %}<i class="icon-{{ data.icon }}"></i> {% endif %}{{ entry }}
{% if "icon" in data %}<i class="icon-{{ data.icon }}"></i> {% endif %}{{ entry|e }}
</a>
{% if "template_header" in data %}
{% include data.template_header ignore missing %}
@ -87,7 +87,7 @@
{% if "data_bind" in data %}data-bind="{{ data.data_bind }}"{% endif %}
{% if "styles_link" in data %} style="{{ data.styles_link|join(', ') }}" {% elif "styles" in data %} style="{{ data.styles|join(', ') }}" {% endif %}
>
<a href="#{{ data._div }}" data-toggle="tab">{{ entry }}</a>
<a href="#{{ data._div }}" data-toggle="tab">{{ entry|e }}</a>
</li>
{% if "custom_bindings" not in data or data["custom_bindings"] %}<!-- /ko -->{% endif %}
{% endfor %}
@ -112,7 +112,7 @@
</div>
<div class="footer">
<ul class="pull-left muted">
<li><small>{{ _('Version') }}: <span class="version">{{ display_version }}</span></small></li>
<li><small>{{ _('Version') }}: <span class="version">{{ display_version|e }}</span></small></li>
</ul>
<ul class="pull-right">
<li><a href="http://octoprint.org"><i class="icon-home"></i> {{ _('Homepage') }}</a></li>

View file

@ -10,13 +10,13 @@
var CONFIG_TIMELAPSEFILESPERPAGE = 10;
var CONFIG_LOGFILESPERPAGE = 10;
var CONFIG_USERSPERPAGE = 10;
var CONFIG_WEBCAM_STREAM = "{{ webcamStream }}";
var CONFIG_WEBCAM_STREAM = "{{ webcamStream|e }}";
var CONFIG_ACCESS_CONTROL = {% if enableAccessControl -%} true; {% else %} false; {%- endif %}
var CONFIG_SD_SUPPORT = {% if enableSdSupport -%} true; {% else %} false; {%- endif %}
var CONFIG_FIRST_RUN = {% if firstRun -%} true; {% else %} false; {%- endif %}
var CONFIG_TEMPERATURE_GRAPH = {% if enableTemperatureGraph -%} true; {% else %} false; {%- endif %}
var CONFIG_GCODE_SIZE_THRESHOLD = {{ gcodeThreshold }};
var CONFIG_GCODE_MOBILE_SIZE_THRESHOLD = {{ gcodeMobileThreshold }};
var CONFIG_GCODE_SIZE_THRESHOLD = {{ gcodeThreshold|e }};
var CONFIG_GCODE_MOBILE_SIZE_THRESHOLD = {{ gcodeMobileThreshold|e }};
var CONFIG_WIZARD = {% if wizard -%} true; {% else %} false; {%- endif %}
var SOCKJS_URI = "{{ url_for('index') }}" + "sockjs";
@ -24,11 +24,11 @@
// sockjs should define CLOSE_NORMAL for us, but they don't (from ws spec)
var SOCKJS_CLOSE_NORMAL = 1000;
var UI_API_KEY = "{{ uiApiKey }}";
var VERSION = "{{ version.number }}";
var DISPLAY_VERSION = "{{ version.display }}";
var BRANCH = "{{ version.branch }}";
var LOCALE = "{{ g.locale }}";
var UI_API_KEY = "{{ uiApiKey|e }}";
var VERSION = "{{ version.number|e }}";
var DISPLAY_VERSION = "{{ version.display|e }}";
var BRANCH = "{{ version.branch|e }}";
var LOCALE = "{{ g.locale|e }}";
var AVAILABLE_LOCALES = {{ locales|tojson }};
var OCTOPRINT_VIEWMODELS = [];

View file

@ -4,9 +4,14 @@
<div class="container">
<div class="hero-unit">
<h1>{{ _('Please reload') }}</h1>
<p>{{ _('There is a new version of the server active now, a reload of the user interface is needed. This will not interrupt any print jobs you might have ongoing. Please reload the web interface now by clicking the button below.') }}</p>
<p>{% trans %}
There is a new version of the server active now, a reload
of the user interface is needed. This will not interrupt
any print jobs you might have ongoing. Please reload the
web interface now by clicking the button below.
{% endtrans %}</p>
<p>
<a class="btn btn-primary btn-large" id="reloadui_overlay_reload">{{ _('Reload now') }}</a>
<a class="btn btn-primary btn-large" title="{{ _('Reload now') }}" href="javascript:void(0)" onclick="location.reload(true); return false;">{{ _('Reload now') }}</a>
</p>
</div>
</div>

View file

@ -98,7 +98,7 @@
<h1>{{ _('General') }}</h1>
<div>
<button class="btn btn-block control-box" data-bind="enable: isOperational() && !isPrinting() && loginState.isUser(), click: function() { $root.sendCustomCommand({type:'command',command:'M18'}) }">{{ _('Motors off') }}</button>
<button class="btn btn-block control-box" data-bind="enable: isOperational() && loginState.isUser(), click: function() { $root.sendCustomCommand({type:'command',command:'M106'}) }">{{ _('Fan on') }}</button>
<button class="btn btn-block control-box" data-bind="enable: isOperational() && loginState.isUser(), click: function() { $root.sendCustomCommand({type:'command',command:'M106 S255'}) }">{{ _('Fan on') }}</button>
<button class="btn btn-block control-box" data-bind="enable: isOperational() && loginState.isUser(), click: function() { $root.sendCustomCommand({type:'command',command:'M106 S0'}) }">{{ _('Fan off') }}</button>
</div>
</div>

View file

@ -7,10 +7,11 @@
<label for="webcam_timelapse_mode">{{ _('Timelapse Mode') }}</label>
<select id="webcam_timelapse_mode" data-bind="value: timelapseType, enable: isOperational() && !isPrinting() && loginState.isUser()">
<option value="off">{{ _('Off') }}</option>
<option value="zchange">{{ _('On Z Change') }}</option>
<option value="timed">{{ _('Timed') }}</option>
<option value="zchange">{{ _('On Z Change') }}</option>
</select>
<span class="help-block" data-bind="visible: timelapseType() == 'zchange'"><span class="label label-warning">{{ _('Warning') }}</span> {{ _('Do not use with spiralized ("Joris") vases or similar continuous Z models.') }}</span>
<span class="help-block" data-bind="visible: timelapseType() == 'zchange'"><span class="label label-info">{{ _('Note') }}</span> {% trans %}Does not work when printing from the printer's SD Card (no way to detect the change in Z reliably). Use "Timed" mode for those prints instead.{% endtrans %}</span>
<label for="webcam_timelapse_fps">{{ _('Timelapse frame rate (in frames per second)') }}</label>
<div class="input-append">

View file

@ -17,6 +17,8 @@ import logging
from octoprint.settings import settings
from octoprint.util import atomic_write
class UserManager(object):
valid_roles = ["user", "admin"]
@ -217,7 +219,7 @@ class FilebasedUserManager(UserManager):
"settings": user._settings
}
with open(self._userfile, "wb") as f:
with atomic_write(self._userfile, "wb") as f:
yaml.safe_dump(data, f, default_flow_style=False, indent=" ", allow_unicode=True)
self._dirty = False
self._load()

View file

@ -510,6 +510,29 @@ def atomic_write(filename, mode="w+b", prefix="tmp", suffix=""):
shutil.move(temp_config.name, filename)
def bom_aware_open(filename, encoding="ascii", mode="r", **kwargs):
import codecs
codec = codecs.lookup(encoding)
encoding = codec.name
if kwargs is None:
kwargs = dict()
potential_bom_attribute = "BOM_" + codec.name.replace("utf-", "utf").upper()
if "r" in mode and hasattr(codecs, potential_bom_attribute):
# these encodings might have a BOM, so let's see if there is one
bom = getattr(codecs, potential_bom_attribute)
with open(filename, "rb") as f:
header = f.read(4)
if header.startswith(bom):
encoding += "-sig"
return codecs.open(filename, encoding=encoding, **kwargs)
class RepeatedTimer(threading.Thread):
"""
This class represents an action that should be run repeatedly in an interval. It is similar to python's

View file

@ -24,7 +24,7 @@ from octoprint.settings import settings, default_settings
from octoprint.events import eventManager, Events
from octoprint.filemanager import valid_file_type
from octoprint.filemanager.destinations import FileDestinations
from octoprint.util import get_exception_string, sanitize_ascii, filter_non_ascii, CountedEvent, RepeatedTimer
from octoprint.util import get_exception_string, sanitize_ascii, filter_non_ascii, CountedEvent, RepeatedTimer, to_unicode, bom_aware_open
try:
import _winreg
@ -548,7 +548,7 @@ class MachineCom(object):
self._clear_to_send.set()
def sendCommand(self, cmd, cmd_type=None, processed=False, force=False):
cmd = cmd.encode('ascii', 'replace')
cmd = to_unicode(cmd, errors="replace")
if not processed:
cmd = process_gcode_line(cmd)
if not cmd:
@ -1631,10 +1631,11 @@ class MachineCom(object):
command_allowing_checksum = gcode is not None or self._sendChecksumWithUnknownCommands
checksum_enabled = self._alwaysSendChecksum or (self.isPrinting() and not self._neverSendChecksum)
command_to_send = command.encode("ascii", errors="replace")
if command_requiring_checksum or (command_allowing_checksum and checksum_enabled):
self._doIncrementAndSendWithChecksum(command)
self._doIncrementAndSendWithChecksum(command_to_send)
else:
self._doSendWithoutChecksum(command)
self._doSendWithoutChecksum(command_to_send)
# trigger "sent" phase and use up one "ok"
self._process_command_phase("sent", command, command_type, gcode=gcode)
@ -2039,7 +2040,7 @@ class PrintingGcodeFileInformation(PrintingFileInformation):
Opens the file for reading and determines the file size.
"""
PrintingFileInformation.start(self)
self._handle = open(self._filename, "r")
self._handle = bom_aware_open(self._filename, encoding="utf-8", errors="replace")
def close(self):
"""
@ -2069,7 +2070,7 @@ class PrintingGcodeFileInformation(PrintingFileInformation):
if self._handle is None:
# file got closed just now
return None
line = self._handle.readline()
line = to_unicode(self._handle.readline())
if not line:
self.close()
processed = process_gcode_line(line, offsets=offsets, current_tool=current_tool)

View file

@ -35,7 +35,9 @@ class gcode(object):
if os.path.isfile(filename):
self.filename = filename
self._fileSize = os.stat(filename).st_size
with open(filename, "r") as f:
import codecs
with codecs.open(filename, encoding="utf-8", errors="replace") as f:
self._load(f, printer_profile, throttle=throttle)
def abort(self):

View file

@ -8,4 +8,3 @@ from __future__ import absolute_import
__author__ = "Gina Häußge <osd@foosel.net>"
__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html'
__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License"

View file

@ -13,6 +13,98 @@ import mock
import octoprint.filemanager
import octoprint.filemanager.util
class FilemanagerMethodTest(unittest.TestCase):
def setUp(self):
# mock plugin manager
self.plugin_manager_patcher = mock.patch("octoprint.plugin.plugin_manager")
self.plugin_manager_getter = self.plugin_manager_patcher.start()
self.plugin_manager = mock.MagicMock()
hook_extensions = dict(
some_plugin=lambda: dict(dict(machinecode=dict(foo=["foo", "f"]))),
other_plugin=lambda: dict(dict(model=dict(amf=["amf"]))),
mime_map=lambda: dict(
mime_map=dict(
mime_map_yes=octoprint.filemanager.ContentTypeMapping(["mime_map_yes"], "application/mime_map_yes")
)
),
mime_detect=lambda: dict(
dict(
machinecode=dict(
mime_detect_yes=octoprint.filemanager.ContentTypeDetector(["mime_detect_yes"], lambda x: "application/mime_detect_yes"),
mime_detect_no=octoprint.filemanager.ContentTypeDetector(["mime_detect_no"], lambda x: None)
)
)
)
)
self.plugin_manager.get_hooks.return_value = hook_extensions
self.plugin_manager_getter.return_value = self.plugin_manager
def tearDown(self):
self.plugin_manager_patcher.stop()
def test_full_extension_tree(self):
full = octoprint.filemanager.full_extension_tree()
self.assertTrue("machinecode" in full)
self.assertTrue("gcode" in full["machinecode"])
self.assertTrue(isinstance(full["machinecode"]["gcode"], octoprint.filemanager.ContentTypeMapping))
self.assertItemsEqual(["gcode", "gco", "g"], full["machinecode"]["gcode"].extensions)
self.assertTrue("foo" in full["machinecode"])
self.assertTrue(isinstance(full["machinecode"]["foo"], list))
self.assertItemsEqual(["f", "foo"], full["machinecode"]["foo"])
self.assertTrue("model" in full)
self.assertTrue("stl" in full["model"])
self.assertTrue(isinstance(full["model"]["stl"], octoprint.filemanager.ContentTypeMapping))
self.assertItemsEqual(["stl"], full["model"]["stl"].extensions)
self.assertTrue("amf" in full["model"])
self.assertTrue(isinstance(full["model"]["amf"], list))
self.assertItemsEqual(["amf"], full["model"]["amf"])
def test_get_mimetype(self):
self.assertEquals(octoprint.filemanager.get_mime_type("foo.stl"), "application/sla")
self.assertEquals(octoprint.filemanager.get_mime_type("foo.gcode"), "text/plain")
self.assertEquals(octoprint.filemanager.get_mime_type("foo.unknown"), "application/octet-stream")
self.assertEquals(octoprint.filemanager.get_mime_type("foo.mime_map_yes"), "application/mime_map_yes")
self.assertEquals(octoprint.filemanager.get_mime_type("foo.mime_map_no"), "application/octet-stream")
self.assertEquals(octoprint.filemanager.get_mime_type("foo.mime_detect_yes"), "application/mime_detect_yes")
self.assertEquals(octoprint.filemanager.get_mime_type("foo.mime_detect_no"), "application/octet-stream")
def test_valid_file_type(self):
self.assertTrue(octoprint.filemanager.valid_file_type("foo.stl", type="model"))
self.assertTrue(octoprint.filemanager.valid_file_type("foo.stl", type="stl"))
self.assertFalse(octoprint.filemanager.valid_file_type("foo.stl", type="machinecode"))
self.assertTrue(octoprint.filemanager.valid_file_type("foo.foo", type="machinecode"))
self.assertTrue(octoprint.filemanager.valid_file_type("foo.foo", type="foo"))
self.assertTrue(octoprint.filemanager.valid_file_type("foo.foo"))
self.assertTrue(octoprint.filemanager.valid_file_type("foo.mime_map_yes"))
self.assertTrue(octoprint.filemanager.valid_file_type("foo.mime_detect_yes"))
self.assertFalse(octoprint.filemanager.valid_file_type("foo.unknown"))
def test_get_file_type(self):
self.assertEquals(["machinecode", "gcode"], octoprint.filemanager.get_file_type("foo.gcode"))
self.assertEquals(["machinecode", "gcode"], octoprint.filemanager.get_file_type("foo.gco"))
self.assertEquals(["machinecode", "foo"], octoprint.filemanager.get_file_type("foo.f"))
self.assertEquals(["model", "stl"], octoprint.filemanager.get_file_type("foo.stl"))
self.assertEquals(["model", "amf"], octoprint.filemanager.get_file_type("foo.amf"))
self.assertIsNone(octoprint.filemanager.get_file_type("foo.unknown"))
def test_hook_failure(self):
def hook():
raise RuntimeError("Boo!")
self.plugin_manager.get_hooks.return_value = dict(hook=hook)
with mock.patch("octoprint.filemanager.logging") as patched_logging:
logger = mock.MagicMock()
patched_logging.getLogger.return_value = logger
octoprint.filemanager.get_all_extensions()
self.assertEquals(1, len(logger.mock_calls))
class FileManagerTest(unittest.TestCase):
def setUp(self):
@ -109,13 +201,13 @@ class FileManagerTest(unittest.TestCase):
self.assertEquals(metadata, expected)
self.local_storage.get_metadata.assert_called_once_with("test.file")
@mock.patch("__builtin__.open", new_callable=mock.mock_open)
@mock.patch("octoprint.filemanager.util.atomic_write")
@mock.patch("io.FileIO")
@mock.patch("shutil.copyfileobj")
@mock.patch("os.remove")
@mock.patch("tempfile.NamedTemporaryFile")
@mock.patch("time.time", side_effect=[1411979916.422, 1411979932.116])
def test_slice(self, mocked_time, mocked_tempfile, mocked_os, mocked_shutil, mocked_fileio, mocked_open):
def test_slice(self, mocked_time, mocked_tempfile, mocked_os, mocked_shutil, mocked_fileio, mocked_atomic_write):
callback = mock.MagicMock()
callback_args = ("one", "two", "three")
@ -187,8 +279,8 @@ class FileManagerTest(unittest.TestCase):
self.local_storage.add_file.assert_called_once_with("dest.file", mock.ANY, printer_profile=expected_printer_profile, allow_overwrite=True, links=expected_links)
# assert that the generated gcode was manipulated as required
expected_open_calls = [mock.call("prefix/dest.file", "wb")]
self.assertEquals(mocked_open.call_args_list, expected_open_calls)
expected_atomic_write_calls = [mock.call("prefix/dest.file", "wb")]
self.assertEquals(mocked_atomic_write.call_args_list, expected_atomic_write_calls)
#mocked_open.return_value.write.assert_called_once_with(";Generated from source.file aabbccddeeff\r")
# assert that shutil was asked to copy the concatenated multistream