Widget to expose the metadata catalog from glance
In Juno, Glance is providing a metadata definitions catalog[1][2] where users can register the available metadata definitions that can be used on different types of resources (images, artifacts, volumes, flavors, aggregates, etc). This includes both simple tags and key / value pairs (properties, specs, etc). This widget will get the metadata definitions from Glance and will let the user add the metadata to the resource being edited. It provides value validation as well as description information about the metadata. An implementation for Images is included in this patch. Additional patches will be made for other resource types. This patch also removes Edit Image Custom Properties screen as new widget provides the same functionality. To avoid regressions owner property is added to Image Detail screen. ********************** TESTING ************************** You can test this code with Glance patches by following the instructions at the bottom of this etherpad: Go to: https://etherpad.openstack.org/p/j3-glance-patches ********************************************************* [1] Approved Glance Juno Spec: https://github.com/openstack/glance-specs/blob/master/specs/juno/metadata-schema-catalog.rst [2] Glance PTL Juno Feature Overview: https://www.youtube.com/watch?v=3ptriiw1wK8&t=14m27s Implements: blueprint tagging DocImpact Co-Authored-By: Santiago Baldassin <santiago.b.baldassin@intel.com> Co-Authored-By: Pawel Skowron <pawel.skowron@intel.com> Co-Authored-By: Travis Tripp <travis.tripp@hp.com> Co-Authored-By: Szymon Wroblewski <szymon.wroblewski@intel.com> Co-Authored-By: Michal Dulko <michal.dulko@intel.com> Co-Authored-By: Bartosz Fic <bartosz.fic@intel.com> Co-Authored-By: Pawel Koniszewski <pawel.koniszewski@intel.com> Co-Authored-By: Heather Whisenhunt <heather.whisenhunt@hp.com> Change-Id: I335d4708f5ce8afe58fb88dbe9efd79e2c04fc9e
This commit is contained in:
parent
354c0c1baa
commit
2e3299dc57
@ -308,7 +308,7 @@ Example: ``[{'text': 'Official', 'tenant': '27d0058849da47c896d205e2fc25a5e8', '
|
||||
Default: ``[]``
|
||||
|
||||
A list of image custom property keys that should not be displayed in the
|
||||
Image Custom Properties table.
|
||||
Update Metadata tree.
|
||||
|
||||
This setting can be used in the case where a separate panel is used for
|
||||
managing a custom property or if a certain custom property should never be
|
||||
|
295
horizon/static/horizon/js/angular/controllers/metadata-widget-controller.js
vendored
Normal file
295
horizon/static/horizon/js/angular/controllers/metadata-widget-controller.js
vendored
Normal file
@ -0,0 +1,295 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
horizonApp.controller('hzMetadataWidgetCtrl', ['$scope', '$window', '$filter', function ($scope, $window, $filter) {
|
||||
|
||||
//// Item class ////
|
||||
|
||||
function Item(parent) {
|
||||
// parent as property to prevent infinite recursion in angular filter
|
||||
Object.defineProperty(this, 'parent', {
|
||||
value: typeof parent !== 'undefined' ? parent : null
|
||||
});
|
||||
this.children = [];
|
||||
// Node properties
|
||||
this.visible = false;
|
||||
this.expanded = false;
|
||||
this.label = '';
|
||||
this.description = '';
|
||||
this.level = parent ? parent.level + 1 : 0;
|
||||
this.addedCount = 0;
|
||||
this.custom = false;
|
||||
// Leaf properties
|
||||
this.leaf = null;
|
||||
this.added = false;
|
||||
}
|
||||
|
||||
Item.prototype.fromNamespace = function(namespace) {
|
||||
this.label = namespace.display_name;
|
||||
this.description = namespace.description;
|
||||
|
||||
if(namespace.objects) {
|
||||
angular.forEach(namespace.objects, function(object) {
|
||||
this.children.push(new Item(this).fromObject(object));
|
||||
}, this);
|
||||
}
|
||||
|
||||
if(namespace.properties){
|
||||
angular.forEach(namespace.properties, function(property, key) {
|
||||
this.children.push(new Item(this).fromProperty(key, property));
|
||||
}, this);
|
||||
}
|
||||
|
||||
this.sortChildren();
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
Item.prototype.fromObject = function(object) {
|
||||
this.label = object.name;
|
||||
this.description = object.description;
|
||||
|
||||
if(object.properties) {
|
||||
angular.forEach(object.properties, function (property, key) {
|
||||
this.children.push(new Item(this).fromProperty(key, property));
|
||||
}, this);
|
||||
}
|
||||
|
||||
this.sortChildren();
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
Item.prototype.fromProperty = function(name, property) {
|
||||
this.leaf = property || {};
|
||||
this.label = this.leaf.title || '';
|
||||
this.description = this.leaf.description || '';
|
||||
this.leaf.name = name;
|
||||
this.leaf.value = this.leaf.default || null;
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
Item.prototype.customProperty = function(name) {
|
||||
this.fromProperty(name, {title: name});
|
||||
this.leaf.type = 'string';
|
||||
this.custom = true;
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
Item.prototype.expand = function() {
|
||||
this.expanded = true;
|
||||
angular.forEach(this.children, function(child) {
|
||||
child.visible = true;
|
||||
}, this);
|
||||
};
|
||||
|
||||
Item.prototype.collapse = function() {
|
||||
this.expanded = false;
|
||||
angular.forEach(this.children, function(child) {
|
||||
child.collapse();
|
||||
child.visible = false;
|
||||
}, this);
|
||||
};
|
||||
|
||||
Item.prototype.sortChildren = function() {
|
||||
this.children.sort(function(a, b) {
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
};
|
||||
|
||||
Item.prototype.markAsAdded = function() {
|
||||
this.added = true;
|
||||
if(this.parent) {
|
||||
this.parent.addedCount += 1;
|
||||
if(this.parent.addedCount === this.parent.children.length) {
|
||||
this.parent.added = true;
|
||||
}
|
||||
}
|
||||
angular.forEach(this.children, function(item) {
|
||||
item.markAsAdded();
|
||||
}, this);
|
||||
};
|
||||
|
||||
Item.prototype.unmarkAsAdded = function(caller) {
|
||||
this.added = false;
|
||||
if(this.parent) {
|
||||
this.parent.addedCount -= 1;
|
||||
this.parent.expand();
|
||||
this.parent.unmarkAsAdded(this);
|
||||
}
|
||||
if(!caller) { // prevent infinite recursion
|
||||
angular.forEach(this.children, function(item) {
|
||||
item.unmarkAsAdded();
|
||||
}, this);
|
||||
}
|
||||
};
|
||||
|
||||
Item.prototype.path = function(path) {
|
||||
path = typeof path !== 'undefined' ? path : [];
|
||||
if(this.parent) this.parent.path(path);
|
||||
path.push(this.label);
|
||||
return path;
|
||||
};
|
||||
|
||||
//// Private functions ////
|
||||
|
||||
var filter = $filter('filter');
|
||||
|
||||
function loadNamespaces(namespaces) {
|
||||
var items = [];
|
||||
|
||||
angular.forEach(namespaces, function(namespace) {
|
||||
var item = new Item().fromNamespace(namespace);
|
||||
item.visible = true;
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
items.sort(function(a, b) {
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function flattenTree(tree, items) {
|
||||
items = typeof items !== 'undefined' ? items : [];
|
||||
|
||||
angular.forEach(tree, function(item) {
|
||||
items.push(item);
|
||||
flattenTree(item.children, items);
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function loadExisting(available, existing) {
|
||||
var itemsMapping = {};
|
||||
|
||||
angular.forEach(available, function(item) {
|
||||
if(item.leaf && item.leaf.name in existing) {
|
||||
itemsMapping[item.leaf.name] = item;
|
||||
}
|
||||
});
|
||||
|
||||
angular.forEach(existing, function(value, key) {
|
||||
var item = itemsMapping[key];
|
||||
if(typeof item === 'undefined') {
|
||||
item = new Item().customProperty(key);
|
||||
available.push(item);
|
||||
}
|
||||
switch (item.leaf.type) {
|
||||
case 'integer': item.leaf.value = parseInt(value); break;
|
||||
case 'number': item.leaf.value = parseFloat(value); break;
|
||||
case 'array': item.leaf.value = value.replace(/^<in> /, ''); break;
|
||||
default: item.leaf.value = value;
|
||||
}
|
||||
item.markAsAdded();
|
||||
});
|
||||
}
|
||||
|
||||
//// Public functions ////
|
||||
|
||||
$scope.onItemClick = function(e, item) {
|
||||
$scope.selected = item;
|
||||
if(!item.expanded) {
|
||||
item.expand();
|
||||
} else {
|
||||
item.collapse();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.onItemAdd = function(e, item) {
|
||||
$scope.selected = item;
|
||||
item.markAsAdded();
|
||||
};
|
||||
|
||||
$scope.onItemDelete = function(e, item) {
|
||||
if(!item.custom) {
|
||||
$scope.selected = item;
|
||||
item.unmarkAsAdded();
|
||||
} else {
|
||||
$scope.selected = null;
|
||||
var i = $scope.flatTree.indexOf(item);
|
||||
if(i > -1) {
|
||||
$scope.flatTree.splice(i, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$scope.onCustomItemAdd = function(e) {
|
||||
var item, name = $scope.customItem.value;
|
||||
if($scope.customItem.found.length > 0) {
|
||||
item = $scope.customItem.found[0];
|
||||
item.markAsAdded();
|
||||
$scope.selected = item;
|
||||
} else {
|
||||
item = new Item().customProperty(name);
|
||||
item.markAsAdded();
|
||||
$scope.selected = item;
|
||||
$scope.flatTree.push(item);
|
||||
}
|
||||
$scope.customItem.valid = false;
|
||||
$scope.customItem.value = '';
|
||||
};
|
||||
|
||||
$scope.formatErrorMessage = function(item, error) {
|
||||
var _ = $window.gettext;
|
||||
if(error.min) return _('Min') + ' ' + item.leaf.minimum;
|
||||
if(error.max) return _('Max') + ' ' + item.leaf.maximum;
|
||||
if(error.minlength) return _('Min length') + ' ' + item.leaf.minLength;
|
||||
if(error.maxlength) return _('Max length') + ' ' + item.leaf.maxLength;
|
||||
if(error.pattern) {
|
||||
if(item.leaf.type === 'integer') return _('Integer required');
|
||||
else return _('Pattern mismatch');
|
||||
}
|
||||
if(error.required) {
|
||||
switch(item.leaf.type) {
|
||||
case 'integer': return _('Integer required');
|
||||
case 'number': return _('Decimal required');
|
||||
default: return _('Required');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$scope.saveMetadata = function () {
|
||||
var metadata = [];
|
||||
var added = filter($scope.flatTree, {'added': true, 'leaf': '!!'});
|
||||
angular.forEach(added, function(item) {
|
||||
metadata.push({
|
||||
key: item.leaf.name,
|
||||
value: (item.leaf.type == 'array' ? '<in> ' : '') + item.leaf.value
|
||||
});
|
||||
});
|
||||
$scope.metadata = JSON.stringify(metadata);
|
||||
};
|
||||
|
||||
$scope.$watch('customItem.value', function() {
|
||||
$scope.customItem.found = filter(
|
||||
$scope.flatTree, {'leaf.name': $scope.customItem.value}, true
|
||||
);
|
||||
$scope.customItem.valid = $scope.customItem.value &&
|
||||
$scope.customItem.found.length === 0;
|
||||
});
|
||||
|
||||
//// Private variables ////
|
||||
|
||||
var tree = loadNamespaces($window.available_metadata.namespaces);
|
||||
|
||||
//// Public variables ////
|
||||
|
||||
$scope.flatTree = flattenTree(tree);
|
||||
$scope.decriptionText = '';
|
||||
$scope.metadata = '';
|
||||
$scope.selected = null;
|
||||
$scope.customItem = {
|
||||
value: '',
|
||||
focused: false,
|
||||
valid: false,
|
||||
found: []
|
||||
};
|
||||
|
||||
loadExisting($scope.flatTree, $window.existing_metadata);
|
||||
|
||||
}]);
|
||||
}());
|
@ -0,0 +1,96 @@
|
||||
/*global describe, it, expect, jasmine, beforeEach, spyOn, angular*/
|
||||
describe('metadata-widget-controller', function () {
|
||||
'use strict';
|
||||
var $scope;
|
||||
beforeEach(function () {
|
||||
angular.mock.module('hz');
|
||||
});
|
||||
|
||||
beforeEach(function () {
|
||||
angular.mock.inject(function ($injector) {
|
||||
var gettext = function (text) {
|
||||
return text;
|
||||
};
|
||||
var $window = {
|
||||
available_metadata: {namespaces: []},
|
||||
gettext: gettext
|
||||
};
|
||||
$scope = $injector.get('$rootScope').$new();
|
||||
var metadataController = $injector.get('$controller')(
|
||||
'hzMetadataWidgetCtrl',
|
||||
{
|
||||
$scope: $scope,
|
||||
$window: $window
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatErrorMessage', function () {
|
||||
it('should return undefined', function () {
|
||||
expect($scope.formatErrorMessage('test', 'test')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should return "Min 2"', function () {
|
||||
var error, item;
|
||||
error = {min: true};
|
||||
item = {leaf: {minimum: '2'}};
|
||||
expect($scope.formatErrorMessage(item, error)).toBe('Min 2');
|
||||
});
|
||||
|
||||
it('should return "Max 2"', function () {
|
||||
var error, item;
|
||||
error = {max: true};
|
||||
item = {leaf: {maximum: '2'}};
|
||||
expect($scope.formatErrorMessage(item, error)).toBe('Max 2');
|
||||
});
|
||||
|
||||
it('should return "Min length 5"', function () {
|
||||
var error, item;
|
||||
error = {minlength: true};
|
||||
item = {leaf: {minLength: '5'}};
|
||||
expect($scope.formatErrorMessage(item, error)).toBe('Min length 5');
|
||||
});
|
||||
|
||||
it('should return "Max length 5"', function () {
|
||||
var error, item;
|
||||
error = {maxlength: true};
|
||||
item = {leaf: {maxLength: '5'}};
|
||||
expect($scope.formatErrorMessage(item, error)).toBe('Max length 5');
|
||||
});
|
||||
|
||||
it('should return "Integer required"', function () {
|
||||
var error, item;
|
||||
error = {pattern: true};
|
||||
item = {leaf: {type: 'integer'}};
|
||||
expect($scope.formatErrorMessage(item, error)).toBe('Integer required');
|
||||
});
|
||||
|
||||
it('should return "Pattern mismatch"', function () {
|
||||
var error, item;
|
||||
error = {pattern: true};
|
||||
item = {leaf: {type: 'wrong pattern'}};
|
||||
expect($scope.formatErrorMessage(item, error)).toBe('Pattern mismatch');
|
||||
});
|
||||
|
||||
it('should return "Integer required"', function () {
|
||||
var error, item;
|
||||
error = {required: true};
|
||||
item = {leaf: {type: 'integer'}};
|
||||
expect($scope.formatErrorMessage(item, error)).toBe('Integer required');
|
||||
});
|
||||
|
||||
it('should return "Decimal required"', function () {
|
||||
var error, item;
|
||||
error = {required: true};
|
||||
item = {leaf: {type: 'number'}};
|
||||
expect($scope.formatErrorMessage(item, error)).toBe('Decimal required');
|
||||
});
|
||||
|
||||
it('should return "Integer required"', function () {
|
||||
var error, item;
|
||||
error = {required: true};
|
||||
item = {leaf: {type: 'mock'}};
|
||||
expect($scope.formatErrorMessage(item, error)).toBe('Required');
|
||||
});
|
||||
});
|
||||
});
|
@ -16,6 +16,7 @@
|
||||
<script src='{{ STATIC_URL }}horizon/js/angular/directives/forms.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/angular/horizon.conf.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/angular/services/horizon.utils.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/angular/controllers/metadata-widget-controller.js'></script>
|
||||
|
||||
<script src='{{ STATIC_URL }}horizon/lib/jquery/jquery.quicksearch.js' type='text/javascript' charset="utf-8"></script>
|
||||
<script src="{{ STATIC_URL }}horizon/lib/jquery/jquery.tablesorter.js" type="text/javascript" charset="utf-8"></script>
|
||||
|
@ -18,6 +18,7 @@
|
||||
class="{% block form_class %}{% endblock %}"
|
||||
action="{% block form_action %}{% endblock %}"
|
||||
method="{% block form-method %}POST{% endblock %}"
|
||||
{% block form_validation %}{% endblock %}
|
||||
{% if add_to_field %}data-add-to-field="{{ add_to_field }}"{% endif %} {% block form_attrs %}{% endblock %}>{% csrf_token %}
|
||||
<div class="modal-body clearfix">
|
||||
{% comment %}
|
||||
|
@ -0,0 +1,243 @@
|
||||
{% extends "horizon/common/_modal_form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block form_name %}metadataForm{% endblock %}
|
||||
{% block form_validation %}novalidate{% endblock %}
|
||||
{% block ng_controller %}hzMetadataWidgetCtrl{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
<div class="capabilities">
|
||||
<div class="row">
|
||||
<p class="col-md-12">{% blocktrans %}
|
||||
You can specify metadata by adding items from the left column to
|
||||
the right column. You may select the metadata added to glance
|
||||
dictionary or you can use the "Other" option using a key of
|
||||
your choice.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default" ng-form="treeForm">
|
||||
<div class="panel-heading">
|
||||
<div class="form-inline">
|
||||
<div class="form-group has-feedback">
|
||||
<strong>{% trans "Available Metadata" %}</strong>
|
||||
<input class="form-control input-sm"
|
||||
type="text" placeholder="Filter"
|
||||
ng-model="treeFilter"/>
|
||||
<span class="glyphicon glyphicon-search form-control-feedback">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group" ng-cloak>
|
||||
<li class="list-group-item">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-addon">Other</span>
|
||||
<input class="form-control" type="text" name="customItem"
|
||||
ng-model="customItem.value"
|
||||
ng-model-options="{updateOn: 'default focus blur', debounce: {default: 500, focus: 0, blur: 0}}"
|
||||
ng-focus="customItem.focused=true"
|
||||
ng-blur="customItem.focused=false"/>
|
||||
<span class="input-group-btn">
|
||||
<a class="btn btn-primary"
|
||||
ng-click="onCustomItemAdd($event)"
|
||||
ng-disabled="!customItem.valid">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-danger"
|
||||
ng-show="!customItem.valid && customItem.focused && treeForm.customItem.$dirty">
|
||||
<span ng-show="customItem.found.length > 0">
|
||||
{% trans "Duplicate keys are not allowed" %}
|
||||
</span>
|
||||
<span ng-hide="customItem.found.length > 0">
|
||||
{% trans "Invalid key name" %}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<li ng-repeat="item in available = (flatTree | filter: {$: treeFilter, visible: true, added: false})"
|
||||
ng-class="'level-' + item.level + (selected===item?' active':'')"
|
||||
ng-class-odd="'dark-stripe'"
|
||||
ng-class-even="'light-stripe'"
|
||||
class="list-group-item"
|
||||
ng-click="onItemClick($event, item)"
|
||||
ng-show="item.visible">
|
||||
<div class="clearfix">
|
||||
<div class="pull-left">
|
||||
<span title="{$ item.label $}" ng-class="{leaf: item.leaf}">
|
||||
<span class="glyphicon" ng-show="!item.leaf"
|
||||
ng-class="item.expanded ? 'glyphicon-chevron-down' : 'glyphicon-chevron-right'"></span>
|
||||
{$ item.label $}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
<a class="btn btn-primary btn-xs"
|
||||
ng-click="onItemAdd($event, item)">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item disabled"
|
||||
ng-show="available.length == 0">
|
||||
{% trans "No existing metadata" %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="form-inline">
|
||||
<div class="form-group has-feedback">
|
||||
<strong>{% trans "Existing Metadata" %}</strong>
|
||||
<input class="form-control input-sm"
|
||||
type="text" placeholder="Filter"
|
||||
ng-model="listFilter"/>
|
||||
<span class="glyphicon glyphicon-search form-control-feedback">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group" ng-cloak>
|
||||
<li ng-repeat="item in existing = (flatTree | filter:{$:listFilter, added:true, leaf:'!!'} | orderBy:'leaf.name')"
|
||||
ng-class="{'active': selected===item}"
|
||||
ng-class-odd="'dark-stripe'"
|
||||
ng-class-even="'light-stripe'"
|
||||
class="list-group-item"
|
||||
ng-click="onItemClick($event, item)"
|
||||
ng-form="itemForm"
|
||||
ng-mouseenter="mouseOverItem = true"
|
||||
ng-mouseleave="mouseOverItem = false">
|
||||
<div class="input-group input-group-sm" name="value-input"
|
||||
ng-switch on="item.leaf.type"
|
||||
ng-class="{'has-error' : itemForm.property.$invalid && itemForm.property.$dirty}">
|
||||
<span class="input-group-addon"
|
||||
title="{$ item.leaf.name $}">
|
||||
{$ item.leaf.name $}
|
||||
</span>
|
||||
<input ng-switch-when="string"
|
||||
ng-if="!item.leaf.enum"
|
||||
name="property"
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-required=true
|
||||
ng-model="item.leaf.value"
|
||||
ng-pattern="/{$ item.leaf.pattern $}/"
|
||||
ng-minlength="{$ item.leaf.minLength $}"
|
||||
ng-maxlength="{$ item.leaf.maxLength $}"
|
||||
tooltip="{$ item.description $}"/>
|
||||
<select ng-switch-when="string"
|
||||
ng-if="item.leaf.enum"
|
||||
name="property"
|
||||
class="form-control"
|
||||
ng-required=true
|
||||
ng-model="item.leaf.value"
|
||||
tooltip="{$ item.description $}"
|
||||
ng-options="item for item in item.leaf.enum">
|
||||
</select>
|
||||
<select ng-switch-when="array"
|
||||
name="property"
|
||||
class="form-control"
|
||||
ng-required=true
|
||||
ng-model="item.leaf.value"
|
||||
tooltip="{$ item.description $}"
|
||||
ng-options="item for item in item.leaf.items.enum">
|
||||
</select>
|
||||
<input ng-switch-when="integer"
|
||||
name="property"
|
||||
type="number"
|
||||
class="form-control"
|
||||
ng-required=true
|
||||
ng-model="item.leaf.value"
|
||||
ng-pattern="/^-?\d+$/"
|
||||
min="{$ item.leaf.minimum $}"
|
||||
max="{$ item.leaf.maximum $}"
|
||||
tooltip="{$ item.description $}"
|
||||
step="1"/>
|
||||
<input ng-switch-when="number"
|
||||
name="property"
|
||||
type="number"
|
||||
class="form-control"
|
||||
ng-required=true
|
||||
ng-model="item.leaf.value"
|
||||
min="{$ item.leaf.minimum $}"
|
||||
max="{$ item.leaf.maximum $}"
|
||||
tooltip="{$ item.description $}"/>
|
||||
<div class="input-group-addon" style="width: 40%;"
|
||||
ng-switch-when="boolean">
|
||||
<input name="property"
|
||||
type="checkbox"
|
||||
ng-model="item.leaf.value"
|
||||
ng-true-value="true"
|
||||
ng-false-value="false"
|
||||
ng-init="item.leaf.value = item.leaf.value ? item.leaf.value : 'false'"
|
||||
tooltip="{$ item.description $}"/>
|
||||
</div>
|
||||
<div class="input-group-btn">
|
||||
<a class="btn btn-default"
|
||||
ng-click="onItemDelete($event, item)">
|
||||
<span class="glyphicon glyphicon-minus"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label label-info" ng-show="mouseOverItem">
|
||||
{$ item.path().join(' › ') $}
|
||||
</div>
|
||||
<div class="label label-danger"
|
||||
ng-if="itemForm.$invalid && itemForm.property.$dirty">
|
||||
{$ formatErrorMessage(item, itemForm.property.$error) $}
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item disabled"
|
||||
ng-show="existing.length == 0">
|
||||
{% trans "No existing metadata" %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="well">
|
||||
<span ng-show="selected">
|
||||
<p>
|
||||
<strong>{$ selected.label $}</strong>
|
||||
<span ng-show="selected.leaf">(<em>{$ selected.leaf.name $}</em>)</span>
|
||||
</p>
|
||||
<p>{$ selected.description $}</p>
|
||||
</span>
|
||||
<span ng-hide="selected">
|
||||
<p>{% blocktrans %}
|
||||
You can specify resource metadata by moving items from the left
|
||||
column to the right column. In the left columns there are metadata
|
||||
definitions from the Glance Metadata Catalog. Use the "Other" option
|
||||
to add metadata with the key of your choice.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
var existing_metadata = {{existing_metadata|safe}};
|
||||
var available_metadata = {{available_metadata|safe}};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block modal-footer %}
|
||||
<div>
|
||||
<input class="btn btn-primary pull-right"
|
||||
ng-disabled="metadataForm.$invalid"
|
||||
ng-click="saveMetadata()" type="submit"
|
||||
value="{% trans "Save" %}"/>
|
||||
<a class="btn btn-default secondary cancel close">{% trans "Cancel" %}</a>
|
||||
<input type="hidden" name="metadata" ng-value="metadata"
|
||||
ng-model="metadata">
|
||||
</div>
|
||||
{% endblock %}
|
@ -15,6 +15,13 @@
|
||||
|
||||
<script type="text/javascript" src="{{ STATIC_URL }}horizon/lib/angular/angular.js"></script>
|
||||
<script type="text/javascript" src="{{ STATIC_URL }}horizon/lib/angular/angular-mock.js"></script>
|
||||
<script type="text/javascript" src="{{ STATIC_URL }}horizon/lib/angular/angular-cookies.js"></script>
|
||||
|
||||
<script type='text/javascript' charset='utf-8'>
|
||||
/* Load angular modules extensions list before we include angular/horizon.js */
|
||||
var angularModuleExtension = {{ HORIZON_CONFIG.angular_modules|default:"[]"|safe }};
|
||||
</script>
|
||||
|
||||
{% for source in sources %}
|
||||
<script type="application/javascript" src="{{ STATIC_URL }}{{ source }}"></script>
|
||||
{% endfor %}
|
||||
|
@ -15,7 +15,13 @@ from horizon.test import helpers as test
|
||||
|
||||
class ServicesTests(test.JasmineTests):
|
||||
sources = [
|
||||
'horizon/js/horizon.js',
|
||||
'horizon/js/angular/horizon.conf.js',
|
||||
'horizon/js/angular/services/horizon.utils.js'
|
||||
'horizon/js/angular/horizon.js',
|
||||
'horizon/js/angular/services/horizon.utils.js',
|
||||
'horizon/js/angular/controllers/metadata-widget-controller.js'
|
||||
]
|
||||
specs = [
|
||||
'horizon/tests/jasmine/utilsSpec.js',
|
||||
'horizon/tests/jasmine/metadataWidgetControllerSpec.js'
|
||||
]
|
||||
specs = ['horizon/tests/jasmine/utilsSpec.js']
|
||||
|
@ -18,9 +18,12 @@
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import collections
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
import glanceclient as glance_client
|
||||
from six.moves import _thread as thread
|
||||
@ -32,14 +35,6 @@ from openstack_dashboard.api import base
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImageCustomProperty(object):
|
||||
def __init__(self, image_id, key, val):
|
||||
self.image_id = image_id
|
||||
self.id = key
|
||||
self.key = key
|
||||
self.value = val
|
||||
|
||||
|
||||
def glanceclient(request, version='1'):
|
||||
url = base.url_for(request, 'image')
|
||||
insecure = getattr(settings, 'OPENSTACK_SSL_NO_VERIFY', False)
|
||||
@ -64,26 +59,6 @@ def image_get(request, image_id):
|
||||
return image
|
||||
|
||||
|
||||
def image_get_properties(request, image_id, reserved=True):
|
||||
"""List all custom properties of an image."""
|
||||
image = glanceclient(request, '2').images.get(image_id)
|
||||
reserved_props = getattr(settings, 'IMAGE_RESERVED_CUSTOM_PROPERTIES', [])
|
||||
properties_list = []
|
||||
for key in image.keys():
|
||||
if reserved or key not in reserved_props:
|
||||
prop = ImageCustomProperty(image_id, key, image.get(key))
|
||||
properties_list.append(prop)
|
||||
return properties_list
|
||||
|
||||
|
||||
def image_get_property(request, image_id, key, reserved=True):
|
||||
"""Get a custom property of an image."""
|
||||
for prop in image_get_properties(request, image_id, reserved):
|
||||
if prop.key == key:
|
||||
return prop
|
||||
return None
|
||||
|
||||
|
||||
def image_list_detailed(request, marker=None, sort_dir='desc',
|
||||
sort_key='created_at', filters=None, paginate=False):
|
||||
limit = getattr(settings, 'API_RESULT_LIMIT', 1000)
|
||||
@ -149,11 +124,107 @@ def image_create(request, **kwargs):
|
||||
return image
|
||||
|
||||
|
||||
def image_update_properties(request, image_id, **kwargs):
|
||||
def image_update_properties(request, image_id, remove_props=None, **kwargs):
|
||||
"""Add or update a custom property of an image."""
|
||||
return glanceclient(request, '2').images.update(image_id, None, **kwargs)
|
||||
return glanceclient(request, '2').images.update(image_id,
|
||||
remove_props,
|
||||
**kwargs)
|
||||
|
||||
|
||||
def image_delete_properties(request, image_id, keys):
|
||||
"""Delete custom properties for an image."""
|
||||
return glanceclient(request, '2').images.update(image_id, keys)
|
||||
|
||||
|
||||
class BaseGlanceMetadefAPIResourceWrapper(base.APIResourceWrapper):
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return (getattr(self._apiresource, 'description', None) or
|
||||
getattr(self._apiresource, 'display_name', None))
|
||||
|
||||
def as_json(self, indent=4):
|
||||
result = collections.OrderedDict()
|
||||
for attr in self._attrs:
|
||||
if hasattr(self, attr):
|
||||
result[attr] = getattr(self, attr)
|
||||
return json.dumps(result, indent=indent)
|
||||
|
||||
|
||||
class Namespace(BaseGlanceMetadefAPIResourceWrapper):
|
||||
|
||||
_attrs = ['namespace', 'display_name', 'description',
|
||||
'resource_type_associations', 'visibility', 'protected',
|
||||
'created_at', 'updated_at', 'properties', 'objects']
|
||||
|
||||
@property
|
||||
def resource_type_associations(self):
|
||||
result = [resource_type['name'] for resource_type in
|
||||
getattr(self._apiresource, 'resource_type_associations')]
|
||||
return result
|
||||
|
||||
@property
|
||||
def public(self):
|
||||
if getattr(self._apiresource, 'visibility') == 'public':
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def metadefs_namespace_get(request, namespace, resource_type=None, wrap=False):
|
||||
namespace = glanceclient(request, '2').\
|
||||
metadefs_namespace.get(namespace, resource_type=resource_type)
|
||||
# There were problems with using the wrapper class in
|
||||
# in nested json serialization. So sometimes, it is not desirable
|
||||
# to wrap.
|
||||
if wrap:
|
||||
return Namespace(namespace)
|
||||
else:
|
||||
return namespace
|
||||
|
||||
|
||||
def metadefs_namespace_list(request,
|
||||
filters={},
|
||||
sort_dir='desc',
|
||||
sort_key='created_at',
|
||||
marker=None,
|
||||
paginate=False):
|
||||
limit = getattr(settings, 'API_RESULT_LIMIT', 1000)
|
||||
page_size = utils.get_page_size(request)
|
||||
|
||||
if paginate:
|
||||
request_size = page_size + 1
|
||||
else:
|
||||
request_size = limit
|
||||
|
||||
kwargs = {'filters': filters}
|
||||
if marker:
|
||||
kwargs['marker'] = marker
|
||||
kwargs['sort_dir'] = sort_dir
|
||||
kwargs['sort_key'] = sort_key
|
||||
|
||||
namespaces_iter = glanceclient(request, '2').metadefs_namespace.list(
|
||||
page_size=request_size, limit=limit, **kwargs)
|
||||
|
||||
has_prev_data = False
|
||||
has_more_data = False
|
||||
if paginate:
|
||||
namespaces = list(itertools.islice(namespaces_iter, request_size))
|
||||
# first and middle page condition
|
||||
if len(namespaces) > page_size:
|
||||
namespaces.pop(-1)
|
||||
has_more_data = True
|
||||
# middle page condition
|
||||
if marker is not None:
|
||||
has_prev_data = True
|
||||
# first page condition when reached via prev back
|
||||
elif sort_dir == 'asc' and marker is not None:
|
||||
has_more_data = True
|
||||
# last page condition
|
||||
elif marker is not None:
|
||||
has_prev_data = True
|
||||
else:
|
||||
namespaces = list(namespaces_iter)
|
||||
|
||||
namespaces = [Namespace(namespace) for namespace in namespaces]
|
||||
return namespaces, has_more_data, has_prev_data
|
||||
|
@ -16,12 +16,52 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from openstack_dashboard.dashboards.project.images.images import forms
|
||||
|
||||
import json
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import exceptions
|
||||
from horizon import forms
|
||||
from horizon import messages
|
||||
|
||||
from openstack_dashboard import api
|
||||
from openstack_dashboard.dashboards.project.images.images \
|
||||
import forms as images_forms
|
||||
|
||||
|
||||
class AdminCreateImageForm(forms.CreateImageForm):
|
||||
class AdminCreateImageForm(images_forms.CreateImageForm):
|
||||
pass
|
||||
|
||||
|
||||
class AdminUpdateImageForm(forms.UpdateImageForm):
|
||||
class AdminUpdateImageForm(images_forms.UpdateImageForm):
|
||||
pass
|
||||
|
||||
|
||||
class UpdateMetadataForm(forms.SelfHandlingForm):
|
||||
|
||||
def handle(self, request, data):
|
||||
id = self.initial['id']
|
||||
old_metadata = self.initial['metadata']
|
||||
|
||||
try:
|
||||
new_metadata = json.loads(self.data['metadata'])
|
||||
|
||||
metadata = dict(
|
||||
(item['key'], str(item['value']))
|
||||
for item in new_metadata
|
||||
)
|
||||
|
||||
remove_props = [key for key in old_metadata if key not in metadata]
|
||||
|
||||
api.glance.image_update_properties(request,
|
||||
id,
|
||||
remove_props,
|
||||
**metadata)
|
||||
message = _('Metadata successfully updated.')
|
||||
messages.success(request, message)
|
||||
except Exception:
|
||||
exceptions.handle(request,
|
||||
_('Unable to update the image metadata.'))
|
||||
return False
|
||||
return True
|
||||
|
@ -1,89 +0,0 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from glanceclient import exc
|
||||
|
||||
from horizon import exceptions
|
||||
from horizon import forms
|
||||
from horizon import messages
|
||||
|
||||
from openstack_dashboard import api
|
||||
|
||||
|
||||
def str2bool(value):
|
||||
"""Convert a string value to boolean
|
||||
"""
|
||||
return value.lower() in ("yes", "true", "1")
|
||||
|
||||
|
||||
# Mapping of property names to type, used for converting input string value
|
||||
# before submitting.
|
||||
PROPERTY_TYPES = {'min_disk': long, 'min_ram': long, 'protected': str2bool}
|
||||
|
||||
|
||||
def convert_value(key, value):
|
||||
"""Convert the property value to the proper type if necessary.
|
||||
"""
|
||||
_type = PROPERTY_TYPES.get(key)
|
||||
if _type:
|
||||
return _type(value)
|
||||
return value
|
||||
|
||||
|
||||
class CreateProperty(forms.SelfHandlingForm):
|
||||
key = forms.CharField(max_length="255", label=_("Key"))
|
||||
value = forms.CharField(label=_("Value"))
|
||||
|
||||
def handle(self, request, data):
|
||||
try:
|
||||
api.glance.image_update_properties(request,
|
||||
self.initial['image_id'],
|
||||
**{data['key']: convert_value(data['key'], data['value'])})
|
||||
msg = _('Created custom property "%s".') % data['key']
|
||||
messages.success(request, msg)
|
||||
return True
|
||||
except exc.HTTPForbidden:
|
||||
msg = _('Unable to create image custom property. Property "%s" '
|
||||
'is read only.') % data['key']
|
||||
exceptions.handle(request, msg)
|
||||
except exc.HTTPConflict:
|
||||
msg = _('Unable to create image custom property. Property "%s" '
|
||||
'already exists.') % data['key']
|
||||
exceptions.handle(request, msg)
|
||||
except Exception:
|
||||
msg = _('Unable to create image custom '
|
||||
'property "%s".') % data['key']
|
||||
exceptions.handle(request, msg)
|
||||
|
||||
|
||||
class EditProperty(forms.SelfHandlingForm):
|
||||
key = forms.CharField(widget=forms.widgets.HiddenInput)
|
||||
value = forms.CharField(label=_("Value"))
|
||||
|
||||
def handle(self, request, data):
|
||||
try:
|
||||
api.glance.image_update_properties(request,
|
||||
self.initial['image_id'],
|
||||
**{data['key']: convert_value(data['key'], data['value'])})
|
||||
msg = _('Saved custom property "%s".') % data['key']
|
||||
messages.success(request, msg)
|
||||
return True
|
||||
except exc.HTTPForbidden:
|
||||
msg = _('Unable to edit image custom property. Property "%s" '
|
||||
'is read only.') % data['key']
|
||||
exceptions.handle(request, msg)
|
||||
except Exception:
|
||||
msg = _('Unable to edit image custom '
|
||||
'property "%s".') % data['key']
|
||||
exceptions.handle(request, msg)
|
@ -1,94 +0,0 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils import http
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import tables
|
||||
|
||||
from openstack_dashboard import api
|
||||
|
||||
|
||||
# Most of the following image custom properties can be found in the glance
|
||||
# project at glance.api.v2.images.RequestDeserializer.
|
||||
|
||||
# Properties that cannot be edited
|
||||
READONLY_PROPERTIES = ['checksum', 'container_format', 'created_at', 'deleted',
|
||||
'deleted_at', 'direct_url', 'disk_format', 'file', 'id', 'is_public',
|
||||
'location', 'owner', 'schema', 'self', 'size',
|
||||
'status', 'tags', 'updated_at', 'virtual_size']
|
||||
|
||||
# Properties that cannot be deleted
|
||||
REQUIRED_PROPERTIES = ['checksum', 'container_format', 'created_at', 'deleted',
|
||||
'deleted_at', 'direct_url', 'disk_format', 'file', 'id', 'is_public',
|
||||
'location', 'min_disk', 'min_ram', 'name', 'owner', 'protected', 'schema',
|
||||
'self', 'size', 'status', 'tags', 'updated_at', 'virtual_size',
|
||||
'visibility']
|
||||
|
||||
|
||||
class PropertyDelete(tables.DeleteAction):
|
||||
data_type_singular = _("Property")
|
||||
data_type_plural = _("Properties")
|
||||
|
||||
def allowed(self, request, prop=None):
|
||||
if prop and prop.key in REQUIRED_PROPERTIES:
|
||||
return False
|
||||
return True
|
||||
|
||||
def delete(self, request, obj_ids):
|
||||
api.glance.image_delete_properties(request, self.table.kwargs['id'],
|
||||
[obj_ids])
|
||||
|
||||
|
||||
class PropertyCreate(tables.LinkAction):
|
||||
name = "create"
|
||||
verbose_name = _("Create Property")
|
||||
url = "horizon:admin:images:properties:create"
|
||||
classes = ("ajax-modal",)
|
||||
icon = "plus"
|
||||
|
||||
def get_link_url(self, custom_property=None):
|
||||
return reverse(self.url, args=[self.table.kwargs['id']])
|
||||
|
||||
|
||||
class PropertyEdit(tables.LinkAction):
|
||||
name = "edit"
|
||||
verbose_name = _("Edit")
|
||||
url = "horizon:admin:images:properties:edit"
|
||||
classes = ("btn-edit", "ajax-modal")
|
||||
|
||||
def allowed(self, request, prop=None):
|
||||
if prop and prop.key in READONLY_PROPERTIES:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_link_url(self, custom_property):
|
||||
return reverse(self.url, args=[self.table.kwargs['id'],
|
||||
http.urlquote(custom_property.key, '')])
|
||||
|
||||
|
||||
class PropertiesTable(tables.DataTable):
|
||||
key = tables.Column('key', verbose_name=_('Key'))
|
||||
value = tables.Column('value', verbose_name=_('Value'))
|
||||
|
||||
class Meta:
|
||||
name = "properties"
|
||||
verbose_name = _("Custom Properties")
|
||||
table_actions = (PropertyCreate, PropertyDelete)
|
||||
row_actions = (PropertyEdit, PropertyDelete)
|
||||
|
||||
def get_object_id(self, datum):
|
||||
return datum.key
|
||||
|
||||
def get_object_display(self, datum):
|
||||
return datum.key
|
@ -1,102 +0,0 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django import http
|
||||
|
||||
from mox import IsA # noqa
|
||||
|
||||
from openstack_dashboard import api
|
||||
from openstack_dashboard.test import helpers as test
|
||||
|
||||
|
||||
class ImageCustomPropertiesTests(test.BaseAdminViewTests):
|
||||
|
||||
@test.create_stubs({api.glance: ('image_get',
|
||||
'image_get_properties'), })
|
||||
def test_list_properties(self):
|
||||
image = self.images.first()
|
||||
props = [api.glance.ImageCustomProperty(image.id, 'k1', 'v1')]
|
||||
api.glance.image_get(IsA(http.HttpRequest), image.id).AndReturn(image)
|
||||
api.glance.image_get_properties(IsA(http.HttpRequest),
|
||||
image.id, False).AndReturn(props)
|
||||
self.mox.ReplayAll()
|
||||
url = reverse('horizon:admin:images:properties:index', args=[image.id])
|
||||
resp = self.client.get(url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTemplateUsed(resp, "admin/images/properties/index.html")
|
||||
|
||||
@test.create_stubs({api.glance: ('image_update_properties',), })
|
||||
def test_property_create_post(self):
|
||||
image = self.images.first()
|
||||
create_url = reverse('horizon:admin:images:properties:create',
|
||||
args=[image.id])
|
||||
index_url = reverse('horizon:admin:images:properties:index',
|
||||
args=[image.id])
|
||||
api.glance.image_update_properties(IsA(http.HttpRequest),
|
||||
image.id, **{'k1': 'v1'})
|
||||
self.mox.ReplayAll()
|
||||
data = {'image_id': image.id,
|
||||
'key': 'k1',
|
||||
'value': 'v1'}
|
||||
resp = self.client.post(create_url, data)
|
||||
self.assertNoFormErrors(resp)
|
||||
self.assertMessageCount(success=1)
|
||||
self.assertRedirectsNoFollow(resp, index_url)
|
||||
|
||||
@test.create_stubs({api.glance: ('image_get',), })
|
||||
def test_property_create_get(self):
|
||||
image = self.images.first()
|
||||
create_url = reverse('horizon:admin:images:properties:create',
|
||||
args=[image.id])
|
||||
api.glance.image_get(IsA(http.HttpRequest), image.id).AndReturn(image)
|
||||
self.mox.ReplayAll()
|
||||
resp = self.client.get(create_url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTemplateUsed(resp, 'admin/images/properties/create.html')
|
||||
|
||||
@test.create_stubs({api.glance: ('image_update_properties',
|
||||
'image_get_property'), })
|
||||
def test_property_update_post(self):
|
||||
image = self.images.first()
|
||||
prop = api.glance.ImageCustomProperty(image.id, 'k1', 'v1')
|
||||
edit_url = reverse('horizon:admin:images:properties:edit',
|
||||
args=[image.id, prop.id])
|
||||
index_url = reverse('horizon:admin:images:properties:index',
|
||||
args=[image.id])
|
||||
api.glance.image_get_property(IsA(http.HttpRequest),
|
||||
image.id, 'k1', False).AndReturn(prop)
|
||||
api.glance.image_update_properties(IsA(http.HttpRequest),
|
||||
image.id, **{'k1': 'v2'})
|
||||
self.mox.ReplayAll()
|
||||
data = {'image_id': image.id,
|
||||
'key': 'k1',
|
||||
'value': 'v2'}
|
||||
resp = self.client.post(edit_url, data)
|
||||
self.assertNoFormErrors(resp)
|
||||
self.assertMessageCount(success=1)
|
||||
self.assertRedirectsNoFollow(resp, index_url)
|
||||
|
||||
@test.create_stubs({api.glance: ('image_get',
|
||||
'image_get_property'), })
|
||||
def test_property_update_get(self):
|
||||
image = self.images.first()
|
||||
prop = api.glance.ImageCustomProperty(image.id, 'k1', 'v1')
|
||||
edit_url = reverse('horizon:admin:images:properties:edit',
|
||||
args=[image.id, prop.id])
|
||||
api.glance.image_get(IsA(http.HttpRequest), image.id).AndReturn(image)
|
||||
api.glance.image_get_property(IsA(http.HttpRequest),
|
||||
image.id, 'k1', False).AndReturn(prop)
|
||||
self.mox.ReplayAll()
|
||||
resp = self.client.get(edit_url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTemplateUsed(resp, 'admin/images/properties/edit.html')
|
@ -1,22 +0,0 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from django.conf.urls import patterns # noqa
|
||||
from django.conf.urls import url # noqa
|
||||
|
||||
from openstack_dashboard.dashboards.admin.images.properties import views
|
||||
|
||||
urlpatterns = patterns('',
|
||||
url(r'^$', views.IndexView.as_view(), name='index'),
|
||||
url(r'^create/$', views.CreateView.as_view(), name='create'),
|
||||
url(r'^(?P<key>[^/]+)/edit/$', views.EditView.as_view(), name='edit')
|
||||
)
|
@ -1,89 +0,0 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils import http
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import exceptions
|
||||
from horizon import forms
|
||||
from horizon import tables
|
||||
|
||||
from openstack_dashboard import api
|
||||
from openstack_dashboard.dashboards.admin.images.properties \
|
||||
import forms as project_forms
|
||||
from openstack_dashboard.dashboards.admin.images.properties \
|
||||
import tables as project_tables
|
||||
|
||||
|
||||
class PropertyMixin(object):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(PropertyMixin, self).get_context_data(**kwargs)
|
||||
try:
|
||||
context['image'] = api.glance.image_get(self.request,
|
||||
self.kwargs['id'])
|
||||
except Exception:
|
||||
exceptions.handle(self.request,
|
||||
_("Unable to retrieve image details."))
|
||||
if 'key' in self.kwargs:
|
||||
context['encoded_key'] = self.kwargs['key']
|
||||
context['key'] = http.urlunquote(self.kwargs['key'])
|
||||
return context
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("horizon:admin:images:properties:index",
|
||||
args=(self.kwargs["id"],))
|
||||
|
||||
|
||||
class IndexView(PropertyMixin, tables.DataTableView):
|
||||
table_class = project_tables.PropertiesTable
|
||||
template_name = 'admin/images/properties/index.html'
|
||||
|
||||
def get_data(self):
|
||||
try:
|
||||
image_id = self.kwargs['id']
|
||||
properties_list = api.glance.image_get_properties(self.request,
|
||||
image_id,
|
||||
False)
|
||||
properties_list.sort(key=lambda prop: (prop.key,))
|
||||
except Exception:
|
||||
properties_list = []
|
||||
exceptions.handle(self.request,
|
||||
_('Unable to retrieve image custom properties list.'))
|
||||
return properties_list
|
||||
|
||||
|
||||
class CreateView(PropertyMixin, forms.ModalFormView):
|
||||
form_class = project_forms.CreateProperty
|
||||
template_name = 'admin/images/properties/create.html'
|
||||
|
||||
def get_initial(self):
|
||||
return {'image_id': self.kwargs['id']}
|
||||
|
||||
|
||||
class EditView(PropertyMixin, forms.ModalFormView):
|
||||
form_class = project_forms.EditProperty
|
||||
template_name = 'admin/images/properties/edit.html'
|
||||
|
||||
def get_initial(self):
|
||||
image_id = self.kwargs['id']
|
||||
key = http.urlunquote(self.kwargs['key'])
|
||||
try:
|
||||
prop = api.glance.image_get_property(self.request, image_id,
|
||||
key, False)
|
||||
except Exception:
|
||||
prop = None
|
||||
exceptions.handle(self.request,
|
||||
_('Unable to retrieve image custom property.'))
|
||||
return {'image_id': image_id,
|
||||
'key': key,
|
||||
'value': prop.value if prop else ''}
|
@ -40,11 +40,12 @@ class AdminEditImage(project_tables.EditImage):
|
||||
return True
|
||||
|
||||
|
||||
class ViewCustomProperties(tables.LinkAction):
|
||||
name = "properties"
|
||||
verbose_name = _("View Custom Properties")
|
||||
url = "horizon:admin:images:properties:index"
|
||||
classes = ("btn-edit",)
|
||||
class UpdateMetadata(tables.LinkAction):
|
||||
url = "horizon:admin:images:update_metadata"
|
||||
name = "update_metadata"
|
||||
verbose_name = _("Update Metadata")
|
||||
classes = ("ajax-modal",)
|
||||
icon = "pencil"
|
||||
|
||||
|
||||
class UpdateRow(tables.Row):
|
||||
@ -76,4 +77,4 @@ class AdminImagesTable(project_tables.ImagesTable):
|
||||
verbose_name = _("Images")
|
||||
table_actions = (AdminCreateImage, AdminDeleteImage,
|
||||
AdminImageFilterAction)
|
||||
row_actions = (AdminEditImage, ViewCustomProperties, AdminDeleteImage)
|
||||
row_actions = (AdminEditImage, UpdateMetadata, AdminDeleteImage)
|
||||
|
@ -0,0 +1,11 @@
|
||||
{% extends 'horizon/common/_modal_form_update_metadata.html' %}
|
||||
{% load i18n %}
|
||||
{% load url from future %}
|
||||
{% block title %}{% trans "Update Image Metadata" %}{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
{% include "horizon/common/_page_header.html" with title=_("Update Image Metadata") %}
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block form_action %}{% url 'horizon:admin:images:update_metadata' id %}{% endblock %}
|
||||
{% block modal-header %}{% trans "Update Metadata" %}{% endblock %}
|
@ -1,28 +0,0 @@
|
||||
{% extends "horizon/common/_modal_form.html" %}
|
||||
{% load i18n %}
|
||||
{% load url from future %}
|
||||
|
||||
{% block form_id %}image_custom_property_create_form{% endblock %}
|
||||
{% block form_action %}{% url 'horizon:admin:images:properties:create' image.id %}{% endblock %}
|
||||
|
||||
|
||||
{% block modal_id %}image_custom_property_create_modal{% endblock %}
|
||||
{% block modal-header %}{% trans "Create Image Custom Property" %}{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
<div class="left">
|
||||
<fieldset>
|
||||
{% include "horizon/common/_form_fields.html" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="right">
|
||||
<h3>{% trans "Description" %}:</h3>
|
||||
<p>{% trans 'Create a new custom property for an image.' %}</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Create" %}" />
|
||||
<a href="{% url 'horizon:admin:images:properties:index' image.id %}" class="btn btn-default secondary cancel close">{% trans "Cancel" %}</a>
|
||||
{% endblock %}
|
||||
|
@ -1,28 +0,0 @@
|
||||
{% extends "horizon/common/_modal_form.html" %}
|
||||
{% load i18n %}
|
||||
{% load url from future %}
|
||||
|
||||
{% block form_id %}custom_property_edit_form{% endblock %}
|
||||
{% block form_action %}{% url 'horizon:admin:images:properties:edit' image.id encoded_key %}{% endblock %}
|
||||
|
||||
|
||||
{% block modal_id %}custom_property_edit_modal{% endblock %}
|
||||
{% block modal-header %}{% trans "Edit Custom Property Value" %}: {{ key }}{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
<div class="left">
|
||||
<fieldset>
|
||||
{% include "horizon/common/_form_fields.html" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="right">
|
||||
<h3>{% trans "Description" %}:</h3>
|
||||
<p>{% blocktrans with key=key %}Update the custom property value for "{{ key }}"{% endblocktrans %}</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Save" %}" />
|
||||
<a href="{% url 'horizon:admin:images:properties:index' image.id %}" class="btn btn-default secondary cancel close">{% trans "Cancel" %}</a>
|
||||
{% endblock %}
|
||||
|
@ -1,12 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Create Image Custom Property" %}{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
<h2>{% trans "Image" %}: {{image.name}} </h2>
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block main %}
|
||||
{% include "admin/images/properties/_create.html" %}
|
||||
{% endblock %}
|
@ -1,12 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Edit Image Custom Property" %}{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
<h2>{% trans "Image" %}: {{image.name}} </h2>
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block main %}
|
||||
{% include "admin/images/properties/_edit.html" %}
|
||||
{% endblock %}
|
@ -1,11 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Image Custom Properties" %}{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
{% include "horizon/common/_page_header.html" with title=_("Image Custom Properties: ")|add:image.name|default:_("Image Custom Properties:") %}
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block main %}
|
||||
{{ table.render }}
|
||||
{% endblock %}
|
@ -0,0 +1,11 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Update Image Metadata" %}{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
{% include "horizon/common/_page_header.html" with title=_("Update Image Metadata") %}
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block main %}
|
||||
{% include 'admin/images/_update_metadata.html' %}
|
||||
{% endblock %}
|
@ -12,6 +12,8 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django import http
|
||||
@ -24,6 +26,10 @@ from openstack_dashboard.test import helpers as test
|
||||
|
||||
from openstack_dashboard.dashboards.admin.images import tables
|
||||
|
||||
IMAGE_METADATA_URL = reverse('horizon:admin:images:update_metadata',
|
||||
kwargs={
|
||||
"id": "007e7d55-fe1e-4c5c-bf08-44b4a4964822"})
|
||||
|
||||
|
||||
class ImageCreateViewTest(test.BaseAdminViewTests):
|
||||
def test_admin_image_create_view_uses_admin_template(self):
|
||||
@ -114,6 +120,64 @@ class ImagesViewTest(test.BaseAdminViewTests):
|
||||
self.assertEqual(len(res.context['images_table'].data),
|
||||
1)
|
||||
|
||||
@test.create_stubs({api.glance: ('image_get',
|
||||
'metadefs_namespace_list',
|
||||
'metadefs_namespace_get')})
|
||||
def test_images_metadata_get(self):
|
||||
image = self.images.first()
|
||||
|
||||
api.glance.image_get(
|
||||
IsA(http.HttpRequest),
|
||||
image.id
|
||||
).AndReturn(image)
|
||||
|
||||
namespaces = self.metadata_defs.list()
|
||||
|
||||
api.glance.metadefs_namespace_list(IsA(http.HttpRequest), filters={
|
||||
'resource_types': ['OS::Glance::Image']}).AndReturn(
|
||||
(namespaces, False, False))
|
||||
|
||||
for namespace in namespaces:
|
||||
api.glance.metadefs_namespace_get(
|
||||
IsA(http.HttpRequest),
|
||||
namespace.namespace,
|
||||
'OS::Glance::Image'
|
||||
).AndReturn(namespace)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
res = self.client.get(IMAGE_METADATA_URL)
|
||||
|
||||
self.assertTemplateUsed(res, 'admin/images/update_metadata.html')
|
||||
self.assertContains(res, 'namespace_1')
|
||||
self.assertContains(res, 'namespace_2')
|
||||
self.assertContains(res, 'namespace_3')
|
||||
self.assertContains(res, 'namespace_4')
|
||||
|
||||
@test.create_stubs({api.glance: ('image_get', 'image_update_properties')})
|
||||
def test_images_metadata_update(self):
|
||||
image = self.images.first()
|
||||
|
||||
api.glance.image_get(
|
||||
IsA(http.HttpRequest),
|
||||
image.id
|
||||
).AndReturn(image)
|
||||
api.glance.image_update_properties(
|
||||
IsA(http.HttpRequest), image.id, ['image_type'],
|
||||
hw_machine_type='mock_value').AndReturn(None)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
metadata = [{"value": "mock_value", "key": "hw_machine_type"}]
|
||||
formData = {"metadata": json.dumps(metadata)}
|
||||
|
||||
res = self.client.post(IMAGE_METADATA_URL, formData)
|
||||
|
||||
self.assertNoFormErrors(res)
|
||||
self.assertMessageCount(success=1)
|
||||
self.assertRedirectsNoFollow(
|
||||
res, reverse('horizon:admin:images:index')
|
||||
)
|
||||
|
||||
@override_settings(API_RESULT_PAGE_SIZE=2)
|
||||
@test.create_stubs({api.glance: ('image_list_detailed',)})
|
||||
def test_images_list_get_prev_pagination(self):
|
||||
|
@ -16,22 +16,19 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from django.conf.urls import include # noqa
|
||||
from django.conf.urls import patterns # noqa
|
||||
from django.conf.urls import url # noqa
|
||||
|
||||
from openstack_dashboard.dashboards.admin.images.properties \
|
||||
import urls as properties_urls
|
||||
from openstack_dashboard.dashboards.admin.images import views
|
||||
|
||||
|
||||
urlpatterns = patterns('openstack_dashboard.dashboards.admin.images.views',
|
||||
url(r'^images/$', views.IndexView.as_view(), name='index'),
|
||||
url(r'^$', views.IndexView.as_view(), name='index'),
|
||||
url(r'^create/$', views.CreateView.as_view(), name='create'),
|
||||
url(r'^(?P<image_id>[^/]+)/update/$',
|
||||
views.UpdateView.as_view(), name='update'),
|
||||
url(r'^(?P<id>[^/]+)/update_metadata/$',
|
||||
views.UpdateMetadataView.as_view(), name='update_metadata'),
|
||||
url(r'^(?P<image_id>[^/]+)/detail/$',
|
||||
views.DetailView.as_view(), name='detail'),
|
||||
url(r'^(?P<id>[^/]+)/properties/',
|
||||
include(properties_urls, namespace='properties')),
|
||||
views.DetailView.as_view(), name='detail')
|
||||
)
|
||||
|
@ -16,18 +16,22 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django import conf
|
||||
from django.core.urlresolvers import reverse_lazy
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import exceptions
|
||||
from horizon import forms
|
||||
from horizon import tables
|
||||
from horizon.utils import memoized
|
||||
|
||||
from openstack_dashboard import api
|
||||
from openstack_dashboard.dashboards.project.images.images import views
|
||||
|
||||
from openstack_dashboard.dashboards.admin.images import forms
|
||||
from openstack_dashboard.dashboards.admin.images import forms as project_forms
|
||||
from openstack_dashboard.dashboards.admin.images \
|
||||
import tables as project_tables
|
||||
|
||||
@ -101,16 +105,93 @@ class IndexView(tables.DataTableView):
|
||||
|
||||
class CreateView(views.CreateView):
|
||||
template_name = 'admin/images/create.html'
|
||||
form_class = forms.AdminCreateImageForm
|
||||
form_class = project_forms.AdminCreateImageForm
|
||||
success_url = reverse_lazy('horizon:admin:images:index')
|
||||
|
||||
|
||||
class UpdateView(views.UpdateView):
|
||||
template_name = 'admin/images/update.html'
|
||||
form_class = forms.AdminUpdateImageForm
|
||||
form_class = project_forms.AdminUpdateImageForm
|
||||
success_url = reverse_lazy('horizon:admin:images:index')
|
||||
|
||||
|
||||
class DetailView(views.DetailView):
|
||||
"""Admin placeholder for image detail view."""
|
||||
pass
|
||||
|
||||
|
||||
class UpdateMetadataView(forms.ModalFormView):
|
||||
template_name = "admin/images/update_metadata.html"
|
||||
form_class = project_forms.UpdateMetadataForm
|
||||
success_url = reverse_lazy('horizon:admin:images:index')
|
||||
|
||||
def get_initial(self):
|
||||
image = self.get_object()
|
||||
return {'id': self.kwargs["id"], 'metadata': image.properties}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(UpdateMetadataView, self).get_context_data(**kwargs)
|
||||
|
||||
image = self.get_object()
|
||||
reserved_props = getattr(conf.settings,
|
||||
'IMAGE_RESERVED_CUSTOM_PROPERTIES', [])
|
||||
image.properties = dict((k, v)
|
||||
for (k, v) in image.properties.iteritems()
|
||||
if k not in reserved_props)
|
||||
try:
|
||||
context['existing_metadata'] = json.dumps(image.properties)
|
||||
except Exception:
|
||||
msg = _('Unable to retrieve image properties.')
|
||||
exceptions.handle(self.request, msg)
|
||||
|
||||
resource_type = 'OS::Glance::Image'
|
||||
metadata = {'namespaces': []}
|
||||
try:
|
||||
# metadefs_namespace_list() returns a tuple with list as 1st elem
|
||||
namespaces = [x.namespace for x in
|
||||
api.glance.metadefs_namespace_list(
|
||||
self.request,
|
||||
filters={"resource_types":
|
||||
[resource_type]}
|
||||
)[0]]
|
||||
for namespace in namespaces:
|
||||
details = api.glance.metadefs_namespace_get(self.request,
|
||||
namespace, resource_type)
|
||||
# Filter out reserved custom properties from namespace
|
||||
if reserved_props:
|
||||
if hasattr(details, 'properties'):
|
||||
details.properties = dict(
|
||||
(k, v)
|
||||
for (k, v) in details.properties.iteritems()
|
||||
if k not in reserved_props
|
||||
)
|
||||
|
||||
if hasattr(details, 'objects'):
|
||||
for obj in details.objects:
|
||||
obj['properties'] = dict(
|
||||
(k, v)
|
||||
for (k, v) in obj['properties'].iteritems()
|
||||
if k not in reserved_props
|
||||
)
|
||||
|
||||
metadata["namespaces"].append(details)
|
||||
|
||||
context['available_metadata'] = json.dumps(metadata)
|
||||
except Exception:
|
||||
msg = _('Unable to retrieve available properties for '
|
||||
'image.')
|
||||
exceptions.handle(self.request, msg)
|
||||
|
||||
context['id'] = self.kwargs['id']
|
||||
return context
|
||||
|
||||
@memoized.memoized_method
|
||||
def get_object(self):
|
||||
image_id = self.kwargs['id']
|
||||
try:
|
||||
image = api.glance.image_get(self.request, image_id)
|
||||
except Exception:
|
||||
msg = _('Unable to retrieve the image to be updated.')
|
||||
exceptions.handle(self.request, msg)
|
||||
else:
|
||||
return image
|
||||
|
@ -218,6 +218,22 @@ class UpdateImageForm(forms.SelfHandlingForm):
|
||||
disk_format = forms.ChoiceField(
|
||||
label=_("Format"),
|
||||
)
|
||||
minimum_disk = forms.IntegerField(label=_("Minimum Disk (GB)"),
|
||||
min_value=0,
|
||||
help_text=_('The minimum disk size'
|
||||
' required to boot the'
|
||||
' image. If unspecified,'
|
||||
' this value defaults to'
|
||||
' 0 (no minimum).'),
|
||||
required=False)
|
||||
minimum_ram = forms.IntegerField(label=_("Minimum RAM (MB)"),
|
||||
min_value=0,
|
||||
help_text=_('The minimum memory size'
|
||||
' required to boot the'
|
||||
' image. If unspecified,'
|
||||
' this value defaults to'
|
||||
' 0 (no minimum).'),
|
||||
required=False)
|
||||
public = forms.BooleanField(label=_("Public"), required=False)
|
||||
protected = forms.BooleanField(label=_("Protected"), required=False)
|
||||
|
||||
@ -244,6 +260,8 @@ class UpdateImageForm(forms.SelfHandlingForm):
|
||||
'disk_format': data['disk_format'],
|
||||
'container_format': container_format,
|
||||
'name': data['name'],
|
||||
'min_ram': (data['minimum_ram'] or 0),
|
||||
'min_disk': (data['minimum_disk'] or 0),
|
||||
'properties': {'description': data['description']}}
|
||||
if data['kernel']:
|
||||
meta['properties']['kernel_id'] = data['kernel']
|
||||
|
@ -115,6 +115,8 @@ class UpdateImageFormTests(test.TestCase):
|
||||
disk_format=data['disk_format'],
|
||||
container_format="bare",
|
||||
name=data['name'],
|
||||
min_ram=data['minimum_ram'],
|
||||
min_disk=data['minimum_disk'],
|
||||
properties={'description': data['description'],
|
||||
'architecture':
|
||||
data['architecture']},
|
||||
|
@ -72,6 +72,8 @@ class UpdateView(forms.ModalFormView):
|
||||
'ramdisk': properties.get('ramdisk_id', ''),
|
||||
'architecture': properties.get('architecture', ''),
|
||||
'disk_format': getattr(image, 'disk_format', None),
|
||||
'minimum_ram': getattr(image, 'min_ram', None),
|
||||
'minimum_disk': getattr(image, 'min_disk', None),
|
||||
'public': getattr(image, 'is_public', None),
|
||||
'protected': getattr(image, 'protected', None)}
|
||||
|
||||
|
@ -14,6 +14,8 @@
|
||||
{% endif %}
|
||||
<dt>{% trans "ID" %}</dt>
|
||||
<dd>{{ image.id|default:_("None") }}</dd>
|
||||
<dt>{% trans "Owner" %}</dt>
|
||||
<dd>{{ image.owner }}</dd>
|
||||
<dt>{% trans "Status" %}</dt>
|
||||
<dd>{{ image.status|default:_("Unknown")|title }}</dd>
|
||||
<dt>{% trans "Public" %}</dt>
|
||||
|
@ -1579,6 +1579,76 @@ label.log-length {
|
||||
margin-top: -60px;
|
||||
}
|
||||
|
||||
/* Capabilities widget UI */
|
||||
.capabilities {
|
||||
min-height: 200px;
|
||||
|
||||
.panel .list-group {
|
||||
height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
|
||||
.panel-heading .form-control {
|
||||
width: 150px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
/* Item lists */
|
||||
|
||||
.dark-stripe {
|
||||
background-color: $table-bg-odd;
|
||||
}
|
||||
.light-stripe {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.list-group-item.level-0>* {
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.list-group-item.level-1>* {
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.list-group-item.level-2>* {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.list-group-item .leaf {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.list-group-item span.input-group-addon {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 50%;
|
||||
max-width: 140px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.list-group-item .label-info {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 1px;
|
||||
left: 25px;
|
||||
max-width: 80%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.list-group-item .label-danger {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
bottom: 1px;
|
||||
left: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Membership widget UI */
|
||||
.membership {
|
||||
min-height: 200px;
|
||||
|
@ -17,9 +17,24 @@ from glanceclient.v1 import images
|
||||
from openstack_dashboard.test.test_data import utils
|
||||
|
||||
|
||||
class Namespace(dict):
|
||||
def __repr__(self):
|
||||
return "<Namespace %s>" % self._info
|
||||
|
||||
def __init__(self, info):
|
||||
super(Namespace, self).__init__()
|
||||
self.__dict__.update(info)
|
||||
self.update(info)
|
||||
self._info = info
|
||||
|
||||
def as_json(self, indent=4):
|
||||
return self.__dict__
|
||||
|
||||
|
||||
def data(TEST):
|
||||
TEST.images = utils.TestDataContainer()
|
||||
TEST.snapshots = utils.TestDataContainer()
|
||||
TEST.metadata_defs = utils.TestDataContainer()
|
||||
|
||||
# Snapshots
|
||||
snapshot_dict = {'name': u'snapshot',
|
||||
@ -190,3 +205,107 @@ def data(TEST):
|
||||
shared_image1, official_image1, multi_prop_image)
|
||||
|
||||
TEST.empty_name_image = no_name_image
|
||||
|
||||
metadef_dict = {
|
||||
'namespace': 'namespace_1',
|
||||
'display_name': 'Namespace 1',
|
||||
'description': 'Mock desc 1',
|
||||
'resource_type_associations': [
|
||||
{
|
||||
'created_at': '2014-08-21T08:39:43Z',
|
||||
'prefix': 'mock',
|
||||
'name': 'mock name'
|
||||
}
|
||||
],
|
||||
'visibility': 'public',
|
||||
'protected': True,
|
||||
'created_at': '2014-08-21T08:39:43Z',
|
||||
'properties': {
|
||||
'cpu_mock:mock': {
|
||||
'default': '1',
|
||||
'type': 'integer',
|
||||
'description': 'Number of mocks.',
|
||||
'title': 'mocks'
|
||||
}
|
||||
}
|
||||
}
|
||||
metadef = Namespace(metadef_dict)
|
||||
TEST.metadata_defs.add(metadef)
|
||||
|
||||
metadef_dict = {
|
||||
'namespace': 'namespace_2',
|
||||
'display_name': 'Namespace 2',
|
||||
'description': 'Mock desc 2',
|
||||
'resource_type_associations': [
|
||||
{
|
||||
'created_at': '2014-08-21T08:39:43Z',
|
||||
'prefix': 'mock',
|
||||
'name': 'mock name'
|
||||
}
|
||||
],
|
||||
'visibility': 'private',
|
||||
'protected': False,
|
||||
'created_at': '2014-08-21T08:39:43Z',
|
||||
'properties': {
|
||||
'hdd_mock:mock': {
|
||||
'default': '2',
|
||||
'type': 'integer',
|
||||
'description': 'Number of mocks.',
|
||||
'title': 'mocks'
|
||||
}
|
||||
}
|
||||
}
|
||||
metadef = Namespace(metadef_dict)
|
||||
TEST.metadata_defs.add(metadef)
|
||||
|
||||
metadef_dict = {
|
||||
'namespace': 'namespace_3',
|
||||
'display_name': 'Namespace 3',
|
||||
'description': 'Mock desc 3',
|
||||
'resource_type_associations': [
|
||||
{
|
||||
'created_at': '2014-08-21T08:39:43Z',
|
||||
'prefix': 'mock',
|
||||
'name': 'mock name'
|
||||
}
|
||||
],
|
||||
'visibility': 'public',
|
||||
'protected': False,
|
||||
'created_at': '2014-08-21T08:39:43Z',
|
||||
'properties': {
|
||||
'gpu_mock:mock': {
|
||||
'default': '2',
|
||||
'type': 'integer',
|
||||
'description': 'Number of mocks.',
|
||||
'title': 'mocks'
|
||||
}
|
||||
}
|
||||
}
|
||||
metadef = Namespace(metadef_dict)
|
||||
TEST.metadata_defs.add(metadef)
|
||||
|
||||
metadef_dict = {
|
||||
'namespace': 'namespace_4',
|
||||
'display_name': 'Namespace 4',
|
||||
'description': 'Mock desc 4',
|
||||
'resource_type_associations': [
|
||||
{
|
||||
'created_at': '2014-08-21T08:39:43Z',
|
||||
'prefix': 'mock',
|
||||
'name': 'mock name'
|
||||
}
|
||||
],
|
||||
'visibility': 'public',
|
||||
'protected': True,
|
||||
'created_at': '2014-08-21T08:39:43Z',
|
||||
'properties': {
|
||||
'ram_mock:mock': {
|
||||
'default': '2',
|
||||
'type': 'integer',
|
||||
'description': 'Number of mocks.',
|
||||
'title': 'mocks'
|
||||
}
|
||||
}
|
||||
}
|
||||
metadef = Namespace(metadef_dict)
|
||||
TEST.metadata_defs.add(metadef)
|
||||
|
Loading…
x
Reference in New Issue
Block a user