diff --git a/horizon/forms/__init__.py b/horizon/forms/__init__.py index 997199ea33..8a0df8af8a 100644 --- a/horizon/forms/__init__.py +++ b/horizon/forms/__init__.py @@ -28,6 +28,8 @@ from horizon.forms.base import SelfHandlingForm # noqa from horizon.forms.base import SelfHandlingMixin # noqa from horizon.forms.fields import DynamicChoiceField # noqa from horizon.forms.fields import DynamicTypedChoiceField # noqa +from horizon.forms.fields import ExternalFileField # noqa +from horizon.forms.fields import ExternalUploadMeta # noqa from horizon.forms.fields import IPField # noqa from horizon.forms.fields import IPv4 # noqa from horizon.forms.fields import IPv6 # noqa diff --git a/horizon/forms/fields.py b/horizon/forms/fields.py index c928b5387f..9c6564b66c 100644 --- a/horizon/forms/fields.py +++ b/horizon/forms/fields.py @@ -22,6 +22,7 @@ import uuid from django.core.exceptions import ValidationError # noqa from django.core import urlresolvers from django.forms import fields +from django.forms import forms from django.forms.utils import flatatt # noqa from django.forms import widgets from django.template import Context # noqa @@ -380,3 +381,53 @@ class ThemableCheckboxFieldRenderer(widgets.CheckboxFieldRenderer): class ThemableCheckboxSelectMultiple(widgets.CheckboxSelectMultiple): renderer = ThemableCheckboxFieldRenderer _empty_value = [] + + +class ExternalFileField(fields.FileField): + """A special flavor of FileField which is meant to be used in cases when + instead of uploading file to Django it should be uploaded to some external + location, while the form validation is done as usual. Should be paired + with ExternalUploadMeta metaclass embedded into the Form class. + """ + def __init__(self, *args, **kwargs): + super(ExternalFileField, self).__init__(*args, **kwargs) + self.widget.attrs.update({'data-external-upload': 'true'}) + + +class ExternalUploadMeta(forms.DeclarativeFieldsMetaclass): + """Set this class as the metaclass of a form that contains + ExternalFileField in order to process ExternalFileField fields in a + specific way. A hidden CharField twin of FieldField is created which + contains just the filename (if any file was selected on browser side) and + a special `clean` method for FileField is defined which extracts just file + name. This allows to avoid actual file upload to Django server, yet + process form clean() phase as usual. Actual file upload happens entirely + on client-side. + """ + def __new__(mcs, name, bases, attrs): + def get_double_name(name): + suffix = '__hidden' + slen = len(suffix) + return name[:-slen] if name.endswith(suffix) else name + suffix + + def make_clean_method(field_name): + def _clean_method(self): + value = self.cleaned_data[field_name] + if value: + self.cleaned_data[get_double_name(field_name)] = value + return value + return _clean_method + + new_attrs = {} + for attr_name, attr in attrs.items(): + new_attrs[attr_name] = attr + if isinstance(attr, ExternalFileField): + hidden_field = fields.CharField(widget=fields.HiddenInput, + required=False) + hidden_field.creation_counter = attr.creation_counter + 1000 + new_attr_name = get_double_name(attr_name) + new_attrs[new_attr_name] = hidden_field + meth_name = 'clean_' + new_attr_name + new_attrs[meth_name] = make_clean_method(new_attr_name) + return super(ExternalUploadMeta, mcs).__new__( + mcs, name, bases, new_attrs) diff --git a/horizon/forms/views.py b/horizon/forms/views.py index 3cc7263ae4..0f7316cdc4 100644 --- a/horizon/forms/views.py +++ b/horizon/forms/views.py @@ -191,6 +191,11 @@ class ModalFormView(ModalFormMixin, views.HorizonFormView): else: success_url = self.get_success_url() response = http.HttpResponseRedirect(success_url) + if hasattr(handled, 'to_dict'): + obj_dict = handled.to_dict() + if 'upload_url' in obj_dict: + response['X-File-Upload-URL'] = obj_dict['upload_url'] + response['X-Auth-Token'] = obj_dict['token_id'] # TODO(gabriel): This is not a long-term solution to how # AJAX should be handled, but it's an expedient solution # until the blueprint for AJAX handling is architected diff --git a/horizon/middleware/base.py b/horizon/middleware/base.py index f0ba39436e..1d60e1c210 100644 --- a/horizon/middleware/base.py +++ b/horizon/middleware/base.py @@ -149,6 +149,11 @@ class HorizonMiddleware(object): # the user *on* the login form... return shortcuts.redirect(exception.location) + @staticmethod + def copy_headers(src, dst, headers): + for header in headers: + dst[header] = src[header] + def process_response(self, request, response): """Convert HttpResponseRedirect to HttpResponse if request is via ajax to allow ajax request to redirect url @@ -183,6 +188,10 @@ class HorizonMiddleware(object): redirect_response.set_cookie( cookie_name, cookie.value, **cookie_kwargs) redirect_response['X-Horizon-Location'] = response['location'] + upload_url_key = 'X-File-Upload-URL' + if upload_url_key in response: + self.copy_headers(response, redirect_response, + (upload_url_key, 'X-Auth-Token')) return redirect_response if queued_msgs: # TODO(gabriel): When we have an async connection to the diff --git a/horizon/static/horizon/js/horizon.modals.js b/horizon/static/horizon/js/horizon.modals.js index 558bb0ed3e..1574e1afeb 100644 --- a/horizon/static/horizon/js/horizon.modals.js +++ b/horizon/static/horizon/js/horizon.modals.js @@ -14,6 +14,7 @@ horizon.modals = { // Storage for our current jqXHR object. _request: null, spinner: null, + progress_bar: null, _init_functions: [] }; @@ -61,6 +62,21 @@ horizon.modals.modal_spinner = function (text) { horizon.modals.spinner.find(".modal-body").spin(horizon.conf.spinner_options.modal); }; +horizon.modals.progress_bar = function (text) { + var template = horizon.templates.compiled_templates["#progress-modal"]; + horizon.modals.bar = $(template.render({text: text})) + .appendTo("#modal_wrapper"); + horizon.modals.bar.modal({backdrop: 'static'}); + + var $progress_bar = horizon.modals.bar.find('.progress-bar'); + horizon.modals.progress_bar.update = function(fraction) { + var percent = Math.round(100 * fraction) + '%'; + $progress_bar + .css('width', Math.round(100 * fraction) + '%') + .parents('.progress-text').find('.progress-bar-text').text(percent); + }; +}; + horizon.modals.init_wizard = function () { // If workflow is in wizard mode, initialize wizard. var _max_visited_step = 0; @@ -176,6 +192,54 @@ horizon.modals.init_wizard = function () { }); }; +horizon.modals.getUploadUrl = function(jqXHR) { + return jqXHR.getResponseHeader("X-File-Upload-URL"); +}; + +horizon.modals.fileUpload = function(url, file, jqXHR) { + var token = jqXHR.getResponseHeader('X-Auth-Token'); + + horizon.modals.progress_bar(gettext("Uploading image")); + return $.ajax({ + type: 'PUT', + url: url, + xhrFields: { + withCredentials: true + }, + headers: { + 'X-Auth-Token': token + }, + data: file, + processData: false, // tell jQuery not to process the data + contentType: 'application/octet-stream', + xhr: function() { + var xhr = new window.XMLHttpRequest(); + xhr.upload.addEventListener('progress', function(evt) { + if (evt.lengthComputable) { + horizon.modals.progress_bar.update(evt.loaded / evt.total); + } + }, false); + return xhr; + } + }); +}; + +horizon.modals.prepareFileUpload = function($form) { + var $elem = $form.find('input[data-external-upload]'); + if (!$elem.length) { + return undefined; + } + var file = $elem.get(0).files[0]; + var $hiddenPseudoFile = $form.find('input[name="' + $elem.attr('name') + '__hidden"]'); + if (file) { + $hiddenPseudoFile.val(file.name); + $elem.remove(); + return file; + } else { + $hiddenPseudoFile.val(''); + return undefined; + } +}; horizon.addInitFunction(horizon.modals.init = function() { @@ -200,7 +264,7 @@ horizon.addInitFunction(horizon.modals.init = function() { update_field_id = $form.attr("data-add-to-field"), headers = {}, modalFileUpload = $form.attr("enctype") === "multipart/form-data", - formData, ajaxOpts, featureFileList, featureFormData; + formData, ajaxOpts, featureFileList, featureFormData, file; if (modalFileUpload) { featureFileList = $("").get(0).files !== undefined; @@ -213,6 +277,7 @@ horizon.addInitFunction(horizon.modals.init = function() { // modal forms won't work in them (namely, IE9). return; } else { + file = horizon.modals.prepareFileUpload($form); formData = new window.FormData(form); } } else { @@ -227,6 +292,38 @@ horizon.addInitFunction(horizon.modals.init = function() { headers["X-Horizon-Add-To-Field"] = update_field_id; } + function processServerSuccess(data, textStatus, jqXHR) { + var redirect_header = jqXHR.getResponseHeader("X-Horizon-Location"), + add_to_field_header = jqXHR.getResponseHeader("X-Horizon-Add-To-Field"), + json_data, field_to_update; + if (redirect_header === null) { + $('.ajax-modal, .dropdown-toggle').removeAttr("disabled"); + } + + if (redirect_header) { + location.href = redirect_header; + } else if (add_to_field_header) { + json_data = $.parseJSON(data); + field_to_update = $("#" + add_to_field_header); + field_to_update.append(""); + field_to_update.change(); + field_to_update.val(json_data[0]); + } else { + horizon.modals.success(data, textStatus, jqXHR); + } + } + + function processServerError(jqXHR, textStatus, errorThrown, $formElement) { + $formElement = $formElement || $form; + if (jqXHR.getResponseHeader('logout')) { + location.href = jqXHR.getResponseHeader("X-Horizon-Location"); + } else { + $('.ajax-modal, .dropdown-toggle').removeAttr("disabled"); + $formElement.closest(".modal").modal("hide"); + horizon.alert("danger", gettext("There was an error submitting the form. Please try again.")); + } + } + ajaxOpts = { type: "POST", url: $form.attr('action'), @@ -246,35 +343,25 @@ horizon.addInitFunction(horizon.modals.init = function() { $button.prop("disabled", false); }, success: function (data, textStatus, jqXHR) { - var redirect_header = jqXHR.getResponseHeader("X-Horizon-Location"), - add_to_field_header = jqXHR.getResponseHeader("X-Horizon-Add-To-Field"), - json_data, field_to_update; - if (redirect_header === null) { - $('.ajax-modal, .dropdown-toggle').removeAttr("disabled"); - } + var promise; + var uploadUrl = horizon.modals.getUploadUrl(jqXHR); $form.closest(".modal").modal("hide"); - if (redirect_header) { - location.href = redirect_header; + if (uploadUrl) { + promise = horizon.modals.fileUpload(uploadUrl, file, jqXHR); } - else if (add_to_field_header) { - json_data = $.parseJSON(data); - field_to_update = $("#" + add_to_field_header); - field_to_update.append(""); - field_to_update.change(); - field_to_update.val(json_data[0]); + if (promise) { + promise.then(function() { + // ignore data resolved in asyncUpload promise + processServerSuccess(data, textStatus, jqXHR); + }, function(jqXHR, statusText, errorThrown) { + var $progressBar = horizon.modals.bar.find('.progress-bar'); + processServerError(jqXHR, statusText, errorThrown, $progressBar); + }); } else { - horizon.modals.success(data, textStatus, jqXHR); + processServerSuccess(data, textStatus, jqXHR); } }, - error: function (jqXHR) { - if (jqXHR.getResponseHeader('logout')) { - location.href = jqXHR.getResponseHeader("X-Horizon-Location"); - } else { - $('.ajax-modal, .dropdown-toggle').removeAttr("disabled"); - $form.closest(".modal").modal("hide"); - horizon.alert("danger", gettext("There was an error submitting the form. Please try again.")); - } - } + error: processServerError }; if (modalFileUpload) { diff --git a/horizon/static/horizon/js/horizon.templates.js b/horizon/static/horizon/js/horizon.templates.js index 244c618fe5..5796cc4dec 100644 --- a/horizon/static/horizon/js/horizon.templates.js +++ b/horizon/static/horizon/js/horizon.templates.js @@ -7,7 +7,8 @@ horizon.templates = { "#alert_message_template", "#spinner-modal", "#membership_template", - "#confirm_modal" + "#confirm_modal", + "#progress-modal" ], compiled_templates: {} }; diff --git a/horizon/templates/bootstrap/progress_bar.html b/horizon/templates/bootstrap/progress_bar.html index 05be8c333e..8bc5e718b5 100644 --- a/horizon/templates/bootstrap/progress_bar.html +++ b/horizon/templates/bootstrap/progress_bar.html @@ -1,6 +1,10 @@ {% load horizon %} {% minifyspace %} +{% if text %} +