diff options
-rw-r--r-- | src/account/models.py | 6 | ||||
-rw-r--r-- | src/account/views.py | 4 | ||||
-rw-r--r-- | src/booking/forms.py | 112 | ||||
-rw-r--r-- | src/booking/migrations/0003_auto_20190115_1733.py | 30 | ||||
-rw-r--r-- | src/booking/models.py | 26 | ||||
-rw-r--r-- | src/booking/quick_deployer.py | 272 | ||||
-rw-r--r-- | src/booking/urls.py | 9 | ||||
-rw-r--r-- | src/booking/views.py | 118 | ||||
-rw-r--r-- | src/resource_inventory/migrations/0005_image_os.py | 19 | ||||
-rw-r--r-- | src/resource_inventory/models.py | 7 | ||||
-rw-r--r-- | src/resource_inventory/resource_manager.py | 11 | ||||
-rw-r--r-- | src/templates/booking/booking_detail.html | 146 | ||||
-rw-r--r-- | src/templates/booking/quick_deploy.html | 206 | ||||
-rw-r--r-- | src/templates/booking/stats.html | 6 | ||||
-rw-r--r-- | src/templates/dashboard/landing.html | 1 | ||||
-rw-r--r-- | src/templates/dashboard/multiple_select_filter_widget.html | 74 | ||||
-rw-r--r-- | src/templates/dashboard/searchable_select_multiple.html | 7 | ||||
-rw-r--r-- | src/workflow/forms.py | 8 |
18 files changed, 919 insertions, 143 deletions
diff --git a/src/account/models.py b/src/account/models.py index bfeead0..0f8154e 100644 --- a/src/account/models.py +++ b/src/account/models.py @@ -94,7 +94,7 @@ class VlanManager(models.Model): vlan_master_list = json.loads(self.vlans) try: iter(vlans) - except: + except Exception: vlans = [vlans] for vlan in vlans: @@ -112,7 +112,7 @@ class VlanManager(models.Model): try: iter(vlans) - except: + except Exception: vlans = [vlans] for vlan in vlans: @@ -125,7 +125,7 @@ class VlanManager(models.Model): try: iter(vlans) - except: + except Exception: vlans = [vlans] vlans = set(vlans) diff --git a/src/account/views.py b/src/account/views.py index 09c5266..e880208 100644 --- a/src/account/views.py +++ b/src/account/views.py @@ -181,8 +181,8 @@ def account_booking_view(request): if not request.user.is_authenticated: return render(request, "dashboard/login.html", {'title': 'Authentication Required'}) template = "account/booking_list.html" - bookings = list(Booking.objects.filter(owner=request.user)) - collab_bookings = list(request.user.collaborators.all()) + bookings = list(Booking.objects.filter(owner=request.user).order_by("-start")) + collab_bookings = list(request.user.collaborators.all().order_by("-start")) context = {"title": "My Bookings", "bookings": bookings, "collab_bookings": collab_bookings} return render(request, template, context=context) diff --git a/src/booking/forms.py b/src/booking/forms.py new file mode 100644 index 0000000..7ba5af0 --- /dev/null +++ b/src/booking/forms.py @@ -0,0 +1,112 @@ +############################################################################## +# Copyright (c) 2018 Parker Berberian, Sawyer Bergeron, and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## +import django.forms as forms +from django.forms.widgets import NumberInput +from django.db.models import Q + +from workflow.forms import ( + SearchableSelectMultipleWidget, + MultipleSelectFilterField, + MultipleSelectFilterWidget, + FormUtils) +from account.models import UserProfile +from resource_inventory.models import Image, Installer, Scenario + + +class QuickBookingForm(forms.Form): + purpose = forms.CharField(max_length=1000) + project = forms.CharField(max_length=400) + image = forms.ModelChoiceField(queryset=Image.objects.all()) + hostname = forms.CharField(max_length=400) + + installer = forms.ModelChoiceField(queryset=Installer.objects.all(), required=False) + scenario = forms.ModelChoiceField(queryset=Scenario.objects.all(), required=False) + + def __init__(self, data=None, user=None, *args, **kwargs): + chosen_users = [] + if "default_user" in kwargs: + default_user = kwargs.pop("default_user") + else: + default_user = "you" + self.default_user = default_user + if "chosen_users" in kwargs: + chosen_users = kwargs.pop("chosen_users") + elif data and "users" in data: + chosen_users = data.getlist("users") + + if user: + self.image = forms.ModelChoiceField(queryset=Image.objects.filter( + Q(public=True) | Q(owner=user)), required=False) + else: + self.image = forms.ModelChoiceField(queryset=Image.objects.all(), required=False) + + super(QuickBookingForm, self).__init__(data=data, **kwargs) + + self.fields['users'] = forms.CharField( + widget=SearchableSelectMultipleWidget( + attrs=self.build_search_widget_attrs(chosen_users, default_user=default_user) + ), + required=False + ) + attrs = FormUtils.getLabData(0) + attrs['selection_data'] = 'false' + self.fields['filter_field'] = MultipleSelectFilterField(widget=MultipleSelectFilterWidget(attrs=attrs)) + self.fields['length'] = forms.IntegerField( + widget=NumberInput( + attrs={ + "type": "range", + 'min': "1", + "max": "21", + "value": "1" + } + ) + ) + + def build_user_list(self): + """ + returns a mapping of UserProfile ids to displayable objects expected by + searchable multiple select widget + """ + try: + users = {} + d_qset = UserProfile.objects.select_related('user').all().exclude(user__username=self.default_user) + for userprofile in d_qset: + user = { + 'id': userprofile.user.id, + 'expanded_name': userprofile.full_name, + 'small_name': userprofile.user.username, + 'string': userprofile.email_addr + } + + users[userprofile.user.id] = user + + return users + except Exception: + pass + + def build_search_widget_attrs(self, chosen_users, default_user="you"): + + attrs = { + 'set': self.build_user_list(), + 'show_from_noentry': "false", + 'show_x_results': 10, + 'scrollable': "false", + 'selectable_limit': -1, + 'name': "users", + 'placeholder': "username", + 'initial': chosen_users, + 'edit': False + } + return attrs + + +class HostReImageForm(forms.Form): + + image_id = forms.IntegerField() + host_id = forms.IntegerField() diff --git a/src/booking/migrations/0003_auto_20190115_1733.py b/src/booking/migrations/0003_auto_20190115_1733.py new file mode 100644 index 0000000..70eecfe --- /dev/null +++ b/src/booking/migrations/0003_auto_20190115_1733.py @@ -0,0 +1,30 @@ +# Generated by Django 2.1 on 2019-01-15 17:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('booking', '0002_booking_pdf'), + ] + + operations = [ + migrations.RemoveField( + model_name='installer', + name='sup_scenarios', + ), + migrations.RemoveField( + model_name='opsys', + name='sup_installers', + ), + migrations.DeleteModel( + name='Installer', + ), + migrations.DeleteModel( + name='Opsys', + ), + migrations.DeleteModel( + name='Scenario', + ), + ] diff --git a/src/booking/models.py b/src/booking/models.py index 74b766d..0972922 100644 --- a/src/booking/models.py +++ b/src/booking/models.py @@ -16,32 +16,6 @@ from django.db import models import resource_inventory.resource_manager -class Scenario(models.Model): - id = models.AutoField(primary_key=True) - name = models.CharField(max_length=300) - - def __str__(self): - return self.name - - -class Installer(models.Model): - id = models.AutoField(primary_key=True) - name = models.CharField(max_length=30) - sup_scenarios = models.ManyToManyField(Scenario, blank=True) - - def __str__(self): - return self.name - - -class Opsys(models.Model): - id = models.AutoField(primary_key=True) - name = models.CharField(max_length=100) - sup_installers = models.ManyToManyField(Installer, blank=True) - - def __str__(self): - return self.name - - class Booking(models.Model): id = models.AutoField(primary_key=True) owner = models.ForeignKey(User, models.CASCADE, related_name='owner') diff --git a/src/booking/quick_deployer.py b/src/booking/quick_deployer.py new file mode 100644 index 0000000..9bc8c66 --- /dev/null +++ b/src/booking/quick_deployer.py @@ -0,0 +1,272 @@ +############################################################################## +# Copyright (c) 2018 Parker Berberian, Sawyer Bergeron, and others. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Apache License, Version 2.0 +# which accompanies this distribution, and is available at +# http://www.apache.org/licenses/LICENSE-2.0 +############################################################################## + + +import json +import uuid +import re +from django.db.models import Q +from django.contrib.auth.models import User +from datetime import timedelta +from django.utils import timezone +from account.models import Lab + +from resource_inventory.models import ( + Installer, + Image, + GenericResourceBundle, + ConfigBundle, + Vlan, + Host, + HostProfile, + HostConfiguration, + GenericResource, + GenericHost, + GenericInterface, + OPNFVRole, + OPNFVConfig +) +from resource_inventory.resource_manager import ResourceManager +from booking.models import Booking +from dashboard.exceptions import ( + InvalidHostnameException, + ResourceAvailabilityException, + ModelValidationException +) +from api.models import JobFactory + + +# model validity exceptions +class IncompatibleInstallerForOS(Exception): + pass + + +class IncompatibleScenarioForInstaller(Exception): + pass + + +class IncompatibleImageForHost(Exception): + pass + + +class ImageOwnershipInvalid(Exception): + pass + + +class ImageNotAvailableAtLab(Exception): + pass + + +class LabDNE(Exception): + pass + + +class HostProfileDNE(Exception): + pass + + +class HostNotAvailable(Exception): + pass + + +class NoLabSelectedError(Exception): + pass + + +class OPNFVRoleDNE(Exception): + pass + + +class NoRemainingPublicNetwork(Exception): + pass + + +def create_from_form(form, request): + quick_booking_id = str(uuid.uuid4()) + + host_field = form.cleaned_data['filter_field'] + host_json = json.loads(host_field) + purpose_field = form.cleaned_data['purpose'] + project_field = form.cleaned_data['project'] + users_field = form.cleaned_data['users'] + host_name = form.cleaned_data['hostname'] + length = form.cleaned_data['length'] + + image = form.cleaned_data['image'] + scenario = form.cleaned_data['scenario'] + installer = form.cleaned_data['installer'] + + # get all initial info we need to validate + lab_dict = host_json['labs'][0] + lab_id = list(lab_dict.keys())[0] + lab_user_id = int(lab_id.split("_")[-1]) + lab = Lab.objects.get(lab_user__id=lab_user_id) + + host_dict = host_json['hosts'][0] + profile_id = list(host_dict.keys())[0] + profile_id = int(profile_id.split("_")[-1]) + profile = HostProfile.objects.get(id=profile_id) + + # check validity of field data before trying to apply to models + if not lab: + raise LabDNE("Lab with provided ID does not exist") + if not profile: + raise HostProfileDNE("Host type with provided ID does not exist") + + # check that hostname is valid + if not re.match(r"(?=^.{1,253}$)(^([A-Za-z0-9-_]{1,62}\.)*[A-Za-z0-9-_]{1,63})$", host_name): + raise InvalidHostnameException("Hostname must comply to RFC 952 and all extensions to it until this point") + # check that image os is compatible with installer + if installer in image.os.sup_installers.all(): + #if installer not here, we can omit that and not check for scenario + if not scenario: + raise IncompatibleScenarioForInstaller("An OPNFV Installer needs a scenario to be chosen to work properly") + if scenario not in installer.sup_scenarios.all(): + raise IncompatibleScenarioForInstaller("The chosen installer does not support the chosen scenario") + if image.from_lab != lab: + raise ImageNotAvailableAtLab("The chosen image is not available at the chosen hosting lab") + if image.host_type != profile: + raise IncompatibleImageForHost("The chosen image is not available for the chosen host type") + if not image.public and image.owner != request.user: + raise ImageOwnershipInvalid("You are not the owner of the chosen private image") + + # check if host type is available + #ResourceManager.getInstance().acquireHost(ghost, lab.name) + available_host_types = ResourceManager.getInstance().getAvailableHostTypes(lab) + if not profile in available_host_types: + # TODO: handle deleting generic resource in this instance along with grb + raise HostNotAvailable("Could not book selected host due to changed availability. Try again later") + + # check if any hosts with profile at lab are still available + hostset = Host.objects.filter(lab=lab, profile=profile).filter(booked=False).filter(working=True) + if not hostset.first(): + raise HostNotAvailable("Couldn't find any matching unbooked hosts") + + # generate GenericResourceBundle + if len(host_json['labs']) != 1: + raise NoLabSelectedError("No lab was selected") + + grbundle = GenericResourceBundle(owner=request.user) + grbundle.lab = lab + grbundle.name = "grbundle for quick booking with uid " + quick_booking_id + grbundle.description = "grbundle created for quick-deploy booking" + grbundle.save() + + # generate GenericResource, GenericHost + gresource = GenericResource(bundle=grbundle, name=host_name) + gresource.save() + + ghost = GenericHost() + ghost.resource = gresource + ghost.profile = profile + ghost.save() + + # generate config bundle + cbundle = ConfigBundle() + cbundle.owner = request.user + cbundle.name = "configbundle for quick booking with uid " + quick_booking_id + cbundle.description = "configbundle created for quick-deploy booking" + cbundle.bundle = grbundle + cbundle.save() + + # generate OPNFVConfig pointing to cbundle + if installer: + opnfvconfig = OPNFVConfig() + opnfvconfig.scenario = scenario + opnfvconfig.installer = installer + opnfvconfig.bundle = cbundle + opnfvconfig.save() + + # generate HostConfiguration pointing to cbundle + hconf = HostConfiguration() + hconf.host = ghost + hconf.image = image + hconf.opnfvRole = OPNFVRole.objects.get(name="Jumphost") + if not hconf.opnfvRole: + raise OPNFVRoleDNE("No jumphost role was found") + hconf.bundle = cbundle + hconf.save() + + # construct generic interfaces + for interface_profile in profile.interfaceprofile.all(): + generic_interface = GenericInterface.objects.create(profile=interface_profile, host=ghost) + generic_interface.save() + ghost.save() + + # get vlan, assign to first interface + publicnetwork = lab.vlan_manager.get_public_vlan() + publicvlan = publicnetwork.vlan + if not publicnetwork: + raise NoRemainingPublicNetwork("No public networks were available for your pod") + lab.vlan_manager.reserve_public_vlan(publicvlan) + + vlan = Vlan.objects.create(vlan_id=publicvlan, tagged=False, public=True) + vlan.save() + ghost.generic_interfaces.first().vlans.add(vlan) + ghost.generic_interfaces.first().save() + + # generate resource bundle + try: + resource_bundle = ResourceManager.getInstance().convertResourceBundle(grbundle, config=cbundle) + except ResourceAvailabilityException: + raise ResourceAvailabilityException("Requested resources not available") + except ModelValidationException: + raise ModelValidationException("Encountered error while saving grbundle") + + # generate booking + booking = Booking() + booking.purpose = purpose_field + booking.project = project_field + booking.lab = lab + booking.owner = request.user + booking.start = timezone.now() + booking.end = timezone.now() + timedelta(days=int(length)) + booking.resource = resource_bundle + booking.pdf = ResourceManager().makePDF(booking.resource) + booking.save() + print("users field:") + print(users_field) + print(type(users_field)) + #users_field = json.loads(users_field) + users_field = users_field[2:-2] + if users_field: #may be empty after split, if no collaborators entered + users_field = json.loads(users_field) + for collaborator in users_field: + user = User.objects.get(id=collaborator['id']) + booking.collaborators.add(user) + booking.save() + + # generate job + JobFactory.makeCompleteJob(booking) + + +def drop_filter(user): + installer_filter = {} + for image in Image.objects.all(): + installer_filter[image.id] = {} + for installer in image.os.sup_installers.all(): + installer_filter[image.id][installer.id] = 1 + + scenario_filter = {} + for installer in Installer.objects.all(): + scenario_filter[installer.id] = {} + for scenario in installer.sup_scenarios.all(): + scenario_filter[installer.id][scenario.id] = 1 + + images = Image.objects.filter(Q(public=True) | Q(owner=user)) + image_filter = {} + for image in images: + image_filter[image.id] = {} + image_filter[image.id]['lab'] = 'lab_' + str(image.from_lab.lab_user.id) + image_filter[image.id]['host_profile'] = 'host_' + str(image.host_type.id) + image_filter[image.id]['name'] = image.name + + return {'installer_filter': json.dumps(installer_filter), + 'scenario_filter': json.dumps(scenario_filter), + 'image_filter': json.dumps(image_filter)} diff --git a/src/booking/urls.py b/src/booking/urls.py index 4d00b7f..310aaa7 100644 --- a/src/booking/urls.py +++ b/src/booking/urls.py @@ -32,7 +32,9 @@ from booking.views import ( bookingDelete, BookingListView, booking_stats_view, - booking_stats_json + booking_stats_json, + quick_create, + booking_modify_image ) app_name = "booking" @@ -41,13 +43,12 @@ urlpatterns = [ url(r'^detail/(?P<booking_id>[0-9]+)/$', booking_detail_view, name='detail'), url(r'^(?P<booking_id>[0-9]+)/$', booking_detail_view, name='booking_detail'), - url(r'^delete/$', BookingDeleteView.as_view(), name='delete_prefix'), url(r'^delete/(?P<booking_id>[0-9]+)/$', BookingDeleteView.as_view(), name='delete'), - url(r'^delete/(?P<booking_id>[0-9]+)/confirm/$', bookingDelete, name='delete_booking'), - + url(r'^modify/(?P<booking_id>[0-9]+)/image/$', booking_modify_image, name='modify_booking_image'), url(r'^list/$', BookingListView.as_view(), name='list'), url(r'^stats/$', booking_stats_view, name='stats'), url(r'^stats/json$', booking_stats_json, name='stats_json'), + url(r'^quick/$', quick_create, name='quick_create'), ] diff --git a/src/booking/views.py b/src/booking/views.py index 29b53e2..3be9c7b 100644 --- a/src/booking/views.py +++ b/src/booking/views.py @@ -10,33 +10,68 @@ from django.contrib import messages from django.shortcuts import get_object_or_404 -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponse from django.utils import timezone from django.views import View from django.views.generic import TemplateView from django.shortcuts import redirect, render -import json +from django.db.models import Q -from resource_inventory.models import ResourceBundle +from resource_inventory.models import ResourceBundle, HostProfile, Image, Host from resource_inventory.resource_manager import ResourceManager -from booking.models import Booking, Installer, Opsys +from account.models import Lab +from booking.models import Booking from booking.stats import StatisticsManager +from booking.forms import HostReImageForm +from api.models import HostHardwareRelation, JobStatus +from workflow.views import login +from booking.forms import QuickBookingForm +from booking.quick_deployer import create_from_form, drop_filter -def drop_filter(context): - installer_filter = {} - for os in Opsys.objects.all(): - installer_filter[os.id] = [] - for installer in os.sup_installers.all(): - installer_filter[os.id].append(installer.id) +def quick_create_clear_fields(request): + request.session['quick_create_forminfo'] = None - scenario_filter = {} - for installer in Installer.objects.all(): - scenario_filter[installer.id] = [] - for scenario in installer.sup_scenarios.all(): - scenario_filter[installer.id].append(scenario.id) - context.update({'installer_filter': json.dumps(installer_filter), 'scenario_filter': json.dumps(scenario_filter)}) +def quick_create(request): + if not request.user.is_authenticated: + return login(request) + + if request.method == 'GET': + context = {} + + r_manager = ResourceManager.getInstance() + profiles = {} + for lab in Lab.objects.all(): + profiles[str(lab)] = r_manager.getAvailableHostTypes(lab) + + context['lab_profile_map'] = profiles + + context['form'] = QuickBookingForm(initial={}, chosen_users=[], default_user=request.user.username, user=request.user) + + context.update(drop_filter(request.user)) + + return render(request, 'booking/quick_deploy.html', context) + if request.method == 'POST': + form = QuickBookingForm(request.POST, user=request.user) + context = {} + context['lab_profile_map'] = {} + context['form'] = form + + if form.is_valid(): + try: + create_from_form(form, request) + except Exception as e: + messages.error(request, "Whoops, looks like an error occurred. " + "Let the admins know that you got the following message: " + str(e)) + return render(request, 'workflow/exit_redirect.html', context) + + messages.success(request, "We've processed your request. " + "Check Account->My Bookings for the status of your new booking") + return render(request, 'workflow/exit_redirect.html', context) + else: + messages.error(request, "Looks like the form didn't validate. Check that you entered everything correctly") + return render(request, 'booking/quick_deploy.html', context) class BookingView(TemplateView): @@ -93,6 +128,19 @@ class ResourceBookingsJSON(View): return JsonResponse({'bookings': list(bookings)}) +def build_image_mapping(lab, user): + mapping = {} + for profile in HostProfile.objects.filter(labs=lab): + images = Image.objects.filter( + from_lab=lab, + host_type=profile + ).filter( + Q(public=True) | Q(owner=user) + ) + mapping[profile.name] = [{"name": image.name, "value": image.id} for image in images] + return mapping + + def booking_detail_view(request, booking_id): user = None if request.user.is_authenticated: @@ -106,15 +154,39 @@ def booking_detail_view(request, booking_id): if user not in allowed_users: return render(request, "dashboard/login.html", {'title': 'This page is private'}) + context = { + 'title': 'Booking Details', + 'booking': booking, + 'pdf': booking.pdf, + 'user_id': user.id, + 'image_mapping': build_image_mapping(booking.lab, user) + } + return render( request, "booking/booking_detail.html", - { - 'title': 'Booking Details', - 'booking': booking, - 'pdf': booking.pdf, - 'user_id': user.id - }) + context + ) + + +def booking_modify_image(request, booking_id): + form = HostReImageForm(request.POST) + if form.is_valid(): + booking = Booking.objects.get(id=booking_id) + if request.user != booking.owner: + return HttpResponse("unauthorized") + if timezone.now() > booking.end: + return HttpResponse("unauthorized") + new_image = Image.objects.get(id=form.cleaned_data['image_id']) + host = Host.objects.get(id=form.cleaned_data['host_id']) + relation = HostHardwareRelation.objects.get(host=host, job__booking=booking) + config = relation.config + config.set_image(new_image.lab_id) + config.save() + relation.status = JobStatus.NEW + relation.save() + return HttpResponse(new_image.name) + return HttpResponse("error") def booking_stats_view(request): @@ -128,6 +200,6 @@ def booking_stats_view(request): def booking_stats_json(request): try: span = int(request.GET.get("days", 14)) - except: + except Exception: span = 14 return JsonResponse(StatisticsManager.getContinuousBookingTimeSeries(span), safe=False) diff --git a/src/resource_inventory/migrations/0005_image_os.py b/src/resource_inventory/migrations/0005_image_os.py new file mode 100644 index 0000000..ede008e --- /dev/null +++ b/src/resource_inventory/migrations/0005_image_os.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1 on 2019-01-10 16:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('resource_inventory', '0004_auto_20181017_1532'), + ] + + operations = [ + migrations.AddField( + model_name='image', + name='os', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='resource_inventory.Opsys'), + ), + ] diff --git a/src/resource_inventory/models.py b/src/resource_inventory/models.py index b56317b..5b07077 100644 --- a/src/resource_inventory/models.py +++ b/src/resource_inventory/models.py @@ -25,7 +25,7 @@ class HostProfile(models.Model): labs = models.ManyToManyField(Lab, related_name="hostprofiles") def validate(self): - validname = re.compile("^[A-Za-z0-9\-\_\.\/\, ]+$") + validname = re.compile(r"^[A-Za-z0-9\-\_\.\/\, ]+$") if not validname.match(self.name): return "Invalid host profile name given. Name must only use A-Z, a-z, 0-9, hyphens, underscores, dots, commas, or spaces." else: @@ -147,7 +147,7 @@ class GenericResourceBundle(models.Model): class GenericResource(models.Model): bundle = models.ForeignKey(GenericResourceBundle, related_name='generic_resources', on_delete=models.DO_NOTHING) - hostname_validchars = RegexValidator(regex='(?=^.{1,253}$)(?=(^([A-Za-z0-9\-\_]{1,62}\.)*[A-Za-z0-9\-\_]{1,63}$))', message="Enter a valid hostname. Full domain name may be 1-253 characters, each hostname 1-63 characters (including suffixed dot), and valid characters for hostnames are A-Z, a-z, 0-9, hyphen (-), and underscore (_)") + hostname_validchars = RegexValidator(regex=r'(?=^.{1,253}$)(?=(^([A-Za-z0-9\-\_]{1,62}\.)*[A-Za-z0-9\-\_]{1,63}$))', message="Enter a valid hostname. Full domain name may be 1-253 characters, each hostname 1-63 characters (including suffixed dot), and valid characters for hostnames are A-Z, a-z, 0-9, hyphen (-), and underscore (_)") name = models.CharField(max_length=200, validators=[hostname_validchars]) def getHost(self): @@ -157,7 +157,7 @@ class GenericResource(models.Model): return self.name def validate(self): - validname = re.compile('(?=^.{1,253}$)(?=(^([A-Za-z0-9\-\_]{1,62}\.)*[A-Za-z0-9\-\_]{1,63}$))') + validname = re.compile(r'(?=^.{1,253}$)(?=(^([A-Za-z0-9\-\_]{1,62}\.)*[A-Za-z0-9\-\_]{1,63}$))') if not validname.match(self.name): return "Enter a valid hostname. Full domain name may be 1-253 characters, each hostname 1-63 characters (including suffixed dot), and valid characters for hostnames are A-Z, a-z, 0-9, hyphen (-), and underscore (_)" else: @@ -265,6 +265,7 @@ class Image(models.Model): # may need to change host_type.on_delete to models.SET() once images are transferrable between compatible host types host_type = models.ForeignKey(HostProfile, on_delete=models.CASCADE) description = models.TextField() + os = models.ForeignKey(Opsys, null=True, on_delete=models.CASCADE) def __str__(self): return self.name diff --git a/src/resource_inventory/resource_manager.py b/src/resource_inventory/resource_manager.py index 9282580..812fcd7 100644 --- a/src/resource_inventory/resource_manager.py +++ b/src/resource_inventory/resource_manager.py @@ -17,7 +17,7 @@ from dashboard.exceptions import ( ResourceProvisioningException, ModelValidationException, ) -from resource_inventory.models import Host, HostConfiguration, ResourceBundle +from resource_inventory.models import Host, HostConfiguration, ResourceBundle, HostProfile class ResourceManager: @@ -33,6 +33,11 @@ class ResourceManager: ResourceManager.instance = ResourceManager() return ResourceManager.instance + def getAvailableHostTypes(self, lab): + hostset = Host.objects.filter(lab=lab).filter(booked=False).filter(working=True) + hostprofileset = HostProfile.objects.filter(host__in=hostset, labs=lab) + return set(hostprofileset) + # public interface def deleteResourceBundle(self, resourceBundle): for host in Host.objects.filter(bundle=resourceBundle): @@ -70,12 +75,12 @@ class ResourceManager: physical_hosts.append(physical_host) self.configureNetworking(physical_host) - except: + except Exception: self.fail_acquire(physical_hosts) raise ResourceProvisioningException("Network configuration failed.") try: physical_host.save() - except: + except Exception: self.fail_acquire(physical_hosts) raise ModelValidationException("Saving hosts failed") diff --git a/src/templates/booking/booking_detail.html b/src/templates/booking/booking_detail.html index cae0e25..51dd328 100644 --- a/src/templates/booking/booking_detail.html +++ b/src/templates/booking/booking_detail.html @@ -1,20 +1,29 @@ {% extends "base.html" %} {% load staticfiles %} +{% load bootstrap3 %} {% block extrahead %} {{block.super}} <script src="https://cdn.rawgit.com/google/code-prettify/master/loader/run_prettify.js?lang=yaml"></script> -<script src="https://unpkg.com/masonry-layout@4/dist/masonry.pkgd.min.js"></script> {% endblock %} {% block content %} + +<style> +#modal_warning { + transition: max-height 0.5s ease-out; + overflow: hidden; +} + +</style> + <div class="container-fluid"> <div class="row"> - <div class="col-lg-6"> + <div class="col-lg-4"> <div class="panel panel-default"> <div class="panel-heading clearfix"> <h4 style="display: inline;">Overview</h4> - <a data-toggle="collapse" data-target="#panel_overview" class="btn pull-right" style="line-height: 1;" >Expand</a> + <a data-toggle="collapse" data-target="#panel_overview" class="btn pull-right" style="line-height: 1;">Expand</a> </div> <div class="panel-body" id="panel_overview"> <table class="table"> @@ -50,9 +59,7 @@ </div> </div> <div class="row"> - - <div class="col-lg-6"> - + <div class="col-lg-12"> <div class="panel panel-default"> <div class="panel-heading clearfix"> <h4 style="display: inline;">Pod</h4> @@ -79,7 +86,15 @@ </tr> <tr> <td>Image:</td> - <td>{{host.config.image}}</td> + <td id="host_image_{{host.id}}"> + {{host.config.image}} + <button + style="margin-left:10px;" + class="btn btn-primary" + data-toggle="modal" + data-target="#imageModal" + onclick="set_image_dropdown('{{host.profile.name}}', {{host.id}});" + >Change/Reset</button></td> </tr> <tr> <td>RAM:</td> @@ -152,10 +167,7 @@ </table> </td> </tr> - - </table> - </td> {% endfor %} </tr> @@ -163,31 +175,15 @@ </div> </div> </div> - <div class="col-lg-6"> - - <div class="panel panel-default"> - <div class="panel-heading clearfix"> - <h4 style="display: inline;">PDF</h4> - <a data-toggle="collapse" data-target="#pdf_panel" class="btn pull-right" style="line-height: 1;" >Expand</a> - </div> - - <div class="panel-body" id="pdf_panel" style="padding: 0px;"> - <pre class="prettyprint lang-yaml" style="margin: 0px; padding: 0px; border: none;"> -{{pdf}} - </pre> - </div> - </div> - </div> </div> </div> - <div class="col-lg-6"> + <div class="col-lg-8"> <div class="panel panel-default"> <div class="panel-heading clearfix"> <h4 style="display: inline;">Deployment Progress</h4> <p style="display: inline; margin-left: 10px;"> These are the different tasks that have to be completed before your deployment is ready</p> <a data-toggle="collapse" data-target="#panel_tasks" class="btn pull-right" style="line-height: 1;" >Expand</a> </div> - <div class="panel-body" id="panel_tasks"> <table class="table"> <style> @@ -215,7 +211,6 @@ border-radius: 50%; animation: fadeInOut 1s infinite alternate; - } @keyframes fadeInOut { from { opacity: 0;} @@ -244,9 +239,7 @@ {% else %} <div class="done"></div> {% endif %} - </td> - - + </td> <td> {% if task.status < 100 %} PENDING @@ -257,7 +250,6 @@ {% endif %} </td> <td> - {% if task.message %} {% if task.type_str == "Access Task" and user_id != task.config.user.id %} Message from Lab: <pre>--secret--</pre> @@ -270,16 +262,104 @@ </td> <td> {{ task.type_str }} - </td> </tr> {% endfor %} </table> </div> </div> + <div class="row"> + <div class="col-lg-8"> + <div class="panel panel-default"> + <div class="panel-heading clearfix"> + <h4 style="display: inline;">PDF</h4> + <a data-toggle="collapse" data-target="#pdf_panel" class="btn pull-right" style="line-height: 1;" >Expand</a> + </div> + <div class="panel-body" id="pdf_panel" style="padding: 0px;"> + <pre class="prettyprint lang-yaml" style="margin: 0px; padding: 15px; border: none;"> +{{pdf}} + </pre> + </div> + </div> + </div> + </div> </div> </div> </div> +<div class="modal fade" id="imageModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true"> + <div class="modal-dialog" style="width: 450px;" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <h4 class="modal-title" id="exampleModalLabel" style="display: inline; float: left;">Host Image</h4> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + <div class="modal-body"> + <form id="image_host_form"> + {% csrf_token %} + <select class="form-control" style="width: 80%; margin-left: 10%" id="image_select" name="image_id"> + </select> + <input id="host_id_input" type="hidden" name="host_id"> + </input> + </form> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> + <button type="button" class="btn btn-primary" onclick="document.getElementById('modal_warning').style['max-height'] = '500px';">Reset Host</button> + </div> + <div id="modal_warning" class="modal-footer" style="max-height:0px;" > + <div style="text-align:center; margin: 5px"> + <h3>Are You Sure?</h3> + <p>This will wipe the disk and reimage the host</p> + <button class="btn" onclick="document.getElementById('modal_warning').style['max-height'] = '0px';">Nevermind</button> + <button class="btn btn-danger" data-dismiss="modal" onclick="submit_image_form();">I'm Sure</button> + </div> + </div> + </div> +</div> + +<script> + var image_mapping = {{image_mapping|safe}}; + var current_host_id = 0; + function set_image_dropdown(profile_name, host_id) { + document.getElementById("host_id_input").value = host_id; + current_host_id = host_id; + var dropdown = document.getElementById("image_select"); + var length = dropdown.length; + //clear dropdown + for(i=length-1; i>=0; i--){ + dropdown.options.remove(i); + } + var images = image_mapping[profile_name]; + var image_length = images.length; + for(i=0; i<image_length; i++){ + var opt = document.createElement("OPTION"); + opt.value = images[i].value; + opt.appendChild(document.createTextNode(images[i].name)); + dropdown.options.add(opt); + } + + document.getElementById("modal_warning").style['max-height'] = '0px'; + } + + function submit_image_form() { + var ajaxForm = $("#image_host_form"); + var formData = ajaxForm.serialize(); + req = new XMLHttpRequest(); + req.open("POST", "/booking/modify/{{booking.id}}/image/", true); + req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.onerror = function() { alert("problem submitting form"); } + req.onreadystatechange = function() { + if(req.readyState === 4) { + node = document.getElementById("host_image_" + current_host_id); + text = document.createTextNode(req.responseText); + node.replaceChild(text, node.firstChild); + } + } + req.send(formData); + } +</script> {% endblock content %} diff --git a/src/templates/booking/quick_deploy.html b/src/templates/booking/quick_deploy.html new file mode 100644 index 0000000..3837315 --- /dev/null +++ b/src/templates/booking/quick_deploy.html @@ -0,0 +1,206 @@ +{% extends "base.html" %} +{% load staticfiles %} +{% load bootstrap3 %} +{% block content %} +<style> + .grid_container { + display: grid; + grid-template-columns: repeat(12, 1fr); + padding: 30px; + } + .grid_element { + border-radius: 3px; + box-shadow: 0px 0px 7px 0px rgba(0,0,0,0.75); + margin: 10px; + padding: 7px; + } + .grid_element_wide { + grid-column-start: span 12; + } + .grid_element_half { + grid-column-start: span 6; + } + .grid_element_1third { + grid-column-start: span 4; + } + .grid_element_2third { + grid-column-start: span 8; + } +</style> +{% bootstrap_form_errors form type='non_fields' %} +<form id="quick_booking_form" action="/booking/quick/" method="POST" class="form"> +{% csrf_token %} +<div class="grid_container"> +<div class="grid_element host_select_pane grid_element_wide"> +<p>Please select a host type you wish to book. Only available types are shown.</p> +{% bootstrap_field form.filter_field %} +</div> +<div class="grid_element booking_info_pane grid_element_1third"> + {% bootstrap_field form.purpose %} + {% bootstrap_field form.project %} + {% bootstrap_field form.length %} + <p style="display:inline;">Days: </p><output id="daysout" style="display:inline;">0</output> + <script> + document.getElementById("id_length").setAttribute("oninput", "daysout.value=this.value"); + document.getElementById("daysout").value = document.getElementById("id_length").value; + </script> +</div> +<div class="grid_element collaborator_pane grid_element_1third"> + <label>Collaborators</label> + {{ form.users }} +</div> +<div class="grid_element configuration_pane grid_element_1third"> + {% bootstrap_field form.hostname %} + {% bootstrap_field form.image %} + {% bootstrap_field form.installer %} + {% bootstrap_field form.scenario %} +</div> +</div> +<script type="text/javascript"> + var normalize = function(data) + { + //converts the top level keys in data to map to lists + var normalized = {} + for( var key in data ){ + normalized[key] = []; + for( var subkey in data[key] ){ + normalized[key].push(data[key][subkey]); + } + } + return normalized; + } + var update_page_contents = function(response) + { + document.open(); + document.write(response); + document.close(); + } + + //form hamdler code + submit_form = function() + { + //altered from initial prototype: form submits automatically, + //but needs formatting for multiple select field + var data = normalize(result); + data = JSON.stringify(data); + document.getElementById("filter_field").value = data; + } + + var sup_image_dict = {{ image_filter|safe }}; + var sup_installer_dict = {{ installer_filter|safe }}; + var sup_scenario_dict = {{ scenario_filter|safe }}; + + function imageHider() { + var data = normalize(result); + var drop = document.getElementById("id_image"); + for( var i=0; i < drop.length; i++ ) + { + if ( drop.options[i].text == '---------' ) + { + drop.selectedIndex = i; + } + } + + $('#id_image').children().hide(); + + var empty_map = {} + + for ( var i=0; i < drop.childNodes.length; i++ ) + { + var image_object = sup_image_dict[drop.childNodes[i].value]; + if( image_object ) //weed out empty option + { + var lab_pk = "" + for( var j in data["labs"][0] ) + { + if( j in {} ) { continue; } + else { lab_pk = j; break; } + } + var host_pk = ""; + for( var j in data["hosts"][0] ) + { + if( j in {} ) { continue; } + else { host_pk = j; break; } + } + if( image_object.host_profile == host_pk && image_object.lab == lab_pk ) + { + drop.childNodes[i].style.display = "inherit"; + } + } + } + } + + $('#id_image').children().hide(); + $('#id_installer').children().hide(); + $('#id_scenario').children().hide(); + + + Array.from(document.getElementsByClassName("grid-item-select-btn")).forEach(function(element) { + element.addEventListener('click', imageHider); + }); + + function installerHider() { + dropFilter("id_installer", sup_installer_dict, "id_image"); + scenarioHider(); + } + document.getElementById('id_image').addEventListener('change', installerHider); + + function scenarioHider() { + dropFilter("id_scenario", sup_scenario_dict, "id_installer"); + } + document.getElementById('id_installer').addEventListener('change', scenarioHider); + + function dropFilter(target, target_filter, master) { + ob = document.getElementById(target); + + for(var i=0; i<ob.options.length; i++) { + if ( ob.options[i].text == '---------' ) { + ob.selectedIndex = i; + } + } + + targ_id = "#" + target; + $(targ_id).children().hide(); + var drop = document.getElementById(master); + var opts = target_filter[drop.options[drop.selectedIndex].value]; + if (!opts) { + opts = {}; + } + var emptyMap = {} + + var map = Object.create(null); + for (var i = 0; i < opts.length; i++) { + var j = opts[i]; + map[j] = true; + } + + for (var i = 0; i < document.getElementById(target).childNodes.length; i++) { + if (document.getElementById(target).childNodes[i].value in opts && !(document.getElementById(target).childNodes[i].value in emptyMap) ) { + document.getElementById(target).childNodes[i].style.display = "inherit"; + } + } + } +</script> + <button onclick="submit_form();" class="btn btn-success">Confirm</button> +</form> +<script> + //context vars + var prefill_host_selection = "{{host_select_field_prefill_data|default:""|safe}}"; + var prefill_purpose = "{{prefill_purpose|default:""|safe}}"; + var prefill_project = "{{prefill_project|default:""|safe}}"; + var prefill_hostname = "{{prefill_hostname|default:""|safe}}"; + + //to handle prefill + function prefill_host_select_field(data) + { + // + if(data) + { + make_selection(data); + } + } + + //call init functions + prefill_host_select_field(prefill_host_selection); +</script> +{% endblock %} diff --git a/src/templates/booking/stats.html b/src/templates/booking/stats.html index abb153b..42eebdd 100644 --- a/src/templates/booking/stats.html +++ b/src/templates/booking/stats.html @@ -42,8 +42,10 @@ function getData(){ {% block content %} <p>Number of days to plot: </p> - <input id="number_days" type="number" min="1" step="1"/> - <button onclick="getData();">Submit</button> + <div class="form-group"> + <input id="number_days" type="number" class="form-control" min="1" step="1" style="display:inline;width:200px"/> + <button class="btn btn-primary" onclick="getData();" style="display:inline;">Submit</button> + </div> <div id="all_graph_container"> <div id="booking_graph_wrapper"> <div id="booking_graph_container"/> diff --git a/src/templates/dashboard/landing.html b/src/templates/dashboard/landing.html index 40e0146..6bbb25b 100644 --- a/src/templates/dashboard/landing.html +++ b/src/templates/dashboard/landing.html @@ -39,6 +39,7 @@ </style> {% if not request.user.is_anonymous %} <div class='wf_create_div'> +<a class="wf_create btn btn-primary" style="color: #FFF;" href="/booking/quick/">Create a Quick Booking</a> <button class="wf_create btn btn-primary" onclick="cwf(0)">Create a Booking</button> <button class="wf_create btn btn-primary" onclick="cwf(1)">Create a Pod</button> <button class="wf_create btn btn-primary" onclick="cwf(2)">Configure a Pod</button> diff --git a/src/templates/dashboard/multiple_select_filter_widget.html b/src/templates/dashboard/multiple_select_filter_widget.html index 31b8f33..4e47ce0 100644 --- a/src/templates/dashboard/multiple_select_filter_widget.html +++ b/src/templates/dashboard/multiple_select_filter_widget.html @@ -97,7 +97,7 @@ <script> var initialized = false; var mapping = {{ mapping|safe }}; -var items = {{ items|safe }}; +var filter_items = {{ filter_items|safe }}; var result = {}; var selection = {{selection_data|default_if_none:"null"|safe}}; var dropdown_count = 0; @@ -108,31 +108,32 @@ make_selection({{selection_data|safe}}); function make_selection( selection_data ){ if(!initialized) { - init(); + filter_field_init(); } for(var k in selection_data) { selected_items = selection_data[k]; - for( var item in selected_items ){ - var node = items[item]; + for( var selected_item in selected_items ){ + var node = filter_items[selected_item]; if(!node['multiple']){ - var input_value = selected_items[item]; + var input_value = selected_items[selected_item]; if( input_value != 'false' ) { select(node); markAndSweep(node); } - var div = document.getElementById(item) + var div = document.getElementById(selected_item) + var inputs = div.parentNode.getElementsByTagName("input") var input = div.parentNode.getElementsByTagName("input")[0] input.value = input_value; - updateResult(item); + updateResult(selected_item); } else { - make_multiple_selection(selected_items, item); + make_multiple_selection(selected_items, selected_item); } } } } function make_multiple_selection(data, item_class){ - var node = items[item_class]; + var node = filter_items[item_class]; select(node); markAndSweep(node); prepop_data = data[item_class]; @@ -143,8 +144,8 @@ function make_multiple_selection(data, item_class){ } function markAndSweep(root){ - for(var nodeId in items) { - node = items[nodeId]; + for(var nodeId in filter_items) { + node = filter_items[nodeId]; node['marked'] = true; //mark all nodes //clears grey background of everything } @@ -164,17 +165,17 @@ function markAndSweep(root){ var neighbors = mapping[mappingId]; for(var neighId in neighbors) { neighId = neighbors[neighId]; - var neighbor = items[neighId]; + var neighbor = filter_items[neighId]; toCheck.push(neighbor); } } } //now remove all nodes still marked - for(var nodeId in items){ - node = items[nodeId]; + for(var nodeId in filter_items){ + node = filter_items[nodeId]; if(node['marked']){ - disable(node); + disable_node(node); } } } @@ -186,8 +187,8 @@ function process(node) { else { var selected = [] //remember the currently selected, then reset everything and reselect one at a time - for(var nodeId in items) { - node = items[nodeId]; + for(var nodeId in filter_items) { + node = filter_items[nodeId]; if(node['selected']) { selected.push(node); } @@ -205,9 +206,9 @@ function process(node) { function select(node) { elem = document.getElementById(node['id']); node['selected'] = true; - elem.classList.remove('cleared_node') - elem.classList.remove('disabled_node') - elem.classList.add('selected_node') + elem.classList.remove('cleared_node'); + elem.classList.remove('disabled_node'); + elem.classList.add('selected_node'); var input = elem.parentNode.getElementsByTagName("input")[0]; input.disabled = false; input.value = true; @@ -218,27 +219,27 @@ function clear(node) { node['selected'] = false; node['selectable'] = true; elem.classList.add('cleared_node') - elem.classList.remove('disabled_node') - elem.classList.remove('selected_node') + elem.classList.remove('disabled_node'); + elem.classList.remove('selected_node'); elem.parentNode.getElementsByTagName("input")[0].disabled = true; } -function disable(node) { +function disable_node(node) { elem = document.getElementById(node['id']); node['selected'] = false; node['selectable'] = false; - elem.classList.remove('cleared_node') - elem.classList.add('disabled_node') - elem.classList.remove('selected_node') + elem.classList.remove('cleared_node'); + elem.classList.add('disabled_node'); + elem.classList.remove('selected_node'); elem.parentNode.getElementsByTagName("input")[0].disabled = true; } function processClick(id, multiple){ if(!initialized){ - init(); + filter_field_init(); } var element = document.getElementById(id); - var node = items[id]; + var node = filter_items[id]; if(!node['selectable']){ return; } @@ -259,11 +260,11 @@ function processClick(id, multiple){ function processClickMultipleObject(node){ select(node); - add_item(node); + add_node(node); process(node); } -function add_item(node){ +function add_node(node){ return add_item_prepopulate(node, {}); } @@ -364,18 +365,19 @@ function remove_dropdown(id){ } } if(deselect_class){ - clear(items[div_class]); + clear(filter_items[div_class]); } } function updateResult(nodeId){ if(!initialized){ - init(); + filter_field_init(); } - if(!items[nodeId]['multiple']){ + if(!filter_items[nodeId]['multiple']){ var node = document.getElementById(nodeId); var value = {} value[nodeId] = node.parentNode.getElementsByTagName("input")[0].value; + result[node.parentNode.parentNode.id] = {}; result[node.parentNode.parentNode.id][nodeId] = value; } } @@ -391,10 +393,10 @@ function updateObjectResult(parentElem){ result[node_type][parentElem.id] = input; } -function init() { - for(nodeId in items) { +function filter_field_init() { + for(nodeId in filter_items) { element = document.getElementById(nodeId); - node = items[nodeId]; + node = filter_items[nodeId]; result[element.parentNode.parentNode.id] = {} } initialized = true; diff --git a/src/templates/dashboard/searchable_select_multiple.html b/src/templates/dashboard/searchable_select_multiple.html index ee460dd..c08fbe5 100644 --- a/src/templates/dashboard/searchable_select_multiple.html +++ b/src/templates/dashboard/searchable_select_multiple.html @@ -116,6 +116,7 @@ string_trie.isComplete = false; var added_items = []; + var initial_log = {{ initial|safe }}; var added_template = {{ added_list|default:"{}" }}; @@ -128,7 +129,7 @@ entry_p.innerText = default_entry; } - init(); + search_field_init(); if( show_from_noentry ) { @@ -149,7 +150,7 @@ } } - function init() { + function search_field_init() { build_all_tries(items); var initial = {{ initial|safe }}; @@ -342,14 +343,12 @@ added_items.push(item); } } - update_selected_list(); document.getElementById("user_field").focus(); } function remove_item(item_ref) { - item = Object.values(items)[item_ref]; var index = added_items.indexOf(item); added_items.splice(index, 1); diff --git a/src/workflow/forms.py b/src/workflow/forms.py index f781663..b8c7f66 100644 --- a/src/workflow/forms.py +++ b/src/workflow/forms.py @@ -330,7 +330,7 @@ class MultipleSelectFilterField(forms.Field): class FormUtils: @staticmethod - def getLabData(): + def getLabData(multiple_selectable_hosts): """ Gets all labs and thier host profiles and returns a serialized version the form can understand. Should be rewritten with a related query to make it faster @@ -361,7 +361,7 @@ class FormUtils: shost['selected'] = 0 shost['selectable'] = 1 shost['follow'] = 0 - shost['multiple'] = 1 + shost['multiple'] = multiple_selectable_hosts items[shost['id']] = shost mapping[slab['id']].append(shost['id']) if shost['id'] not in mapping: @@ -374,7 +374,7 @@ class FormUtils: context = { 'filter_objects': filter_objects, 'mapping': mapping, - 'items': items + 'filter_items': items } return context @@ -384,7 +384,7 @@ class HardwareDefinitionForm(forms.Form): def __init__(self, *args, **kwargs): selection_data = kwargs.pop("selection_data", False) super(HardwareDefinitionForm, self).__init__(*args, **kwargs) - attrs = FormUtils.getLabData() + attrs = FormUtils.getLabData(1) attrs['selection_data'] = selection_data self.fields['filter_field'] = MultipleSelectFilterField( widget=MultipleSelectFilterWidget( |