Merge "[Django] Allow to upload the image directly to Glance service"
This commit is contained in:
commit
70596b1d00
horizon
forms
middleware
static/horizon/js
templates
templatetags
openstack_dashboard
dashboards/project/images/images
static/dashboard/scss/components
test/test_data
releasenotes/notes
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 = $("<input type='file'/>").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("<option value='" + json_data[0] + "'>" + json_data[1] + "</option>");
|
||||
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("<option value='" + json_data[0] + "'>" + json_data[1] + "</option>");
|
||||
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) {
|
||||
|
@ -7,7 +7,8 @@ horizon.templates = {
|
||||
"#alert_message_template",
|
||||
"#spinner-modal",
|
||||
"#membership_template",
|
||||
"#confirm_modal"
|
||||
"#confirm_modal",
|
||||
"#progress-modal"
|
||||
],
|
||||
compiled_templates: {}
|
||||
};
|
||||
|
@ -1,6 +1,10 @@
|
||||
{% load horizon %}
|
||||
|
||||
{% minifyspace %}
|
||||
{% if text %}
|
||||
<div class="progress-text">
|
||||
{% endif %}
|
||||
|
||||
<div class="progress">
|
||||
{% for this_bar in bars %}
|
||||
<div class="progress-bar
|
||||
@ -17,7 +21,7 @@
|
||||
aria-valuenow="{{ this_bar.percent }}"
|
||||
aria-valuemin="{{ min_val }}"
|
||||
aria-valuemax="{{ max_val }}"
|
||||
style="width: {{ this_bar.percent }}%;{% if text %} min-width: 2em;{% endif %}">
|
||||
style="width: {{ this_bar.percent }}%;">
|
||||
{% if not text %}
|
||||
<span class="sr-only">
|
||||
{{ this_bar.percent }}%
|
||||
@ -26,4 +30,9 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if text %}
|
||||
<span class="progress-bar-text">{{ text }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endminifyspace %}
|
||||
|
19
horizon/templates/horizon/client_side/_progress.html
Normal file
19
horizon/templates/horizon/client_side/_progress.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% extends "horizon/client_side/template.html" %}
|
||||
{% load i18n horizon bootstrap %}
|
||||
|
||||
{% block id %}progress-modal{% endblock %}
|
||||
|
||||
{% block template %}{% spaceless %}{% jstemplate %}
|
||||
<div class="modal loading">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-body">
|
||||
<div class="modal-progress-loader">
|
||||
{% bs_progress_bar 0 text="0%" %}
|
||||
<div class="progress-label text-center h4">[[text]]</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endjstemplate %}{% endspaceless %}{% endblock %}
|
@ -4,3 +4,4 @@
|
||||
{% include "horizon/client_side/_loading.html" %}
|
||||
{% include "horizon/client_side/_membership.html" %}
|
||||
{% include "horizon/client_side/_confirm.html" %}
|
||||
{% include "horizon/client_side/_progress.html" %}
|
@ -29,7 +29,7 @@ def bs_progress_bar(*args, **kwargs):
|
||||
param args (Array of Numbers: 0-100): Percent of Progress Bars
|
||||
param context (String): Adds 'progress-bar-{context} to the class attribute
|
||||
param contexts (Array of Strings): Cycles through contexts for stacked bars
|
||||
param text (Boolean): True: shows value within the bar, False: uses sr span
|
||||
param text (String): True: shows value within the bar, False: uses sr span
|
||||
param striped (Boolean): Adds 'progress-bar-striped' to the class attribute
|
||||
param animated (Boolean): Adds 'active' to the class attribute if striped
|
||||
param min_val (0): Used for the aria-min value
|
||||
|
@ -26,6 +26,7 @@ from django.forms import ValidationError # noqa
|
||||
from django.forms.widgets import HiddenInput # noqa
|
||||
from django.template import defaultfilters
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
import six
|
||||
|
||||
from horizon import exceptions
|
||||
from horizon import forms
|
||||
@ -86,7 +87,16 @@ def create_image_metadata(data):
|
||||
return meta
|
||||
|
||||
|
||||
class CreateImageForm(forms.SelfHandlingForm):
|
||||
if api.glance.get_image_upload_mode() == 'direct':
|
||||
FileField = forms.ExternalFileField
|
||||
CreateParent = six.with_metaclass(forms.ExternalUploadMeta,
|
||||
forms.SelfHandlingForm)
|
||||
else:
|
||||
FileField = forms.FileField
|
||||
CreateParent = forms.SelfHandlingForm
|
||||
|
||||
|
||||
class CreateImageForm(CreateParent):
|
||||
name = forms.CharField(max_length=255, label=_("Name"))
|
||||
description = forms.CharField(
|
||||
max_length=255,
|
||||
@ -121,10 +131,10 @@ class CreateImageForm(forms.SelfHandlingForm):
|
||||
'ng-change': 'ctrl.selectImageFormat(ctrl.imageFile.name)',
|
||||
'image-file-on-change': None
|
||||
}
|
||||
image_file = forms.FileField(label=_("Image File"),
|
||||
help_text=_("A local image to upload."),
|
||||
widget=forms.FileInput(attrs=image_attrs),
|
||||
required=False)
|
||||
image_file = FileField(label=_("Image File"),
|
||||
help_text=_("A local image to upload."),
|
||||
widget=forms.FileInput(attrs=image_attrs),
|
||||
required=False)
|
||||
kernel = forms.ChoiceField(
|
||||
label=_('Kernel'),
|
||||
required=False,
|
||||
@ -274,7 +284,7 @@ class CreateImageForm(forms.SelfHandlingForm):
|
||||
if (api.glance.get_image_upload_mode() != 'off' and
|
||||
policy.check((("image", "upload_image"),), request) and
|
||||
data.get('image_file', None)):
|
||||
meta['data'] = self.files['image_file']
|
||||
meta['data'] = data['image_file']
|
||||
elif data['is_copying']:
|
||||
meta['copy_from'] = data['image_url']
|
||||
else:
|
||||
|
@ -8,7 +8,7 @@
|
||||
}
|
||||
|
||||
.progress-bar-text {
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
line-height: 1.5em;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
@ -18,7 +18,6 @@
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@include text-overflow();
|
||||
}
|
||||
}
|
||||
@ -38,4 +37,24 @@
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-progress-loader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.progress-text {
|
||||
flex: 1 0 auto;
|
||||
position: relative;
|
||||
|
||||
.progress,
|
||||
.progress-bar-text {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
@ -141,10 +141,10 @@ def data(TEST):
|
||||
|
||||
admin_role_dict = {'id': '1',
|
||||
'name': 'admin'}
|
||||
admin_role = roles.Role(roles.RoleManager, admin_role_dict)
|
||||
admin_role = roles.Role(roles.RoleManager, admin_role_dict, loaded=True)
|
||||
member_role_dict = {'id': "2",
|
||||
'name': settings.OPENSTACK_KEYSTONE_DEFAULT_ROLE}
|
||||
member_role = roles.Role(roles.RoleManager, member_role_dict)
|
||||
member_role = roles.Role(roles.RoleManager, member_role_dict, loaded=True)
|
||||
TEST.roles.add(admin_role, member_role)
|
||||
TEST.roles.admin = admin_role
|
||||
TEST.roles.member = member_role
|
||||
@ -370,14 +370,14 @@ def data(TEST):
|
||||
'remote_ids': ['rid_1', 'rid_2']}
|
||||
idp_1 = identity_providers.IdentityProvider(
|
||||
identity_providers.IdentityProviderManager,
|
||||
idp_dict_1)
|
||||
idp_dict_1, loaded=True)
|
||||
idp_dict_2 = {'id': 'idp_2',
|
||||
'description': 'identity provider 2',
|
||||
'enabled': True,
|
||||
'remote_ids': ['rid_3', 'rid_4']}
|
||||
idp_2 = identity_providers.IdentityProvider(
|
||||
identity_providers.IdentityProviderManager,
|
||||
idp_dict_2)
|
||||
idp_dict_2, loaded=True)
|
||||
TEST.identity_providers.add(idp_1, idp_2)
|
||||
|
||||
idp_mapping_dict = {
|
||||
@ -420,5 +420,6 @@ def data(TEST):
|
||||
'mapping_id': 'mapping_1'}
|
||||
idp_protocol = protocols.Protocol(
|
||||
protocols.ProtocolManager,
|
||||
idp_protocol_dict_1)
|
||||
idp_protocol_dict_1,
|
||||
loaded=True)
|
||||
TEST.idp_protocols.add(idp_protocol)
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
features:
|
||||
- Create from a local file feature is added to the Angular
|
||||
Create Image workflow. It works either in a 'legacy' mode
|
||||
- Create from a local file feature is added to both Angular and Django
|
||||
Create Image workflows. It works either in a 'legacy' mode
|
||||
which proxies an image upload through Django, or in a new
|
||||
'direct' mode, which in turn implements
|
||||
[`blueprint horizon-glance-large-image-upload
|
||||
|
Loading…
x
Reference in New Issue
Block a user