Commit 4c29a7bb by Szabolcs Gelencser

Implement template share with users

parent 0ec3899c
......@@ -433,11 +433,17 @@ class TemplateForm(forms.ModelForm):
# }))
name = forms.TextInput()
flavor = forms.ChoiceField(['a','b','c'])
# flavor = forms.ChoiceField(['a','b','c'])
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super(TemplateForm, self).__init__(*args, **kwargs)
self.allowed_fields = (
'name',
# 'lease',
)
#
# self.fields['networks'].queryset = ()#Vlan.get_objects_with_level('user', self.user)
#
......@@ -487,34 +493,34 @@ class TemplateForm(forms.ModelForm):
def clean_max_ram_size(self):
return self.cleaned_data.get("ram_size", 0)
def _clean_fields(self):
try:
old = InstanceTemplate.objects.get(pk=self.instance.pk)
except InstanceTemplate.DoesNotExist:
old = None
for name, field in self.fields.items():
if name in self.allowed_fields:
value = field.widget.value_from_datadict(
self.data, self.files, self.add_prefix(name))
try:
value = field.clean(value)
self.cleaned_data[name] = value
if hasattr(self, 'clean_%s' % name):
value = getattr(self, 'clean_%s' % name)()
self.cleaned_data[name] = value
except ValidationError as e:
self._errors[name] = self.error_class(e.messages)
if name in self.cleaned_data:
del self.cleaned_data[name]
elif old:
if name == 'networks':
self.cleaned_data[name] = [
i.vlan for i in self.instance.interface_set.all()]
else:
self.cleaned_data[name] = getattr(old, name)
# def _clean_fields(self):
# try:
# old = InstanceTemplate.objects.get(pk=self.instance.pk)
# except InstanceTemplate.DoesNotExist:
# old = None
# for name, field in self.fields.items():
# if name in self.allowed_fields:
# value = field.widget.value_from_datadict(
# self.data, self.files, self.add_prefix(name))
# try:
# value = field.clean(value)
# self.cleaned_data[name] = value
# if hasattr(self, 'clean_%s' % name):
# value = getattr(self, 'clean_%s' % name)()
# self.cleaned_data[name] = value
# except ValidationError as e:
# self._errors[name] = self.error_class(e.messages)
# if name in self.cleaned_data:
# del self.cleaned_data[name]
# elif old:
# if name == 'networks':
# self.cleaned_data[name] = [
# i.vlan for i in self.instance.interface_set.all()]
# else:
# self.cleaned_data[name] = getattr(old, name)
if "req_traits" not in self.allowed_fields:
self.cleaned_data['req_traits'] = self.instance.req_traits.all()
# if "req_traits" not in self.allowed_fields:
# self.cleaned_data['req_traits'] = self.instance.req_traits.all()
def save(self, commit=True):
data = self.cleaned_data
......@@ -1300,12 +1306,7 @@ class UserEditForm(forms.ModelForm):
class AclUserOrGroupAddForm(forms.Form):
name = forms.CharField(
widget=autocomplete.ListSelect2(
url='autocomplete.acl.user-group',
attrs={'class': 'form-control',
'data-html': 'true',
'data-placeholder': _("Name of group or user")}))
name = forms.CharField(required=False)
class TransferOwnershipForm(forms.Form):
......
{% load i18n %}
<form action="{{ acl.url }}" method="post">{% csrf_token %}
<form action="{{ manage_access_url }}" method="post">{% csrf_token %}
<table class="table table-striped table-with-form-fields acl-table" id="{{table_id}}">
<thead>
<tr>
<th></th>
<th>{% trans "Who" %}</th>
<th>{% trans "What" %}</th>
<th><i id="manage-access-select-all" class="fa fa-times"></i></th>
</tr>
</thead>
<tbody>
{% for i in acl.users %}
{% for member in shared_with_users %}
<tr>
<td>
<img class="profile-avatar" src="{{ i.user.profile.get_avatar_url }}"/>
{# <img class="profile-avatar" src="{{ i.user.profile.get_avatar_url }}"/>#}
</td>
<td>
<a href="{% url "dashboard.views.profile" username=i.user.username %}"
title="{{ i.user.username }}">
{% include "dashboard/_display-name.html" with user=i.user show_org=True %}
</a>
</td>
<td>
<select class="form-control" name="perm-u-{{i.user.id}}"{% if i.level not in acl.allowed_levels %} disabled{% endif %}>
{% for id, name in acl.levels %}
<option{%if id == i.level%} selected="selected"{%endif%}
{% if id not in acl.allowed_levels %} disabled{% endif %}
value="{{id}}">{{name}}</option>
{% endfor %}
</select>
{# <a href="{% url "dashboard.views.profile" username=i.user.username %}"#}
{# title="{{ i.user.username }}">#}
{# {% include "dashboard/_display-name.html" with user=i.user show_org=True %}#}
{# </a>#}
{{ member.username }}
</td>
<td>
<input type="checkbox" name="remove-u-{{i.user.id}}" title="{% trans "Remove" %}"/>
<input type="checkbox" name="remove-u-{{ member.member_id }}" title="{% trans "Remove" %}"/>
</td>
</tr>
{% endfor %}
......@@ -57,15 +48,10 @@
</td>
</tr>
{% endfor %}
<tr><td><i class="fa fa-plus"></i></td>
<tr>
<td><i class="fa fa-plus"></i></td>
<td>{{aclform.name }}</td>
<td><select class="form-control" name="level">
{% for id, name in acl.levels %}
{% if id in acl.allowed_levels %}
<option value="{{id}}">{{name}}</option>
{% endif %}
{% endfor %}
</select></td><td></td>
<td></td>
</tr>
</tbody>
</table>
......
......@@ -42,8 +42,8 @@
{# {{ form.max_ram_size|as_crispy_field }}#}
</fieldset>
<fieldset>
<legend>{% trans "Virtual machine settings" %}</legend>
{# <fieldset>#}
{# <legend>{% trans "Virtual machine settings" %}</legend>#}
{# {{ form.arch|as_crispy_field }}#}
{# {{ form.access_method|as_crispy_field }}#}
{# {{ form.boot_menu|as_crispy_field }}#}
......@@ -52,11 +52,11 @@
{# {{ form.description|as_crispy_field }}#}
{# {{ form.system|as_crispy_field }}#}
{# {{ form.has_agent|as_crispy_field }}#}
</fieldset>
{# </fieldset>#}
<fieldset>
<legend>{% trans "External resources" %}</legend>
{# {{ form.networks|as_crispy_field }}#}
{# {{ form.lease|as_crispy_field }}#}
{{ form.lease|as_crispy_field }}
{##}
{# {{ form.tags|as_crispy_field }}#}
</fieldset>
......@@ -82,26 +82,6 @@
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="no-margin"><i class="fa fa-user"></i> {% trans "Owner" %}</h4>
</div>
<div class="panel-body">
{% if user == object.owner %}
{% blocktrans %}You are the current owner of this template.{% endblocktrans %}
{% else %}
{% url "dashboard.views.profile" username=object.owner.username as url %}
{% blocktrans with owner=object.owner name=object.owner.get_full_name%}
The current owner of this template is <a href="{{url}}">{{name}} ({{owner}})</a>.
{% endblocktrans %}
{% endif %}
{% if user == object.owner or user.is_superuser %}
<a href="{% url "dashboard.views.template-transfer-ownership" object.pk %}"
class="btn btn-link tx-tpl-ownership">{% trans "Transfer ownership..." %}</a>
{% endif %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="no-margin"><i class="fa fa-group"></i> {% trans "Manage access" %}</h4>
</div>
<div class="panel-body">
......@@ -111,40 +91,6 @@
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="no-margin">
<i class="fa fa-question-circle"></i>
{% trans "Access level rights" %}
</h4>
</div>
<div class="panel-body">
<dl>
<dt>{% trans "User" %}</dt>
<dd>
{% blocktrans %}
User can deploy instances from this template.
{% endblocktrans %}
</dd>
<dt>{% trans "Operator" %}</dt>
<dd>
{% blocktrans %}
Operators are able to deploy and grant/revoke User level access to this template.
{% endblocktrans %}
</dd>
<dt>{% trans "Owner" %}</dt>
<dd>
{% blocktrans %}
Owners can edit attributes or delete the template.
Owners are able to grant/revoke User, Operator and Owner level access to the template.
The accountable owner (the one who created the template) can not be demoted.
The accountable ownership can be transferred to other User via the "Transfer onwership" button.
{% endblocktrans %}
</dd>
</dl>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="no-margin"><i class="fa fa-file"></i> {% trans "Disk list" %}</h4>
</div>
<div class="panel-body">
......
......@@ -19,7 +19,8 @@ from __future__ import absolute_import
from dashboard.views.autocomplete import AclUserGroupAutocomplete, AclUserAutocomplete
from dashboard.views.template import TemplateList, TemplateChoose, TemplateDetail, TemplateDelete, \
TransferTemplateOwnershipConfirmView, TransferTemplateOwnershipView, LeaseCreate, LeaseDetail, LeaseDelete
TransferTemplateOwnershipConfirmView, TransferTemplateOwnershipView, LeaseCreate, LeaseDetail, LeaseDelete, \
TemplateAclUpdateView
from dashboard.views.vm import VmDetailView, VmList, VmCreate, vm_activity, vm_ops, FavouriteView, VmPlainImageCreate
from django.conf.urls import url
......@@ -47,8 +48,8 @@ urlpatterns = [
# name="dashboard.views.template-create"),
url(r'^template/choose/$', TemplateChoose.as_view(),
name="dashboard.views.template-choose"),
# url(r'template/(?P<pk>\d+)/acl/$', TemplateAclUpdateView.as_view(),
# name='dashboard.views.template-acl'),
url(r'template/(?P<pk>\d+)/acl/$', TemplateAclUpdateView.as_view(),
name='dashboard.views.template-acl'),
url(r'^template/(?P<pk>\d+)/$', TemplateDetail.as_view(),
name='dashboard.views.template-detail'),
url(r"^template/list/$", TemplateList.as_view(),
......
......@@ -89,10 +89,9 @@ class AclUserAutocomplete(autocomplete.Select2ListView):
}), content_type="application/json")
class AclUserGroupAutocomplete(AclUserAutocomplete):
group_search_fields = ('name', 'groupprofile__org_id')
class AclUserGroupAutocomplete(autocomplete.Select2ListView): # TODO: was AclUserAutocomplete inherited
def get_list(self):
groups = AclUpdateView.get_allowed_groups(self.request.user)
groups = self.filter(groups, self.group_search_fields)
return super(AclUserGroupAutocomplete, self).get_list() + groups
from openstack_api import keystone
groups = keystone.group_list(request=self.request, user=self.request.user)
group_names = [g.name for g in groups]
return super(AclUserGroupAutocomplete, self).get_list() + group_names
......@@ -41,6 +41,9 @@ from braces.views import (
)
from django.views.generic.edit import BaseUpdateView, ModelFormMixin, FormView
from django_tables2 import SingleTableView
from keystoneauth1 import session
from keystoneauth1.identity import v3
from openstack_auth.utils import fix_auth_url_version
from vm.models import (
InstanceTemplate, InterfaceTemplate, Instance, Lease, InstanceActivity
......@@ -289,6 +292,8 @@ class TemplateDelete(DeleteViewBase):
object.delete()
class TemplateDetail(LoginRequiredMixin, GraphMixin, SuccessMessageMixin, UpdateView):
model = InstanceTemplate
template_name = "dashboard/template-edit.html"
......@@ -325,11 +330,57 @@ class TemplateDetail(LoginRequiredMixin, GraphMixin, SuccessMessageMixin, Update
else:
return super(TemplateDetail, self).get(request, *args, **kwargs)
def __get_glance_admin_client(self, request):
from keystoneauth1 import session
from glanceclient import Client
auth = v3.Password(
auth_url=fix_auth_url_version(settings.OPENSTACK_KEYSTONE_URL),
user_id=settings.OPENSTACK_CIRCLE_USERID,
password=settings.OPENSTACK_CIRCLE_PASSWORD,
project_id=request.user.tenant_id,
)
session = session.Session(auth=auth, verify=False)
return Client('2', session=session)
def __get_members_shared_with(self):
template = self.get_object()
glance = self.__get_glance_admin_client(self.request)
members_generator = glance.image_members.list(template.image_id)
return [m for m in members_generator]
def __get_project_client(self, project_id):
from keystoneclient.v3 import client
auth = v3.Password(
auth_url=fix_auth_url_version(settings.OPENSTACK_KEYSTONE_URL),
user_id=settings.OPENSTACK_CIRCLE_USERID,
password=settings.OPENSTACK_CIRCLE_PASSWORD,
project_id=project_id,
)
sess = session.Session(auth=auth, verify=False)
return client.Client(session=sess, interface='public')
def __get_username_for(self, project_id):
client = self.__get_project_client(project_id)
project = client.projects.get(project_id)
return project.name # as username = project's name
def __get_users_shared_with(self):
members = self.__get_members_shared_with()
for m in members:
m['username'] = self.__get_username_for(m.member_id)
return members
def get_context_data(self, **kwargs):
template = self.get_object()
context = super(TemplateDetail, self).get_context_data(**kwargs)
# context['acl'] = AclUpdateView.get_acl_data(
# template, self.request.user, 'dashboard.views.template-acl')
context['manage_access_url'] = reverse_lazy('dashboard.views.template-acl',
kwargs={'pk': template.id})
context['shared_with_users'] = self.__get_users_shared_with()
# context['disks'] = template.disks.all()
context['is_owner'] = template.owner_id == self.request.user.id
context['aclform'] = AclUserOrGroupAddForm()
......@@ -341,13 +392,14 @@ class TemplateDetail(LoginRequiredMixin, GraphMixin, SuccessMessageMixin, Update
return reverse_lazy("dashboard.views.template-detail",
kwargs=self.kwargs)
def post(self, request, *args, **kwargs):
def post(self, request):
template = self.get_object()
if not template.has_level(request.user, 'owner'):
# TODO: multiple users
if template.owner_id != request.user.id:
raise PermissionDenied()
return super(TemplateDetail, self).post(self, request, args, kwargs)
def get_form_kwargs(self):
def get_form_kwargs(self, *args, **kwargs):
kwargs = super(TemplateDetail, self).get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
......
......@@ -43,6 +43,8 @@ from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import DetailView, View, DeleteView, FormView
from django.views.generic.detail import SingleObjectMixin
from keystoneauth1.identity import v3
from openstack_auth.utils import fix_auth_url_version
from vm.models import Instance
from ..models import GroupProfile
......@@ -411,159 +413,141 @@ class RequestFormOperationMixin(FormOperationMixin):
class AclUpdateView(LoginRequiredMixin, View, SingleObjectMixin):
def send_success_message(self, whom, old_level, new_level):
if old_level and new_level:
msg = _("Acl user/group %(w)s successfully modified.")
elif not old_level and new_level:
msg = _("Acl user/group %(w)s successfully added.")
elif old_level and not new_level:
msg = _("Acl user/group %(w)s successfully removed.")
if msg:
messages.success(self.request, msg % {'w': whom})
def get_level(self, whom):
for u, level in self.acl_data:
if u == whom:
return level
# def send_success_message(self, whom, old_level, new_level):
# if old_level and new_level:
# msg = _("Acl user/group %(w)s successfully modified.")
# elif not old_level and new_level:
# msg = _("Acl user/group %(w)s successfully added.")
# elif old_level and not new_level:
# msg = _("Acl user/group %(w)s successfully removed.")
# if msg:
# messages.success(self.request, msg % {'w': whom})
#
# def add_levels(self):
# self.request, _('User "%s" has already '
# 'access to this object.') % name)
# self.request, _('Group "%s" has already '
# 'access to this object.') % name)
# self.request, _('User or group "%s" not found.') % name)
def __get_current_users_groups(self, request):
from openstack_api import keystone
groups = keystone.group_list(request=self.request, user=self.request.user)
return [g.name for g in groups]
def __get_glance_admin_client(self, project_id):
from keystoneauth1 import session
from glanceclient import Client
auth = v3.Password(
auth_url=fix_auth_url_version(settings.OPENSTACK_KEYSTONE_URL),
user_id=settings.OPENSTACK_CIRCLE_USERID,
password=settings.OPENSTACK_CIRCLE_PASSWORD,
project_id=project_id,
)
session = session.Session(auth=auth, verify=False)
return Client('2', session=session)
def __get_all_projects(self):
from keystoneauth1 import session
from keystoneclient.v3 import client
auth = v3.Password(
auth_url=fix_auth_url_version(settings.OPENSTACK_KEYSTONE_URL),
user_id=settings.OPENSTACK_CIRCLE_USERID,
password=settings.OPENSTACK_CIRCLE_PASSWORD,
)
sess = session.Session(auth=auth, verify=False)
keystone = client.Client(session=sess, interface=settings.OPENSTACK_INTERFACE)
return keystone.projects.list(
domain=settings.OPENSTACK_CIRCLE_DOMAIN_ID,
user=settings.OPENSTACK_CIRCLE_USERID
)
def __get_project_id_by_name(self, request, name):
projects = self.__get_all_projects()
for p in projects:
if p.name == name:
return p.id
return None
@classmethod
def get_acl_data(cls, obj, user, url):
levels = obj.ACL_LEVELS
allowed_levels = list(l for l in OrderedDict(levels)
if cls.has_next_level(user, obj, l))
is_owner = 'owner' in allowed_levels
allowed_users = cls.get_allowed_users(user)
allowed_groups = (set(cls.get_allowed_groups(user)) |
set(user.groups.all()))
user_levels = list(
{'user': u, 'level': l} for u, l in obj.get_users_with_level()
if is_owner or u == user or u in allowed_users)
group_levels = list(
{'group': g, 'level': l} for g, l in obj.get_groups_with_level()
if is_owner or g in allowed_groups)
return {'users': user_levels,
'groups': group_levels,
'levels': levels,
'allowed_levels': allowed_levels,
'url': reverse(url, args=[obj.pk])}
def __accept_membership(self, membership, project_id_of_user):
template = self.get_object()
glance = self.__get_glance_admin_client(project_id_of_user)
glance.image_members.update(template.image_id, project_id_of_user, 'accepted')
def __handle_group_assignment(self, request):
template = self.get_object()
pass
def __handle_user_assignment(self, request):
glance = self.__get_glance_admin_client(request.user.tenant_id)
template = self.get_object()
new_template_user = request.POST['name']
project_id_of_user = self.__get_project_id_by_name(
request, new_template_user
)
@classmethod
def has_next_level(self, user, instance, level):
levels = OrderedDict(instance.ACL_LEVELS).keys()
next_levels = dict(zip([None] + levels, levels + levels[-1:]))
# {None: 'user', 'user': 'operator', 'operator: 'owner',
# 'owner: 'owner'}
next_level = next_levels[level]
return instance.has_level(user, next_level)
old_members_generator = glance.image_members.list(template.image_id)
old_members = [m.member_id for m in old_members_generator]
@classmethod
def get_allowed_groups(cls, user):
if user.has_perm('dashboard.use_autocomplete'):
return Group.objects.all()
if project_id_of_user in old_members:
msg = _("Template is already shared with %s" % new_template_user)
messages.warning(self.request, msg)
elif project_id_of_user is not None:
membership = glance.image_members.create(template.image_id, project_id_of_user)
self.__accept_membership(membership, project_id_of_user)
msg = _("Successfully shared with %s" % new_template_user)
messages.success(self.request, msg)
else:
profiles = GroupProfile.get_objects_with_level('owner', user)
return Group.objects.filter(groupprofile__in=profiles).distinct()
msg = _("User or group with name '%s' doesn't exist" % new_template_user)
messages.error(self.request, msg)
@classmethod
def get_allowed_users(cls, user):
if user.has_perm('dashboard.use_autocomplete'):
return User.objects.all()
else:
groups = cls.get_allowed_groups(user)
return User.objects.filter(
Q(groups__in=groups) | Q(pk=user.pk)).distinct()
def check_auth(self, whom, old_level, new_level):
if isinstance(whom, Group):
if (not self.is_owner and whom not in
AclUpdateView.get_allowed_groups(self.request.user)):
return False
elif isinstance(whom, User):
if (not self.is_owner and whom not in
AclUpdateView.get_allowed_users(self.request.user)):
return False
return (
AclUpdateView.has_next_level(self.request.user,
self.instance, new_level) and
AclUpdateView.has_next_level(self.request.user,
self.instance, old_level))
def set_level(self, whom, new_level):
user = self.request.user
old_level = self.get_level(whom)
if old_level == new_level:
return
if getattr(self.instance, "owner", None) == whom:
logger.info("Tried to set owner's acl level for %s by %s.",
unicode(self.instance), unicode(user))
msg = _("The original owner cannot be removed, however "
"you can transfer ownership.")
if not getattr(self, 'hide_messages', False):
messages.warning(self.request, msg)
elif self.check_auth(whom, old_level, new_level):
logger.info(
u"Set %s's acl level for %s to %s by %s.", unicode(whom),
unicode(self.instance), new_level, unicode(user))
if not getattr(self, 'hide_messages', False):
self.send_success_message(whom, old_level, new_level)
self.instance.set_level(whom, new_level)
def __handle_assignments(self, request):
current_users_groups = self.__get_current_users_groups(request)
new_template_user = request.POST['name']
if new_template_user is not None and len(new_template_user) > 0:
if new_template_user in current_users_groups:
self.__handle_group_assignment(request)
else:
logger.warning(
u"Tried to set %s's acl_level for %s (%s->%s) by %s.",
unicode(whom), unicode(self.instance), old_level, new_level,
unicode(user))
def set_or_remove_levels(self):
for key, value in self.request.POST.items():
m = re.match('(perm|remove)-([ug])-(\d+)', key)
self.__handle_user_assignment(request)
def __get_removes(self, request):
removes = {
"u": [],
"g": [],
}
for key, value in request.POST.items():
m = re.match('remove-([ug])-(.+)', key)
if m:
cmd, typ, id = m.groups()
if cmd == 'remove':
value = None
entity = {'u': User, 'g': Group}[typ].objects.get(id=id)
self.set_level(entity, value)
def add_levels(self):
name = self.request.POST.get('name', None)
level = self.request.POST.get('level', None)
if not name or not level:
return
try:
entity = search_user(name)
if self.instance.object_level_set.filter(users__in=[entity]):
messages.warning(
self.request, _('User "%s" has already '
'access to this object.') % name)
return
except User.DoesNotExist:
entity = None
try:
entity = Group.objects.get(name=name)
if self.instance.object_level_set.filter(groups__in=[entity]):
messages.warning(
self.request, _('Group "%s" has already '
'access to this object.') % name)
return
except Group.DoesNotExist:
messages.warning(
self.request, _('User or group "%s" not found.') % name)
return
self.set_level(entity, level)
t, name = m.groups()
removes[t].append(name)
return removes
def __remove_user(self, request, member_id):
template = self.get_object()
glance = self.__get_glance_admin_client(request.user.tenant_id)
glance.image_members.delete(template.image_id, member_id)
def __handle_removes(self, request):
removes = self.__get_removes(request)
for member_id in removes['u']:
self.__remove_user(request, member_id)
messages.success(request, _("Successfully removed user"))
def post(self, request, *args, **kwargs):
self.instance = self.get_object()
self.is_owner = self.instance.has_level(request.user, 'owner')
self.acl_data = (self.instance.get_users_with_level() +
self.instance.get_groups_with_level())
self.set_or_remove_levels()
self.add_levels()
return redirect("%s#access" % self.instance.get_absolute_url())
template = self.get_object()
openstack_api.glance.image_update(request, template.image_id, visibility="shared")
self.__handle_assignments(request)
self.__handle_removes(request)
return redirect("%s#access" % template.get_absolute_url())
class GraphMixin(object):
......
......@@ -593,7 +593,7 @@ def group_delete(request, group_id):
def group_list(request, domain=None, project=None, user=None, filters=None):
manager = keystoneclient(request, admin=True).groups
manager = keystoneclient(request).groups # TODO: was admin API
groups = []
kwargs = {
"domain": domain,
......@@ -834,7 +834,7 @@ def remove_tenant_user(request, project=None, user=None, domain=None):
def roles_for_group(request, group, domain=None, project=None):
manager = keystoneclient(request, admin=True).roles
manager = keystoneclient(request).roles
return manager.list(group=group, domain=domain, project=project)
......
......@@ -119,6 +119,15 @@ class VirtualMachineDescModel(BaseResourceConfigModel):
abstract = True
class TemplateMemberGroup(Model):
group_id = CharField(blank=False, max_length=100, unique=True)
class Meta:
app_label = 'vm'
db_table = 'vm_template_member_group'
class InstanceTemplate(TimeStampedModel):
"""Virtual machine template.
"""
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment