Commit 6f4187b1 by Szeberényi Imre

Merge branch 'group_export_import' into 'master'

Export and import groups via the user store

See merge request !425
parents 4b62f1c2 2d0244f9
Pipeline #1438 passed with stage
in 0 seconds
...@@ -255,6 +255,44 @@ class GroupCreateForm(NoFormTagMixin, forms.ModelForm): ...@@ -255,6 +255,44 @@ class GroupCreateForm(NoFormTagMixin, forms.ModelForm):
fields = ('name',) fields = ('name',)
class GroupImportForm(NoFormTagMixin, forms.Form):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super(GroupImportForm, self).__init__(*args, **kwargs)
exported_group_paths = Store(self.user).get_files_with_exts(["group"])
exported_group_names = [
os.path.basename(item) for item in exported_group_paths
]
self.choices = zip(exported_group_paths, exported_group_names)
self.fields["group_path"] = forms.ChoiceField(
label=_("Group to import"),
choices=self.choices
)
@property
def helper(self):
helper = super(GroupImportForm, self).helper
helper.add_input(Submit("submit", _("Import")))
return helper
class GroupExportForm(NoFormTagMixin, forms.Form):
def __init__(self, *args, **kwargs):
default = kwargs.pop("group_name")
super(GroupExportForm, self).__init__(*args, **kwargs)
self.fields["exported_name"] = forms.CharField(
max_length=100, label=_('Filename'), initial=default
)
@property
def helper(self):
helper = super(GroupExportForm, self).helper
helper.add_input(Submit("submit", _("Export")))
return helper
class GroupProfileUpdateForm(NoFormTagMixin, forms.ModelForm): class GroupProfileUpdateForm(NoFormTagMixin, forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
...@@ -935,7 +973,9 @@ class VmImportDiskForm(OperationForm): ...@@ -935,7 +973,9 @@ class VmImportDiskForm(OperationForm):
self.user = kwargs.pop('user') self.user = kwargs.pop('user')
super(VmImportDiskForm, self).__init__(*args, **kwargs) super(VmImportDiskForm, self).__init__(*args, **kwargs)
disk_paths = Store(self.user).get_disk_images() disk_paths = Store(self.user).get_files_with_exts(
[f[0] for f in Disk.EXPORT_FORMATS]
)
disk_filenames = [os.path.basename(item) for item in disk_paths] disk_filenames = [os.path.basename(item) for item in disk_paths]
self.choices = zip(disk_paths, disk_filenames) self.choices = zip(disk_paths, disk_filenames)
......
...@@ -17,14 +17,15 @@ ...@@ -17,14 +17,15 @@
from __future__ import absolute_import from __future__ import absolute_import
from datetime import timedelta import json
from itertools import chain
from hashlib import md5 from hashlib import md5
from logging import getLogger from logging import getLogger
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group, Permission
from django.contrib.auth.signals import user_logged_in from django.contrib.auth.signals import user_logged_in
from django.core.exceptions import ObjectDoesNotExist
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db.models import ( from django.db.models import (
Model, ForeignKey, OneToOneField, CharField, IntegerField, TextField, Model, ForeignKey, OneToOneField, CharField, IntegerField, TextField,
...@@ -36,20 +37,16 @@ from django.utils import timezone ...@@ -36,20 +37,16 @@ from django.utils import timezone
from django.utils.html import escape from django.utils.html import escape
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_sshkey.models import UserKey from django_sshkey.models import UserKey
from django.core.exceptions import ObjectDoesNotExist from itertools import chain
from sizefield.models import FileSizeField
from jsonfield import JSONField from jsonfield import JSONField
from model_utils.models import TimeFramedModel, TimeStampedModel
from model_utils.fields import StatusField
from model_utils import Choices from model_utils import Choices
from model_utils.fields import StatusField
from model_utils.models import TimeFramedModel, TimeStampedModel
from sizefield.models import FileSizeField
from acl.models import AclBase from acl.models import AclBase
from common.models import HumanReadableObject, create_readable, Encoder from common.models import HumanReadableObject, create_readable, Encoder
from vm.models.instance import ACCESS_METHODS from vm.models.instance import ACCESS_METHODS
from .store_api import Store, NoStoreException, NotOkException from .store_api import Store, NoStoreException, NotOkException
from .validators import connect_command_template_validator from .validators import connect_command_template_validator
...@@ -323,6 +320,62 @@ class GroupProfile(AclBase): ...@@ -323,6 +320,62 @@ class GroupProfile(AclBase):
return reverse('dashboard.views.group-detail', return reverse('dashboard.views.group-detail',
kwargs={'pk': self.group.pk}) kwargs={'pk': self.group.pk})
@classmethod
def create_from_json(cls, owner, json_data):
group = Group()
try:
data = json.loads(json_data)
group.name = data["name"]
group.save()
profile = group.profile
profile.set_user_level(owner, "owner")
profile.description = data["desc"]
profile.org_id = data["org_id"]
profile.instance_limit = int(data["instance_limit"])
profile.template_instance_limit = int(data["template_instance_limit"])
profile.disk_quota = long(data["disk_quota"])
profile.save()
for org_id in data["users"]:
try:
if org_id is not None:
user = Profile.objects.get(org_id=org_id).user
user.groups.add(group)
except ObjectDoesNotExist:
future_member = FutureMember(org_id=org_id, group=group)
future_member.save()
for permission in data["permissions"]:
group.permissions.add(
Permission.objects.get_by_natural_key(*permission)
)
return group.profile
except (KeyError, ValueError, TypeError):
if group.id is not None:
group.delete()
logger.error("Invalid group JSON")
def convert_to_json(self):
json_group = {
"name": self.group.name,
"desc": self.description,
"org_id": self.org_id,
"instance_limit": self.instance_limit,
"template_instance_limit": self.template_instance_limit,
"disk_quota": self.disk_quota,
"users": [
user.profile.org_id for user in self.group.user_set.all()
],
"permissions": [
permission.natural_key()
for permission in self.group.permissions.all()
]
}
return json.dumps(json_group)
def get_or_create_profile(self): def get_or_create_profile(self):
obj, created = GroupProfile.objects.get_or_create(group_id=self.pk) obj, created = GroupProfile.objects.get_or_create(group_id=self.pk)
......
...@@ -29,7 +29,7 @@ $(function () { ...@@ -29,7 +29,7 @@ $(function () {
return false; return false;
}); });
$('.group-create, .node-create, .tx-tpl-ownership, .group-delete, .node-delete, .disk-remove, .template-delete, .delete-from-group, .group-remove-all-btn, .lease-delete').click(function(e) { $('.group-create, .group-import, .group-export, .node-create, .tx-tpl-ownership, .group-delete, .node-delete, .disk-remove, .template-delete, .delete-from-group, .group-remove-all-btn, .lease-delete').click(function(e) {
$.ajax({ $.ajax({
type: 'GET', type: 'GET',
url: $(this).prop('href'), url: $(this).prop('href'),
......
...@@ -110,14 +110,16 @@ class Store(object): ...@@ -110,14 +110,16 @@ class Store(object):
else: else:
return result return result
def get_disk_images(self, path='/'): def get_files_with_exts(self, exts, path='/'):
images = [] """
Get list of files from store with the given file extensions.
"""
matching_files = []
file_list = self.list(path, process=False) file_list = self.list(path, process=False)
export_formats = [item[0] for item in Disk.EXPORT_FORMATS]
for item in file_list: for item in file_list:
if os.path.splitext(item['NAME'])[1].strip('.') in export_formats: if os.path.splitext(item['NAME'])[1].strip('.') in exts:
images.append(os.path.join(path, item['NAME'])) matching_files.append(os.path.join(path, item['NAME']))
return images return matching_files
def request_download(self, path): def request_download(self, path):
r = self._request_cmd("DOWNLOAD", PATH=path, timeout=10) r = self._request_cmd("DOWNLOAD", PATH=path, timeout=10)
......
...@@ -12,6 +12,9 @@ ...@@ -12,6 +12,9 @@
<a title="{% trans "Rename" %}" class="btn btn-default btn-xs group-details-rename-button"> <a title="{% trans "Rename" %}" class="btn btn-default btn-xs group-details-rename-button">
<i class="fa fa-pencil"></i> <i class="fa fa-pencil"></i>
</a> </a>
<a title="{% trans "Export" %}" data-group-pk="{{ group.pk }}" class="btn btn-default btn-xs group-export" href="{% url "dashboard.views.group-export" group_pk=group.pk %}">
<i class="fa fa-upload"></i>
</a>
<a title="{% trans "Delete" %}" data-group-pk="{{ group.pk }}" class="btn btn-default btn-xs real-link group-delete" href="{% url "dashboard.views.delete-group" pk=group.pk %}"> <a title="{% trans "Delete" %}" data-group-pk="{{ group.pk }}" class="btn btn-default btn-xs real-link group-delete" href="{% url "dashboard.views.delete-group" pk=group.pk %}">
<i class="fa fa-trash-o"></i> <i class="fa fa-trash-o"></i>
</a> </a>
...@@ -141,7 +144,7 @@ ...@@ -141,7 +144,7 @@
<hr /> <hr />
<script type="text/javascript" src="/static/admin/js/vendor/jquery/jquery.min.js"></script> <script type="text/javascript" src="/static/admin/js/vendor/jquery/jquery.min.js"></script>
<script type="text/javascript" src="/static/admin/js/jquery.init.js"></script> <script type="text/javascript" src="/static/admin/js/jquery.init.js"></script>
<script type="text/javascript" src="/static/autocomplete_light/jquery.init.js"></script> <script type="text/javascript" src="/static/autocomplete_light/jquery.init.js"></script>
<script type="text/javascript" src="/static/autocomplete_light/vendor/select2/dist/js/select2.js"></script> <script type="text/javascript" src="/static/autocomplete_light/vendor/select2/dist/js/select2.js"></script>
{{ group_perm_form.media }} {{ group_perm_form.media }}
......
{% load crispy_forms_tags %}
{% load i18n %}
<p class="text-muted">
{% trans "Export a group to the user store with the given filename." %}
</p>
<form method="POST" data-group_pk="{{ group.pk }}" action="{% url "dashboard.views.group-export" group_pk=group.pk %}">
{% csrf_token %}
{% crispy form %}
</form>
{% load crispy_forms_tags %}
{% load i18n %}
<p class="text-muted">
{% trans "Import a previously exported group from the user store." %}
</p>
<form method="POST" action="{% url "dashboard.views.group-import" %}">
{% csrf_token %}
{% crispy form %}
</form>
<a data-group-pk="{{ record.pk }}" <a data-group-pk="{{ record.pk }}"
class="btn btn-danger btn-xs real-link group-delete" class="btn btn-danger btn-xs real-link group-delete"
href="{% url "dashboard.views.delete-group" pk=record.pk %}?next={{ request.path }}"> href="{% url "dashboard.views.delete-group" pk=record.pk %}?next={{ request.path }}">
<i class="fa fa-trash-o"></i> <i class="fa fa-trash-o"></i>
</a> </a>
...@@ -40,7 +40,8 @@ ...@@ -40,7 +40,8 @@
{% trans "list" %} {% trans "list" %}
{% endif %} {% endif %}
</a> </a>
<a class="btn btn-success btn-xs group-create" href="{% url "dashboard.views.group-create" %}"><i class="fa fa-plus-circle"></i> {% trans "new" %} </a> <a class="btn btn-success btn-xs group-create" href="{% url "dashboard.views.group-create" %}" title="{% trans "new" %}"><i class="fa fa-plus-circle"></i></a>
<a class="btn btn-success btn-xs group-import" href="{% url "dashboard.views.group-import" %}" title="{% trans "import" %}"><i class="fa fa-download"></i></a>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
# with CIRCLE. If not, see <http://www.gnu.org/licenses/>. # with CIRCLE. If not, see <http://www.gnu.org/licenses/>.
from __future__ import absolute_import from __future__ import absolute_import
from django.conf.urls import url from django.conf.urls import url
from vm.models import Instance from vm.models import Instance
...@@ -57,11 +58,10 @@ from .views import ( ...@@ -57,11 +58,10 @@ from .views import (
MessageList, MessageDetail, MessageCreate, MessageDelete, MessageList, MessageDetail, MessageCreate, MessageDelete,
EnableTwoFactorView, DisableTwoFactorView, EnableTwoFactorView, DisableTwoFactorView,
AclUserGroupAutocomplete, AclUserAutocomplete, AclUserGroupAutocomplete, AclUserAutocomplete,
RescheduleView, RescheduleView, GroupImportView, GroupExportView
) )
from .views.vm import vm_ops, vm_mass_ops
from .views.node import node_ops from .views.node import node_ops
from .views.vm import vm_ops, vm_mass_ops
urlpatterns = [ urlpatterns = [
url(r'^$', IndexView.as_view(), name="dashboard.index"), url(r'^$', IndexView.as_view(), name="dashboard.index"),
...@@ -198,6 +198,11 @@ urlpatterns = [ ...@@ -198,6 +198,11 @@ urlpatterns = [
name="dashboard.views.remove-all-users"), name="dashboard.views.remove-all-users"),
url(r'^group/create/$', GroupCreate.as_view(), url(r'^group/create/$', GroupCreate.as_view(),
name='dashboard.views.group-create'), name='dashboard.views.group-create'),
url(r'^group/import/$', GroupImportView.as_view(),
name="dashboard.views.group-import"),
url(r'^group/(?P<group_pk>\d+)/export/$',
GroupExportView.as_view(),
name="dashboard.views.group-export"),
url(r'^group/(?P<group_pk>\d+)/permissions/$', url(r'^group/(?P<group_pk>\d+)/permissions/$',
GroupPermissionsView.as_view(), GroupPermissionsView.as_view(),
name="dashboard.views.group-permissions"), name="dashboard.views.group-permissions"),
......
...@@ -18,31 +18,33 @@ from __future__ import unicode_literals, absolute_import ...@@ -18,31 +18,33 @@ from __future__ import unicode_literals, absolute_import
import json import json
import logging import logging
from itertools import chain
import requests
from braces.views import SuperuserRequiredMixin, LoginRequiredMixin
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied, SuspiciousOperation
from django.core.urlresolvers import reverse, reverse_lazy from django.core.urlresolvers import reverse, reverse_lazy
from django.http import HttpResponse, Http404 from django.http import HttpResponse, Http404
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views.generic import UpdateView, TemplateView from django.views.generic import UpdateView, TemplateView
from django.views.generic.detail import SingleObjectMixin
from braces.views import SuperuserRequiredMixin, LoginRequiredMixin
from django_tables2 import SingleTableView from django_tables2 import SingleTableView
from itertools import chain
from vm.models import Instance, InstanceTemplate
from .util import (CheckedDetailView, AclUpdateView, search_user,
saml_available, DeleteViewBase)
from ..forms import ( from ..forms import (
AddGroupMemberForm, AclUserOrGroupAddForm, GroupPermissionForm, AddGroupMemberForm, AclUserOrGroupAddForm, GroupPermissionForm,
GroupCreateForm, GroupProfileUpdateForm, GroupCreateForm, GroupImportForm, GroupProfileUpdateForm, GroupExportForm,
) )
from ..models import FutureMember, GroupProfile from ..models import FutureMember, GroupProfile
from vm.models import Instance, InstanceTemplate from ..store_api import Store, NoStoreException
from ..tables import GroupListTable from ..tables import GroupListTable
from .util import (CheckedDetailView, AclUpdateView, search_user,
saml_available, DeleteViewBase)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -325,7 +327,6 @@ class GroupDelete(DeleteViewBase): ...@@ -325,7 +327,6 @@ class GroupDelete(DeleteViewBase):
class GroupCreate(GroupCodeMixin, LoginRequiredMixin, TemplateView): class GroupCreate(GroupCodeMixin, LoginRequiredMixin, TemplateView):
form_class = GroupCreateForm form_class = GroupCreateForm
def get_template_names(self): def get_template_names(self):
...@@ -361,16 +362,153 @@ class GroupCreate(GroupCodeMixin, LoginRequiredMixin, TemplateView): ...@@ -361,16 +362,153 @@ class GroupCreate(GroupCodeMixin, LoginRequiredMixin, TemplateView):
savedform.profile.set_level(request.user, 'owner') savedform.profile.set_level(request.user, 'owner')
messages.success(request, _('Group successfully created.')) messages.success(request, _('Group successfully created.'))
if request.is_ajax(): if request.is_ajax():
return HttpResponse(json.dumps({'redirect': return HttpResponse(
savedform.profile.get_absolute_url()}), json.dumps(
content_type="application/json") {'redirect': savedform.profile.get_absolute_url()}
),
content_type="application/json"
)
else: else:
return redirect(savedform.profile.get_absolute_url()) return redirect(savedform.profile.get_absolute_url())
class GroupImportView(LoginRequiredMixin, TemplateView):
form_class = GroupImportForm
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/_modal.html']
else:
return ['dashboard/nojs-wrapper.html']
def get(self, request, form=None, *args, **kwargs):
if not request.user.has_module_perms('auth'):
raise PermissionDenied()
try:
Store(request.user)
except NoStoreException:
raise PermissionDenied()
if form is None:
form = self.form_class(user=request.user)
context = self.get_context_data(**kwargs)
context.update({
'template': 'dashboard/group-import.html',
'box_title': _('Import a Group'),
'form': form,
'ajax_title': True,
})
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
if not request.user.has_module_perms('auth'):
raise PermissionDenied()
try:
Store(request.user)
except NoStoreException:
raise PermissionDenied()
form = self.form_class(request.POST, user=request.user)
if form.is_valid():
group_path = form.cleaned_data["group_path"]
url = Store(request.user).request_download(group_path)
json_str = requests.get(url).content
profile = GroupProfile.create_from_json(request.user, json_str)
if profile is None:
raise SuspiciousOperation()
success_message = _("Group successfully imported.")
if request.is_ajax():
response = {
'message': success_message,
'redirect': profile.get_absolute_url()
}
return HttpResponse(
json.dumps(response),
content_type="application/json"
)
else:
messages.success(request, success_message)
return redirect(profile.get_absolute_url())
else:
return self.get(request, form, *args, **kwargs)
class GroupExportView(LoginRequiredMixin, SingleObjectMixin, TemplateView):
form_class = GroupExportForm
model = Group
pk_url_kwarg = "group_pk"
def __init__(self):
super(GroupExportView, self).__init__()
self.object = None
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/_modal.html']
else:
return ['dashboard/nojs-wrapper.html']
def get(self, request, form=None, *args, **kwargs):
self.object = self.get_object()
if not self.object.profile.has_level(request.user, 'operator'):
raise PermissionDenied()
try:
Store(request.user)
except NoStoreException:
raise PermissionDenied()
if form is None:
form = self.form_class(group_name=self.object.name)
context = self.get_context_data(**kwargs)
context.update({
'group': self.object,
'template': 'dashboard/group-export.html',
'box_title': _('Export Group'),
'form': form,
'ajax_title': True,
})
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
group = self.object
if not group.profile.has_level(request.user, 'operator'):
raise PermissionDenied()
try:
Store(request.user)
except NoStoreException:
raise PermissionDenied()
form = self.form_class(request.POST, group_name=self.object.name)
if form.is_valid():
name = form.cleaned_data["exported_name"]
group_json = group.profile.convert_to_json()
store = Store(request.user)
url = store.request_upload("/")
data = {'data': (name + '.group', group_json)}
requests.post(url, files=data)
success_message = _("Group successfully exported.")
if request.is_ajax():
response = {
'message': success_message,
'redirect': group.profile.get_absolute_url()
}
return HttpResponse(
json.dumps(response),
content_type="application/json"
)
else:
messages.success(request, success_message)
return redirect(group.profile.get_absolute_url())
else:
return self.get(request, form, *args, **kwargs)
class GroupProfileUpdate(SuccessMessageMixin, GroupCodeMixin, class GroupProfileUpdate(SuccessMessageMixin, GroupCodeMixin,
LoginRequiredMixin, UpdateView): LoginRequiredMixin, UpdateView):
form_class = GroupProfileUpdateForm form_class = GroupProfileUpdateForm
model = Group model = Group
success_message = _('Group is successfully updated.') success_message = _('Group is successfully updated.')
......
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