diff options
Diffstat (limited to 'src/booking')
-rw-r--r-- | src/booking/__init__.py | 8 | ||||
-rw-r--r-- | src/booking/admin.py | 16 | ||||
-rw-r--r-- | src/booking/apps.py | 15 | ||||
-rw-r--r-- | src/booking/forms.py | 104 | ||||
-rw-r--r-- | src/booking/lib.py | 36 | ||||
-rw-r--r-- | src/booking/migrations/0001_initial.py | 68 | ||||
-rw-r--r-- | src/booking/migrations/0002_booking_pdf.py | 18 | ||||
-rw-r--r-- | src/booking/migrations/0003_auto_20190115_1733.py | 30 | ||||
-rw-r--r-- | src/booking/migrations/0004_auto_20190124_1700.py | 20 | ||||
-rw-r--r-- | src/booking/migrations/0005_booking_idf.py | 18 | ||||
-rw-r--r-- | src/booking/migrations/0006_booking_opnfv_config.py | 20 | ||||
-rw-r--r-- | src/booking/migrations/__init__.py | 0 | ||||
-rw-r--r-- | src/booking/models.py | 65 | ||||
-rw-r--r-- | src/booking/quick_deployer.py | 355 | ||||
-rw-r--r-- | src/booking/stats.py | 58 | ||||
-rw-r--r-- | src/booking/tests/__init__.py | 8 | ||||
-rw-r--r-- | src/booking/tests/test_models.py | 232 | ||||
-rw-r--r-- | src/booking/tests/test_quick_booking.py | 150 | ||||
-rw-r--r-- | src/booking/urls.py | 54 | ||||
-rw-r--r-- | src/booking/views.py | 201 |
20 files changed, 1476 insertions, 0 deletions
diff --git a/src/booking/__init__.py b/src/booking/__init__.py new file mode 100644 index 0000000..b6fef6c --- /dev/null +++ b/src/booking/__init__.py @@ -0,0 +1,8 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt 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 +############################################################################## diff --git a/src/booking/admin.py b/src/booking/admin.py new file mode 100644 index 0000000..162777e --- /dev/null +++ b/src/booking/admin.py @@ -0,0 +1,16 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# 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 +############################################################################## + + +from django.contrib import admin + +from booking.models import Booking + +admin.site.register(Booking) diff --git a/src/booking/apps.py b/src/booking/apps.py new file mode 100644 index 0000000..99bf115 --- /dev/null +++ b/src/booking/apps.py @@ -0,0 +1,15 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt 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 +############################################################################## + + +from django.apps import AppConfig + + +class BookingConfig(AppConfig): + name = 'booking' diff --git a/src/booking/forms.py b/src/booking/forms.py new file mode 100644 index 0000000..df88cc6 --- /dev/null +++ b/src/booking/forms.py @@ -0,0 +1,104 @@ +############################################################################## +# 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 workflow.forms import ( + MultipleSelectFilterField, + MultipleSelectFilterWidget, + FormUtils) +from account.models import UserProfile +from resource_inventory.models import Image, Installer, Scenario +from workflow.forms import SearchableSelectMultipleField +from booking.lib import get_user_items, get_user_field_opts + + +class QuickBookingForm(forms.Form): + purpose = forms.CharField(max_length=1000) + project = forms.CharField(max_length=400) + 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): + if "default_user" in kwargs: + default_user = kwargs.pop("default_user") + else: + default_user = "you" + self.default_user = default_user + + super(QuickBookingForm, self).__init__(data=data, **kwargs) + + self.fields["image"] = forms.ModelChoiceField( + Image.objects.filter(public=True) | Image.objects.filter(owner=user) + ) + + self.fields['users'] = SearchableSelectMultipleField( + queryset=UserProfile.objects.select_related('user').exclude(user=user), + items=get_user_items(exclude=user), + required=False, + **get_user_field_opts() + ) + + attrs = FormUtils.getLabData(0) + self.fields['filter_field'] = MultipleSelectFilterField(widget=MultipleSelectFilterWidget(**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/lib.py b/src/booking/lib.py new file mode 100644 index 0000000..8132c75 --- /dev/null +++ b/src/booking/lib.py @@ -0,0 +1,36 @@ +############################################################################## +# Copyright (c) 2019 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 +############################################################################## + +from account.models import UserProfile + + +def get_user_field_opts(): + return { + 'show_from_noentry': False, + 'show_x_results': 5, + 'results_scrollable': True, + 'selectable_limit': -1, + 'placeholder': 'Search for other users', + 'name': 'users', + 'disabled': False + } + + +def get_user_items(exclude=None): + qs = UserProfile.objects.select_related('user').exclude(user=exclude) + items = {} + for up in qs: + item = { + 'id': up.id, + 'expanded_name': up.full_name, + 'small_name': up.user.username, + 'string': up.email_addr + } + items[up.id] = item + return items diff --git a/src/booking/migrations/0001_initial.py b/src/booking/migrations/0001_initial.py new file mode 100644 index 0000000..20415fe --- /dev/null +++ b/src/booking/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 2.1 on 2018-09-14 14:48 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('account', '0001_initial'), + ('resource_inventory', '__first__'), + ] + + operations = [ + migrations.CreateModel( + name='Booking', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('start', models.DateTimeField()), + ('end', models.DateTimeField()), + ('reset', models.BooleanField(default=False)), + ('jira_issue_id', models.IntegerField(blank=True, null=True)), + ('jira_issue_status', models.CharField(blank=True, max_length=50)), + ('purpose', models.CharField(max_length=300)), + ('ext_count', models.IntegerField(default=2)), + ('project', models.CharField(blank=True, default='', max_length=100, null=True)), + ('collaborators', models.ManyToManyField(related_name='collaborators', to=settings.AUTH_USER_MODEL)), + ('config_bundle', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='resource_inventory.ConfigBundle')), + ('lab', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='account.Lab')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owner', to=settings.AUTH_USER_MODEL)), + ('resource', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='resource_inventory.ResourceBundle')), + ], + options={ + 'db_table': 'booking', + }, + ), + migrations.CreateModel( + name='Installer', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=30)), + ], + ), + migrations.CreateModel( + name='Opsys', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('sup_installers', models.ManyToManyField(blank=True, to='booking.Installer')), + ], + ), + migrations.CreateModel( + name='Scenario', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=300)), + ], + ), + migrations.AddField( + model_name='installer', + name='sup_scenarios', + field=models.ManyToManyField(blank=True, to='booking.Scenario'), + ), + ] diff --git a/src/booking/migrations/0002_booking_pdf.py b/src/booking/migrations/0002_booking_pdf.py new file mode 100644 index 0000000..53232c9 --- /dev/null +++ b/src/booking/migrations/0002_booking_pdf.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1 on 2018-11-09 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('booking', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='booking', + name='pdf', + field=models.TextField(blank=True, default=''), + ), + ] 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/migrations/0004_auto_20190124_1700.py b/src/booking/migrations/0004_auto_20190124_1700.py new file mode 100644 index 0000000..baa32d2 --- /dev/null +++ b/src/booking/migrations/0004_auto_20190124_1700.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1 on 2019-01-24 17:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('booking', '0003_auto_20190115_1733'), + ] + + operations = [ + migrations.AlterField( + model_name='booking', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='owner', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/booking/migrations/0005_booking_idf.py b/src/booking/migrations/0005_booking_idf.py new file mode 100644 index 0000000..31e9170 --- /dev/null +++ b/src/booking/migrations/0005_booking_idf.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1 on 2019-04-12 19:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('booking', '0004_auto_20190124_1700'), + ] + + operations = [ + migrations.AddField( + model_name='booking', + name='idf', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/src/booking/migrations/0006_booking_opnfv_config.py b/src/booking/migrations/0006_booking_opnfv_config.py new file mode 100644 index 0000000..e5ffc71 --- /dev/null +++ b/src/booking/migrations/0006_booking_opnfv_config.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1 on 2019-05-01 18:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('resource_inventory', '0010_auto_20190430_1405'), + ('booking', '0005_booking_idf'), + ] + + operations = [ + migrations.AddField( + model_name='booking', + name='opnfv_config', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='resource_inventory.OPNFVConfig'), + ), + ] diff --git a/src/booking/migrations/__init__.py b/src/booking/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/booking/migrations/__init__.py diff --git a/src/booking/models.py b/src/booking/models.py new file mode 100644 index 0000000..9836730 --- /dev/null +++ b/src/booking/models.py @@ -0,0 +1,65 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# Copyright (c) 2018 Sawyer Bergeron, Parker Berberian, 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 +############################################################################## + + +from resource_inventory.models import ResourceBundle, ConfigBundle, OPNFVConfig +from account.models import Lab +from django.contrib.auth.models import User +from django.db import models +import resource_inventory.resource_manager + + +class Booking(models.Model): + id = models.AutoField(primary_key=True) + owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name='owner') + collaborators = models.ManyToManyField(User, related_name='collaborators') + start = models.DateTimeField() + end = models.DateTimeField() + reset = models.BooleanField(default=False) + jira_issue_id = models.IntegerField(null=True, blank=True) + jira_issue_status = models.CharField(max_length=50, blank=True) + purpose = models.CharField(max_length=300, blank=False) + ext_count = models.IntegerField(default=2) + resource = models.ForeignKey(ResourceBundle, on_delete=models.SET_NULL, null=True) + config_bundle = models.ForeignKey(ConfigBundle, on_delete=models.SET_NULL, null=True) + opnfv_config = models.ForeignKey(OPNFVConfig, on_delete=models.SET_NULL, null=True) + project = models.CharField(max_length=100, default="", blank=True, null=True) + lab = models.ForeignKey(Lab, null=True, on_delete=models.SET_NULL) + pdf = models.TextField(blank=True, default="") + idf = models.TextField(blank=True, default="") + + class Meta: + db_table = 'booking' + + def save(self, *args, **kwargs): + """ + Save the booking if self.user is authorized and there is no overlapping booking. + Raise PermissionError if the user is not authorized + Raise ValueError if there is an overlapping booking + """ + if self.start >= self.end: + raise ValueError('Start date is after end date') + # conflicts end after booking starts, and start before booking ends + conflicting_dates = Booking.objects.filter(resource=self.resource).exclude(id=self.id) + conflicting_dates = conflicting_dates.filter(end__gt=self.start) + conflicting_dates = conflicting_dates.filter(start__lt=self.end) + if conflicting_dates.count() > 0: + raise ValueError('This booking overlaps with another booking') + return super(Booking, self).save(*args, **kwargs) + + def delete(self, *args, **kwargs): + res = self.resource + self.resource = None + self.save() + resource_inventory.resource_manager.ResourceManager.getInstance().deleteResourceBundle(res) + return super(self.__class__, self).delete(*args, **kwargs) + + def __str__(self): + return str(self.purpose) + ' from ' + str(self.start) + ' until ' + str(self.end) diff --git a/src/booking/quick_deployer.py b/src/booking/quick_deployer.py new file mode 100644 index 0000000..0e0cc5a --- /dev/null +++ b/src/booking/quick_deployer.py @@ -0,0 +1,355 @@ +############################################################################## +# 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 datetime import timedelta +from django.utils import timezone +from account.models import Lab + +from resource_inventory.models import ( + Installer, + Image, + GenericResourceBundle, + ConfigBundle, + Host, + HostProfile, + HostConfiguration, + GenericResource, + GenericHost, + GenericInterface, + OPNFVRole, + OPNFVConfig, + Network, + NetworkConnection, + NetworkRole, + HostOPNFVConfig, +) +from resource_inventory.resource_manager import ResourceManager +from resource_inventory.pdf_templater import PDFTemplater +from notifier.manager import NotificationHandler +from booking.models import Booking +from dashboard.exceptions import ( + InvalidHostnameException, + ResourceAvailabilityException, + ModelValidationException, + BookingLengthException +) +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 + + +class BookingPermissionException(Exception): + pass + + +def parse_host_field(host_json): + lab, profile = (None, None) + lab_dict = host_json['lab'] + for lab_info in lab_dict.values(): + if lab_info['selected']: + lab = Lab.objects.get(lab_user__id=lab_info['id']) + + host_dict = host_json['host'] + for host_info in host_dict.values(): + if host_info['selected']: + profile = HostProfile.objects.get(pk=host_info['id']) + + if lab is None: + raise NoLabSelectedError("No lab was selected") + if profile is None: + raise HostProfileDNE("No Host was selected") + + return lab, profile + + +def check_available_matching_host(lab, hostprofile): + available_host_types = ResourceManager.getInstance().getAvailableHostTypes(lab) + if hostprofile not in available_host_types: + # TODO: handle deleting generic resource in this instance along with grb + raise HostNotAvailable('Requested host type is not available. Please try again later. Host availability can be viewed in the "Hosts" tab to the left.') + + hostset = Host.objects.filter(lab=lab, profile=hostprofile).filter(booked=False).filter(working=True) + if not hostset.exists(): + raise HostNotAvailable("Couldn't find any matching unbooked hosts") + + return True + + +def generate_grb(owner, lab, common_id): + grbundle = GenericResourceBundle(owner=owner) + grbundle.lab = lab + grbundle.name = "grbundle for quick booking with uid " + common_id + grbundle.description = "grbundle created for quick-deploy booking" + grbundle.save() + + return grbundle + + +def generate_gresource(bundle, hostname): + if not re.match(r"(?=^.{1,253}$)(^([A-Za-z0-9-_]{1,62}\.)*[A-Za-z0-9-_]{1,63})$", hostname): + raise InvalidHostnameException("Hostname must comply to RFC 952 and all extensions to it until this point") + gresource = GenericResource(bundle=bundle, name=hostname) + gresource.save() + + return gresource + + +def generate_ghost(generic_resource, host_profile): + ghost = GenericHost() + ghost.resource = generic_resource + ghost.profile = host_profile + ghost.save() + + return ghost + + +def generate_config_bundle(owner, common_id, grbundle): + cbundle = ConfigBundle() + cbundle.owner = owner + cbundle.name = "configbundle for quick booking with uid " + common_id + cbundle.description = "configbundle created for quick-deploy booking" + cbundle.bundle = grbundle + cbundle.save() + + return cbundle + + +def generate_opnfvconfig(scenario, installer, config_bundle): + opnfvconfig = OPNFVConfig() + opnfvconfig.scenario = scenario + opnfvconfig.installer = installer + opnfvconfig.bundle = config_bundle + opnfvconfig.save() + + return opnfvconfig + + +def generate_hostconfig(generic_host, image, config_bundle): + hconf = HostConfiguration() + hconf.host = generic_host + hconf.image = image + hconf.bundle = config_bundle + hconf.is_head_node = True + hconf.save() + + return hconf + + +def generate_hostopnfv(hostconfig, opnfvconfig): + config = HostOPNFVConfig() + role = None + try: + role = OPNFVRole.objects.get(name="Jumphost") + except Exception: + role = OPNFVRole.objects.create( + name="Jumphost", + description="Single server jumphost role" + ) + config.role = role + config.host_config = hostconfig + config.opnfv_config = opnfvconfig + config.save() + return config + + +def generate_resource_bundle(generic_resource_bundle, config_bundle): # warning: requires cleanup + try: + resource_manager = ResourceManager.getInstance() + resource_bundle = resource_manager.convertResourceBundle(generic_resource_bundle, config=config_bundle) + return resource_bundle + except ResourceAvailabilityException: + raise ResourceAvailabilityException("Requested resources not available") + except ModelValidationException: + raise ModelValidationException("Encountered error while saving grbundle") + + +def check_invariants(request, **kwargs): + installer = kwargs['installer'] + image = kwargs['image'] + scenario = kwargs['scenario'] + lab = kwargs['lab'] + host_profile = kwargs['host_profile'] + length = kwargs['length'] + # 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 != host_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") + if length < 1 or length > 21: + raise BookingLengthException("Booking must be between 1 and 21 days long") + + +def configure_networking(grb, config): + # create network + net = Network.objects.create(name="public", bundle=grb, is_public=True) + # connect network to generic host + grb.getHosts()[0].generic_interfaces.first().connections.add( + NetworkConnection.objects.create(network=net, vlan_is_tagged=False) + ) + # asign network role + role = NetworkRole.objects.create(name="public", network=net) + opnfv_config = config.opnfv_config.first() + if opnfv_config: + opnfv_config.networks.add(role) + + +def create_from_form(form, request): + quick_booking_id = str(uuid.uuid4()) + + host_field = form.cleaned_data['filter_field'] + purpose_field = form.cleaned_data['purpose'] + project_field = form.cleaned_data['project'] + users_field = form.cleaned_data['users'] + hostname = form.cleaned_data['hostname'] + length = form.cleaned_data['length'] + + image = form.cleaned_data['image'] + scenario = form.cleaned_data['scenario'] + installer = form.cleaned_data['installer'] + + lab, host_profile = parse_host_field(host_field) + data = form.cleaned_data + data['lab'] = lab + data['host_profile'] = host_profile + check_invariants(request, **data) + + # check booking privileges + if Booking.objects.filter(owner=request.user, end__gt=timezone.now()).count() >= 3 and not request.user.userprofile.booking_privledge: + raise BookingPermissionException("You do not have permission to have more than 3 bookings at a time.") + + check_available_matching_host(lab, host_profile) # requires cleanup if failure after this point + + grbundle = generate_grb(request.user, lab, quick_booking_id) + gresource = generate_gresource(grbundle, hostname) + ghost = generate_ghost(gresource, host_profile) + cbundle = generate_config_bundle(request.user, quick_booking_id, grbundle) + hconf = generate_hostconfig(ghost, image, cbundle) + + # if no installer provided, just create blank host + opnfv_config = None + if installer: + opnfv_config = generate_opnfvconfig(scenario, installer, cbundle) + generate_hostopnfv(hconf, opnfv_config) + + # construct generic interfaces + for interface_profile in host_profile.interfaceprofile.all(): + generic_interface = GenericInterface.objects.create(profile=interface_profile, host=ghost) + generic_interface.save() + + configure_networking(grbundle, cbundle) + + # generate resource bundle + resource_bundle = generate_resource_bundle(grbundle, cbundle) + + # generate booking + booking = Booking.objects.create( + purpose=purpose_field, + project=project_field, + lab=lab, + owner=request.user, + start=timezone.now(), + end=timezone.now() + timedelta(days=int(length)), + resource=resource_bundle, + config_bundle=cbundle, + opnfv_config=opnfv_config + ) + booking.pdf = PDFTemplater.makePDF(booking) + + for collaborator in users_field: # list of UserProfiles + booking.collaborators.add(collaborator.user) + + booking.save() + + # generate job + JobFactory.makeCompleteJob(booking) + NotificationHandler.notify_new_booking(booking) + + return 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/stats.py b/src/booking/stats.py new file mode 100644 index 0000000..383723a --- /dev/null +++ b/src/booking/stats.py @@ -0,0 +1,58 @@ +############################################################################## +# 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 +############################################################################## +from booking.models import Booking +import datetime +import pytz + + +class StatisticsManager(object): + + @staticmethod + def getContinuousBookingTimeSeries(span=28): + """ + Will return a dictionary of names and 2-D array of x and y data points. + e.g. {"plot1": [["x1", "x2", "x3"],["y1", "y2", "y3]]} + x values will be dates in string + every change (booking start / end) will be reflected, + instead of one data point per day + y values are the integer number of bookings/users active at + some point in the given date span is the number of days to plot. + The last x value will always be the current time + """ + data = [] + x = [] + y = [] + users = [] + now = datetime.datetime.now(pytz.utc) + delta = datetime.timedelta(days=span) + end = now - delta + bookings = Booking.objects.filter(start__lte=now, end__gte=end).prefetch_related("collaborators") + for booking in bookings: # collect data from each booking + user_list = [u.pk for u in booking.collaborators.all()] + user_list.append(booking.owner.pk) + data.append((booking.start, 1, user_list)) + data.append((booking.end, -1, user_list)) + + # sort based on time + data.sort(key=lambda i: i[0]) + + # collect data + count = 0 + active_users = {} + for datum in data: + x.append(str(datum[0])) # time + count += datum[1] # booking count + y.append(count) + for pk in datum[2]: # maintain count of each user's active bookings + active_users[pk] = active_users.setdefault(pk, 0) + datum[1] + if active_users[pk] == 0: + del active_users[pk] + users.append(len([x for x in active_users.values() if x > 0])) + + return {"booking": [x, y], "user": [x, users]} diff --git a/src/booking/tests/__init__.py b/src/booking/tests/__init__.py new file mode 100644 index 0000000..b6fef6c --- /dev/null +++ b/src/booking/tests/__init__.py @@ -0,0 +1,8 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt 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 +############################################################################## diff --git a/src/booking/tests/test_models.py b/src/booking/tests/test_models.py new file mode 100644 index 0000000..6170295 --- /dev/null +++ b/src/booking/tests/test_models.py @@ -0,0 +1,232 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# 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 +############################################################################## + + +from datetime import timedelta + +from django.contrib.auth.models import Permission, User +from django.test import TestCase +from django.utils import timezone + +# from booking.models import * +from booking.models import Booking +from resource_inventory.models import ResourceBundle, GenericResourceBundle, ConfigBundle + + +class BookingModelTestCase(TestCase): + + count = 0 + + def setUp(self): + self.owner = User.objects.create(username='owner') + + self.res1 = ResourceBundle.objects.create( + template=GenericResourceBundle.objects.create( + name="gbundle" + str(self.count) + ) + ) + self.count += 1 + self.res2 = ResourceBundle.objects.create( + template=GenericResourceBundle.objects.create( + name="gbundle2" + str(self.count) + ) + ) + self.count += 1 + self.user1 = User.objects.create(username='user1') + + self.add_booking_perm = Permission.objects.get(codename='add_booking') + self.user1.user_permissions.add(self.add_booking_perm) + + self.user1 = User.objects.get(pk=self.user1.id) + self.config_bundle = ConfigBundle.objects.create( + owner=self.user1, + name="test config" + ) + + def test_start_end(self): + """ + if the start of a booking is greater or equal then the end, + saving should raise a ValueException + """ + start = timezone.now() + end = start - timedelta(weeks=1) + self.assertRaises( + ValueError, + Booking.objects.create, + start=start, + end=end, + resource=self.res1, + owner=self.user1, + config_bundle=self.config_bundle + ) + end = start + self.assertRaises( + ValueError, + Booking.objects.create, + start=start, + end=end, + resource=self.res1, + owner=self.user1, + config_bundle=self.config_bundle + ) + + def test_conflicts(self): + """ + saving an overlapping booking on the same resource + should raise a ValueException + saving for different resources should succeed + """ + start = timezone.now() + end = start + timedelta(weeks=1) + self.assertTrue( + Booking.objects.create( + start=start, + end=end, + owner=self.user1, + resource=self.res1, + config_bundle=self.config_bundle + ) + ) + + self.assertRaises( + ValueError, + Booking.objects.create, + start=start, + end=end, + resource=self.res1, + owner=self.user1, + config_bundle=self.config_bundle + ) + + self.assertRaises( + ValueError, + Booking.objects.create, + start=start + timedelta(days=1), + end=end - timedelta(days=1), + resource=self.res1, + owner=self.user1, + config_bundle=self.config_bundle + ) + + self.assertRaises( + ValueError, + Booking.objects.create, + start=start - timedelta(days=1), + end=end, + resource=self.res1, + owner=self.user1, + config_bundle=self.config_bundle + ) + + self.assertRaises( + ValueError, + Booking.objects.create, + start=start - timedelta(days=1), + end=end - timedelta(days=1), + resource=self.res1, + owner=self.user1, + config_bundle=self.config_bundle + ) + + self.assertRaises( + ValueError, + Booking.objects.create, + start=start, + end=end + timedelta(days=1), + resource=self.res1, + owner=self.user1, + config_bundle=self.config_bundle + ) + + self.assertRaises( + ValueError, + Booking.objects.create, + start=start + timedelta(days=1), + end=end + timedelta(days=1), + resource=self.res1, + owner=self.user1, + config_bundle=self.config_bundle + ) + + self.assertTrue( + Booking.objects.create( + start=start - timedelta(days=1), + end=start, + owner=self.user1, + resource=self.res1, + config_bundle=self.config_bundle + ) + ) + + self.assertTrue( + Booking.objects.create( + start=end, + end=end + timedelta(days=1), + owner=self.user1, + resource=self.res1, + config_bundle=self.config_bundle + ) + ) + + self.assertTrue( + Booking.objects.create( + start=start - timedelta(days=2), + end=start - timedelta(days=1), + owner=self.user1, + resource=self.res1, + config_bundle=self.config_bundle + ) + ) + + self.assertTrue( + Booking.objects.create( + start=end + timedelta(days=1), + end=end + timedelta(days=2), + owner=self.user1, + resource=self.res1, + config_bundle=self.config_bundle + ) + ) + + self.assertTrue( + Booking.objects.create( + start=start, + end=end, + owner=self.user1, + resource=self.res2, + config_bundle=self.config_bundle + ) + ) + + def test_extensions(self): + """ + saving a booking with an extended end time is allows to happen twice, + and each extension must be a maximum of one week long + """ + start = timezone.now() + end = start + timedelta(weeks=1) + self.assertTrue( + Booking.objects.create( + start=start, + end=end, + owner=self.user1, + resource=self.res1, + config_bundle=self.config_bundle + ) + ) + + booking = Booking.objects.all().first() # should be only thing in db + + self.assertEquals(booking.ext_count, 2) + booking.end = booking.end + timedelta(days=3) + try: + booking.save() + except Exception: + self.fail("save() threw an exception") diff --git a/src/booking/tests/test_quick_booking.py b/src/booking/tests/test_quick_booking.py new file mode 100644 index 0000000..e445860 --- /dev/null +++ b/src/booking/tests/test_quick_booking.py @@ -0,0 +1,150 @@ +############################################################################## +# 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 datetime + +from django.test import TestCase, Client + +from booking.models import Booking +from dashboard.testing_utils import ( + make_host, + make_user, + make_user_profile, + make_lab, + make_installer, + make_image, + make_scenario, + make_os, + make_complete_host_profile, + make_opnfv_role, + make_public_net, +) + + +class QuickBookingValidFormTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = make_user(False, username="newtestuser", password="testpassword") + make_user_profile(cls.user, True) + + lab_user = make_user(True) + cls.lab = make_lab(lab_user) + + cls.host_profile = make_complete_host_profile(cls.lab) + cls.scenario = make_scenario() + cls.installer = make_installer([cls.scenario]) + os = make_os([cls.installer]) + cls.image = make_image(cls.lab, 1, cls.user, os, cls.host_profile) + cls.host = make_host(cls.host_profile, cls.lab) + cls.role = make_opnfv_role() + cls.pubnet = make_public_net(10, cls.lab) + + cls.post_data = cls.build_post_data() + cls.client = Client() + + @classmethod + def build_post_data(cls): + return { + 'filter_field': '{"hosts":[{"host_' + str(cls.host_profile.id) + '":"true"}], "labs": [{"lab_' + str(cls.lab.lab_user.id) + '":"true"}]}', + 'purpose': 'my_purpose', + 'project': 'my_project', + 'length': '3', + 'ignore_this': 1, + 'users': '', + 'hostname': 'my_host', + 'image': str(cls.image.id), + 'installer': str(cls.installer.id), + 'scenario': str(cls.scenario.id) + } + + def post(self, changed_fields={}): + payload = self.post_data.copy() + payload.update(changed_fields) + response = self.client.post('/booking/quick/', payload) + return response + + def setUp(self): + self.client.login(username=self.user.username, password="testpassword") + + def assertValidBooking(self, booking): + self.assertEqual(booking.owner, self.user) + self.assertEqual(booking.purpose, 'my_purpose') + self.assertEqual(booking.project, 'my_project') + delta = booking.end - booking.start + delta -= datetime.timedelta(days=3) + self.assertLess(delta, datetime.timedelta(minutes=1)) + + resource_bundle = booking.resource + config_bundle = booking.config_bundle + + opnfv_config = config_bundle.opnfv_config.first() + self.assertEqual(self.installer, opnfv_config.installer) + self.assertEqual(self.scenario, opnfv_config.scenario) + + host = resource_bundle.hosts.first() + self.assertEqual(host.profile, self.host_profile) + self.assertEqual(host.template.resource.name, 'my_host') + + def test_with_too_long_length(self): + response = self.post({'length': '22'}) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(Booking.objects.first()) + + def test_with_negative_length(self): + response = self.post({'length': '-1'}) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(Booking.objects.first()) + + def test_with_invalid_installer(self): + response = self.post({'installer': str(self.installer.id + 100)}) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(Booking.objects.first()) + + def test_with_invalid_scenario(self): + response = self.post({'scenario': str(self.scenario.id + 100)}) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(Booking.objects.first()) + + def test_with_invalid_host_id(self): + response = self.post({'filter_field': '{"hosts":[{"host_' + str(self.host_profile.id + 100) + '":"true"}], "labs": [{"lab_' + str(self.lab.lab_user.id) + '":"true"}]}'}) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(Booking.objects.first()) + + def test_with_invalid_lab_id(self): + response = self.post({'filter_field': '{"hosts":[{"host_' + str(self.host_profile.id) + '":"true"}], "labs": [{"lab_' + str(self.lab.lab_user.id + 100) + '":"true"}]}'}) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(Booking.objects.first()) + + def test_with_invalid_empty_filter_field(self): + response = self.post({'filter_field': ''}) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(Booking.objects.first()) + + def test_with_garbage_users_field(self): # expected behavior: treat as though field is empty if it has garbage data + response = self.post({'users': 'X�]QP�槰DP�+m���h�U�_�yJA:.rDi��QN|.��C��n�P��F!��D�����5ȅj�9�LV��'}) # output from /dev/urandom + + self.assertEqual(response.status_code, 200) + booking = Booking.objects.first() + self.assertIsNotNone(booking) + self.assertValidBooking(booking) + + def test_with_valid_form(self): + response = self.post() + + self.assertEqual(response.status_code, 200) + booking = Booking.objects.first() + self.assertIsNotNone(booking) + self.assertValidBooking(booking) diff --git a/src/booking/urls.py b/src/booking/urls.py new file mode 100644 index 0000000..310aaa7 --- /dev/null +++ b/src/booking/urls.py @@ -0,0 +1,54 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# 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 +############################################################################## + + +"""pharos_dashboard URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.10/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import url + +from booking.views import ( + booking_detail_view, + BookingDeleteView, + bookingDelete, + BookingListView, + booking_stats_view, + booking_stats_json, + quick_create, + booking_modify_image +) + +app_name = "booking" +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 new file mode 100644 index 0000000..bad7dc9 --- /dev/null +++ b/src/booking/views.py @@ -0,0 +1,201 @@ +############################################################################## +# Copyright (c) 2016 Max Breitenfeldt and others. +# 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 +############################################################################## + +from django.contrib import messages +from django.shortcuts import get_object_or_404 +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 +from django.db.models import Q +from django.urls import reverse + +from resource_inventory.models import ResourceBundle, HostProfile, Image, Host +from resource_inventory.resource_manager import ResourceManager +from account.models import Lab +from booking.models import Booking +from booking.stats import StatisticsManager +from booking.forms import HostReImageForm +from api.models import JobFactory +from workflow.views import login +from booking.forms import QuickBookingForm +from booking.quick_deployer import create_from_form, drop_filter + + +def quick_create_clear_fields(request): + request.session['quick_create_forminfo'] = None + + +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(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: + booking = create_from_form(form, request) + messages.success(request, "We've processed your request. " + "Check Account->My Bookings for the status of your new booking") + return redirect(reverse('booking:booking_detail', kwargs={'booking_id': booking.id})) + except Exception as e: + messages.error(request, "Whoops, an error occurred: " + str(e)) + return render(request, 'booking/quick_deploy.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): + template_name = "booking/booking_detail.html" + + def get_context_data(self, **kwargs): + booking = get_object_or_404(Booking, id=self.kwargs['booking_id']) + title = 'Booking Details' + context = super(BookingView, self).get_context_data(**kwargs) + context.update({'title': title, 'booking': booking}) + return context + + +class BookingDeleteView(TemplateView): + template_name = "booking/booking_delete.html" + + def get_context_data(self, **kwargs): + booking = get_object_or_404(Booking, id=self.kwargs['booking_id']) + title = 'Delete Booking' + context = super(BookingDeleteView, self).get_context_data(**kwargs) + context.update({'title': title, 'booking': booking}) + return context + + +def bookingDelete(request, booking_id): + booking = get_object_or_404(Booking, id=booking_id) + booking.delete() + messages.add_message(request, messages.SUCCESS, 'Booking deleted') + return redirect('../../../../') + + +class BookingListView(TemplateView): + template_name = "booking/booking_list.html" + + def get_context_data(self, **kwargs): + bookings = Booking.objects.filter(end__gte=timezone.now()) + title = 'Search Booking' + context = super(BookingListView, self).get_context_data(**kwargs) + context.update({'title': title, 'bookings': bookings}) + return context + + +class ResourceBookingsJSON(View): + def get(self, request, *args, **kwargs): + resource = get_object_or_404(ResourceBundle, id=self.kwargs['resource_id']) + bookings = resource.booking_set.get_queryset().values( + 'id', + 'start', + 'end', + 'purpose', + 'jira_issue_status', + 'config_bundle__name' + ) + 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: + user = request.user + else: + return render(request, "dashboard/login.html", {'title': 'Authentication Required'}) + + booking = get_object_or_404(Booking, id=booking_id) + allowed_users = set(list(booking.collaborators.all())) + allowed_users.add(booking.owner) + 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", + 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']) + host.config.image = new_image + host.config.save() + JobFactory.reimageHost(new_image, booking, host) + return HttpResponse(new_image.name) + return HttpResponse("error") + + +def booking_stats_view(request): + return render( + request, + "booking/stats.html", + context={"data": StatisticsManager.getContinuousBookingTimeSeries(), "title": "Booking Statistics"} + ) + + +def booking_stats_json(request): + try: + span = int(request.GET.get("days", 14)) + except Exception: + span = 14 + return JsonResponse(StatisticsManager.getContinuousBookingTimeSeries(span), safe=False) |