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:
Michal Dulko 2014-09-11 19:10:41 +02:00
parent 354c0c1baa
commit 2e3299dc57
33 changed files with 1190 additions and 539 deletions

View File

@ -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

View 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);
}]);
}());

View File

@ -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');
});
});
});

View File

@ -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>

View File

@ -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 %}

View File

@ -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(' &rsaquo; ') $}
</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 %}

View File

@ -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 %}

View File

@ -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']

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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')

View File

@ -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')
)

View File

@ -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 ''}

View File

@ -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)

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 &quot;{{ key }}&quot;{% 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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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):

View File

@ -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')
)

View File

@ -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

View File

@ -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']

View File

@ -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']},

View File

@ -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)}

View File

@ -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>

View File

@ -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;

View File

@ -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)