diff options
134 files changed, 9054 insertions, 4122 deletions
diff --git a/dashboard/__init__.py b/dashboard/__init__.py deleted file mode 100644 index b6fef6c..0000000 --- a/dashboard/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -############################################################################## -# 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/dashboard/docker-compose.yml b/dashboard/docker-compose.yml index f9cf0bb..eac84e6 100644 --- a/dashboard/docker-compose.yml +++ b/dashboard/docker-compose.yml @@ -47,6 +47,7 @@ services: - pharos-data:/var/lib/postgresql/data rabbitmq: + restart: always image: rabbitmq container_name: rm01 env_file: config.env diff --git a/dashboard/open-api-spec.yaml b/dashboard/open-api-spec.yaml new file mode 100644 index 0000000..2e8dfd6 --- /dev/null +++ b/dashboard/open-api-spec.yaml @@ -0,0 +1,523 @@ +--- +swagger: "2.0" +info: + description: This is the Lab as a Service API + version: 2.0.1 + title: LaaS API + contact: + email: nfvlab@iol.unh.edu + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html +host: virtserver.swaggerhub.com +basePath: /IOL-OPNFV-LaaS/Labs/1.0.0 +tags: +- name: admin + description: Secured Admin-only calls +- name: developers + description: Operations available to regular developers +schemes: +- https +paths: + /api/labs/{lab-name}/jobs/new: + get: + summary: list of new, unstarted jobs for the lab + description: | + List of jobs for <lab-name> to start. These jobs all must have a status of `new`, + meaning they are unstarted. + produces: + - application/json + parameters: + - name: lab-name + in: path + required: true + type: string + responses: + 200: + description: search results matching criteria + schema: + type: array + items: + $ref: '#/definitions/Job' + /api/labs/{lab-name}/jobs/current: + get: + summary: list of unfinished jobs + description: | + List of jobs for <lab-name> that are still in progress. A job is in progress if + it has been started but has not finished. + produces: + - application/json + parameters: + - name: lab-name + in: path + required: true + type: string + responses: + 200: + description: search results matching criteria + schema: + type: array + items: + $ref: '#/definitions/Job' + /api/labs/{lab-name}/jobs/done: + get: + summary: list of done jobs + description: | + List of jobs for <lab-name> that were started and are no longer in progress. + A job can be marked 'done' with a succesful or error status. + produces: + - application/json + parameters: + - name: lab-name + in: path + required: true + type: string + responses: + 200: + description: search results matching criteria + schema: + type: array + items: + $ref: '#/definitions/Job' + /api/labs/{lab-name}/jobs/{job_id}/{task_id}>: + post: + summary: update job information + produces: + - application/json + parameters: + - name: lab-name + in: path + required: true + type: string + - name: job_id + in: path + required: true + type: integer + - name: task_id + in: path + required: true + type: string + - in: body + name: payload + description: payload, schema based on job type + required: true + schema: + $ref: '#/definitions/JobUpdate' + responses: + 200: + description: success + /api/labs/{lab-name}/inventory: + get: + summary: lab inventory + produces: + - application/json + parameters: + - name: lab-name + in: path + required: true + type: string + responses: + 200: + description: lab inventory + schema: + $ref: '#/definitions/Inventory' + post: + summary: updates lab inventory + parameters: + - name: lab-name + in: path + required: true + type: string + - in: body + name: inventory + required: true + schema: + $ref: '#/definitions/Inventory' + responses: + 200: + description: success + /api/labs/{lab-name}/profile: + get: + summary: lab profile + produces: + - application/json + parameters: + - name: lab-name + in: path + required: true + type: string + responses: + 200: + description: lab profile + schema: + $ref: '#/definitions/Profile' + post: + summary: updates lab profile + parameters: + - name: lab-name + in: path + required: true + type: string + - in: body + name: profile + required: true + schema: + $ref: '#/definitions/Profile' + responses: + 200: + description: success +definitions: + Host_Interface: + properties: + mac: + type: string + example: 00:11:22:33:44:55 + description: mac address + busaddr: + type: string + example: 0000:02:00.1 + description: bus address reported by `ethtool -i <ifname>` + switchport: + $ref: '#/definitions/Switchport' + Generic_Interface: + properties: + speed: + type: string + example: 10G + description: speed in M or G + name: + type: string + example: eno3 + description: interface name + Generic_Disk: + properties: + size: + type: string + example: 500G + description: size in M, G, or T + type: + type: string + example: SSD + description: must be SSD or HDD + name: + type: string + example: sda + description: name of root block device + CPU: + properties: + cores: + type: integer + format: int32 + example: 64 + description: how many CPU cores the host has (across all physical cpus) + minimum: 1 + arch: + type: string + example: x86_64 + description: must be x86_64 or aarch64 + cpus: + type: integer + example: 2 + description: Number of different physical CPU chips + minimum: 1 + Image: + properties: + name: + type: string + description: + type: string + lab_id: + type: string + description: identifier provided by lab + dashboard_id: + type: string + description: identifier provided by dashboard + Inventory_Host: + properties: + interfaces: + type: array + items: + $ref: '#/definitions/Host_Interface' + hostname: + type: string + example: hpe3.opnfv.iol.unh.edu + description: globally unique fqdn + host_type: + type: string + description: name of host type this host belongs to + Inventory_Network: + properties: + cidr: + type: string + example: 174.0.5.0/24 + description: subnet description + gateway: + type: string + example: 174.0.5.1 + description: ip of gateway + vlan: + type: integer + example: 100 + description: vlan tag of this network + Inventory: + properties: + hosts: + type: array + description: all hosts + items: + $ref: '#/definitions/Inventory_Host' + networks: + type: array + description: all networks + items: + $ref: '#/definitions/Inventory_Network' + images: + type: array + description: available images + items: + $ref: '#/definitions/Image' + host_types: + type: array + description: all host types hosted by a lab + items: + $ref: '#/definitions/Host_Type' + Host_Type: + properties: + cpu: + $ref: '#/definitions/CPU' + disks: + type: array + items: + $ref: '#/definitions/Generic_Disk' + description: + type: string + description: human readable description of host type + interface: + type: array + items: + $ref: '#/definitions/Generic_Interface' + ram: + $ref: '#/definitions/Ram' + name: + type: string + description: lab-unique name + Ram: + properties: + amount: + type: integer + example: 16 + description: amount of ram in Gibibytes (GiB) + Switchport: + properties: + switch_name: + type: string + example: Cisco-9 + description: name of switch owning this switchport + port_name: + type: string + example: Ethernet1/34 + description: name of port on switch + invariant_config: + type: array + description: list of vlans that may not be modified on this port + items: + $ref: '#/definitions/Vlan' + current_config: + type: array + description: list of current vlan configuration + items: + $ref: '#/definitions/Vlan' + Vlan: + properties: + vlan_id: + type: integer + example: 100 + description: vlan id + minimum: 1 + maximum: 4098 + tagged: + type: boolean + example: true + description: whether this vlan is tagged or untagged + Job: + properties: + id: + type: integer + description: globally unique job identifier + payload: + $ref: '#/definitions/JobPayload' + JobPayload: + properties: + hardware: + $ref: '#/definitions/HardwareTask' + software: + $ref: '#/definitions/SoftwareTask' + network: + $ref: '#/definitions/NetworkTask' + access: + $ref: '#/definitions/AccessTask' + snapshot: + $ref: '#/definitions/SnapshotTask' + HardwareTask: + properties: + taskId: + $ref: '#/definitions/HardwareConfig' + SoftwareTask: + properties: + taskId: + $ref: '#/definitions/SoftwarePayload' + NetworkTask: + properties: + taskId: + $ref: '#/definitions/NetworkPayload' + AccessTask: + properties: + taskId: + $ref: '#/definitions/AccessPayload' + SnapshotTask: + properties: + taskId: + $ref: '#/definitions/SnapshotPayload' + SnapshotPayload: + properties: + host: + type: string + example: hpe3 + description: how the lab identifies the host + image: + type: string + example: "4" + description: lab id of existing image, if updating an existing image. if this key does not exist, the lab must create a new image + dashboard_id: + type: string + description: how the dashboard identifies this image / snapshot + AccessPayload: + properties: + revoke: + type: boolean + description: whether to revoke key during completion of job + user: + type: string + description: PK/ID of user access is being given to + access_type: + type: string + example: ssh + description: type of access key to be generated. Options include "vpn and ssh" + hosts: + type: array + description: hosts to grant access to if applicable + items: + type: string + description: id of host + lab_token: + type: string + description: identifier provided by lab to this task + HardwareConfig: + properties: + id: + type: string + description: ID of host + image: + type: integer + example: 42 + description: lab provided ID of the request image + power: + type: string + example: on + description: desired power state, either on or off + hostname: + type: string + example: my_new_machine + description: user-defined hostname + ipmi_create: + type: boolean + description: whether or not to create an ipmi account + lab_token: + type: string + description: identifier provided by lab to this task + SoftwarePayload: + properties: + opnfv: + $ref: '#/definitions/OpnfvConfiguration' + lab_token: + type: string + description: identifier provided by lab to this task + OpnfvHost: + properties: + hostname: + type: string + example: Jumphost + description: maps hostname to OPNFV role + OpnfvConfiguration: + properties: + installer: + type: string + description: Installer user wants + scenario: + type: string + description: scenario of OPNFV to deploy + pdf: + type: string + example: LaaS.com/api/my_job/pdf + description: URL to find the Pod Descriptor File contents + idf: + type: string + example: LaaS.com/api/my_job/idf + description: URL to find the Installer Descriptor File contents + roles: + type: array + description: role the host will play in OPNFV + items: + $ref: '#/definitions/OpnfvHost' + NetworkPayload: + properties: + hostId: + $ref: '#/definitions/NetworkConfig' + lab_token: + type: string + description: identifier provided by lab to this task + NetworkConfig: + properties: + interface_name: + type: array + description: list of vlans on this interface + items: + $ref: '#/definitions/Vlan' + JobUpdate: + properties: + status: + type: integer + description: status type, see status enum + message: + type: string + description: message from lab for user + lab_token: + type: string + description: identifier provided by lab to this task + Profile: + properties: + name: + type: string + description: proper expanded lab name + contact: + $ref: '#/definitions/Contact' + description: + type: string + host_count: + type: array + items: + $ref: '#/definitions/Host_Number' + Host_Number: + properties: + type: + type: string + count: + type: integer + Contact: + properties: + phone: + type: string + description: phone number at which a lab can be reached + email: + type: string + description: email at which a lab can be reached diff --git a/dashboard/requirements.txt b/dashboard/requirements.txt index 9ea10a4..55e5fc9 100644 --- a/dashboard/requirements.txt +++ b/dashboard/requirements.txt @@ -1,7 +1,7 @@ celery==3.1.23 cryptography==2.3.1 Django==2.1 -django-bootstrap3==10.0.1 +django-bootstrap4==0.0.8 django-crispy-forms==1.7.2 django-filter==2.0.0 django-registration==2.1.2 diff --git a/dashboard/src/__init__.py b/dashboard/src/__init__.py deleted file mode 100644 index b6fef6c..0000000 --- a/dashboard/src/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -############################################################################## -# 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/dashboard/src/account/models.py b/dashboard/src/account/models.py index bfeead0..4fc7c40 100644 --- a/dashboard/src/account/models.py +++ b/dashboard/src/account/models.py @@ -61,7 +61,7 @@ class VlanManager(models.Model): new_vlan = vlans.index(1) # will throw if none available vlans[new_vlan] = 0 allocated.append(new_vlan) - if count is 1: + if count == 1: return allocated[0] return allocated @@ -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,13 +125,13 @@ class VlanManager(models.Model): try: iter(vlans) - except: + except Exception: vlans = [vlans] vlans = set(vlans) for vlan in vlans: - if my_vlans[vlan] is 0: + if my_vlans[vlan] == 0: raise ValueError("vlan " + str(vlan) + " is not available") my_vlans[vlan] = 0 diff --git a/dashboard/src/account/tests/test_general.py b/dashboard/src/account/tests/test_general.py index 57ad291..3fb52b0 100644 --- a/dashboard/src/account/tests/test_general.py +++ b/dashboard/src/account/tests/test_general.py @@ -47,7 +47,7 @@ class AccountMiddlewareTestCase(TestCase): self.user1profile.timezone = 'Etc/Greenwich' self.user1profile.save() self.client.get(url) - self.assertEqual(timezone.get_current_timezone_name(), 'Etc/Greenwich') + self.assertEqual(timezone.get_current_timezone_name(), 'GMT') # if there is no profile for a user, it should be created user2 = User.objects.create(username='user2') diff --git a/dashboard/src/account/urls.py b/dashboard/src/account/urls.py index 85f0f1a..8aad80c 100644 --- a/dashboard/src/account/urls.py +++ b/dashboard/src/account/urls.py @@ -25,6 +25,7 @@ Including another URLconf 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ from django.conf.urls import url +from django.urls import path from account.views import ( AccountSettingsView, @@ -36,7 +37,11 @@ from account.views import ( account_booking_view, account_images_view, account_configuration_view, - account_detail_view + account_detail_view, + resource_delete_view, + booking_cancel_view, + image_delete_view, + configuration_delete_view ) app_name = "account" @@ -46,9 +51,13 @@ urlpatterns = [ url(r'^login/$', JiraLoginView.as_view(), name='login'), url(r'^logout/$', JiraLogoutView.as_view(), name='logout'), url(r'^users/$', UserListView.as_view(), name='users'), - url(r'^my/resources', account_resource_view, name="my-resources"), - url(r'^my/bookings', account_booking_view, name="my-bookings"), - url(r'^my/images', account_images_view, name="my-images"), - url(r'^my/configurations', account_configuration_view, name="my-configurations"), + url(r'^my/resources/$', account_resource_view, name="my-resources"), + path('my/resources/delete/<int:resource_id>', resource_delete_view), + url(r'^my/bookings/$', account_booking_view, name="my-bookings"), + path('my/bookings/cancel/<int:booking_id>', booking_cancel_view), + url(r'^my/images/$', account_images_view, name="my-images"), + path('my/images/delete/<int:image_id>', image_delete_view), + url(r'^my/configurations/$', account_configuration_view, name="my-configurations"), + path('my/configurations/delete/<int:config_id>', configuration_delete_view), url(r'^my/$', account_detail_view, name="my-account"), ] diff --git a/dashboard/src/account/views.py b/dashboard/src/account/views.py index 09c5266..2b4eccb 100644 --- a/dashboard/src/account/views.py +++ b/dashboard/src/account/views.py @@ -14,12 +14,15 @@ import urllib import oauth2 as oauth from django.conf import settings +from django.utils import timezone from django.contrib import messages from django.contrib.auth import logout, authenticate, login from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User from django.urls import reverse +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from django.views.generic import RedirectView, TemplateView, UpdateView from django.shortcuts import render @@ -30,7 +33,7 @@ from account.forms import AccountSettingsForm from account.jira_util import SignatureMethod_RSA_SHA1 from account.models import UserProfile from booking.models import Booking -from resource_inventory.models import GenericResourceBundle, ConfigBundle, Image +from resource_inventory.models import GenericResourceBundle, ConfigBundle, Image, Host @method_decorator(login_required, name='dispatch') @@ -172,8 +175,22 @@ def account_resource_view(request): if not request.user.is_authenticated: return render(request, "dashboard/login.html", {'title': 'Authentication Required'}) template = "account/resource_list.html" - resources = list(GenericResourceBundle.objects.filter(owner=request.user)) - context = {"resources": resources, "title": "My Resources"} + resources = GenericResourceBundle.objects.filter( + owner=request.user).prefetch_related("configbundle_set") + mapping = {} + resource_list = [] + booking_mapping = {} + for grb in resources: + resource_list.append(grb) + mapping[grb.id] = [{"id": x.id, "name": x.name} for x in grb.configbundle_set.all()] + if Booking.objects.filter(resource__template=grb, end__gt=timezone.now()).exists(): + booking_mapping[grb.id] = "true" + context = { + "resources": resource_list, + "grb_mapping": mapping, + "booking_mapping": booking_mapping, + "title": "My Resources" + } return render(request, template, context=context) @@ -181,9 +198,17 @@ 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()) - context = {"title": "My Bookings", "bookings": bookings, "collab_bookings": collab_bookings} + bookings = list(Booking.objects.filter(owner=request.user, end__gt=timezone.now()).order_by("-start")) + my_old_bookings = Booking.objects.filter(owner=request.user, end__lt=timezone.now()).order_by("-start") + collab_old_bookings = request.user.collaborators.filter(end__lt=timezone.now()).order_by("-start") + expired_bookings = list(my_old_bookings.union(collab_old_bookings)) + collab_bookings = list(request.user.collaborators.filter(end__gt=timezone.now()).order_by("-start")) + context = { + "title": "My Bookings", + "bookings": bookings, + "collab_bookings": collab_bookings, + "expired_bookings": expired_bookings + } return render(request, template, context=context) @@ -202,5 +227,66 @@ def account_images_view(request): template = "account/image_list.html" my_images = Image.objects.filter(owner=request.user) public_images = Image.objects.filter(public=True) - context = {"title": "Images", "images": my_images, "public_images": public_images} + used_images = {} + for image in my_images: + if Host.objects.filter(booked=True, config__image=image).exists(): + used_images[image.id] = "true" + context = { + "title": "Images", + "images": my_images, + "public_images": public_images, + "used_images": used_images + } return render(request, template, context=context) + + +def resource_delete_view(request, resource_id=None): + if not request.user.is_authenticated: + return HttpResponse('no') # 403? + grb = get_object_or_404(GenericResourceBundle, pk=resource_id) + if not request.user.id == grb.owner.id: + return HttpResponse('no') # 403? + if Booking.objects.filter(resource__template=grb, end__gt=timezone.now()).exists(): + return HttpResponse('no') # 403? + grb.delete() + return HttpResponse('') + + +def configuration_delete_view(request, config_id=None): + if not request.user.is_authenticated: + return HttpResponse('no') # 403? + config = get_object_or_404(ConfigBundle, pk=config_id) + if not request.user.id == config.owner.id: + return HttpResponse('no') # 403? + if Booking.objects.filter(config_bundle=config, end__gt=timezone.now()).exists(): + return HttpResponse('no') + config.delete() + return HttpResponse('') + + +def booking_cancel_view(request, booking_id=None): + if not request.user.is_authenticated: + return HttpResponse('no') # 403? + booking = get_object_or_404(Booking, pk=booking_id) + if not request.user.id == booking.owner.id: + return HttpResponse('no') # 403? + + if booking.end < timezone.now(): # booking already over + return HttpResponse('') + + booking.end = timezone.now() + booking.save() + return HttpResponse('') + + +def image_delete_view(request, image_id=None): + if not request.user.is_authenticated: + return HttpResponse('no') # 403? + image = get_object_or_404(Image, pk=image_id) + if image.public or image.owner.id != request.user.id: + return HttpResponse('no') # 403? + # check if used in booking + if Host.objects.filter(booked=True, config__image=image).exists(): + return HttpResponse('no') # 403? + image.delete() + return HttpResponse('') diff --git a/dashboard/src/api/admin.py b/dashboard/src/api/admin.py index 3d32c78..8b2fcb3 100644 --- a/dashboard/src/api/admin.py +++ b/dashboard/src/api/admin.py @@ -17,6 +17,7 @@ from api.models import ( HardwareConfig, NetworkConfig, SoftwareConfig, + AccessConfig, AccessRelation, SoftwareRelation, HostHardwareRelation, @@ -33,6 +34,7 @@ admin.site.register(OpnfvApiConfig) admin.site.register(HardwareConfig) admin.site.register(NetworkConfig) admin.site.register(SoftwareConfig) +admin.site.register(AccessConfig) admin.site.register(AccessRelation) admin.site.register(SoftwareRelation) admin.site.register(HostHardwareRelation) diff --git a/dashboard/src/api/migrations/0003_auto_20190102_1956.py b/dashboard/src/api/migrations/0003_auto_20190102_1956.py new file mode 100644 index 0000000..2ea5d70 --- /dev/null +++ b/dashboard/src/api/migrations/0003_auto_20190102_1956.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1 on 2019-01-02 19:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_remove_job_delta'), + ] + + operations = [ + migrations.AlterField( + model_name='accessconfig', + name='delta', + field=models.TextField(default='{}'), + ), + ] diff --git a/dashboard/src/api/migrations/0004_snapshotconfig_snapshotrelation.py b/dashboard/src/api/migrations/0004_snapshotconfig_snapshotrelation.py new file mode 100644 index 0000000..62bc7af --- /dev/null +++ b/dashboard/src/api/migrations/0004_snapshotconfig_snapshotrelation.py @@ -0,0 +1,42 @@ +# Generated by Django 2.1 on 2019-01-17 15:54 + +import api.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('resource_inventory', '0004_auto_20181017_1532'), + ('api', '0003_auto_20190102_1956'), + ] + + operations = [ + migrations.CreateModel( + name='SnapshotConfig', + fields=[ + ('taskconfig_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='api.TaskConfig')), + ('image', models.IntegerField(null=True)), + ('dashboard_id', models.IntegerField()), + ('host', models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='resource_inventory.Host')), + ], + bases=('api.taskconfig',), + ), + migrations.CreateModel( + name='SnapshotRelation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.IntegerField(default=0)), + ('task_id', models.CharField(default=api.models.get_task_uuid, max_length=37)), + ('lab_token', models.CharField(default='null', max_length=50)), + ('message', models.TextField(default='')), + ('config', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='api.SnapshotConfig')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.Job')), + ('snapshot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource_inventory.Image')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/dashboard/src/api/migrations/0005_snapshotconfig_delta.py b/dashboard/src/api/migrations/0005_snapshotconfig_delta.py new file mode 100644 index 0000000..559af90 --- /dev/null +++ b/dashboard/src/api/migrations/0005_snapshotconfig_delta.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1 on 2019-01-17 16:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_snapshotconfig_snapshotrelation'), + ] + + operations = [ + migrations.AddField( + model_name='snapshotconfig', + name='delta', + field=models.TextField(default='{}'), + ), + ] diff --git a/dashboard/src/api/migrations/0006_auto_20190313_1729.py b/dashboard/src/api/migrations/0006_auto_20190313_1729.py new file mode 100644 index 0000000..ec148bd --- /dev/null +++ b/dashboard/src/api/migrations/0006_auto_20190313_1729.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1 on 2019-03-13 17:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0005_snapshotconfig_delta'), + ] + + operations = [ + migrations.AlterField( + model_name='opnfvapiconfig', + name='installer', + field=models.CharField(max_length=200), + ), + migrations.AlterField( + model_name='opnfvapiconfig', + name='scenario', + field=models.CharField(max_length=300), + ), + ] diff --git a/dashboard/src/api/migrations/0007_auto_20190417_1511.py b/dashboard/src/api/migrations/0007_auto_20190417_1511.py new file mode 100644 index 0000000..e7d2c59 --- /dev/null +++ b/dashboard/src/api/migrations/0007_auto_20190417_1511.py @@ -0,0 +1,25 @@ +# Generated by Django 2.1 on 2019-04-17 15:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0006_auto_20190313_1729'), + ] + + operations = [ + migrations.AddField( + model_name='opnfvapiconfig', + name='idf', + field=models.CharField(default='', max_length=100), + preserve_default=False, + ), + migrations.AddField( + model_name='opnfvapiconfig', + name='pdf', + field=models.CharField(default='', max_length=100), + preserve_default=False, + ), + ] diff --git a/dashboard/src/api/migrations/0007_opnfvapiconfig_opnfv_config.py b/dashboard/src/api/migrations/0007_opnfvapiconfig_opnfv_config.py new file mode 100644 index 0000000..46f3631 --- /dev/null +++ b/dashboard/src/api/migrations/0007_opnfvapiconfig_opnfv_config.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1 on 2019-05-01 18:53 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('resource_inventory', '0010_auto_20190430_1405'), + ('api', '0006_auto_20190313_1729'), + ] + + operations = [ + migrations.AddField( + model_name='opnfvapiconfig', + name='opnfv_config', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='resource_inventory.OPNFVConfig'), + ), + ] diff --git a/dashboard/src/api/migrations/0008_auto_20190419_1414.py b/dashboard/src/api/migrations/0008_auto_20190419_1414.py new file mode 100644 index 0000000..03c3865 --- /dev/null +++ b/dashboard/src/api/migrations/0008_auto_20190419_1414.py @@ -0,0 +1,28 @@ +# Generated by Django 2.1 on 2019-04-19 14:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('resource_inventory', '0009_auto_20190315_1757'), + ('api', '0007_auto_20190417_1511'), + ] + + operations = [ + migrations.CreateModel( + name='BridgeConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('interfaces', models.ManyToManyField(to='resource_inventory.Interface')), + ('opnfv_config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource_inventory.OPNFVConfig')), + ], + ), + migrations.AddField( + model_name='opnfvapiconfig', + name='bridge_config', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.BridgeConfig'), + ), + ] diff --git a/dashboard/src/api/migrations/0009_merge_20190508_1317.py b/dashboard/src/api/migrations/0009_merge_20190508_1317.py new file mode 100644 index 0000000..1a34380 --- /dev/null +++ b/dashboard/src/api/migrations/0009_merge_20190508_1317.py @@ -0,0 +1,14 @@ +# Generated by Django 2.1 on 2019-05-08 13:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0008_auto_20190419_1414'), + ('api', '0007_opnfvapiconfig_opnfv_config'), + ] + + operations = [ + ] diff --git a/dashboard/src/api/models.py b/dashboard/src/api/models.py index 78ec920..1f708ae 100644 --- a/dashboard/src/api/models.py +++ b/dashboard/src/api/models.py @@ -11,6 +11,8 @@ from django.contrib.auth.models import User from django.db import models from django.core.exceptions import PermissionDenied +from django.shortcuts import get_object_or_404 +from django.urls import reverse import json import uuid @@ -21,8 +23,13 @@ from resource_inventory.models import ( HostProfile, Host, Image, - Interface + Interface, + HostOPNFVConfig, + RemoteInfo, + OPNFVConfig ) +from resource_inventory.idf_templater import IDFTemplater +from resource_inventory.pdf_templater import PDFTemplater class JobStatus(object): @@ -42,7 +49,7 @@ class LabManagerTracker(object): """ try: lab = Lab.objects.get(name=lab_name) - except: + except Exception: raise PermissionDenied("Lab not found") if lab.api_token == token: return LabManager(lab) @@ -60,6 +67,47 @@ class LabManager(object): def __init__(self, lab): self.lab = lab + def update_host_remote_info(self, data, host_id): + host = get_object_or_404(Host, labid=host_id, lab=self.lab) + info = {} + try: + info['address'] = data['address'] + info['mac_address'] = data['mac_address'] + info['password'] = data['password'] + info['user'] = data['user'] + info['type'] = data['type'] + info['versions'] = json.dumps(data['versions']) + except Exception as e: + return {"error": "invalid arguement: " + str(e)} + remote_info = host.remote_management + if "default" in remote_info.mac_address: + remote_info = RemoteInfo() + remote_info.address = info['address'] + remote_info.mac_address = info['mac_address'] + remote_info.password = info['password'] + remote_info.user = info['user'] + remote_info.type = info['type'] + remote_info.versions = info['versions'] + remote_info.save() + host.remote_management = remote_info + host.save() + booking = Booking.objects.get(resource=host.bundle) + self.update_xdf(booking) + return {"status": "success"} + + def update_xdf(self, booking): + booking.pdf = PDFTemplater.makePDF(booking) + booking.idf = IDFTemplater().makeIDF(booking) + booking.save() + + def get_pdf(self, booking_id): + booking = get_object_or_404(Booking, pk=booking_id, lab=self.lab) + return booking.pdf + + def get_idf(self, booking_id): + booking = get_object_or_404(Booking, pk=booking_id, lab=self.lab) + return booking.idf + def get_profile(self): prof = {} prof['name'] = self.lab.name @@ -88,6 +136,22 @@ class LabManager(object): inventory['host_types'] = self.serialize_host_profiles(profiles) return inventory + def get_host(self, hostname): + host = get_object_or_404(Host, labid=hostname, lab=self.lab) + return { + "booked": host.booked, + "working": host.working, + "type": host.profile.name + } + + def update_host(self, hostname, data): + host = get_object_or_404(Host, labid=hostname, lab=self.lab) + if "working" in data: + working = data['working'] == "true" + host.working = working + host.save() + return self.get_host(hostname) + def get_status(self): return {"status": self.lab.status} @@ -214,6 +278,10 @@ class Job(models.Model): if 'network' not in d: d['network'] = {} d['network'][relation.task_id] = relation.config.to_dict() + for relation in SnapshotRelation.objects.filter(job=self): + if 'snapshot' not in d: + d['snapshot'] = {} + d['snapshot'][relation.task_id] = relation.config.to_dict() j['payload'] = d @@ -221,7 +289,13 @@ class Job(models.Model): def get_tasklist(self, status="all"): tasklist = [] - clist = [HostHardwareRelation, AccessRelation, HostNetworkRelation, SoftwareRelation] + clist = [ + HostHardwareRelation, + AccessRelation, + HostNetworkRelation, + SoftwareRelation, + SnapshotRelation + ] if status == "all": for cls in clist: tasklist += list(cls.objects.filter(job=self)) @@ -261,6 +335,10 @@ class Job(models.Model): if 'network' not in d: d['network'] = {} d['network'][relation.task_id] = relation.config.get_delta() + for relation in SnapshotRelation.objects.filter(job=self).filter(status=status): + if 'snapshot' not in d: + d['snapshot'] = {} + d['snapshot'][relation.task_id] = relation.config.get_delta() j['payload'] = d return j @@ -283,25 +361,71 @@ class TaskConfig(models.Model): self.delta = '{}' +class BridgeConfig(models.Model): + """ + Displays mapping between jumphost interfaces and + bridges + """ + interfaces = models.ManyToManyField(Interface) + opnfv_config = models.ForeignKey(OPNFVConfig, on_delete=models.CASCADE) + + def to_dict(self): + d = {} + hid = self.interfaces.first().host.labid + d[hid] = {} + for interface in self.interfaces.all(): + d[hid][interface.mac_address] = [] + for vlan in interface.config.all(): + network_role = self.opnfv_model.networks().filter(network=vlan.network) + bridge = IDFTemplater.bridge_names[network_role.name] + br_config = { + "vlan_id": vlan.vlan_id, + "tagged": vlan.tagged, + "bridge": bridge + } + d[hid][interface.mac_address].append(br_config) + return d + + def to_json(self): + return json.dumps(self.to_dict()) + + class OpnfvApiConfig(models.Model): - installer = models.CharField(max_length=100) - scenario = models.CharField(max_length=100) + installer = models.CharField(max_length=200) + scenario = models.CharField(max_length=300) roles = models.ManyToManyField(Host) + # pdf and idf are url endpoints, not the actual file + pdf = models.CharField(max_length=100) + idf = models.CharField(max_length=100) + bridge_config = models.OneToOneField(BridgeConfig, on_delete=models.CASCADE, null=True) delta = models.TextField() + opnfv_config = models.ForeignKey(OPNFVConfig, null=True, on_delete=models.SET_NULL) def to_dict(self): d = {} + if not self.opnfv_config: + return d if self.installer: d['installer'] = self.installer if self.scenario: d['scenario'] = self.scenario + if self.pdf: + d['pdf'] = self.pdf + if self.idf: + d['idf'] = self.idf + if self.bridge_config: + d['bridged_interfaces'] = self.bridge_config.to_dict() hosts = self.roles.all() if hosts.exists(): d['roles'] = [] - for host in self.roles.all(): - d['roles'].append({host.labid: host.config.opnfvRole.name}) + for host in hosts: + d['roles'].append({ + host.labid: self.opnfv_config.host_opnfv_config.get( + host_config__pk=host.config.pk + ).role.name + }) return d @@ -320,6 +444,16 @@ class OpnfvApiConfig(models.Model): d['scenario'] = scenario self.delta = json.dumps(d) + def set_xdf(self, booking, update_delta=True): + kwargs = {'lab_name': booking.lab.name, 'booking_id': booking.id} + self.pdf = reverse('get-pdf', kwargs=kwargs) + self.idf = reverse('get-idf', kwargs=kwargs) + if update_delta: + d = json.loads(self.delta) + d['pdf'] = self.pdf + d['idf'] = self.idf + self.delta = json.dumps(d) + def add_role(self, host): self.roles.add(host) d = json.loads(self.delta) @@ -343,14 +477,17 @@ class AccessConfig(TaskConfig): user = models.ForeignKey(User, on_delete=models.CASCADE) revoke = models.BooleanField(default=False) context = models.TextField(default="") - delta = models.TextField() + delta = models.TextField(default="{}") def to_dict(self): d = {} d['access_type'] = self.access_type d['user'] = self.user.id d['revoke'] = self.revoke - d['context'] = json.loads(self.context) + try: + d['context'] = json.loads(self.context) + except Exception: + pass return d def get_delta(self): @@ -531,8 +668,64 @@ class NetworkConfig(TaskConfig): self.delta = json.dumps(d) +class SnapshotConfig(TaskConfig): + + host = models.ForeignKey(Host, null=True, on_delete=models.DO_NOTHING) + image = models.IntegerField(null=True) + dashboard_id = models.IntegerField() + delta = models.TextField(default="{}") + + def to_dict(self): + d = {} + if self.host: + d['host'] = self.host.labid + if self.image: + d['image'] = self.image + d['dashboard_id'] = self.dashboard_id + return d + + def to_json(self): + return json.dumps(self.to_dict()) + + def get_delta(self): + if not self.delta: + self.delta = self.to_json() + self.save() + + d = json.loads(self.delta) + return d + + def clear_delta(self): + self.delta = json.dumps(self.to_dict()) + self.save() + + def set_host(self, host): + self.host = host + d = json.loads(self.delta) + d['host'] = host.labid + self.delta = json.dumps(d) + + def set_image(self, image): + self.image = image + d = json.loads(self.delta) + d['image'] = self.image + self.delta = json.dumps(d) + + def clear_image(self): + self.image = None + d = json.loads(self.delta) + d.pop("image", None) + self.delta = json.dumps(d) + + def set_dashboard_id(self, dash): + self.dashboard_id = dash + d = json.loads(self.delta) + d['dashboard_id'] = self.dashboard_id + self.delta = json.dumps(d) + + def get_task(task_id): - for taskclass in [AccessRelation, SoftwareRelation, HostHardwareRelation, HostNetworkRelation]: + for taskclass in [AccessRelation, SoftwareRelation, HostHardwareRelation, HostNetworkRelation, SnapshotRelation]: try: ret = taskclass.objects.get(task_id=task_id) return ret @@ -614,15 +807,72 @@ class HostNetworkRelation(TaskRelation): return super(self.__class__, self).delete(*args, **kwargs) +class SnapshotRelation(TaskRelation): + snapshot = models.ForeignKey(Image, on_delete=models.CASCADE) + config = models.OneToOneField(SnapshotConfig, on_delete=models.CASCADE) + + def type_str(self): + return "Snapshot Task" + + def get_delta(self): + return self.config.to_dict() + + def delete(self, *args, **kwargs): + self.config.delete() + return super(self.__class__, self).delete(*args, **kwargs) + + class JobFactory(object): @classmethod + def reimageHost(cls, new_image, booking, host): + """ + This method will make all necessary changes to make a lab + reimage a host. + """ + job = Job.objects.get(booking=booking) + # make hardware task new + hardware_relation = HostHardwareRelation.objects.get(host=host, job=job) + hardware_relation.config.set_image(new_image.lab_id) + hardware_relation.config.save() + hardware_relation.status = JobStatus.NEW + + # re-apply networking after host is reset + net_relation = HostNetworkRelation.objects.get(host=host, job=job) + net_relation.status = JobStatus.NEW + + # re-apply ssh access after host is reset + for relation in AccessRelation.objects.filter(job=job, config__access_type="ssh"): + relation.status = JobStatus.NEW + relation.save() + + hardware_relation.save() + net_relation.save() + + @classmethod + def makeSnapshotTask(cls, image, booking, host): + relation = SnapshotRelation() + job = Job.objects.get(booking=booking) + config = SnapshotConfig.objects.create(dashboard_id=image.id) + + relation.job = job + relation.config = config + relation.config.save() + relation.config = relation.config + relation.snapshot = image + relation.save() + + config.clear_delta() + config.set_host(host) + config.save() + + @classmethod def makeCompleteJob(cls, booking): hosts = Host.objects.filter(bundle=booking.resource) job = None try: job = Job.objects.get(booking=booking) - except: + except Exception: job = Job.objects.create(status=JobStatus.NEW, booking=booking) cls.makeHardwareConfigs( hosts=hosts, @@ -633,7 +883,7 @@ class JobFactory(object): job=job ) cls.makeSoftware( - hosts=hosts, + booking=booking, job=job ) all_users = list(booking.collaborators.all()) @@ -652,7 +902,7 @@ class JobFactory(object): revoke=False, job=job, context={ - "key": user.userprofile.ssh_public_key.read(), + "key": user.userprofile.ssh_public_key.open().read().decode(encoding="UTF-8"), "hosts": [host.labid for host in hosts] } ) @@ -665,7 +915,7 @@ class JobFactory(object): hardware_config = None try: hardware_config = HardwareConfig.objects.get(relation__host=host) - except: + except Exception: hardware_config = HardwareConfig() relation = HostHardwareRelation() @@ -691,12 +941,12 @@ class JobFactory(object): config = AccessConfig() config.access_type = access_type config.user = user - if context: - config.set_context(context) config.save() relation.config = config relation.save() config.clear_delta() + if context: + config.set_context(context) config.set_access_type(access_type) config.set_revoke(revoke) config.set_user(user) @@ -708,7 +958,7 @@ class JobFactory(object): network_config = None try: network_config = NetworkConfig.objects.get(relation__host=host) - except: + except Exception: network_config = NetworkConfig.objects.create() relation = HostNetworkRelation() @@ -724,28 +974,42 @@ class JobFactory(object): network_config.save() @classmethod - def makeSoftware(cls, hosts=[], job=Job()): - def init_config(host): - opnfv_config = OpnfvApiConfig() - if host is not None: - opnfv = host.config.bundle.opnfv_config.first() - opnfv_config.installer = opnfv.installer.name - opnfv_config.scenario = opnfv.scenario.name - opnfv_config.save() - return opnfv_config - + def make_bridge_config(cls, booking): + if booking.resource.hosts.count() < 2: + return None try: - host = None - if len(hosts) > 0: - host = hosts[0] - opnfv_config = init_config(host) + jumphost_config = HostOPNFVConfig.objects.filter( + role__name__iexact="jumphost" + ) + jumphost = Host.objects.get( + bundle=booking.resource, + config=jumphost_config.host_config + ) + except Exception: + return None + br_config = BridgeConfig.objects.create(opnfv_config=booking.opnfv_config) + for iface in jumphost.interfaces.all(): + br_config.interfaces.add(iface) + return br_config - for host in hosts: - opnfv_config.roles.add(host) - software_config = SoftwareConfig.objects.create(opnfv=opnfv_config) - software_config.save() - software_relation = SoftwareRelation.objects.create(job=job, config=software_config) - software_relation.save() - return software_relation - except: + @classmethod + def makeSoftware(cls, booking=None, job=Job()): + + if not booking.opnfv_config: return None + + opnfv_api_config = OpnfvApiConfig.objects.create( + opnfv_config=booking.opnfv_config, + installer=booking.opnfv_config.installer.name, + scenario=booking.opnfv_config.scenario.name, + bridge_config=cls.make_bridge_config(booking) + ) + + opnfv_api_config.set_xdf(booking, False) + opnfv_api_config.save() + + for host in booking.resource.hosts.all(): + opnfv_api_config.roles.add(host) + software_config = SoftwareConfig.objects.create(opnfv=opnfv_api_config) + software_relation = SoftwareRelation.objects.create(job=job, config=software_config) + return software_relation diff --git a/dashboard/src/api/tests/test_models_unittest.py b/dashboard/src/api/tests/test_models_unittest.py new file mode 100644 index 0000000..e6f97a6 --- /dev/null +++ b/dashboard/src/api/tests/test_models_unittest.py @@ -0,0 +1,269 @@ +############################################################################## +# Copyright (c) 2019 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 api.models import ( + Job, + JobStatus, + JobFactory, + HostNetworkRelation, + HostHardwareRelation, + SoftwareRelation, + AccessConfig, + SnapshotRelation +) + +from resource_inventory.models import ( + OPNFVRole, + HostProfile, +) + +from django.test import TestCase, Client + +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_booking, +) + + +class ValidBookingCreatesValidJob(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = make_user(False, username="newtestuser", password="testpassword") + cls.userprofile = make_user_profile(cls.user) + cls.lab = make_lab() + + 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) + for i in range(30): + make_host(cls.host_profile, cls.lab, name="host" + str(i), labid="host" + str(i)) + cls.client = Client() + + def setUp(self): + self.booking, self.compute_hostnames, self.jump_hostname = self.create_multinode_generic_booking() + + def create_multinode_generic_booking(self): + topology = {} + + compute_hostnames = ["cmp01", "cmp02", "cmp03"] + + host_type = HostProfile.objects.first() + + universal_networks = [ + {"name": "public", "tagged": False, "public": True}, + {"name": "admin", "tagged": True, "public": False}] + compute_networks = [{"name": "private", "tagged": True, "public": False}] + jumphost_networks = [{"name": "external", "tagged": True, "public": True}] + + # generate a bunch of extra networks + for i in range(10): + net = {"tagged": False, "public": False} + net["name"] = "net" + str(i) + universal_networks.append(net) + + jumphost_info = { + "type": host_type, + "role": OPNFVRole.objects.get_or_create(name="Jumphost")[0], + "nets": self.make_networks(host_type, jumphost_networks + universal_networks), + "image": self.image + } + topology["jump"] = jumphost_info + + for hostname in compute_hostnames: + host_info = { + "type": host_type, + "role": OPNFVRole.objects.get_or_create(name="Compute")[0], + "nets": self.make_networks(host_type, compute_networks + universal_networks), + "image": self.image + } + topology[hostname] = host_info + + booking = make_booking( + owner=self.user, + lab=self.lab, + topology=topology, + installer=self.installer, + scenario=self.scenario + ) + + if not booking.resource: + raise Exception("Booking does not have a resource when trying to pass to makeCompleteJob") + return booking, compute_hostnames, "jump" + + def make_networks(self, hostprofile, nets): + """ + distributes nets accross hostprofile's interfaces + returns a 2D array + """ + network_struct = [] + count = hostprofile.interfaceprofile.all().count() + for i in range(count): + network_struct.append([]) + while(nets): + index = len(nets) % count + network_struct[index].append(nets.pop()) + + return network_struct + + ################################################################# + # Complete Job Tests + ################################################################# + + def test_complete_job_makes_access_configs(self): + JobFactory.makeCompleteJob(self.booking) + job = Job.objects.get(booking=self.booking) + self.assertIsNotNone(job) + + access_configs = AccessConfig.objects.filter(accessrelation__job=job) + + vpn_configs = access_configs.filter(access_type="vpn") + ssh_configs = access_configs.filter(access_type="ssh") + + self.assertFalse(AccessConfig.objects.exclude(access_type__in=["vpn", "ssh"]).exists()) + + all_users = list(self.booking.collaborators.all()) + all_users.append(self.booking.owner) + + for user in all_users: + self.assertTrue(vpn_configs.filter(user=user).exists()) + self.assertTrue(ssh_configs.filter(user=user).exists()) + + def test_complete_job_makes_network_configs(self): + JobFactory.makeCompleteJob(self.booking) + job = Job.objects.get(booking=self.booking) + self.assertIsNotNone(job) + + booking_hosts = self.booking.resource.hosts.all() + + netrelations = HostNetworkRelation.objects.filter(job=job) + netconfigs = [r.config for r in netrelations] + + netrelation_hosts = [r.host for r in netrelations] + + for config in netconfigs: + for interface in config.interfaces.all(): + self.assertTrue(interface.host in booking_hosts) + + # if no interfaces are referenced that shouldn't have vlans, + # and no vlans exist outside those accounted for in netconfigs, + # then the api is faithfully representing networks + # as netconfigs reference resource_inventory models directly + + # this test relies on the assumption that + # every interface is configured, whether it does or does not have vlans + # if this is not true, the test fails + + for host in booking_hosts: + self.assertTrue(host in netrelation_hosts) + relation = HostNetworkRelation.objects.filter(job=job).get(host=host) + + # do 2 direction matching that interfaces are one to one + config = relation.config + for interface in config.interfaces.all(): + self.assertTrue(interface in host.interfaces) + for interface in host.interfaces.all(): + self.assertTrue(interface in config.interfaces) + + for host in netrelation_hosts: + self.assertTrue(host in booking_hosts) + + def test_complete_job_makes_hardware_configs(self): + JobFactory.makeCompleteJob(self.booking) + job = Job.objects.get(booking=self.booking) + self.assertIsNotNone(job) + + hardware_relations = HostHardwareRelation.objects.filter(job=job) + + job_hosts = [r.host for r in hardware_relations] + + booking_hosts = self.booking.resource.hosts.all() + + self.assertEqual(len(booking_hosts), len(job_hosts)) + + for relation in hardware_relations: + self.assertTrue(relation.host in booking_hosts) + self.assertEqual(relation.status, JobStatus.NEW) + config = relation.config + host = relation.host + self.assertEqual(config.hostname, host.template.resource.name) + + def test_complete_job_makes_software_configs(self): + JobFactory.makeCompleteJob(self.booking) + job = Job.objects.get(booking=self.booking) + self.assertIsNotNone(job) + + srelation = SoftwareRelation.objects.filter(job=job).first() + self.assertIsNotNone(srelation) + + sconfig = srelation.config + self.assertIsNotNone(sconfig) + + oconfig = sconfig.opnfv + self.assertIsNotNone(oconfig) + + # not onetoone in models, but first() is safe here based on how ConfigBundle and a matching OPNFVConfig are created + # this should, however, be made explicit + self.assertEqual(oconfig.installer, self.booking.config_bundle.opnfv_config.first().installer.name) + self.assertEqual(oconfig.scenario, self.booking.config_bundle.opnfv_config.first().scenario.name) + + for host in oconfig.roles.all(): + role_name = host.config.host_opnfv_config.first().role.name + if str(role_name).lower() == "jumphost": + self.assertEqual(host.template.resource.name, self.jump_hostname) + elif str(role_name).lower() == "compute": + self.assertTrue(host.template.resource.name in self.compute_hostnames) + else: + self.fail(msg="Host with non-configured role name related to job: " + str(role_name)) + + def test_make_snapshot_task(self): + host = self.booking.resource.hosts.first() + image = make_image(self.lab, -1, None, None, host.profile) + + Job.objects.create(booking=self.booking) + + JobFactory.makeSnapshotTask(image, self.booking, host) + + snap_relation = SnapshotRelation.objects.get(job=self.booking.job) + config = snap_relation.config + self.assertEqual(host.id, config.host.id) + self.assertEqual(config.dashboard_id, image.id) + self.assertEqual(snap_relation.snapshot.id, image.id) + + def test_make_hardware_configs(self): + hosts = self.booking.resource.hosts.all() + job = Job.objects.create(booking=self.booking) + JobFactory.makeHardwareConfigs(hosts=hosts, job=job) + + hardware_relations = HostHardwareRelation.objects.filter(job=job) + + self.assertEqual(hardware_relations.count(), hosts.count()) + + host_set = set([h.id for h in hosts]) + + for relation in hardware_relations: + try: + host_set.remove(relation.host.id) + except KeyError: + self.fail("Hardware Relation/Config not created for host " + str(relation.host)) + + self.assertEqual(relation.config.power, "on") + self.assertTrue(relation.config.ipmi_create) + # TODO: the rest of hwconf attrs + + self.assertEqual(len(host_set), 0) diff --git a/dashboard/src/api/tests/test_serializers.py b/dashboard/src/api/tests/test_serializers.py deleted file mode 100644 index c1fa5af..0000000 --- a/dashboard/src/api/tests/test_serializers.py +++ /dev/null @@ -1,229 +0,0 @@ -############################################################################## -# Copyright (c) 2018 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.test import TestCase -from booking.models import Booking -from account.models import Lab -from api.serializers.booking_serializer import BookingField -from datetime import timedelta -from django.utils import timezone -from django.contrib.auth.models import Permission, User -from resource_inventory.models import ( - Image, - OPNFVRole, - HostConfiguration, - HostProfile, - InterfaceProfile, - DiskProfile, - CpuProfile, - RamProfile, - GenericResourceBundle, - GenericResource, - GenericHost, - Host, - Vlan, - Interface, - ConfigBundle, - ResourceBundle -) - - -class BookingSerializerTestCase(TestCase): - - count = 0 - - def makeHostConfigurations(self, hosts, config): - lab_user = User.objects.create(username="asfasdfasdf") - owner = User.objects.create(username="asfasdfasdffffff") - lab = Lab.objects.create( - lab_user=lab_user, - name="TestLab123123", - contact_email="mail@email.com", - contact_phone="" - ) - jumphost = True - for host in hosts: - image = Image.objects.create( - lab_id=12, - from_lab=lab, - name="this is a test image", - owner=owner - ) - name = "jumphost" - if not jumphost: - name = "compute" - role = OPNFVRole.objects.create( - name=name, - description="stuff" - ) - - HostConfiguration.objects.create( - host=host, - image=image, - bundle=config, - opnfvRole=role - ) - jumphost = False - - def setUp(self): - self.serializer = BookingField() - lab_user = User.objects.create(username="lab user") - lab = Lab.objects.create(name="test lab", lab_user=lab_user) - # create hostProfile - hostProfile = HostProfile.objects.create( - host_type=0, - name='Test profile', - description='a test profile' - ) - InterfaceProfile.objects.create( - speed=1000, - name='eno3', - host=hostProfile - ) - DiskProfile.objects.create( - size=1000, - media_type="SSD", - name='/dev/sda', - host=hostProfile - ) - CpuProfile.objects.create( - cores=96, - architecture="x86_64", - cpus=2, - host=hostProfile - ) - RamProfile.objects.create( - amount=256, - channels=4, - host=hostProfile - ) - - # create GenericResourceBundle - genericBundle = GenericResourceBundle.objects.create() - - gres1 = GenericResource.objects.create( - bundle=genericBundle, - name='generic resource ' + str(self.count) - ) - self.count += 1 - gHost1 = GenericHost.objects.create( - resource=gres1, - profile=hostProfile - ) - - gres2 = GenericResource.objects.create( - bundle=genericBundle, - name='generic resource ' + str(self.count) - ) - self.count += 1 - gHost2 = GenericHost.objects.create( - resource=gres2, - profile=hostProfile - ) - user1 = User.objects.create(username='user1') - - add_booking_perm = Permission.objects.get(codename='add_booking') - user1.user_permissions.add(add_booking_perm) - - user1 = User.objects.get(pk=user1.id) - - conf = ConfigBundle.objects.create(owner=user1, name="test conf") - self.makeHostConfigurations([gHost1, gHost2], conf) - - # actual resource bundle - bundle = ResourceBundle.objects.create( - template=genericBundle - ) - - host1 = Host.objects.create( - template=gHost1, - booked=True, - name='host1', - bundle=bundle, - profile=hostProfile, - lab=lab - ) - - host2 = Host.objects.create( - template=gHost2, - booked=True, - name='host2', - bundle=bundle, - profile=hostProfile, - lab=lab - ) - - vlan1 = Vlan.objects.create(vlan_id=300, tagged=False) - vlan2 = Vlan.objects.create(vlan_id=300, tagged=False) - - iface1 = Interface.objects.create( - mac_address='00:11:22:33:44:55', - bus_address='some bus address', - switch_name='switch1', - port_name='port10', - host=host1 - ) - - iface1.config = [vlan1] - - iface2 = Interface.objects.create( - mac_address='00:11:22:33:44:56', - bus_address='some bus address', - switch_name='switch1', - port_name='port12', - host=host2 - ) - - iface2.config = [vlan2] - - # finally, can create booking - self.booking = Booking.objects.create( - owner=user1, - start=timezone.now(), - end=timezone.now() + timedelta(weeks=1), - purpose='Testing', - resource=bundle, - config_bundle=conf - ) - - serialized_booking = {} - - host1 = {} - host1['hostname'] = 'host1' - host1['image'] = {} # TODO: Images - host1['deploy_image'] = True - host2 = {} - host2['hostname'] = 'host2' - host2['image'] = {} # TODO: Images - host2['deploy_image'] = True - - serialized_booking['hosts'] = [host1, host2] - - net = {} - net['name'] = 'network_name' - net['vlan_id'] = 300 - netHost1 = {} - netHost1['hostname'] = 'host1' - netHost1['tagged'] = False - netHost1['interface'] = 0 - netHost2 = {} - netHost2['hostname'] = 'host2' - netHost2['tagged'] = False - netHost2['interface'] = 0 - net['hosts'] = [netHost1, netHost2] - - serialized_booking['networking'] = [net] - serialized_booking['jumphost'] = 'host1' - - self.serialized_booking = serialized_booking - - def test_to_representation(self): - keys = ['hosts', 'networking', 'jumphost'] - serialized_form = self.serializer.to_representation(self.booking) - for key in keys: - self.assertEquals(serialized_form[key], self.serialized_booking) diff --git a/dashboard/src/api/urls.py b/dashboard/src/api/urls.py index 50cc6ac..7a48425 100644 --- a/dashboard/src/api/urls.py +++ b/dashboard/src/api/urls.py @@ -39,6 +39,10 @@ from api.views import ( new_jobs, current_jobs, done_jobs, + update_host_bmc, + lab_host, + get_pdf, + get_idf, GenerateTokenView ) @@ -51,6 +55,10 @@ urlpatterns = [ path('labs/<slug:lab_name>/profile', lab_profile), path('labs/<slug:lab_name>/status', lab_status), path('labs/<slug:lab_name>/inventory', lab_inventory), + path('labs/<slug:lab_name>/hosts/<slug:host_id>', lab_host), + path('labs/<slug:lab_name>/hosts/<slug:host_id>/bmc', update_host_bmc), + path('labs/<slug:lab_name>/booking/<int:booking_id>/pdf', get_pdf, name="get-pdf"), + path('labs/<slug:lab_name>/booking/<int:booking_id>/idf', get_idf, name="get-idf"), path('labs/<slug:lab_name>/jobs/<int:job_id>', specific_job), path('labs/<slug:lab_name>/jobs/<int:job_id>/<slug:task_id>', specific_task), path('labs/<slug:lab_name>/jobs/new', new_jobs), diff --git a/dashboard/src/api/views.py b/dashboard/src/api/views.py index c72c85c..fb28958 100644 --- a/dashboard/src/api/views.py +++ b/dashboard/src/api/views.py @@ -13,7 +13,7 @@ from django.contrib.auth.decorators import login_required from django.shortcuts import redirect from django.utils.decorators import method_decorator from django.views import View -from django.http.response import JsonResponse +from django.http.response import JsonResponse, HttpResponse from rest_framework import viewsets from rest_framework.authtoken.models import Token from django.views.decorators.csrf import csrf_exempt @@ -54,6 +54,28 @@ def lab_inventory(request, lab_name=""): return JsonResponse(lab_manager.get_inventory(), safe=False) +@csrf_exempt +def lab_host(request, lab_name="", host_id=""): + lab_token = request.META.get('HTTP_AUTH_TOKEN') + lab_manager = LabManagerTracker.get(lab_name, lab_token) + if request.method == "GET": + return JsonResponse(lab_manager.get_host(host_id), safe=False) + if request.method == "POST": + return JsonResponse(lab_manager.update_host(host_id, request.POST), safe=False) + + +def get_pdf(request, lab_name="", booking_id=""): + lab_token = request.META.get('HTTP_AUTH_TOKEN') + lab_manager = LabManagerTracker.get(lab_name, lab_token) + return HttpResponse(lab_manager.get_pdf(booking_id), content_type="text/plain") + + +def get_idf(request, lab_name="", booking_id=""): + lab_token = request.META.get('HTTP_AUTH_TOKEN') + lab_manager = LabManagerTracker.get(lab_name, lab_token) + return HttpResponse(lab_manager.get_idf(booking_id), content_type="text/plain") + + def lab_status(request, lab_name=""): lab_token = request.META.get('HTTP_AUTH_TOKEN') lab_manager = LabManagerTracker.get(lab_name, lab_token) @@ -62,6 +84,18 @@ def lab_status(request, lab_name=""): return JsonResponse(lab_manager.get_status(), safe=False) +@csrf_exempt +def update_host_bmc(request, lab_name="", host_id=""): + lab_token = request.META.get('HTTP_AUTH_TOKEN') + lab_manager = LabManagerTracker.get(lab_name, lab_token) + if request.method == "POST": + # update / create RemoteInfo for host + return JsonResponse( + lab_manager.update_host_remote_info(request.POST, host_id), + safe=False + ) + + def lab_profile(request, lab_name=""): lab_token = request.META.get('HTTP_AUTH_TOKEN') lab_manager = LabManagerTracker.get(lab_name, lab_token) @@ -79,6 +113,8 @@ def specific_task(request, lab_name="", job_id="", task_id=""): task.status = request.POST.get('status') if 'message' in request.POST: task.message = request.POST.get('message') + if 'lab_token' in request.POST: + task.lab_token = request.POST.get('lab_token') task.save() NotificationHandler.task_updated(task) d = {} @@ -93,6 +129,7 @@ def specific_task(request, lab_name="", job_id="", task_id=""): return JsonResponse(get_task(task_id).config.get_delta()) +@csrf_exempt def specific_job(request, lab_name="", job_id=""): lab_token = request.META.get('HTTP_AUTH_TOKEN') lab_manager = LabManagerTracker.get(lab_name, lab_token) diff --git a/dashboard/src/booking/forms.py b/dashboard/src/booking/forms.py new file mode 100644 index 0000000..df88cc6 --- /dev/null +++ b/dashboard/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/dashboard/src/booking/lib.py b/dashboard/src/booking/lib.py new file mode 100644 index 0000000..8132c75 --- /dev/null +++ b/dashboard/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/dashboard/src/booking/migrations/0002_booking_pdf.py b/dashboard/src/booking/migrations/0002_booking_pdf.py new file mode 100644 index 0000000..53232c9 --- /dev/null +++ b/dashboard/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/dashboard/src/booking/migrations/0003_auto_20190115_1733.py b/dashboard/src/booking/migrations/0003_auto_20190115_1733.py new file mode 100644 index 0000000..70eecfe --- /dev/null +++ b/dashboard/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/dashboard/src/booking/migrations/0004_auto_20190124_1700.py b/dashboard/src/booking/migrations/0004_auto_20190124_1700.py new file mode 100644 index 0000000..baa32d2 --- /dev/null +++ b/dashboard/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/dashboard/src/booking/migrations/0005_booking_idf.py b/dashboard/src/booking/migrations/0005_booking_idf.py new file mode 100644 index 0000000..31e9170 --- /dev/null +++ b/dashboard/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/dashboard/src/booking/migrations/0006_booking_opnfv_config.py b/dashboard/src/booking/migrations/0006_booking_opnfv_config.py new file mode 100644 index 0000000..e5ffc71 --- /dev/null +++ b/dashboard/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/dashboard/src/booking/models.py b/dashboard/src/booking/models.py index d0c77b4..9836730 100644 --- a/dashboard/src/booking/models.py +++ b/dashboard/src/booking/models.py @@ -9,42 +9,16 @@ ############################################################################## -from resource_inventory.models import ResourceBundle, ConfigBundle +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 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') + owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name='owner') collaborators = models.ManyToManyField(User, related_name='collaborators') start = models.DateTimeField() end = models.DateTimeField() @@ -55,8 +29,11 @@ class Booking(models.Model): 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' diff --git a/dashboard/src/booking/quick_deployer.py b/dashboard/src/booking/quick_deployer.py new file mode 100644 index 0000000..0e0cc5a --- /dev/null +++ b/dashboard/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/dashboard/src/booking/stats.py b/dashboard/src/booking/stats.py index b706577..383723a 100644 --- a/dashboard/src/booking/stats.py +++ b/dashboard/src/booking/stats.py @@ -25,34 +25,34 @@ class StatisticsManager(object): some point in the given date span is the number of days to plot. The last x value will always be the current time """ - x_set = set() + 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) - for booking in bookings: - x_set.add(booking.start) - if booking.end < now: - x_set.add(booking.end) + 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)) - x_set.add(now) - x_set.add(end) + # sort based on time + data.sort(key=lambda i: i[0]) - x_list = list(x_set) - x_list.sort(reverse=True) - for time in x_list: - x.append(str(time)) - active = Booking.objects.filter(start__lte=time, end__gt=time) - booking_count = len(active) - users_set = set() - for booking in active: - users_set.add(booking.owner) - for user in booking.collaborators.all(): - users_set.add(user) - y.append(booking_count) - users.append(len(users_set)) + # 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/dashboard/src/booking/tests/test_models.py b/dashboard/src/booking/tests/test_models.py index c7fb25d..6170295 100644 --- a/dashboard/src/booking/tests/test_models.py +++ b/dashboard/src/booking/tests/test_models.py @@ -230,10 +230,3 @@ class BookingModelTestCase(TestCase): booking.save() except Exception: self.fail("save() threw an exception") - booking.end = booking.end + timedelta(weeks=2) - self.assertRaises(ValueError, booking.save) - booking.end = booking.end - timedelta(days=8) - try: - self.assertTrue(booking.save()) - except Exception: - self.fail("save() threw an exception") diff --git a/dashboard/src/booking/tests/test_quick_booking.py b/dashboard/src/booking/tests/test_quick_booking.py new file mode 100644 index 0000000..e445860 --- /dev/null +++ b/dashboard/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/dashboard/src/booking/urls.py b/dashboard/src/booking/urls.py index 4d00b7f..310aaa7 100644 --- a/dashboard/src/booking/urls.py +++ b/dashboard/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/dashboard/src/booking/views.py b/dashboard/src/booking/views.py index ab43519..bad7dc9 100644 --- a/dashboard/src/booking/views.py +++ b/dashboard/src/booking/views.py @@ -10,33 +10,67 @@ 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 django.urls import reverse -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 JobFactory +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(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): @@ -93,6 +127,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 +153,36 @@ 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': ResourceManager().makePDF(booking.resource), - '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']) + 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): @@ -128,6 +196,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/dashboard/src/dashboard/actions.py b/dashboard/src/dashboard/actions.py new file mode 100644 index 0000000..44b1fdd --- /dev/null +++ b/dashboard/src/dashboard/actions.py @@ -0,0 +1,47 @@ +############################################################################## +# 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 resource_inventory.models import Host, Vlan +from account.models import Lab +from booking.models import Booking +from datetime import timedelta +from django.utils import timezone + + +def free_leaked_hosts(free_old_bookings=False, old_booking_age=timedelta(days=1)): + bundles = [booking.resource for booking in Booking.objects.filter(end__gt=timezone.now())] + active_hosts = set() + for bundle in bundles: + active_hosts.update([host for host in bundle.hosts.all()]) + + marked_hosts = set(Host.objects.filter(booked=True)) + + for host in (marked_hosts - active_hosts): + host.booked = False + host.save() + + +def free_leaked_public_vlans(): + booked_host_interfaces = [] + + for lab in Lab.objects.all(): + + for host in Host.objects.filter(booked=True).filter(lab=lab): + for interface in host.interfaces.all(): + booked_host_interfaces.append(interface) + + in_use_vlans = Vlan.objects.filter(public=True).distinct('vlan_id').filter(interface__in=booked_host_interfaces) + + manager = lab.vlan_manager + + for vlan in Vlan.objects.all(): + if vlan not in in_use_vlans: + if vlan.public: + manager.release_public_vlan(vlan.vlan_id) + manager.release_vlans(vlan) diff --git a/dashboard/src/dashboard/exceptions.py b/dashboard/src/dashboard/exceptions.py index 9c16a06..7111bf8 100644 --- a/dashboard/src/dashboard/exceptions.py +++ b/dashboard/src/dashboard/exceptions.py @@ -50,3 +50,7 @@ class InvalidVlanConfigurationException(Exception): class NetworkExistsException(Exception): pass + + +class BookingLengthException(Exception): + pass diff --git a/dashboard/src/dashboard/populate_db_iol.py b/dashboard/src/dashboard/populate_db_iol.py index 4368520..916dd97 100644 --- a/dashboard/src/dashboard/populate_db_iol.py +++ b/dashboard/src/dashboard/populate_db_iol.py @@ -307,7 +307,7 @@ class Populator: size = 0 try: size = int(disk_data['size'].split('.')[0]) - except: + except Exception: size = int(disk_data['size'].split('.')[0][:-1]) DiskProfile.objects.create( size=size, diff --git a/dashboard/src/dashboard/tasks.py b/dashboard/src/dashboard/tasks.py index b1d97b7..597629f 100644 --- a/dashboard/src/dashboard/tasks.py +++ b/dashboard/src/dashboard/tasks.py @@ -11,10 +11,9 @@ from celery import shared_task from django.utils import timezone -from django.db.models import Q from booking.models import Booking from notifier.manager import NotificationHandler -from api.models import JobStatus, SoftwareRelation, HostHardwareRelation, HostNetworkRelation, AccessRelation +from api.models import Job, JobStatus, SoftwareRelation, HostHardwareRelation, HostNetworkRelation, AccessRelation from resource_inventory.resource_manager import ResourceManager @@ -41,7 +40,7 @@ def booking_poll(): if vlan.public: try: host.lab.vlan_manager.release_public_vlan(vlan.vlan_id) - except: # will fail if we already released in this loop + except Exception: # will fail if we already released in this loop pass else: vlans.append(vlan.vlan_id) @@ -92,12 +91,15 @@ def free_hosts(): """ gets all hosts from the database that need to be freed and frees them """ - networks = ~Q(~Q(job__hostnetworkrelation__status=200)) - hardware = ~Q(~Q(job__hosthardwarerelation__status=200)) + undone_statuses = [JobStatus.NEW, JobStatus.CURRENT, JobStatus.ERROR] + undone_jobs = Job.objects.filter( + hostnetworkrelation__status__in=undone_statuses, + hosthardwarerelation__status__in=undone_statuses + ) - bookings = Booking.objects.filter( - networks, - hardware, + bookings = Booking.objects.exclude( + job__in=undone_jobs + ).filter( end__lt=timezone.now(), job__complete=True, resource__isnull=False diff --git a/dashboard/src/dashboard/testing_utils.py b/dashboard/src/dashboard/testing_utils.py new file mode 100644 index 0000000..a96b6d0 --- /dev/null +++ b/dashboard/src/dashboard/testing_utils.py @@ -0,0 +1,396 @@ +############################################################################## +# 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.auth.models import User +from django.core.files.base import ContentFile +from django.utils import timezone + +import json +import re +from datetime import timedelta + +from dashboard.exceptions import InvalidHostnameException +from booking.models import Booking +from account.models import UserProfile, Lab, LabStatus, VlanManager, PublicNetwork +from resource_inventory.models import ( + Host, + HostProfile, + InterfaceProfile, + DiskProfile, + CpuProfile, + Opsys, + Image, + Scenario, + Installer, + OPNFVRole, + RamProfile, + Network, + GenericResourceBundle, + GenericResource, + GenericHost, + ConfigBundle, + GenericInterface, + HostConfiguration, + OPNFVConfig, + NetworkConnection, + HostOPNFVConfig +) +from resource_inventory.resource_manager import ResourceManager + +""" +Info for make_booking() function: +[topology] argument structure: + the [topology] argument should describe the structure of the pod + the top level should be a dictionary, with each key being a hostname + each value in the top level should be a dictionary with two keys: + "type" should map to a host profile instance + "nets" should map to a list of interfaces each with a list of + dictionaries each defining a network in the format + { "name": "netname", "tagged": True|False, "public": True|False } + each network is defined if a matching name is not found + + sample argument structure: + topology={ + "host1": { + "type": instanceOf HostProfile, + "role": instanceOf OPNFVRole + "image": instanceOf Image + "nets": [ + 0: [ + 0: { "name": "public", "tagged": True, "public": True }, + 1: { "name": "private", "tagged": False, "public": False }, + ] + 1: [] + ] + } + } +""" + + +def make_booking(owner=None, start=timezone.now(), + end=timezone.now() + timedelta(days=1), + lab=None, purpose="my_purpose", + project="my_project", collaborators=[], + topology={}, installer=None, scenario=None): + + grb, host_set = make_grb(topology, owner, lab) + config_bundle, opnfv_bundle = make_config_bundle(grb, owner, topology, host_set, installer, scenario) + resource = ResourceManager.getInstance().convertResourceBundle(grb, config=config_bundle) + if not resource: + raise Exception("Resource not created") + + return Booking.objects.create( + resource=resource, + config_bundle=config_bundle, + start=start, + end=end, + owner=owner, + purpose=purpose, + project=project, + lab=lab, + opnfv_config=opnfv_bundle + ) + + +def make_config_bundle(grb, owner, topology={}, host_set={}, + installer=None, scenario=None): + cb = ConfigBundle.objects.create( + owner=owner, + name="config bundle " + str(ConfigBundle.objects.count()), + description="cb generated by make_config_bundle() method" + ) + + opnfv_config = OPNFVConfig.objects.create( + installer=installer, + scenario=scenario, + bundle=cb + ) + + # generate host configurations based on topology and host set + for hostname, host_info in topology.items(): + host_config = HostConfiguration.objects.create( + host=host_set[hostname], + image=host_info["image"], + bundle=cb, + is_head_node=host_info['role'].name.lower() == "jumphost" + ) + HostOPNFVConfig.objects.create( + role=host_info["role"], + host_config=host_config, + opnfv_config=opnfv_config + ) + return cb, opnfv_config + + +def make_network(name, lab, grb, public): + network = Network(name=name, bundle=grb, is_public=public) + if public: + public_net = lab.vlan_manager.get_public_vlan() + if not public_net: + raise Exception("No more public networks available") + lab.vlan_manager.reserve_public_vlan(public_net.vlan) + network.vlan_id = public_net.vlan + else: + private_net = lab.vlan_manager.get_vlan() + if not private_net: + raise Exception("No more generic vlans are available") + lab.vlan_manager.reserve_vlans([private_net]) + network.vlan_id = private_net + + network.save() + return network + + +def make_grb(topology, owner, lab): + + grb = GenericResourceBundle.objects.create( + owner=owner, + lab=lab, + name="Generic ResourceBundle " + str(GenericResourceBundle.objects.count()), + description="grb generated by make_grb() method" + ) + + networks = {} + host_set = {} + + for hostname, info in topology.items(): + host_profile = info["type"] + + # need to construct host from hostname and type + generic_host = make_generic_host(grb, host_profile, hostname) + host_set[hostname] = generic_host + + # set up networks + nets = info["nets"] + for interface_index, interface_profile in enumerate(host_profile.interfaceprofile.all()): + generic_interface = GenericInterface.objects.create(host=generic_host, profile=interface_profile) + netconfig = nets[interface_index] + for network_info in netconfig: + network_name = network_info["name"] + if network_name not in networks: + networks[network_name] = make_network(network_name, lab, grb, network_info['public']) + + generic_interface.connections.add(NetworkConnection.objects.create( + network=networks[network_name], + vlan_is_tagged=network_info["tagged"] + )) + + return grb, host_set + + +def make_generic_host(grb, host_profile, 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") + gresource = GenericResource.objects.create(bundle=grb, name=hostname) + + return GenericHost.objects.create(resource=gresource, profile=host_profile) + + +def make_user(is_superuser=False, username="testuser", + password="testpassword", email="default_email@user.com"): + user = User.objects.create_user(username=username, email=email, password=password) + user.is_superuser = is_superuser + user.save() + + return user + + +def make_user_profile(user=None, email_addr="email@email.com", + company="company", full_name="John Doe", + booking_privledge=True, ssh_file=None): + user = user or User.objects.first() or make_user() + profile = UserProfile.objects.create( + email_addr=email_addr, + company=company, + full_name=full_name, + booking_privledge=booking_privledge, + user=user + ) + profile.ssh_public_key.save("user_ssh_key", ssh_file if ssh_file else ContentFile("public key content string")) + + return profile + + +def make_vlan_manager(vlans=None, block_size=20, allow_overlapping=False, reserved_vlans=None): + if not vlans: + vlans = [vlan % 2 for vlan in range(4095)] + if not reserved_vlans: + reserved_vlans = [0 for i in range(4095)] + + return VlanManager.objects.create( + vlans=json.dumps(vlans), + reserved_vlans=json.dumps(vlans), + block_size=block_size, + allow_overlapping=allow_overlapping + ) + + +def make_lab(user=None, name="Test_Lab_Instance", + status=LabStatus.UP, vlan_manager=None, + pub_net_count=5): + if not vlan_manager: + vlan_manager = make_vlan_manager() + + if not user: + user = make_user() + + lab = Lab.objects.create( + lab_user=user, + name=name, + contact_email='test_lab@test_site.org', + contact_phone='603 123 4567', + status=status, + vlan_manager=vlan_manager, + description='test lab instantiation', + api_token='12345678' + ) + + for i in range(pub_net_count): + make_public_net(vlan=i * 2 + 1, lab=lab) + + return lab + + +""" +resource_inventory instantiation section for permanent resources +""" + + +def make_complete_host_profile(lab, name="test_hostprofile"): + host_profile = make_host_profile(lab, name=name) + make_disk_profile(host_profile, 500, name=name) + make_cpu_profile(host_profile) + make_interface_profile(host_profile, name=name) + make_ram_profile(host_profile) + + return host_profile + + +def make_host_profile(lab, host_type=0, name="test hostprofile"): + host_profile = HostProfile.objects.create( + host_type=host_type, + name=name, + description='test hostprofile instance' + ) + host_profile.labs.add(lab) + + return host_profile + + +def make_ram_profile(host, channels=4, amount=256): + return RamProfile.objects.create( + host=host, + amount=amount, + channels=channels + ) + + +def make_disk_profile(hostprofile, size=0, media_type="SSD", + name="test diskprofile", rotation=0, + interface="sata"): + return DiskProfile.objects.create( + name=name, + size=size, + media_type=media_type, + host=hostprofile, + rotation=rotation, + interface=interface + ) + + +def make_cpu_profile(hostprofile, + cores=4, + architecture="x86_64", + cpus=4,): + return CpuProfile.objects.create( + cores=cores, + architecture=architecture, + cpus=cpus, + host=hostprofile, + cflags='' + ) + + +def make_interface_profile(hostprofile, + speed=1000, + name="test interface profile", + nic_type="pcie"): + return InterfaceProfile.objects.create( + host=hostprofile, + name=name, + speed=speed, + nic_type=nic_type + ) + + +def make_image(lab, lab_id, owner, os, host_profile, + public=True, name="default image", description="default image"): + return Image.objects.create( + from_lab=lab, + lab_id=lab_id, + os=os, + host_type=host_profile, + public=public, + name=name, + description=description + ) + + +def make_scenario(name="test scenario"): + return Scenario.objects.create(name=name) + + +def make_installer(scenarios, name="test installer"): + installer = Installer.objects.create(name=name) + for scenario in scenarios: + installer.sup_scenarios.add(scenario) + + return installer + + +def make_os(installers, name="test OS"): + os = Opsys.objects.create(name=name) + for installer in installers: + os.sup_installers.add(installer) + + return os + + +def make_host(host_profile, lab, labid="test_host", name="test_host", + booked=False, working=True, config=None, template=None, + bundle=None, model="Model 1", vendor="ACME"): + return Host.objects.create( + lab=lab, + profile=host_profile, + name=name, + booked=booked, + working=working, + config=config, + template=template, + bundle=bundle, + model=model, + vendor=vendor + ) + + +def make_opnfv_role(name="Jumphost", description="test opnfvrole"): + return OPNFVRole.objects.create( + name=name, + description=description + ) + + +def make_public_net(vlan, lab, in_use=False, + cidr="0.0.0.0/0", gateway="0.0.0.0"): + return PublicNetwork.objects.create( + lab=lab, + vlan=vlan, + cidr=cidr, + gateway=gateway + ) diff --git a/dashboard/src/dashboard/views.py b/dashboard/src/dashboard/views.py index 36c3253..aaad7ab 100644 --- a/dashboard/src/dashboard/views.py +++ b/dashboard/src/dashboard/views.py @@ -46,7 +46,7 @@ def lab_detail_view(request, lab_name): 'title': "Lab Overview", 'lab': lab, 'hostprofiles': lab.hostprofiles.all(), - 'images': images + 'images': images, } ) @@ -78,7 +78,7 @@ def landing_view(request): manager_detected = True if request.method == 'GET': - return render(request, 'dashboard/landing.html', {'manager': manager_detected, 'title': "Welcome!"}) + return render(request, 'dashboard/landing.html', {'manager': manager_detected, 'title': "Welcome to the Lab as a Service Dashboard"}) if request.method == 'POST': try: diff --git a/dashboard/src/notifier/manager.py b/dashboard/src/notifier/manager.py index 3361074..240cf85 100644 --- a/dashboard/src/notifier/manager.py +++ b/dashboard/src/notifier/manager.py @@ -18,13 +18,13 @@ class NotificationHandler(object): @classmethod def notify_new_booking(cls, booking): template = "notifier/new_booking.html" - titles = ["You have a new Booking", "You have been added to a Booking"] + titles = ["You have a new booking (" + str(booking.id) + ")", "You have been added to a booking (" + str(booking.id) + ")"] cls.booking_notify(booking, template, titles) @classmethod def notify_booking_end(cls, booking): template = "notifier/end_booking.html" - titles = ["Your booking has ended", "A booking you collaborate on has ended"] + titles = ["Your booking (" + str(booking.id) + ") has ended", "A booking (" + str(booking.id) + ") that you collaborate on has ended"] cls.booking_notify(booking, template, titles) @classmethod @@ -75,7 +75,7 @@ class NotificationHandler(object): if (not hasattr(task, "user")) or task.user == user: user_tasklist.append( { - "title": task.type_str + " Message: ", + "title": task.type_str() + " Message: ", "content": task.message } ) @@ -94,7 +94,7 @@ class NotificationHandler(object): "Your Booking is Ready", message, os.environ.get("DEFAULT_FROM_EMAIL", "opnfv@pharos-dashboard"), - user.userprofile.email_addr, + [user.userprofile.email_addr], fail_silently=False ) diff --git a/dashboard/src/notifier/migrations/0003_auto_20190123_1741.py b/dashboard/src/notifier/migrations/0003_auto_20190123_1741.py new file mode 100644 index 0000000..f491993 --- /dev/null +++ b/dashboard/src/notifier/migrations/0003_auto_20190123_1741.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1 on 2019-01-23 17:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifier', '0002_auto_20181102_1631'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='is_html', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='notification', + name='is_read', + field=models.BooleanField(default=True), + ), + ] diff --git a/dashboard/src/notifier/migrations/0004_auto_20190124_2115.py b/dashboard/src/notifier/migrations/0004_auto_20190124_2115.py new file mode 100644 index 0000000..306ec7b --- /dev/null +++ b/dashboard/src/notifier/migrations/0004_auto_20190124_2115.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1 on 2019-01-24 21:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0003_publicnetwork'), + ('notifier', '0003_auto_20190123_1741'), + ] + + operations = [ + migrations.RemoveField( + model_name='notification', + name='is_read', + ), + migrations.AddField( + model_name='notification', + name='read_by', + field=models.ManyToManyField(related_name='read_notifications', to='account.UserProfile'), + ), + ] diff --git a/dashboard/src/notifier/migrations/0005_auto_20190306_1616.py b/dashboard/src/notifier/migrations/0005_auto_20190306_1616.py new file mode 100644 index 0000000..d92c988 --- /dev/null +++ b/dashboard/src/notifier/migrations/0005_auto_20190306_1616.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1 on 2019-03-06 16:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifier', '0004_auto_20190124_2115'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='recipients', + field=models.ManyToManyField(related_name='notifications', to='account.UserProfile'), + ), + ] diff --git a/dashboard/src/notifier/models.py b/dashboard/src/notifier/models.py index 5e7c60e..49189e8 100644 --- a/dashboard/src/notifier/models.py +++ b/dashboard/src/notifier/models.py @@ -14,7 +14,9 @@ from account.models import UserProfile class Notification(models.Model): title = models.CharField(max_length=150) content = models.TextField() - recipients = models.ManyToManyField(UserProfile) + recipients = models.ManyToManyField(UserProfile, related_name='notifications') + is_html = models.BooleanField(default=True) + read_by = models.ManyToManyField(UserProfile, related_name='read_notifications') def __str__(self): return self.title diff --git a/dashboard/src/notifier/views.py b/dashboard/src/notifier/views.py index 4ee757f..3a85eda 100644 --- a/dashboard/src/notifier/views.py +++ b/dashboard/src/notifier/views.py @@ -7,27 +7,52 @@ # http://www.apache.org/licenses/LICENSE-2.0 ############################################################################## -from notifier.models import Notification from django.shortcuts import render +from notifier.models import Notification +from django.db.models import Q def InboxView(request): if request.user.is_authenticated: user = request.user else: - return render(request, "dashboard/login.html", {'title': 'Authentication Required'}) + return render(request, "dashboard/login.html", + {'title': 'Authentication Required'}) - return render(request, "notifier/inbox.html", {'notifications': Notification.objects.filter(recipients=user.userprofile)}) + return render(request, + "notifier/inbox.html", + {'unread_notifications': Notification.objects.filter(recipients=user.userprofile).order_by('-id').filter(~Q(read_by=user.userprofile)), + 'read_notifications': Notification.objects.filter(recipients=user.userprofile).order_by('-id').filter(read_by=user.userprofile)}) def NotificationView(request, notification_id): + if request.user.is_authenticated: user = request.user else: - return render(request, "dashboard/login.html", {'title': 'Authentication Required'}) + return render(request, + "dashboard/login.html", + {'title': 'Authentication Required'}) notification = Notification.objects.get(id=notification_id) if user.userprofile not in notification.recipients.all(): - return render(request, "dashboard/login.html", {'title': 'Access Denied'}) - - return render(request, "notifier/notification.html", {'notification': notification}) + return render(request, + "dashboard/login.html", {'title': 'Access Denied'}) + + notification.read_by.add(user.userprofile) + notification.save() + if request.method == 'POST': + if 'delete' in request.POST: + # handle deleting + notification.recipients.remove(user.userprofile) + if not notification.recipients.exists(): + notification.delete() + else: + notification.save() + + if 'unread' in request.POST: + notification.read_by.remove(user.userprofile) + notification.save() + + return render(request, + "notifier/notification.html", {'notification': notification}) diff --git a/dashboard/src/pharos_dashboard/settings.py b/dashboard/src/pharos_dashboard/settings.py index 793eec7..86de78c 100644 --- a/dashboard/src/pharos_dashboard/settings.py +++ b/dashboard/src/pharos_dashboard/settings.py @@ -35,7 +35,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', - 'bootstrap3', + 'bootstrap4', 'crispy_forms', 'rest_framework', 'rest_framework.authtoken', diff --git a/dashboard/src/resource_inventory/admin.py b/dashboard/src/resource_inventory/admin.py index e063cc0..7ff510b 100644 --- a/dashboard/src/resource_inventory/admin.py +++ b/dashboard/src/resource_inventory/admin.py @@ -32,7 +32,8 @@ from resource_inventory.models import ( OPNFVConfig, OPNFVRole, Image, - HostConfiguration + HostConfiguration, + RemoteInfo ) profiles = [HostProfile, InterfaceProfile, DiskProfile, CpuProfile, RamProfile] @@ -47,6 +48,6 @@ physical = [Host, Interface, Network, Vlan, ResourceBundle] admin.site.register(physical) -config = [Scenario, Installer, Opsys, ConfigBundle, OPNFVConfig, OPNFVRole, Image, HostConfiguration] +config = [Scenario, Installer, Opsys, ConfigBundle, OPNFVConfig, OPNFVRole, Image, HostConfiguration, RemoteInfo] admin.site.register(config) diff --git a/dashboard/src/resource_inventory/idf_templater.py b/dashboard/src/resource_inventory/idf_templater.py new file mode 100644 index 0000000..bf6eda0 --- /dev/null +++ b/dashboard/src/resource_inventory/idf_templater.py @@ -0,0 +1,151 @@ +############################################################################## +# 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 django.template.loader import render_to_string + +from account.models import PublicNetwork + +from resource_inventory.models import Vlan + + +class IDFTemplater: + """ + Utility class to create a full IDF yaml file + """ + net_names = ["admin", "mgmt", "private", "public"] + bridge_names = { + "admin": "br-admin", + "mgmt": "br-mgmt", + "private": "br-private", + "public": "br-public" + } + + def __init__(self): + self.networks = {} + for i, name in enumerate(self.net_names): + self.networks[name] = { + "name": name, + "vlan": -1, + "interface": i, + "ip": "10.250." + str(i) + ".0", + "netmask": 24 + } + + def makeIDF(self, booking): + """ + fills the installer descriptor file template with info about the resource + """ + template = "dashboard/idf.yaml" + info = {} + info['version'] = "0.1" + info['net_config'] = self.get_net_config(booking) + info['fuel'] = self.get_fuel_config(booking) + + return render_to_string(template, context=info) + + def get_net_config(self, booking): + net_config = {} + try: + net_config['oob'] = self.get_oob_net(booking) + except Exception: + net_config['oob'] = {} + try: + net_config['public'] = self.get_public_net(booking) + except Exception: + net_config['public'] = {} + + for net in [net for net in self.net_names if net != "public"]: + try: + net_config[net] = self.get_single_net_config(booking, net) + except Exception: + net_config[net] = {} + + return net_config + + def get_public_net(self, booking): + public = {} + config = booking.opnfv_config + public_role = config.networks.get(name="public") + public_vlan = Vlan.objects.filter(network=public_role.network).first() + public_network = PublicNetwork.objects.get(vlan=public_vlan.vlan_id, lab=booking.lab) + self.networks['public']['vlan'] = public_vlan.vlan_id + public['interface'] = self.networks['public']['interface'] + public['vlan'] = public_network.vlan # untagged?? + public['network'] = public_network.cidr.split("/")[0] + public['mask'] = public_network.cidr.split("/")[1] + # public['ip_range'] = 4 # necesary? + public['gateway'] = public_network.gateway + public['dns'] = ["1.1.1.1", "8.8.8.8"] + + return public + + def get_oob_net(self, booking): + net = {} + hosts = booking.resource.hosts.all() + addrs = [host.remote_management.address for host in hosts] + net['ip_range'] = ",".join(addrs) + net['vlan'] = "native" + return net + + def get_single_net_config(self, booking, net_name): + config = booking.opnfv_config + role = config.networks.get(name=net_name) + vlan = Vlan.objects.filter(network=role.network).first() + self.networks[net_name]['vlan'] = vlan.vlan_id + net = {} + net['interface'] = self.networks[net_name]['interface'] + net['vlan'] = vlan.vlan_id + net['network'] = self.networks[net_name]['ip'] + net['mask'] = self.networks[net_name]['netmask'] + + return net + + def get_fuel_config(self, booking): + fuel = {} + fuel['jumphost'] = {} + try: + fuel['jumphost']['bridges'] = self.get_fuel_bridges() + except Exception: + fuel['jumphost']['bridges'] = {} + + fuel['network'] = {} + try: + fuel['network']['nodes'] = self.get_fuel_nodes(booking) + except Exception: + fuel['network']['nodes'] = {} + + return fuel + + def get_fuel_bridges(self): + return self.bridge_names + + def get_fuel_nodes(self, booking): + jumphost = booking.opnfv_config.host_opnfv_config.get( + role__name__iexact="jumphost" + ) + hosts = booking.resource.hosts.exclude(pk=jumphost.pk) + nodes = [] + for host in hosts: + node = {} + ordered_interfaces = self.get_node_interfaces(host) + node['interfaces'] = [iface['name'] for iface in ordered_interfaces] + node['bus_addrs'] = [iface['bus'] for iface in ordered_interfaces] + nodes.append(node) + + return nodes + + def get_node_interfaces(self, node): + # TODO: this should sync with pdf ordering + interfaces = [] + + for iface in node.interfaces.all(): + interfaces.append({"name": iface.name, "bus": iface.bus_address}) + + return interfaces diff --git a/dashboard/src/resource_inventory/migrations/0005_image_os.py b/dashboard/src/resource_inventory/migrations/0005_image_os.py new file mode 100644 index 0000000..ede008e --- /dev/null +++ b/dashboard/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/dashboard/src/resource_inventory/migrations/0006_auto_20190124_1700.py b/dashboard/src/resource_inventory/migrations/0006_auto_20190124_1700.py new file mode 100644 index 0000000..a5a972f --- /dev/null +++ b/dashboard/src/resource_inventory/migrations/0006_auto_20190124_1700.py @@ -0,0 +1,76 @@ +# 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 +import resource_inventory.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resource_inventory', '0005_image_os'), + ] + + operations = [ + migrations.AlterField( + model_name='cpuprofile', + name='host', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cpuprofile', to='resource_inventory.HostProfile'), + ), + migrations.AlterField( + model_name='diskprofile', + name='host', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='storageprofile', to='resource_inventory.HostProfile'), + ), + migrations.AlterField( + model_name='generichost', + name='profile', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource_inventory.HostProfile'), + ), + migrations.AlterField( + model_name='generichost', + name='resource', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='generic_host', to='resource_inventory.GenericResource'), + ), + migrations.AlterField( + model_name='genericinterface', + name='host', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generic_interfaces', to='resource_inventory.GenericHost'), + ), + migrations.AlterField( + model_name='genericresource', + name='bundle', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generic_resources', to='resource_inventory.GenericResourceBundle'), + ), + migrations.AlterField( + model_name='genericresourcebundle', + name='lab', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='account.Lab'), + ), + migrations.AlterField( + model_name='genericresourcebundle', + name='owner', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='hostconfiguration', + name='opnfvRole', + field=models.ForeignKey(on_delete=models.SET(resource_inventory.models.get_sentinal_opnfv_role), to='resource_inventory.OPNFVRole'), + ), + migrations.AlterField( + model_name='interfaceprofile', + name='host', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaceprofile', to='resource_inventory.HostProfile'), + ), + migrations.AlterField( + model_name='ramprofile', + name='host', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ramprofile', to='resource_inventory.HostProfile'), + ), + migrations.AlterField( + model_name='resourcebundle', + name='template', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='resource_inventory.GenericResourceBundle'), + ), + ] diff --git a/dashboard/src/resource_inventory/migrations/0007_auto_20190306_1616.py b/dashboard/src/resource_inventory/migrations/0007_auto_20190306_1616.py new file mode 100644 index 0000000..19a49c5 --- /dev/null +++ b/dashboard/src/resource_inventory/migrations/0007_auto_20190306_1616.py @@ -0,0 +1,31 @@ +# Generated by Django 2.1 on 2019-03-06 16:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('resource_inventory', '0006_auto_20190124_1700'), + ] + + operations = [ + migrations.CreateModel( + name='RemoteInfo', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('address', models.CharField(max_length=15)), + ('mac_address', models.CharField(max_length=17)), + ('password', models.CharField(max_length=100)), + ('user', models.CharField(max_length=100)), + ('management_type', models.CharField(default='ipmi', max_length=50)), + ('versions', models.CharField(max_length=100)), + ], + ), + migrations.AlterField( + model_name='genericinterface', + name='profile', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource_inventory.InterfaceProfile'), + ), + ] diff --git a/dashboard/src/resource_inventory/migrations/0008_host_remote_management.py b/dashboard/src/resource_inventory/migrations/0008_host_remote_management.py new file mode 100644 index 0000000..f74a535 --- /dev/null +++ b/dashboard/src/resource_inventory/migrations/0008_host_remote_management.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1 on 2019-03-06 16:42 + +from django.db import migrations, models +import resource_inventory.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resource_inventory', '0007_auto_20190306_1616'), + ] + + operations = [ + migrations.AddField( + model_name='host', + name='remote_management', + field=models.ForeignKey(default=resource_inventory.models.get_default_remote_info, on_delete=models.SET(resource_inventory.models.get_default_remote_info), to='resource_inventory.RemoteInfo'), + ), + ] diff --git a/dashboard/src/resource_inventory/migrations/0009_auto_20190315_1757.py b/dashboard/src/resource_inventory/migrations/0009_auto_20190315_1757.py new file mode 100644 index 0000000..92ed0e9 --- /dev/null +++ b/dashboard/src/resource_inventory/migrations/0009_auto_20190315_1757.py @@ -0,0 +1,73 @@ +# Generated by Django 2.1 on 2019-03-15 17:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('resource_inventory', '0008_host_remote_management'), + ] + + operations = [ + migrations.CreateModel( + name='NetworkConnection', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('vlan_is_tagged', models.BooleanField()), + ], + ), + migrations.CreateModel( + name='NetworkRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ], + ), + migrations.RemoveField( + model_name='genericinterface', + name='vlans', + ), + migrations.RemoveField( + model_name='network', + name='vlan_id', + ), + migrations.AddField( + model_name='network', + name='bundle', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='networks', to='resource_inventory.GenericResourceBundle'), + preserve_default=False, + ), + migrations.AddField( + model_name='network', + name='is_public', + field=models.BooleanField(default=False), + preserve_default=False, + ), + migrations.AddField( + model_name='vlan', + name='network', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='resource_inventory.Network'), + ), + migrations.AddField( + model_name='networkrole', + name='network', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource_inventory.Network'), + ), + migrations.AddField( + model_name='networkconnection', + name='network', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource_inventory.Network'), + ), + migrations.AddField( + model_name='genericinterface', + name='connections', + field=models.ManyToManyField(to='resource_inventory.NetworkConnection'), + ), + migrations.AddField( + model_name='opnfvconfig', + name='networks', + field=models.ManyToManyField(to='resource_inventory.NetworkRole'), + ), + ] diff --git a/dashboard/src/resource_inventory/migrations/0010_auto_20190430_1405.py b/dashboard/src/resource_inventory/migrations/0010_auto_20190430_1405.py new file mode 100644 index 0000000..3823eaf --- /dev/null +++ b/dashboard/src/resource_inventory/migrations/0010_auto_20190430_1405.py @@ -0,0 +1,54 @@ +# Generated by Django 2.1 on 2019-04-30 14:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('resource_inventory', '0009_auto_20190315_1757'), + ] + + operations = [ + migrations.CreateModel( + name='HostOPNFVConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), + migrations.RemoveField( + model_name='hostconfiguration', + name='opnfvRole', + ), + migrations.AddField( + model_name='hostconfiguration', + name='is_head_node', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='opnfvconfig', + name='description', + field=models.CharField(blank=True, default='', max_length=600), + ), + migrations.AddField( + model_name='opnfvconfig', + name='name', + field=models.CharField(blank=True, default='', max_length=300), + ), + migrations.AddField( + model_name='hostopnfvconfig', + name='host_config', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='host_opnfv_config', to='resource_inventory.HostConfiguration'), + ), + migrations.AddField( + model_name='hostopnfvconfig', + name='opnfv_config', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='host_opnfv_config', to='resource_inventory.OPNFVConfig'), + ), + migrations.AddField( + model_name='hostopnfvconfig', + name='role', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='host_opnfv_configs', to='resource_inventory.OPNFVRole'), + ), + ] diff --git a/dashboard/src/resource_inventory/models.py b/dashboard/src/resource_inventory/models.py index b56317b..b9f2c44 100644 --- a/dashboard/src/resource_inventory/models.py +++ b/dashboard/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: @@ -39,7 +39,7 @@ class InterfaceProfile(models.Model): id = models.AutoField(primary_key=True) speed = models.IntegerField() name = models.CharField(max_length=100) - host = models.ForeignKey(HostProfile, on_delete=models.DO_NOTHING, related_name='interfaceprofile') + host = models.ForeignKey(HostProfile, on_delete=models.CASCADE, related_name='interfaceprofile') nic_type = models.CharField( max_length=50, choices=[ @@ -61,7 +61,7 @@ class DiskProfile(models.Model): ("HDD", "HDD") ]) name = models.CharField(max_length=50) - host = models.ForeignKey(HostProfile, on_delete=models.DO_NOTHING, related_name='storageprofile') + host = models.ForeignKey(HostProfile, on_delete=models.CASCADE, related_name='storageprofile') rotation = models.IntegerField(default=0) interface = models.CharField( max_length=50, @@ -88,7 +88,7 @@ class CpuProfile(models.Model): ("aarch64", "aarch64") ]) cpus = models.IntegerField() - host = models.ForeignKey(HostProfile, on_delete=models.DO_NOTHING, related_name='cpuprofile') + host = models.ForeignKey(HostProfile, on_delete=models.CASCADE, related_name='cpuprofile') cflags = models.TextField(null=True) def __str__(self): @@ -99,39 +99,19 @@ class RamProfile(models.Model): id = models.AutoField(primary_key=True) amount = models.IntegerField() channels = models.IntegerField() - host = models.ForeignKey(HostProfile, on_delete=models.DO_NOTHING, related_name='ramprofile') + host = models.ForeignKey(HostProfile, on_delete=models.CASCADE, related_name='ramprofile') def __str__(self): return str(self.amount) + "G for " + str(self.host) -# Networking -- located here due to import order requirements -class Network(models.Model): - id = models.AutoField(primary_key=True) - vlan_id = models.IntegerField() - name = models.CharField(max_length=100) - - def __str__(self): - return self.name - - -class Vlan(models.Model): - id = models.AutoField(primary_key=True) - vlan_id = models.IntegerField() - tagged = models.BooleanField() - public = models.BooleanField(default=False) - - def __str__(self): - return str(self.vlan_id) + ("_T" if self.tagged else "") - - # Generic resource templates class GenericResourceBundle(models.Model): id = models.AutoField(primary_key=True) name = models.CharField(max_length=300, unique=True) xml = models.TextField() - owner = models.ForeignKey(User, null=True, on_delete=models.DO_NOTHING) - lab = models.ForeignKey(Lab, null=True, on_delete=models.DO_NOTHING) + owner = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) + lab = models.ForeignKey(Lab, null=True, on_delete=models.SET_NULL) description = models.CharField(max_length=1000, default="") def getHosts(self): @@ -145,9 +125,35 @@ class GenericResourceBundle(models.Model): return self.name +class Network(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=100) + bundle = models.ForeignKey(GenericResourceBundle, on_delete=models.CASCADE, related_name="networks") + is_public = models.BooleanField() + + def __str__(self): + return self.name + + +class NetworkConnection(models.Model): + network = models.ForeignKey(Network, on_delete=models.CASCADE) + vlan_is_tagged = models.BooleanField() + + +class Vlan(models.Model): + id = models.AutoField(primary_key=True) + vlan_id = models.IntegerField() + tagged = models.BooleanField() + public = models.BooleanField(default=False) + network = models.ForeignKey(Network, on_delete=models.DO_NOTHING, null=True) + + def __str__(self): + return str(self.vlan_id) + ("_T" if self.tagged else "") + + 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 (_)") + bundle = models.ForeignKey(GenericResourceBundle, related_name='generic_resources', on_delete=models.CASCADE) + 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 +163,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: @@ -167,8 +173,8 @@ class GenericResource(models.Model): # Host template class GenericHost(models.Model): id = models.AutoField(primary_key=True) - profile = models.ForeignKey(HostProfile, on_delete=models.DO_NOTHING) - resource = models.OneToOneField(GenericResource, related_name='generic_host', on_delete=models.DO_NOTHING) + profile = models.ForeignKey(HostProfile, on_delete=models.CASCADE) + resource = models.OneToOneField(GenericResource, related_name='generic_host', on_delete=models.CASCADE) def __str__(self): return self.resource.name @@ -177,20 +183,22 @@ class GenericHost(models.Model): # Physical, actual resources class ResourceBundle(models.Model): id = models.AutoField(primary_key=True) - template = models.ForeignKey(GenericResourceBundle, on_delete=models.DO_NOTHING) + template = models.ForeignKey(GenericResourceBundle, on_delete=models.SET_NULL, null=True) def __str__(self): + if self.template is None: + return "Resource bundle " + str(self.id) + " with no template" return "instance of " + str(self.template) - -# Networking + def get_host(self, role="Jumphost"): + return Host.objects.filter(bundle=self, config__is_head_node=True).first() # should only ever be one, but it is not an invariant in the models class GenericInterface(models.Model): id = models.AutoField(primary_key=True) - vlans = models.ManyToManyField(Vlan) - profile = models.ForeignKey(InterfaceProfile, on_delete=models.DO_NOTHING) - host = models.ForeignKey(GenericHost, on_delete=models.DO_NOTHING, related_name='generic_interfaces') + profile = models.ForeignKey(InterfaceProfile, on_delete=models.CASCADE) + host = models.ForeignKey(GenericHost, on_delete=models.CASCADE, related_name='generic_interfaces') + connections = models.ManyToManyField(NetworkConnection) def __str__(self): return "type " + str(self.profile) + " on host " + str(self.host) @@ -222,9 +230,14 @@ class Opsys(models.Model): return self.name +class NetworkRole(models.Model): + name = models.CharField(max_length=100) + network = models.ForeignKey(Network, on_delete=models.CASCADE) + + class ConfigBundle(models.Model): id = models.AutoField(primary_key=True) - owner = models.ForeignKey(User, on_delete=models.CASCADE) # consider setting to root user? + owner = models.ForeignKey(User, on_delete=models.CASCADE) name = models.CharField(max_length=200, unique=True) description = models.CharField(max_length=1000, default="") bundle = models.ForeignKey(GenericResourceBundle, null=True, on_delete=models.CASCADE) @@ -238,6 +251,9 @@ class OPNFVConfig(models.Model): installer = models.ForeignKey(Installer, on_delete=models.CASCADE) scenario = models.ForeignKey(Scenario, on_delete=models.CASCADE) bundle = models.ForeignKey(ConfigBundle, related_name="opnfv_config", on_delete=models.CASCADE) + networks = models.ManyToManyField(NetworkRole) + name = models.CharField(max_length=300, blank=True, default="") + description = models.CharField(max_length=600, blank=True, default="") def __str__(self): return "OPNFV job with " + str(self.installer) + " and " + str(self.scenario) @@ -262,14 +278,18 @@ class Image(models.Model): name = models.CharField(max_length=200) owner = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) public = models.BooleanField(default=True) - # 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 +def get_sentinal_opnfv_role(): + return OPNFVRole.objects.get_or_create(name="deleted", description="Role was deleted.") + + class HostConfiguration(models.Model): """ model to represent a complete configuration for a single @@ -279,12 +299,38 @@ class HostConfiguration(models.Model): host = models.ForeignKey(GenericHost, related_name="configuration", on_delete=models.CASCADE) image = models.ForeignKey(Image, on_delete=models.PROTECT) bundle = models.ForeignKey(ConfigBundle, related_name="hostConfigurations", null=True, on_delete=models.CASCADE) - opnfvRole = models.ForeignKey(OPNFVRole, on_delete=models.PROTECT) + is_head_node = models.BooleanField(default=False) def __str__(self): return "config with " + str(self.host) + " and image " + str(self.image) +class HostOPNFVConfig(models.Model): + role = models.ForeignKey(OPNFVRole, related_name="host_opnfv_configs", on_delete=models.CASCADE) + host_config = models.ForeignKey(HostConfiguration, related_name="host_opnfv_config", on_delete=models.CASCADE) + opnfv_config = models.ForeignKey(OPNFVConfig, related_name="host_opnfv_config", on_delete=models.CASCADE) + + +class RemoteInfo(models.Model): + address = models.CharField(max_length=15) + mac_address = models.CharField(max_length=17) + password = models.CharField(max_length=100) + user = models.CharField(max_length=100) + management_type = models.CharField(max_length=50, default="ipmi") + versions = models.CharField(max_length=100) # json serialized list of floats + + +def get_default_remote_info(): + return RemoteInfo.objects.get_or_create( + address="default", + mac_address="default", + password="default", + user="default", + management_type="default", + versions="[default]" + )[0].pk + + # Concrete host, actual machine in a lab class Host(models.Model): id = models.AutoField(primary_key=True) @@ -299,6 +345,7 @@ class Host(models.Model): working = models.BooleanField(default=True) vendor = models.CharField(max_length=100, default="unknown") model = models.CharField(max_length=150, default="unknown") + remote_management = models.ForeignKey(RemoteInfo, default=get_default_remote_info, on_delete=models.SET(get_default_remote_info)) def __str__(self): return self.name @@ -314,3 +361,11 @@ class Interface(models.Model): def __str__(self): return self.mac_address + " on host " + str(self.host) + + +class OPNFV_SETTINGS(): + """ + This is a static configuration class + """ + # all the required network types in PDF/IDF spec + NETWORK_ROLES = ["public", "private", "admin", "mgmt"] diff --git a/dashboard/src/resource_inventory/pdf_templater.py b/dashboard/src/resource_inventory/pdf_templater.py new file mode 100644 index 0000000..2302530 --- /dev/null +++ b/dashboard/src/resource_inventory/pdf_templater.py @@ -0,0 +1,193 @@ +############################################################################## +# 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.template.loader import render_to_string +import booking +from resource_inventory.models import Host, InterfaceProfile + + +class PDFTemplater: + """ + Utility class to create a full PDF yaml file + """ + + @classmethod + def makePDF(cls, booking): + """ + fills the pod descriptor file template with info about the resource + """ + template = "dashboard/pdf.yaml" + info = {} + info['details'] = cls.get_pdf_details(booking.resource) + info['jumphost'] = cls.get_pdf_jumphost(booking) + info['nodes'] = cls.get_pdf_nodes(booking) + + return render_to_string(template, context=info) + + @classmethod + def get_pdf_details(cls, resource): + """ + Info for the "details" section + """ + details = {} + owner = "Anon" + email = "email@mail.com" + resource_lab = resource.template.lab + lab = resource_lab.name + location = resource_lab.location + pod_type = "development" + link = "https://wiki.opnfv.org/display/INF/Pharos+Laas" + + try: + # try to get more specific info that may fail, we dont care if it does + booking_owner = booking.models.Booking.objects.get(resource=resource).owner + owner = booking_owner.username + email = booking_owner.userprofile.email_addr + except Exception: + pass + + details['contact'] = email + details['lab'] = lab + details['link'] = link + details['owner'] = owner + details['location'] = location + details['type'] = pod_type + + return details + + @classmethod + def get_jumphost(cls, booking): + jumphost = None + if booking.opnfv_config: + jumphost_opnfv_config = booking.opnfv_config.host_opnfv_config.get( + role__name__iexact="jumphost" + ) + jumphost = booking.resource.hosts.get(config=jumphost_opnfv_config.host_config) + else: # if there is no opnfv config, use headnode + jumphost = Host.objects.filter( + bundle=booking.resource, + config__is_head_node=True + ).first() + + return jumphost + + @classmethod + def get_pdf_jumphost(cls, booking): + """ + returns a dict of all the info for the "jumphost" section + """ + jumphost = cls.get_jumphost(booking) + jumphost_info = cls.get_pdf_host(jumphost) + jumphost_info['os'] = jumphost.config.image.os.name + return jumphost_info + + @classmethod + def get_pdf_nodes(cls, booking): + """ + returns a list of all the "nodes" (every host except jumphost) + """ + pdf_nodes = [] + nodes = set(Host.objects.filter(bundle=booking.resource)) + nodes.discard(cls.get_jumphost(booking)) + + for node in nodes: + pdf_nodes.append(cls.get_pdf_host(node)) + + return pdf_nodes + + @classmethod + def get_pdf_host(cls, host): + """ + method to gather all needed info about a host + returns a dict + """ + host_info = {} + host_info['name'] = host.template.resource.name + host_info['node'] = cls.get_pdf_host_node(host) + host_info['disks'] = [] + for disk in host.profile.storageprofile.all(): + host_info['disks'].append(cls.get_pdf_host_disk(disk)) + + host_info['interfaces'] = [] + for interface in host.interfaces.all(): + host_info['interfaces'].append(cls.get_pdf_host_iface(interface)) + + host_info['remote'] = cls.get_pdf_host_remote_management(host) + + return host_info + + @classmethod + def get_pdf_host_node(cls, host): + """ + returns "node" info for a given host + """ + d = {} + d['type'] = "baremetal" + d['vendor'] = host.vendor + d['model'] = host.model + d['memory'] = str(host.profile.ramprofile.first().amount) + "G" + + cpu = host.profile.cpuprofile.first() + d['arch'] = cpu.architecture + d['cpus'] = cpu.cpus + d['cores'] = cpu.cores + cflags = cpu.cflags + if cflags and cflags.strip(): + d['cpu_cflags'] = cflags + else: + d['cpu_cflags'] = "none" + + return d + + @classmethod + def get_pdf_host_disk(cls, disk): + """ + returns a dict describing the given disk + """ + disk_info = {} + disk_info['name'] = disk.name + disk_info['capacity'] = str(disk.size) + "G" + disk_info['type'] = disk.media_type + disk_info['interface'] = disk.interface + disk_info['rotation'] = disk.rotation + return disk_info + + @classmethod + def get_pdf_host_iface(cls, interface): + """ + returns a dict describing given interface + """ + iface_info = {} + iface_info['features'] = "none" + iface_info['mac_address'] = interface.mac_address + iface_info['name'] = interface.name + speed = "unknown" + try: + profile = InterfaceProfile.objects.get(host=interface.host.profile, name=interface.name) + speed = str(int(profile.speed / 1000)) + "gb" + except Exception: + pass + iface_info['speed'] = speed + return iface_info + + @classmethod + def get_pdf_host_remote_management(cls, host): + """ + gives the remote params of the host + """ + man = host.remote_management + mgmt = {} + mgmt['address'] = man.address + mgmt['mac_address'] = man.mac_address + mgmt['pass'] = man.password + mgmt['type'] = man.management_type + mgmt['user'] = man.user + mgmt['versions'] = [man.versions] + return mgmt diff --git a/dashboard/src/resource_inventory/resource_manager.py b/dashboard/src/resource_inventory/resource_manager.py index 9282580..652e4e3 100644 --- a/dashboard/src/resource_inventory/resource_manager.py +++ b/dashboard/src/resource_inventory/resource_manager.py @@ -8,16 +8,20 @@ ############################################################################## -from django.template.loader import render_to_string - -import booking from dashboard.exceptions import ( ResourceExistenceException, ResourceAvailabilityException, ResourceProvisioningException, ModelValidationException, ) -from resource_inventory.models import Host, HostConfiguration, ResourceBundle +from resource_inventory.models import ( + Host, + HostConfiguration, + ResourceBundle, + HostProfile, + Network, + Vlan +) class ResourceManager: @@ -33,61 +37,107 @@ 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) + + def hostsAvailable(self, grb): + """ + This method will check if the given GenericResourceBundle + is available. No changes to the database + """ + + # count up hosts + profile_count = {} + for host in grb.getHosts(): + if host.profile not in profile_count: + profile_count[host.profile] = 0 + profile_count[host.profile] += 1 + + # check that all required hosts are available + for profile in profile_count.keys(): + available = Host.objects.filter( + booked=False, + lab=grb.lab, + profile=profile + ).count() + needed = profile_count[profile] + if available < needed: + return False + return True + # public interface def deleteResourceBundle(self, resourceBundle): for host in Host.objects.filter(bundle=resourceBundle): self.releaseHost(host) resourceBundle.delete() - def convertResourceBundle(self, genericResourceBundle, lab=None, config=None): + def get_vlans(self, genericResourceBundle): + networks = {} + vlan_manager = genericResourceBundle.lab.vlan_manager + for network in genericResourceBundle.networks.all(): + if network.is_public: + public_net = vlan_manager.get_public_vlan() + vlan_manager.reserve_public_vlan(public_net.vlan) + networks[network.name] = public_net.vlan + else: + vlan = vlan_manager.get_vlan() + vlan_manager.reserve_vlans(vlan) + networks[network.name] = vlan + return networks + + def convertResourceBundle(self, genericResourceBundle, config=None): """ Takes in a GenericResourceBundle and 'converts' it into a ResourceBundle """ - resource_bundle = ResourceBundle() - resource_bundle.template = genericResourceBundle - resource_bundle.save() - - hosts = genericResourceBundle.getHosts() - - # current supported case: user creating new booking - # currently unsupported: editing existing booking - + resource_bundle = ResourceBundle.objects.create(template=genericResourceBundle) + generic_hosts = genericResourceBundle.getHosts() physical_hosts = [] - for host in hosts: + vlan_map = self.get_vlans(genericResourceBundle) + + for generic_host in generic_hosts: host_config = None if config: - host_config = HostConfiguration.objects.get(bundle=config, host=host) + host_config = HostConfiguration.objects.get(bundle=config, host=generic_host) try: - physical_host = self.acquireHost(host, genericResourceBundle.lab.name) + physical_host = self.acquireHost(generic_host, genericResourceBundle.lab.name) except ResourceAvailabilityException: - self.fail_acquire(physical_hosts) + self.fail_acquire(physical_hosts, vlan_map, genericResourceBundle) raise ResourceAvailabilityException("Could not provision hosts, not enough available") try: physical_host.bundle = resource_bundle - physical_host.template = host + physical_host.template = generic_host physical_host.config = host_config physical_hosts.append(physical_host) - self.configureNetworking(physical_host) - except: - self.fail_acquire(physical_hosts) + self.configureNetworking(physical_host, vlan_map) + except Exception: + self.fail_acquire(physical_hosts, vlan_map, genericResourceBundle) raise ResourceProvisioningException("Network configuration failed.") try: physical_host.save() - except: - self.fail_acquire(physical_hosts) + except Exception: + self.fail_acquire(physical_hosts, vlan_map, genericResourceBundle) raise ModelValidationException("Saving hosts failed") return resource_bundle - def configureNetworking(self, host): + def configureNetworking(self, host, vlan_map): generic_interfaces = list(host.template.generic_interfaces.all()) for int_num, physical_interface in enumerate(host.interfaces.all()): generic_interface = generic_interfaces[int_num] physical_interface.config.clear() - for vlan in generic_interface.vlans.all(): - physical_interface.config.add(vlan) + for connection in generic_interface.connections.all(): + physical_interface.config.add( + Vlan.objects.create( + vlan_id=vlan_map[connection.network.name], + tagged=connection.vlan_is_tagged, + public=connection.network.is_public, + network=connection.network + ) + ) # private interface def acquireHost(self, genericHost, labName): @@ -109,93 +159,16 @@ class ResourceManager: host.booked = False host.save() - def fail_acquire(self, hosts): + def releaseNetworks(self, grb, vlan_manager, vlans): + for net_name, vlan_id in vlans.items(): + net = Network.objects.get(name=net_name, bundle=grb) + if(net.is_public): + vlan_manager.release_public_vlan(vlan_id) + else: + vlan_manager.release_vlans(vlan_id) + + def fail_acquire(self, hosts, vlans, grb): + vlan_manager = grb.lab.vlan_manager + self.releaseNetworks(grb, vlan_manager, vlans) for host in hosts: self.releaseHost(host) - - def makePDF(self, resource): - """ - fills the pod descriptor file template with info about the resource - """ - template = "dashboard/pdf.yaml" - info = {} - info['details'] = self.get_pdf_details(resource) - info['jumphost'] = self.get_pdf_jumphost(resource) - info['nodes'] = self.get_pdf_nodes(resource) - - return render_to_string(template, context=info) - - def get_pdf_details(self, resource): - details = {} - owner = "Anon" - email = "email@mail.com" - resource_lab = resource.template.lab - lab = resource_lab.name - location = resource_lab.location - pod_type = "development" - link = "https://wiki.opnfv.org/display/INF/Pharos+Laas" - - try: - # try to get more specific info that may fail, we dont care if it does - booking_owner = booking.models.Booking.objects.get(resource=resource).owner - owner = booking_owner.username - email = booking_owner.userprofile.email_addr - except Exception: - pass - - details['owner'] = owner - details['email'] = email - details['lab'] = lab - details['location'] = location - details['type'] = pod_type - details['link'] = link - - return details - - def get_pdf_jumphost(self, resource): - jumphost = Host.objects.get(bundle=resource, config__opnfvRole__name__iexact="jumphost") - return self.get_pdf_host(jumphost) - - def get_pdf_nodes(self, resource): - pdf_nodes = [] - nodes = Host.objects.filter(bundle=resource).exclude(config__opnfvRole__name__iexact="jumphost") - for node in nodes: - pdf_nodes.append(self.get_pdf_host(node)) - - return pdf_nodes - - def get_pdf_host(self, host): - host_info = {} - host_info['name'] = host.template.resource.name - host_info['node'] = {} - host_info['node']['type'] = "baremetal" - host_info['node']['vendor'] = host.vendor - host_info['node']['model'] = host.model - host_info['node']['arch'] = host.profile.cpuprofile.first().architecture - host_info['node']['cpus'] = host.profile.cpuprofile.first().cpus - host_info['node']['cores'] = host.profile.cpuprofile.first().cores - cflags = host.profile.cpuprofile.first().cflags - if cflags and cflags.strip(): - host_info['node']['cpu_cflags'] = cflags - host_info['node']['memory'] = str(host.profile.ramprofile.first().amount) + "G" - host_info['disks'] = [] - for disk in host.profile.storageprofile.all(): - disk_info = {} - disk_info['name'] = disk.name - disk_info['capacity'] = str(disk.size) + "G" - disk_info['type'] = disk.media_type - disk_info['interface'] = disk.interface - disk_info['rotation'] = disk.rotation - host_info['disks'].append(disk_info) - - host_info['interfaces'] = [] - for interface in host.interfaces.all(): - iface_info = {} - iface_info['name'] = interface.name - iface_info['address'] = "unknown" - iface_info['mac_address'] = interface.mac_address - vlans = "|".join([str(vlan.vlan_id) for vlan in interface.config.all()]) - iface_info['vlans'] = vlans - host_info['interfaces'].append(iface_info) - - return host_info diff --git a/dashboard/src/resource_inventory/urls.py b/dashboard/src/resource_inventory/urls.py index 4e159ba..a72871b 100644 --- a/dashboard/src/resource_inventory/urls.py +++ b/dashboard/src/resource_inventory/urls.py @@ -25,10 +25,11 @@ Including another URLconf 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ from django.conf.urls import url -from resource_inventory.views import HostView +from resource_inventory.views import HostView, hostprofile_detail_view app_name = "resource" urlpatterns = [ - url(r'^hosts$', HostView.as_view(), name='hosts') + url(r'^hosts$', HostView.as_view(), name='hosts'), + url(r'^profiles/(?P<hostprofile_id>.+)/$', hostprofile_detail_view, name='host_detail'), ] diff --git a/dashboard/src/resource_inventory/views.py b/dashboard/src/resource_inventory/views.py index 2937bd7..8c3d899 100644 --- a/dashboard/src/resource_inventory/views.py +++ b/dashboard/src/resource_inventory/views.py @@ -9,8 +9,10 @@ from django.views.generic import TemplateView +from django.shortcuts import get_object_or_404 +from django.shortcuts import render -from resource_inventory.models import Host +from resource_inventory.models import HostProfile, Host class HostView(TemplateView): @@ -21,3 +23,16 @@ class HostView(TemplateView): hosts = Host.objects.filter(working=True) context.update({'hosts': hosts, 'title': "Hardware Resources"}) return context + + +def hostprofile_detail_view(request, hostprofile_id): + hostprofile = get_object_or_404(HostProfile, id=hostprofile_id) + + return render( + request, + "resource/hostprofile_detail.html", + { + 'title': "Host Type: " + str(hostprofile.name), + 'hostprofile': hostprofile + } + ) diff --git a/dashboard/src/static/bower.json b/dashboard/src/static/bower.json index 9ae744a..dda786d 100644 --- a/dashboard/src/static/bower.json +++ b/dashboard/src/static/bower.json @@ -16,12 +16,14 @@ "tests" ], "dependencies": { - "eonasdan-bootstrap-datetimepicker": "^4.17.37", "fullcalendar": "^2.9.0", "jquery-migrate": "^3.0.0", - "startbootstrap-sb-admin-2-blackrockdigital": "^3.3.7" - }, - "resolutions": { - "font-awesome": "~4.6.3" + "bootstrap": "4.3.1", + "popper.js": "1.14.3", + "Font-Awesome": "5.9.0", + "datatables.net": "1.10.19", + "datatables.net-bs4": "1.10.19", + "datatables.net-responsive": "2.1.1", + "datatables.net-responsive-bs4": "2.2.3" } } diff --git a/dashboard/src/static/css/base.css b/dashboard/src/static/css/base.css new file mode 100644 index 0000000..c51728c --- /dev/null +++ b/dashboard/src/static/css/base.css @@ -0,0 +1,8 @@ +/* Rotating arrows when dropdown happens */ +i.fas.rotate { + transition: transform 0.3s ease-in-out; +} + +a[aria-expanded="true"] > i.rotate { + transform: rotate(180deg); +} diff --git a/dashboard/src/static/css/detail_view.css b/dashboard/src/static/css/detail_view.css new file mode 100644 index 0000000..c3d0a4d --- /dev/null +++ b/dashboard/src/static/css/detail_view.css @@ -0,0 +1,39 @@ +.card_container { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-gap: 25px 25px; + justify-items: stretch; +} + +.card_container ul > li { + padding: 7px !important; + font-size: 16px; +} + +.detail_button_container .btn { + width: 49%; +} + +.detail_button_container .btn-danger { + float: right; +} + +#modal_warning { + transition: max-height 0.5s ease-out; + overflow: hidden; +} + +.detail_card { + border-radius: 5px; + border-top: 1px solid #ccc; + border-left: 1px solid #ccc; + border-right: 1px solid #ccc; + border-bottom: 1px solid #ccc; + margin: 5px; + padding-left: 25px; + padding-right: 25px; + padding-bottom: 15px; + display: flex; + flex-direction: column; + justify-content: space-between; +} diff --git a/dashboard/src/static/css/graph_common.css b/dashboard/src/static/css/graph_common.css index 7f90a66..cff1516 100644 --- a/dashboard/src/static/css/graph_common.css +++ b/dashboard/src/static/css/graph_common.css @@ -26,9 +26,6 @@ div.mxRubberband { margin: 0px; } div.mxWindow { - -webkit-box-shadow: 3px 3px 12px #C0C0C0; - -moz-box-shadow: 3px 3px 12px #C0C0C0; - box-shadow: 3px 3px 12px #C0C0C0; background: url('../img/mxgraph/window.gif'); border:1px solid #c3c3c3; position: absolute; @@ -67,19 +64,32 @@ td.mxWindowPane td { font-size: 8pt; } td.mxWindowPane input, td.mxWindowPane select, td.mxWindowPane textarea, td.mxWindowPane radio { - border-color: #8C8C8C; - border-style: solid; - border-width: 1px; font-family: Arial; font-size: 8pt; padding: 1px; } td.mxWindowPane button { - background: url('/static/img/mxgraph/button.gif') repeat-x; - font-family: Arial; - font-size: 8pt; - padding: 2px; - float: left; + color: #fff; + background-color: #337ab7; + border-color: #2e6da4; + display: inline-block; + margin: 2%; + font-size: 14px; + font-weight: 400; + line-height: 1.42857143; + text-align: center; + white-space: nowrap; + vertical-align: middle; + -ms-touch-action: manipulation; + touch-action: manipulation; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-image: none; + border: 1px solid transparent; + border-radius: 4px; } img.mxToolbarItem { margin-right: 6px; diff --git a/dashboard/src/static/js/dashboard.js b/dashboard/src/static/js/dashboard.js new file mode 100644 index 0000000..84c3703 --- /dev/null +++ b/dashboard/src/static/js/dashboard.js @@ -0,0 +1,1134 @@ +class MultipleSelectFilterWidget { + + constructor(neighbors, items, initial) { + this.inputs = []; + this.graph_neighbors = neighbors; + this.filter_items = items; + this.result = {}; + this.dropdown_count = 0; + + for(let nodeId in this.filter_items) { + const node = this.filter_items[nodeId]; + this.result[node.class] = {} + } + + this.make_selection(initial); + } + + make_selection( initial_data ){ + if(!initial_data || jQuery.isEmptyObject(initial_data)) + return; + for(let item_class in initial_data) { + const selected_items = initial_data[item_class]; + for( let node_id in selected_items ){ + const node = this.filter_items[node_id]; + const selection_data = selected_items[node_id] + if( selection_data.selected ) { + this.select(node); + this.markAndSweep(node); + this.updateResult(node); + } + if(node['multiple']){ + this.make_multiple_selection(node, selection_data); + } + } + } + } + + make_multiple_selection(node, selection_data){ + const prepop_data = selection_data.values; + for(let k in prepop_data){ + const div = this.add_item_prepopulate(node, prepop_data[k]); + this.updateObjectResult(node, div.id, prepop_data[k]); + } + } + + markAndSweep(root){ + for(let i in this.filter_items) { + const node = this.filter_items[i]; + node['marked'] = true; //mark all nodes + } + + const toCheck = [root]; + while(toCheck.length > 0){ + const node = toCheck.pop(); + if(!node['marked']) { + continue; //already visited, just continue + } + node['marked'] = false; //mark as visited + if(node['follow'] || node == root){ //add neighbors if we want to follow this node + const neighbors = this.graph_neighbors[node.id]; + for(let neighId of neighbors) { + const neighbor = this.filter_items[neighId]; + toCheck.push(neighbor); + } + } + } + + //now remove all nodes still marked + for(let i in this.filter_items){ + const node = this.filter_items[i]; + if(node['marked']){ + this.disable_node(node); + } + } + } + + process(node) { + if(node['selected']) { + this.markAndSweep(node); + } + else { //TODO: make this not dumb + const selected = [] + //remember the currently selected, then reset everything and reselect one at a time + for(let nodeId in this.filter_items) { + node = this.filter_items[nodeId]; + if(node['selected']) { + selected.push(node); + } + this.clear(node); + } + for(let node of selected) { + this.select(node); + this.markAndSweep(node); + } + } + } + + select(node) { + const elem = document.getElementById(node['id']); + node['selected'] = true; + elem.classList.remove('disabled_node', 'cleared_node'); + elem.classList.add('selected_node'); + } + + clear(node) { + const elem = document.getElementById(node['id']); + node['selected'] = false; + node['selectable'] = true; + elem.classList.add('cleared_node') + elem.classList.remove('disabled_node', 'selected_node'); + } + + disable_node(node) { + const elem = document.getElementById(node['id']); + node['selected'] = false; + node['selectable'] = false; + elem.classList.remove('cleared_node', 'selected_node'); + elem.classList.add('disabled_node'); + } + + processClick(id){ + const node = this.filter_items[id]; + if(!node['selectable']) + return; + + if(node['multiple']){ + return this.processClickMultiple(node); + } else { + return this.processClickSingle(node); + } + } + + processClickSingle(node){ + node['selected'] = !node['selected']; //toggle on click + if(node['selected']) { + this.select(node); + } else { + this.clear(node); + } + this.process(node); + this.updateResult(node); + } + + processClickMultiple(node){ + this.select(node); + const div = this.add_item_prepopulate(node, false); + this.process(node); + this.updateObjectResult(node, div.id, ""); + } + + restrictchars(input){ + if( input.validity.patternMismatch ){ + input.setCustomValidity("Only alphanumeric characters (a-z, A-Z, 0-9), underscore(_), and hyphen (-) are allowed"); + input.reportValidity(); + } + input.value = input.value.replace(/([^A-Za-z0-9-_.])+/g, ""); + this.checkunique(input); + } + + checkunique(tocheck){ //TODO: use set + const val = tocheck.value; + for( let input of this.inputs ){ + if( input.value == val && input != tocheck){ + tocheck.setCustomValidity("All hostnames must be unique"); + tocheck.reportValidity(); + return; + } + } + tocheck.setCustomValidity(""); + } + + make_remove_button(div, node){ + const button = document.createElement("BUTTON"); + button.type = "button"; + button.appendChild(document.createTextNode("Remove")); + button.classList.add("btn", "btn-danger"); + const that = this; + button.onclick = function(){ that.remove_dropdown(div.id, node.id); } + return button; + } + + make_input(div, node, prepopulate){ + const input = document.createElement("INPUT"); + input.type = node.form.type; + input.name = node.id + node.form.name + input.pattern = "(?=^.{1,253}$)(^([A-Za-z0-9-_]{1,62}\.)*[A-Za-z0-9-_]{1,63})"; + input.title = "Only alphanumeric characters (a-z, A-Z, 0-9), underscore(_), and hyphen (-) are allowed" + input.placeholder = node.form.placeholder; + this.inputs.push(input); + const that = this; + input.onchange = function() { that.updateObjectResult(node, div.id, input.value); that.restrictchars(this); }; + input.oninput = function() { that.restrictchars(this); }; + if(prepopulate) + input.value = prepopulate; + return input; + } + + add_item_prepopulate(node, prepopulate){ + const div = document.createElement("DIV"); + div.id = "dropdown_" + this.dropdown_count; + div.classList.add("dropdown_item"); + this.dropdown_count++; + const label = document.createElement("H5") + label.appendChild(document.createTextNode(node['name'])) + div.appendChild(label); + div.appendChild(this.make_input(div, node, prepopulate)); + div.appendChild(this.make_remove_button(div, node)); + document.getElementById("dropdown_wrapper").appendChild(div); + return div; + } + + remove_dropdown(div_id, node_id){ + const div = document.getElementById(div_id); + const node = this.filter_items[node_id] + const parent = div.parentNode; + div.parentNode.removeChild(div); + delete this.result[node.class][node.id]['values'][div.id]; + + //checks if we have removed last item in class + if(jQuery.isEmptyObject(this.result[node.class][node.id]['values'])){ + delete this.result[node.class][node.id]; + this.clear(node); + } + } + + updateResult(node){ + if(!node['multiple']){ + this.result[node.class][node.id] = {selected: node.selected, id: node.model_id} + if(!node.selected) + delete this.result[node.class][node.id]; + } + } + + updateObjectResult(node, childKey, childValue){ + if(!this.result[node.class][node.id]) + this.result[node.class][node.id] = {selected: true, id: node.model_id, values: {}} + + this.result[node.class][node.id]['values'][childKey] = childValue; + } + + finish(){ + document.getElementById("filter_field").value = JSON.stringify(this.result); + } +} + +class NetworkStep { + constructor(debug, xml, hosts, added_hosts, removed_host_ids, graphContainer, overviewContainer, toolbarContainer){ + if(!this.check_support()) + return; + + this.currentWindow = null; + this.netCount = 0; + this.netColors = ['red', 'blue', 'purple', 'green', 'orange', '#8CCDF5', '#1E9BAC']; + this.hostCount = 0; + this.lastHostBottom = 100; + this.networks = new Set(); + this.has_public_net = false; + this.debug = debug; + this.editor = new mxEditor(); + this.graph = this.editor.graph; + + this.editor.setGraphContainer(graphContainer); + this.doGlobalConfig(); + this.prefill(xml, hosts, added_hosts, removed_host_ids); + this.addToolbarButton(this.editor, toolbarContainer, 'zoomIn', '', "/static/img/mxgraph/zoom_in.png", true); + this.addToolbarButton(this.editor, toolbarContainer, 'zoomOut', '', "/static/img/mxgraph/zoom_out.png", true); + + if(this.debug){ + this.editor.addAction('printXML', function(editor, cell) { + mxLog.write(this.encodeGraph()); + mxLog.show(); + }.bind(this)); + this.addToolbarButton(this.editor, toolbarContainer, 'printXML', '', '/static/img/mxgraph/fit_to_size.png', true); + } + + new mxOutline(this.graph, overviewContainer); + //sets the edge color to be the same as the network + this.graph.addListener(mxEvent.CELL_CONNECTED, function(sender, event) {this.cellConnectionHandler(sender, event)}.bind(this)); + //hooks up double click functionality + this.graph.dblClick = function(evt, cell) {this.doubleClickHandler(evt, cell);}.bind(this); + + if(!this.has_public_net){ + this.addPublicNetwork(); + } + } + + check_support(){ + if (!mxClient.isBrowserSupported()) { + mxUtils.error('Browser is not supported', 200, false); + return false; + } + return true; + } + + prefill(xml, hosts, added_hosts, removed_host_ids){ + //populate existing data + if(xml){ + this.restoreFromXml(xml, this.editor); + } else if(hosts){ + for(const host of hosts) + this.makeHost(host); + } + + //apply any changes + if(added_hosts){ + for(const host of added_hosts) + this.makeHost(host); + this.updateHosts([]); //TODO: why? + } + this.updateHosts(removed_host_ids); + } + + cellConnectionHandler(sender, event){ + const edge = event.getProperty('edge'); + const terminal = event.getProperty('terminal') + const source = event.getProperty('source'); + if(this.checkAllowed(edge, terminal, source)) { + this.colorEdge(edge, terminal, source); + this.alertVlan(edge, terminal, source); + } + } + + doubleClickHandler(evt, cell) { + if( cell != null ){ + if( cell.getParent() != null && cell.getParent().getId().indexOf("network") > -1) { + cell = cell.getParent(); + } + if( cell.isEdge() || cell.getId().indexOf("network") > -1 ) { + this.createDeleteDialog(cell.getId()); + } + else { + this.showDetailWindow(cell); + } + } + } + + alertVlan(edge, terminal, source) { + if( terminal == null || edge.getTerminal(!source) == null) { + return; + } + const form = document.createElement("form"); + const tagged = document.createElement("input"); + tagged.type = "radio"; + tagged.name = "tagged"; + tagged.value = "True"; + form.appendChild(tagged); + form.appendChild(document.createTextNode(" Tagged")); + form.appendChild(document.createElement("br")); + + const untagged = document.createElement("input"); + untagged.type = "radio"; + untagged.name = "tagged"; + untagged.value = "False"; + form.appendChild(untagged); + form.appendChild(document.createTextNode(" Untagged")); + form.appendChild(document.createElement("br")); + + const yes_button = document.createElement("button"); + yes_button.onclick = function() {this.parseVlanWindow(edge.getId());}.bind(this); + yes_button.appendChild(document.createTextNode("Okay")); + + const cancel_button = document.createElement("button"); + cancel_button.onclick = function() {this.deleteVlanWindow(edge.getId());}.bind(this); + cancel_button.appendChild(document.createTextNode("Cancel")); + + const error_div = document.createElement("div"); + error_div.id = "current_window_errors"; + form.appendChild(error_div); + + const content = document.createElement('div'); + content.appendChild(form); + content.appendChild(yes_button); + content.appendChild(cancel_button); + this.showWindow("Vlan Selection", content, 200, 200); + } + + createDeleteDialog(id) { + const content = document.createElement('div'); + const remove_button = document.createElement("button"); + remove_button.style.width = '46%'; + remove_button.onclick = function() { this.deleteCell(id);}.bind(this); + remove_button.appendChild(document.createTextNode("Remove")); + const cancel_button = document.createElement("button"); + cancel_button.style.width = '46%'; + cancel_button.onclick = function() { this.closeWindow();}.bind(this); + cancel_button.appendChild(document.createTextNode("Cancel")); + + content.appendChild(remove_button); + content.appendChild(cancel_button); + this.showWindow('Do you want to delete this network?', content, 200, 62); + } + + checkAllowed(edge, terminal, source) { + //check if other terminal is null, and that they are different + const otherTerminal = edge.getTerminal(!source); + if(terminal != null && otherTerminal != null) { + if( terminal.getParent().getId().split('_')[0] == //'host' or 'network' + otherTerminal.getParent().getId().split('_')[0] ) { + //not allowed + this.graph.removeCells([edge]); + return false; + } + } + return true; + } + + colorEdge(edge, terminal, source) { + if(terminal.getParent().getId().indexOf('network') >= 0) { + const styles = terminal.getParent().getStyle().split(';'); + let color = 'black'; + for(let style of styles){ + const kvp = style.split('='); + if(kvp[0] == "fillColor"){ + color = kvp[1]; + } + } + edge.setStyle('strokeColor=' + color); + } + } + + showDetailWindow(cell) { + const info = JSON.parse(cell.getValue()); + const content = document.createElement("div"); + const pre_tag = document.createElement("pre"); + pre_tag.appendChild(document.createTextNode("Name: " + info.name + "\nDescription:\n" + info.description)); + const ok_button = document.createElement("button"); + ok_button.onclick = function() { this.closeWindow();}; + content.appendChild(pre_tag); + content.appendChild(ok_button); + this.showWindow('Details', content, 400, 400); + } + + restoreFromXml(xml, editor) { + const doc = mxUtils.parseXml(xml); + const node = doc.documentElement; + editor.readGraphModel(node); + + //Iterate over all children, and parse the networks to add them to the sidebar + for( const cell of this.graph.getModel().getChildren(this.graph.getDefaultParent())) { + if(cell.getId().indexOf("network") > -1) { + const info = JSON.parse(cell.getValue()); + const name = info['name']; + this.networks.add(name); + const styles = cell.getStyle().split(";"); + let color = null; + for(const style of styles){ + const kvp = style.split('='); + if(kvp[0] == "fillColor") { + color = kvp[1]; + break; + } + } + if(info.public){ + this.has_public_net = true; + } + this.netCount++; + this.makeSidebarNetwork(name, color, cell.getId()); + } + } + } + + deleteCell(cellId) { + var cell = this.graph.getModel().getCell(cellId); + if( cellId.indexOf("network") > -1 ) { + let elem = document.getElementById(cellId); + elem.parentElement.removeChild(elem); + } + this.graph.removeCells([cell]); + this.currentWindow.destroy(); + } + + newNetworkWindow() { + const input = document.createElement("input"); + input.type = "text"; + input.name = "net_name"; + input.maxlength = 100; + input.id = "net_name_input"; + input.style.margin = "5px"; + + const yes_button = document.createElement("button"); + yes_button.onclick = function() {this.parseNetworkWindow();}.bind(this); + yes_button.appendChild(document.createTextNode("Okay")); + + const cancel_button = document.createElement("button"); + cancel_button.onclick = function() {this.closeWindow();}.bind(this); + cancel_button.appendChild(document.createTextNode("Cancel")); + + const error_div = document.createElement("div"); + error_div.id = "current_window_errors"; + + const content = document.createElement("div"); + content.appendChild(document.createTextNode("Name: ")); + content.appendChild(input); + content.appendChild(document.createElement("br")); + content.appendChild(yes_button); + content.appendChild(cancel_button); + content.appendChild(document.createElement("br")); + content.appendChild(error_div); + + this.showWindow("Network Creation", content, 300, 300); + } + + parseNetworkWindow() { + const net_name = document.getElementById("net_name_input").value + const error_div = document.getElementById("current_window_errors"); + if( this.networks.has(net_name) ){ + error_div.innerHTML = "All network names must be unique"; + return; + } + this.addNetwork(net_name); + this.currentWindow.destroy(); + } + + addToolbarButton(editor, toolbar, action, label, image, isTransparent) { + const button = document.createElement('button'); + button.style.fontSize = '10'; + if (image != null) { + const img = document.createElement('img'); + img.setAttribute('src', image); + img.style.width = '16px'; + img.style.height = '16px'; + img.style.verticalAlign = 'middle'; + img.style.marginRight = '2px'; + button.appendChild(img); + } + if (isTransparent) { + button.style.background = 'transparent'; + button.style.color = '#FFFFFF'; + button.style.border = 'none'; + } + mxEvent.addListener(button, 'click', function(evt) { + editor.execute(action); + }); + mxUtils.write(button, label); + toolbar.appendChild(button); + }; + + encodeGraph() { + const encoder = new mxCodec(); + const xml = encoder.encode(this.graph.getModel()); + return mxUtils.getXml(xml); + } + + doGlobalConfig() { + //general graph stuff + this.graph.setMultigraph(false); + this.graph.setCellsSelectable(false); + this.graph.setCellsMovable(false); + + //testing + this.graph.vertexLabelIsMovable = true; + + //edge behavior + this.graph.setConnectable(true); + this.graph.setAllowDanglingEdges(false); + mxEdgeHandler.prototype.snapToTerminals = true; + mxConstants.MIN_HOTSPOT_SIZE = 16; + mxConstants.DEFAULT_HOTSPOT = 1; + //edge 'style' (still affects behavior greatly) + const style = this.graph.getStylesheet().getDefaultEdgeStyle(); + style[mxConstants.STYLE_EDGE] = mxConstants.EDGESTYLE_ELBOW; + style[mxConstants.STYLE_ENDARROW] = mxConstants.NONE; + style[mxConstants.STYLE_ROUNDED] = true; + style[mxConstants.STYLE_FONTCOLOR] = 'black'; + style[mxConstants.STYLE_STROKECOLOR] = 'red'; + style[mxConstants.STYLE_LABEL_BACKGROUNDCOLOR] = '#FFFFFF'; + style[mxConstants.STYLE_STROKEWIDTH] = '3'; + style[mxConstants.STYLE_ROUNDED] = true; + style[mxConstants.STYLE_EDGE] = mxEdgeStyle.EntityRelation; + + const hostStyle = this.graph.getStylesheet().getDefaultVertexStyle(); + hostStyle[mxConstants.STYLE_ROUNDED] = 1; + + this.graph.convertValueToString = function(cell) { + try{ + //changes value for edges with xml value + if(cell.isEdge()) { + if(JSON.parse(cell.getValue())["tagged"]) { + return "tagged"; + } + return "untagged"; + } + else{ + return JSON.parse(cell.getValue())['name']; + } + } + catch(e){ + return cell.getValue(); + } + }; + } + + showWindow(title, content, width, height) { + //create transparent black background + const background = document.createElement('div'); + background.style.position = 'absolute'; + background.style.left = '0px'; + background.style.top = '0px'; + background.style.right = '0px'; + background.style.bottom = '0px'; + background.style.background = 'black'; + mxUtils.setOpacity(background, 50); + document.body.appendChild(background); + + const x = Math.max(0, document.body.scrollWidth/2-width/2); + const y = Math.max(10, (document.body.scrollHeight || + document.documentElement.scrollHeight)/2-height*2/3); + + const wnd = new mxWindow(title, content, x, y, width, height, false, true); + wnd.setClosable(false); + + wnd.addListener(mxEvent.DESTROY, function(evt) { + this.graph.setEnabled(true); + mxEffects.fadeOut(background, 50, true, 10, 30, true); + }.bind(this)); + this.currentWindow = wnd; + + this.graph.setEnabled(false); + this.currentWindow.setVisible(true); + }; + + closeWindow() { + //allows the current window to be destroyed + this.currentWindow.destroy(); + }; + + othersUntagged(edgeID) { + const edge = this.graph.getModel().getCell(edgeID); + const end1 = edge.getTerminal(true); + const end2 = edge.getTerminal(false); + + if( end1.getParent().getId().split('_')[0] == 'host' ){ + var netint = end1; + } else { + var netint = end2; + } + + var edges = netint.edges; + for( let edge of edges) { + if( edge.getValue() ) { + var tagged = JSON.parse(edge.getValue()).tagged; + } else { + var tagged = true; + } + if( !tagged ) { + return true; + } + } + return false; + }; + + + deleteVlanWindow(edgeID) { + const cell = this.graph.getModel().getCell(edgeID); + this.graph.removeCells([cell]); + this.currentWindow.destroy(); + } + + parseVlanWindow(edgeID) { + //do parsing and data manipulation + const radios = document.getElementsByName("tagged"); + const edge = this.graph.getModel().getCell(edgeID); + + for(let radio of radios){ + if(radio.checked) { + //set edge to be tagged or untagged + if( radio.value == "False") { + if( this.othersUntagged(edgeID) ) { + document.getElementById("current_window_errors").innerHTML = "Only one untagged vlan per interface is allowed."; + return; + } + } + const edgeVal = {tagged: radio.value == "True"}; + edge.setValue(JSON.stringify(edgeVal)); + break; + } + } + this.graph.refresh(edge); + this.closeWindow(); + } + + makeMxNetwork(net_name, is_public = false) { + const model = this.graph.getModel(); + const width = 10; + const height = 1700; + const xoff = 400 + (30 * this.netCount); + const yoff = -10; + let color = this.netColors[this.netCount]; + if( this.netCount > (this.netColors.length - 1)) { + color = Math.floor(Math.random() * 16777215); //int in possible color space + color = '#' + color.toString(16).toUpperCase(); //convert to hex + } + const net_val = { name: net_name, public: is_public}; + const net = this.graph.insertVertex( + this.graph.getDefaultParent(), + 'network_' + this.netCount, + JSON.stringify(net_val), + xoff, + yoff, + width, + height, + 'fillColor=' + color, + false + ); + const num_ports = 45; + for(var i=0; i<num_ports; i++){ + let port = this.graph.insertVertex( + net, + null, + '', + 0, + (1/num_ports) * i, + 10, + height / num_ports, + 'fillColor=black;opacity=0', + true + ); + } + + const ret_val = { color: color, element_id: "network_" + this.netCount }; + + this.networks.add(net_name); + this.netCount++; + return ret_val; + } + + addPublicNetwork() { + const net = this.makeMxNetwork("public", true); + this.makeSidebarNetwork("public", net['color'], net['element_id']); + this.has_public_net = true; + } + + addNetwork(net_name) { + const ret = this.makeMxNetwork(net_name); + this.makeSidebarNetwork(net_name, ret.color, ret.element_id); + } + + updateHosts(removed) { + const cells = [] + for(const hostID of removed) { + cells.push(this.graph.getModel().getCell("host_" + hostID)); + } + this.graph.removeCells(cells); + + const hosts = this.graph.getChildVertices(this.graph.getDefaultParent()); + let topdist = 100; + for(const i in hosts) { + const host = hosts[i]; + if(host.id.startsWith("host_")){ + const geometry = host.getGeometry(); + geometry.y = topdist + 50; + topdist = geometry.y + geometry.height; + host.setGeometry(geometry); + } + } + } + + makeSidebarNetwork(net_name, color, net_id){ + const newNet = document.createElement("li"); + const colorBlob = document.createElement("div"); + colorBlob.className = "colorblob"; + const textContainer = document.createElement("p"); + textContainer.className = "network_innertext"; + newNet.id = net_id; + const deletebutton = document.createElement("button"); + deletebutton.className = "btn btn-danger"; + deletebutton.style = "float: right; height: 20px; line-height: 8px; vertical-align: middle; width: 20px; padding-left: 5px;"; + deletebutton.appendChild(document.createTextNode("X")); + deletebutton.addEventListener("click", function() { this.createDeleteDialog(net_id); }.bind(this), false); + textContainer.appendChild(document.createTextNode(net_name)); + colorBlob.style['background'] = color; + newNet.appendChild(colorBlob); + newNet.appendChild(textContainer); + if( net_name != "public" ) { + newNet.appendChild(deletebutton); + } + document.getElementById("network_list").appendChild(newNet); + } + + makeHost(hostInfo) { + const value = JSON.stringify(hostInfo['value']); + const interfaces = hostInfo['interfaces']; + const width = 100; + const height = (25 * interfaces.length) + 25; + const xoff = 75; + const yoff = this.lastHostBottom + 50; + this.lastHostBottom = yoff + height; + const host = this.graph.insertVertex( + this.graph.getDefaultParent(), + 'host_' + hostInfo['id'], + value, + xoff, + yoff, + width, + height, + 'editable=0', + false + ); + host.getGeometry().offset = new mxPoint(-50,0); + host.setConnectable(false); + this.hostCount++; + + for(var i=0; i<interfaces.length; i++) { + const port = this.graph.insertVertex( + host, + null, + JSON.stringify(interfaces[i]), + 90, + (i * 25) + 12, + 20, + 20, + 'fillColor=blue;editable=0', + false + ); + port.getGeometry().offset = new mxPoint(-4*interfaces[i].name.length -2,0); + this.graph.refresh(port); + } + this.graph.refresh(host); + } + + submitForm() { + const form = document.getElementById("xml_form"); + const input_elem = document.getElementById("hidden_xml_input"); + input_elem.value = this.encodeGraph(this.graph); + const req = new XMLHttpRequest(); + req.open("POST", "/wf/workflow/", false); + req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.onerror = function() { alert("problem with form submission"); } + const formData = $("#xml_form").serialize(); + req.send(formData); + } +} + +class SearchableSelectMultipleWidget { + constructor(format_vars, field_dataset, field_initial) { + this.format_vars = format_vars; + this.items = field_dataset; + this.initial = field_initial; + + this.expanded_name_trie = {"isComplete": false}; + this.small_name_trie = {"isComplete": false}; + this.string_trie = {"isComplete": false}; + + this.added_items = new Set(); + + for( let e of ["show_from_noentry", "show_x_results", "results_scrollable", "selectable_limit", "placeholder"] ) + { + this[e] = format_vars[e]; + } + + this.search_field_init(); + + if( this.show_from_noentry ) + { + this.search(""); + } + } + + disable() { + const textfield = document.getElementById("user_field"); + const drop = document.getElementById("drop_results"); + + textfield.disabled = "True"; + drop.style.display = "none"; + + const btns = document.getElementsByClassName("btn-remove"); + for( const btn of btns ) + { + btn.classList.add("disabled"); + btn.onclick = ""; + } + } + + search_field_init() { + this.build_all_tries(this.items); + + for( const elem of this.initial ) + { + this.select_item(elem); + } + if(this.initial.length == 1) + { + this.search(this.items[this.initial[0]]["small_name"]); + document.getElementById("user_field").value = this.items[this.initial[0]]["small_name"]; + } + } + + build_all_tries(dict) + { + for( const key in dict ) + { + this.add_item(dict[key]); + } + } + + add_item(item) + { + const id = item['id']; + this.add_to_tree(item['expanded_name'], id, this.expanded_name_trie); + this.add_to_tree(item['small_name'], id, this.small_name_trie); + this.add_to_tree(item['string'], id, this.string_trie); + } + + add_to_tree(str, id, trie) + { + let inner_trie = trie; + while( str ) + { + if( !inner_trie[str.charAt(0)] ) + { + var new_trie = {}; + inner_trie[str.charAt(0)] = new_trie; + } + else + { + var new_trie = inner_trie[str.charAt(0)]; + } + + if( str.length == 1 ) + { + new_trie.isComplete = true; + if( !new_trie.ids ) + { + new_trie.ids = []; + } + new_trie.ids.push(id); + } + inner_trie = new_trie; + str = str.substring(1); + } + } + + search(input) + { + if( input.length == 0 && !this.show_from_noentry){ + this.dropdown([]); + return; + } + else if( input.length == 0 && this.show_from_noentry) + { + this.dropdown(this.items); //show all items + } + else + { + const trees = [] + const tr1 = this.getSubtree(input, this.expanded_name_trie); + trees.push(tr1); + const tr2 = this.getSubtree(input, this.small_name_trie); + trees.push(tr2); + const tr3 = this.getSubtree(input, this.string_trie); + trees.push(tr3); + const results = this.collate(trees); + this.dropdown(results); + } + } + + getSubtree(input, given_trie) + { + /* + recursive function to return the trie accessed at input + */ + + if( input.length == 0 ){ + return given_trie; + } + + else{ + const substr = input.substring(0, input.length - 1); + const last_char = input.charAt(input.length-1); + const subtrie = this.getSubtree(substr, given_trie); + + if( !subtrie ) //substr not in the trie + { + return {}; + } + + const indexed_trie = subtrie[last_char]; + return indexed_trie; + } + } + + serialize(trie) + { + /* + takes in a trie and returns a list of its item id's + */ + let itemIDs = []; + if ( !trie ) + { + return itemIDs; //empty, base case + } + for( const key in trie ) + { + if(key.length > 1) + { + continue; + } + itemIDs = itemIDs.concat(this.serialize(trie[key])); + } + if ( trie.isComplete ) + { + itemIDs.push(...trie.ids); + } + + return itemIDs; + } + + collate(trees) + { + /* + takes a list of tries + returns a list of ids of objects that are available + */ + const results = []; + for( const tree of trees ) + { + const available_IDs = this.serialize(tree); + + for( const itemID of available_IDs ) { + results[itemID] = this.items[itemID]; + } + } + return results; + } + + generate_element_text(obj) + { + const content_strings = [obj.expanded_name, obj.small_name, obj.string].filter(x => Boolean(x)); + const result = content_strings.shift(); + if( result == null || content_strings.length < 1) { + return result; + } else { + return result + " (" + content_strings.join(", ") + ")"; + } + } + + dropdown(ids) + { + /* + takes in a mapping of ids to objects in items + and displays them in the dropdown + */ + const drop = document.getElementById("drop_results"); + while(drop.firstChild) + { + drop.removeChild(drop.firstChild); + } + + for( const id in ids ) + { + const result_entry = document.createElement("li"); + const result_button = document.createElement("a"); + const obj = this.items[id]; + const result_text = this.generate_element_text(obj); + result_button.appendChild(document.createTextNode(result_text)); + result_button.onclick = function() { searchable_select_multiple_widget.select_item(obj.id); }; + const tooltip = document.createElement("span"); + const tooltiptext = document.createTextNode(result_text); + tooltip.appendChild(tooltiptext); + tooltip.setAttribute('class', 'entry_tooltip'); + result_button.appendChild(tooltip); + result_entry.appendChild(result_button); + drop.appendChild(result_entry); + } + + const scroll_restrictor = document.getElementById("scroll_restrictor"); + + if( !drop.firstChild ) + { + scroll_restrictor.style.visibility = 'hidden'; + } + else + { + scroll_restrictor.style.visibility = 'inherit'; + } + } + + select_item(item_id) + { + if( (this.selectable_limit > -1 && this.added_items.size < this.selectable_limit) || this.selectable_limit < 0 ) + { + this.added_items.add(item_id); + } + this.update_selected_list(); + // clear search bar contents + document.getElementById("user_field").value = ""; + document.getElementById("user_field").focus(); + this.search(""); + } + + remove_item(item_id) + { + this.added_items.delete(item_id); + + this.update_selected_list() + document.getElementById("user_field").focus(); + } + + update_selected_list() + { + document.getElementById("added_number").innerText = this.added_items.size; + const selector = document.getElementById('selector'); + selector.value = JSON.stringify([...this.added_items]); + const added_list = document.getElementById('added_list'); + + while(selector.firstChild) + { + selector.removeChild(selector.firstChild); + } + while(added_list.firstChild) + { + added_list.removeChild(added_list.firstChild); + } + + let list_html = ""; + + for( const item_id of this.added_items ) + { + const item = this.items[item_id]; + + const element_entry_text = this.generate_element_text(item); + + list_html += '<div class="list_entry">' + + '<p class="added_entry_text">' + + element_entry_text + + '</p>' + + '<button onclick="searchable_select_multiple_widget.remove_item(' + + item_id + + ')" class="btn-remove btn">remove</button>'; + list_html += '</div>'; + } + added_list.innerHTML = list_html; + } +} diff --git a/dashboard/src/templates/account/booking_list.html b/dashboard/src/templates/account/booking_list.html index ef4df3a..98ab5c8 100644 --- a/dashboard/src/templates/account/booking_list.html +++ b/dashboard/src/templates/account/booking_list.html @@ -1,52 +1,136 @@ {% extends "base.html" %} {% block content %} -<script> -function edit_booking(pk){ - var csrf = $('input[name="csrfmiddlewaretoken"]').val(); - $.ajax({ - type: "POST", - url: "/", - data: { "target": pk, "create": 0, "csrfmiddlewaretoken": csrf}, - beforeSend: function(request) { - request.setRequestHeader("X-CSFRToken", csrf); - } - }).done(function(){ - window.location.replace("/wf/"); - }).fail(function(){}) -} -</script> <h2>Bookings I Own</h2> + <div class="card_container"> {% for booking in bookings %} - <div style="border:2px;border-style:solid;border-color:grey;margin:5px"> - <ul> - <li>id: {{booking.id}}</li> - <li>lab: {{booking.resource.template.lab.lab_user.username}}</li> - <li>resource: {{booking.resource.template.name}}</li> - <li>start: {{booking.start}}</li> - <li>end: {{booking.end}}</li> - <li>purpose: {{booking.purpose}}</li> - </ul> - <div style="display:inline;margin:3px;padding:3px"> - <button onclick="edit_booking({{booking.id}});">Edit</button> - <button onclick="location.href='/booking/detail/{{booking.id}}/';">Details</button> + <div class="card"> + <div class="card-header"> + <h3>Booking {{booking.id}}</h3> + </div> + <div class="card-body"> + <ul class="list-group"> + <li class="list-group-item">id: {{booking.id}}</li> + <li class="list-group-item">lab: {{booking.lab}}</li> + <li class="list-group-item">resource: {{booking.resource.template.name}}</li> + <li class="list-group-item">start: {{booking.start}}</li> + <li class="list-group-item">end: {{booking.end}}</li> + <li class="list-group-item">purpose: {{booking.purpose}}</li> + </ul> + </div> + <div class="card-footer d-flex"> + <a class="btn btn-primary ml-auto mr-2" href="/booking/detail/{{booking.id}}/">Details</a> + <button + class="btn btn-danger" + onclick='cancel_booking({{booking.id}});' + data-toggle="modal" + data-target="#resModal" + >Cancel</button> </div> </div> {% endfor %} + </div> <h2>Bookings I Collaborate On</h2> - {% for booking in collab_bookings %} - <div style="border:2px;border-style:solid;border-color:grey;margin:5px"> - <ul> - <li>id: {{booking.id}}</li> - <li>lab: {{booking.lab}}</li> - <li>resource: {{booking.resource_name}}</li> - <li>start: {{booking.start}}</li> - <li>end: {{booking.end}}</li> - <li>purpose: {{booking.purpose}}</li> - </ul> - <div style="display:inline;margin:3px;padding:3px"> - <button disabled=true onclick="edit_booking({{booking.id}});">Edit</button> - <button onclick="location.href='/booking/detail/{{booking.id}}/';">Details</button> + <div class="card_container"> + {% for booking in collab_bookings %} + <div class="card"> + <div class="card-header"> + <h3>Booking {{booking.id}}</h3> + </div> + <div class="card-body"> + <ul class="list-group"> + <li class="list-group-item">id: {{booking.id}}</li> + <li class="list-group-item">lab: {{booking.lab}}</li> + <li class="list-group-item">resource: {{booking.resource.template.name}}</li> + <li class="list-group-item">start: {{booking.start}}</li> + <li class="list-group-item">end: {{booking.end}}</li> + <li class="list-group-item">purpose: {{booking.purpose}}</li> + </ul> + </div> + <div class="card-footer d-flex"> + <a class="btn btn-primary ml-auto" href="/booking/detail/{{booking.id}}/">Details</a> + </div> + </div> + {% endfor %} + </div> + <h2>Expired Bookings + <i class="fa fa-fw fa-caret-down" onclick='toggle_display("expired_bookings");'></i> + </h2> + <div id="expired_bookings" class="card_container" style="display:none;"> + {% for booking in expired_bookings %} + <div class="card"> + <div class="card-header"> + <h3>Booking {{booking.id}}</h3> + </div> + <div class="card-body"> + <ul class="list-group"> + <li class="list-group-item">id: {{booking.id}}</li> + <li class="list-group-item">lab: {{booking.lab}}</li> + <li class="list-group-item">resource: {{booking.resource.template.name}}</li> + <li class="list-group-item">start: {{booking.start}}</li> + <li class="list-group-item">end: {{booking.end}}</li> + <li class="list-group-item">purpose: {{booking.purpose}}</li> + <li class="list-group-item">owner: {{booking.owner.userprofile.email_addr}}</li> + </ul> + </div> + <div class="card-footer d-flex"> + <a class="btn btn-primary ml-auto" href="/booking/detail/{{booking.id}}/">Details</a> </div> </div> {% endfor %} + </div> +<script> + var current_booking_id = -1; + function cancel_booking(booking_id) { + current_booking_id = booking_id; + document.getElementById('modal_warning').style['max-height'] = '0px'; + } + + function submit_cancel_form() { + var ajaxForm = $("#booking_cancel_form"); + var formData = ajaxForm.serialize(); + req = new XMLHttpRequest(); + var url = "cancel/" + current_booking_id; + req.open("POST", url, true); + req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.onerror = function() { alert("problem submitting form"); } + req.send(formData); + } + + function toggle_display(elem_id){ + var e = document.getElementById(elem_id); + if (e.style.display === "none"){ + e.style.display = "grid"; + } else { + e.style.display = "none"; + } + } +</script> +<div class="modal fade" id="resModal" tabindex="-1" role="dialog" aria-labelledby="my_modal" 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="my_modal" style="display: inline; float: left;">Cancel Booking?</h4> + <p>Everthing on your machine(s) will be lost</p> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + <form id="booking_cancel_form"> + {% csrf_token %} + </form> + <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';">Cancel Booking</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 cannot be undone</p> + <button class="btn" onclick="document.getElementById('modal_warning').style['max-height'] = '0px';">Nevermind</button> + <button class="btn btn-danger" id="confirm_cancel_button" data-dismiss="modal" onclick="submit_cancel_form();">I'm Sure</button> + </div> + </div> + </div> + </div> +</div> {% endblock %} diff --git a/dashboard/src/templates/account/configuration_list.html b/dashboard/src/templates/account/configuration_list.html index ee61e97..6f7844a 100644 --- a/dashboard/src/templates/account/configuration_list.html +++ b/dashboard/src/templates/account/configuration_list.html @@ -1,28 +1,72 @@ {% extends "base.html" %} {% block content %} +<div class="card_container"> +{% for config in configurations %} + <div class="card"> + <div class="card-header"> + <h3>Configuration {{config.id}}</h3> + </div> + <div class="card-body"> + <ul class="list-group"> + <li class="list-group-item">id: {{config.id}}</li> + <li class="list-group-item">name: {{config.name}}</li> + <li class="list-group-item">description: {{config.description}}</li> + <li class="list-group-item">resource: {{config.bundle}}</li> + </ul> + </div> + <div class="card-footer"> + <button + class="btn btn-danger w-100" + onclick='delete_config({{config.id}});' + data-toggle="modal" + data-target="#configModal" + >Delete</button> + </div> + </div> +{% endfor %} +</div> <script> -function edit_configuration(pk){ - var csrf = $('input[name="csrfmiddlewaretoken"]').val(); - $.ajax({ - type: "POST", - url: "/", - data: { "target": pk, "create": 2, "csrfmiddlewaretoken": csrf}, - beforeSend: function(request) { - request.setRequestHeader("X-CSFRToken", csrf); - } - }).done(function(){ - window.location.replace("/wf/"); - }).fail(function(){}); -} + var current_config_id = -1; + function delete_config(config_id) { + current_config_id = config_id; + document.getElementById('modal_warning').style['max-height'] = '0px'; + } + + function submit_delete_form() { + var ajaxForm = $("#config_delete_form"); + var formData = ajaxForm.serialize(); + req = new XMLHttpRequest(); + var url = "delete/" + current_config_id; + req.open("POST", url, true); + req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.onerror = function() { alert("problem submitting form"); } + req.send(formData); + } </script> - {% for config in configurations %} - <div style="border:2px;border-style:solid;border-color:grey;margin:5px"> - <ul> - <li>id: {{config.id}}</li> - <li>name: {{config.name}}</li> - <li>description: {{config.description}}</li> - </ul> - <button onclick="edit_configuration({{config.id}});">Edit</button> +<div class="modal fade" id="configModal" tabindex="-1" role="dialog" aria-labelledby="my_modal" 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="my_modal" style="display: inline; float: left;">Delete Configuration?</h4> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + <form id="config_delete_form"> + {% csrf_token %} + </form> + <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';">Delete</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 cannot be undone</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_delete_form();">I'm Sure</button> + </div> </div> - {% endfor %} + </div> +</div> {% endblock %} diff --git a/dashboard/src/templates/account/details.html b/dashboard/src/templates/account/details.html index 5641064..3092ad0 100644 --- a/dashboard/src/templates/account/details.html +++ b/dashboard/src/templates/account/details.html @@ -2,8 +2,8 @@ {% load staticfiles %} {% block content %} <h1>Account Details</h1> -<button onclick="location.href = '{% url 'account:my-resources' %}'">My Resources</button> -<button onclick="location.href = '{% url 'account:my-bookings' %}'">My Bookings</button> -<button onclick="location.href = '{% url 'account:my-configurations' %}'">My Configurations</button> -<button onclick="location.href = '{% url 'account:my-images' %}'">My Snapshots</button> +<a class="btn btn-primary" href="{% url 'account:my-resources' %}">My Resources</a> +<a class="btn btn-primary" href="{% url 'account:my-bookings' %}">My Bookings</a> +<a class="btn btn-primary" href="{% url 'account:my-configurations' %}">My Configurations</a> +<a class="btn btn-primary" href="{% url 'account:my-images' %}">My Snapshots</a> {% endblock content %} diff --git a/dashboard/src/templates/account/image_list.html b/dashboard/src/templates/account/image_list.html index fb436df..068e096 100644 --- a/dashboard/src/templates/account/image_list.html +++ b/dashboard/src/templates/account/image_list.html @@ -1,27 +1,123 @@ {% extends "base.html" %} {% block content %} <h2>Images I Own</h2> - {% for image in images %} - <div style="border:2px;border-style:solid;border-color:grey;margin:5px"> - <ul> - <li>id: {{image.id}}</li> - <li>lab: {{image.from_lab.name}}</li> - <li>name: {{image.name}}</li> - <li>description: {{image.description}}</li> - <li>host profile: {{image.host_type.name}}</li> +<div class="card_container"> +{% for image in images %} + <div class="card"> + <div class="card-header"> + <h3>Image {{image.id}}</h3> + </div> + <div class="card-body"> + <ul class="list-group"> + <li class="list-group-item">id: {{image.id}}</li> + <li class="list-group-item">lab: {{image.from_lab.name}}</li> + <li class="list-group-item">name: {{image.name}}</li> + <li class="list-group-item">description: {{image.description}}</li> + <li class="list-group-item">host profile: {{image.host_type.name}}</li> </ul> </div> - {% endfor %} + <div class="card-footer"> + <button + class="btn btn-danger w-100" + onclick='delete_image({{image.id}});' + data-toggle="modal" + data-target="#imageModal" + >Delete</button> + </div> + </div> +{% endfor %} +</div> <h2>Public Images</h2> +<div class="card_container"> {% for image in public_images %} - <div style="border:2px;border-style:solid;border-color:grey;margin:5px"> - <ul> - <li>id: {{image.id}}</li> - <li>lab: {{image.from_lab.name}}</li> - <li>name: {{image.name}}</li> - <li>description: {{image.description}}</li> - <li>host profile: {{image.host_type.name}}</li> - </ul> + <div class="card"> + <div class="card-header"> + <h3>Image {{image.id}}</h3> + </div> + <div class="card-body"> + <ul class="list-group"> + <li class="list-group-item">id: {{image.id}}</li> + <li class="list-group-item">lab: {{image.from_lab.name}}</li> + <li class="list-group-item">name: {{image.name}}</li> + <li class="list-group-item">description: {{image.description}}</li> + <li class="list-group-item">host profile: {{image.host_type.name}}</li> + </ul> + </div> </div> {% endfor %} +</div> + +<script> + var current_image_id = -1; + var used_images = {{used_images|safe|default:"{}"}}; + function delete_image(image_id) { + current_image_id = image_id; + document.getElementById('modal_warning').style['max-height'] = '0px'; + var warning_header = document.getElementById("warning_header"); + var warning_text = document.getElementById("warning_text"); + var delete_image_button = document.getElementById("final_delete_b"); + clear(warning_header); + clear(warning_text); + if(used_images[image_id]) { + warning_header.appendChild( + document.createTextNode("Cannot Delete") + ); + warning_text.appendChild( + document.createTextNode("This snapshot is being used in a booking.") + ); + delete_image_button.disabled = true; + } else { + warning_header.appendChild( + document.createTextNode("Are You Sure?") + ); + warning_text.appendChild( + document.createTextNode("This cannot be undone") + ); + delete_image_button.removeAttribute("disabled"); + } + } + + function submit_delete_form() { + var ajaxForm = $("#image_delete_form"); + var formData = ajaxForm.serialize(); + req = new XMLHttpRequest(); + var url = "delete/" + current_image_id; + req.open("POST", url, true); + req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.onerror = function() { alert("problem submitting form"); } + req.send(formData); + } + + function clear(node) { + while(node.lastChild) { + node.removeChild(node.lastChild); + } + } +</script> +<div class="modal fade" id="imageModal" tabindex="-1" role="dialog" aria-labelledby="my_modal" 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="my_modal" style="display: inline; float: left;">Delete Configuration?</h4> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + <form id="image_delete_form"> + {% csrf_token %} + </form> + <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';">Delete</button> + </div> + <div id="modal_warning" class="modal-footer" style="max-height:0px;" > + <div style="text-align:center; margin: 5px"> + <h3 id="warning_header">Are You Sure?</h3> + <p id="warning_text">This cannot be undone</p> + <button class="btn" onclick="document.getElementById('modal_warning').style['max-height'] = '0px';">Nevermind</button> + <button id="final_delete_b" class="btn btn-danger" data-dismiss="modal" onclick="submit_delete_form();">I'm Sure</button> + </div> + </div> + </div> +</div> {% endblock %} diff --git a/dashboard/src/templates/account/resource_list.html b/dashboard/src/templates/account/resource_list.html index 482a000..f92f78e 100644 --- a/dashboard/src/templates/account/resource_list.html +++ b/dashboard/src/templates/account/resource_list.html @@ -1,28 +1,118 @@ {% extends "base.html" %} {% block content %} +<div class="card_container"> +{% for resource in resources %} + <div class="card"> + <div class="card-header"> + <h3>Resource {{resource.id}}</h3> + </div> + <div class="card-body p-4"> + <ul class="list-group"> + <li class="list-group-item">id: {{resource.id}}</li> + <li class="list-group-item">name: {{resource.name}}</li> + <li class="list-group-item">description: {{resource.description}}</li> + </ul> + </div> + <div class="card-footer"> + <button + class="btn btn-danger w-100" + onclick='delete_resource({{resource.id}});' + data-toggle="modal" + data-target="#resModal" + >Delete</button> + </div> + </div> +{% endfor %} +</div> <script> -function edit_resource(pk){ - var csrf = $('input[name="csrfmiddlewaretoken"]').val(); - $.ajax({ - type: "POST", - url: "/", - data: { "target": pk, "create": 1, "csrfmiddlewaretoken": csrf}, - beforeSend: function(request) { - request.setRequestHeader("X-CSFRToken", csrf); + var grb_mapping = {{grb_mapping|safe|default:"{}"}}; + var booking_mapping = {{booking_mapping|safe|default:"{}"}}; + var current_resource_id = -1; + function delete_resource(resource_id) { + document.getElementById("confirm_delete_button").removeAttribute("disabled"); + var configs = grb_mapping[resource_id]; + var warning = document.createTextNode("Are You Sure?"); + var warning_subtext = document.createTextNode("This cannot be undone"); + if(booking_mapping[resource_id]){ + var warning = document.createTextNode("This resource is being used. It cannot be deleted."); + var warning_subtext = document.createTextNode("If your booking just ended, you may need to give us a few minutes to clean it up before this can be removed."); + + document.getElementById("confirm_delete_button").disabled = true; + } + else if(configs.length > 0) { + list_configs(configs); + warning_text = "Are You Sure? The following Configurations will also be deleted."; + warning = document.createTextNode(warning_text); + } + + current_resource_id = resource_id; + set_modal_text(warning, warning_subtext); + } + + function set_modal_text(title, text) { + var clear = function(node) { + while(node.lastChild) { + node.removeChild(node.lastChild); } - }).done(function(){ - window.location.replace("/wf/"); - }).fail(function(){}); -} + } + var warning_title = document.getElementById("config_warning"); + var warning_text = document.getElementById("warning_subtext"); + + clear(warning_title); + clear(warning_text); + + warning_title.appendChild(title); + warning_text.appendChild(text); + document.getElementById('modal_warning').style['max-height'] = '0px'; + } + + function list_configs(configs) { + var list = document.getElementById("config_list"); + for(var i=0; i<configs.length; i++){ + var str = configs[i].name; + var list_item = document.createElement("LI"); + list_item.appendChild(document.createTextNode(str)); + list.appendChild(list_item); + } + } + + function submit_delete_form() { + var ajaxForm = $("#res_delete_form"); + var formData = ajaxForm.serialize(); + req = new XMLHttpRequest(); + var url = "delete/" + current_resource_id; + req.open("POST", url, true); + req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.onerror = function() { alert("problem submitting form"); } + req.send(formData); + } </script> - {% for resource in resources %} - <div style="border:2px;border-style:solid;border-color:grey;margin:5px"> - <ul> - <li>id: {{resource.id}}</li> - <li>name: {{resource.name}}</li> - <li>description: {{resource.description}}</li> - </ul> - <button onclick="edit_resource({{resource.id}});">Edit</button> +<div class="modal fade" id="resModal" tabindex="-1" role="dialog" aria-labelledby="my_modal" 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="my_modal" style="display: inline; float: left;">Delete Resource?</h4> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + <form id="res_delete_form"> + {% csrf_token %} + </form> + <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';">Delete</button> + </div> + <div id="modal_warning" class="modal-footer" style="max-height:0px;" > + <div style="text-align:center; margin: 5px"> + <h3 id="config_warning">Are You Sure?</h3> + <p id="warning_subtext">This cannot be undone</p> + <ul id="config_list"></ul> + <button class="btn" onclick="document.getElementById('modal_warning').style['max-height'] = '0px';">Nevermind</button> + <button class="btn btn-danger" id="confirm_delete_button" data-dismiss="modal" onclick="submit_delete_form();">I'm Sure</button> + </div> </div> - {% endfor %} + </div> +</div> + {% endblock %} diff --git a/dashboard/src/templates/account/userprofile_update_form.html b/dashboard/src/templates/account/userprofile_update_form.html index f4bb7b5..6ab8242 100644 --- a/dashboard/src/templates/account/userprofile_update_form.html +++ b/dashboard/src/templates/account/userprofile_update_form.html @@ -1,23 +1,18 @@ -{% extends "layout.html" %} -{% load bootstrap3 %} +{% extends "base.html" %} +{% load bootstrap4 %} -{% block basecontent %} - <div class="container"> +{% block content %} + <div class="container-fluid"> <div class="row"> - <div class="col-md-4 col-md-offset-4"> + <div class="col-12 col-xl-6"> {% bootstrap_messages %} <div class="login-panel panel panel-default"> - <div class="panel-heading"> - <h3 class="panel-title"> - {{ title }} - </h3> - </div> <div class="panel-body"> <form enctype="multipart/form-data" method="post"> {% csrf_token %} {% bootstrap_form form %} <p><b>API Token</b> - <a href="{% url 'generate_token' %}" class="btn btn-default"> + <a href="{% url 'generate_token' %}" class="btn btn-primary"> Generate </a> </p> @@ -35,4 +30,4 @@ </div> </div> </div> -{% endblock basecontent %} +{% endblock content %} diff --git a/dashboard/src/templates/base.html b/dashboard/src/templates/base.html index c63db8c..62a9ed5 100644 --- a/dashboard/src/templates/base.html +++ b/dashboard/src/templates/base.html @@ -1,51 +1,51 @@ {% extends "layout.html" %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% load staticfiles %} {% block extrahead %} - <!-- Custom CSS --> - <link href="{% static "bower_components/startbootstrap-sb-admin-2-blackrockdigital/dist/css/sb-admin-2.min.css" %}" - rel="stylesheet"> - <link href="{% static "css/theme.css" %}" rel="stylesheet"> - +<!-- Custom CSS --> +<link href="{% static "css/detail_view.css" %}" rel="stylesheet"> +<link href="{% static "css/base.css" %}" rel="stylesheet"> <script type="text/javascript"> - function cwf(type) - { + function cwf(type) { $.ajax({ type: "POST", url: "/", - data: {"create":type}, - beforeSend: function(request) { + data: { + "create": type + }, + beforeSend: function (request) { request.setRequestHeader("X-CSRFToken", - $('input[name="csrfmiddlewaretoken"]').val() + $('input[name="csrfmiddlewaretoken"]').val() ); } }).done(function (data) { window.location.replace("/wf/"); - }).fail(function(jqxHR, textstatus) { - alert("Something went wrong...");}); + }).fail(function (jqxHR, textstatus) { + alert("Something went wrong..."); + }); } - function continue_wf() - { + + function continue_wf() { window.location.replace("/wf/"); } - function toggle_create_drop() - { + function toggle_create_drop() { drop_div = document.getElementById("create_drop"); - if (drop_div.style.display === "none") - { + if (drop_div.style.display === "none") { drop_div.style.display = "inherit"; - } - else - { + } else { drop_div.style.display = "none"; } } </script> <style> + .navbar { + min-width: 200px; + } + .create_drop { display: none; width: 100%; @@ -65,140 +65,172 @@ border-top: 1px solid #E7E7E7; border-bottom: 1px solid #E7E7E7; } + + #wrapper { + height: 100vh; + } </style> {% endblock %} {% block basecontent %} - <div id="wrapper"> - <!-- Navigation --> - <nav class="navbar navbar-default navbar-static-top" role="navigation" - style="margin-bottom: 0"> - <div class="navbar-header"> - <button type="button" class="navbar-toggle" data-toggle="collapse" - data-target=".navbar-collapse"> - <span class="sr-only">Toggle navigation</span> - <span class="icon-bar"></span> - <span class="icon-bar"></span> - <span class="icon-bar"></span> - </button> - <a href="https://www.opnfv.org/" class="navbar-left"><img - src="{% static "img/opnfv-logo.png" %}"></a> - <a class="navbar-brand" href={% url 'dashboard:index' %}>Pharos Dashboard</a> +<div id="wrapper" class="d-flex flex-column"> + <!-- Navigation --> + <nav class="navbar navbar-light bg-light navbar-fixed-top border-bottom py-0" role="navigation" style="margin-bottom: 0"> + <div class="container-fluid"> + <div class="col order-2 order-lg-1 text-center text-lg-left"> + <a href="https://www.opnfv.org/" class="navbar-brand"> + <img src="{% static "img/opnfv-logo.png" %}"> + </a> + <a class="navbar-brand" href={% url 'dashboard:index' %}> + Pharos Dashboard + </a> </div> <!-- /.navbar-header --> + <div class="col-2 order-1 order-lg-3 d-lg-none"> + <button class="btn border" type="button" data-toggle="collapse" data-target="#sidebar" + aria-expanded="false" aria-controls="sidebar"> + <i class="fas fa-bars"></i> + </button> + </div> + <div class="col-2 order-3"> + <ul class="nav ml-auto"> + <li class="dropdown ml-auto"> + <a class="nav-link p-0 text-dark p-2" data-toggle="dropdown" href="#"> + {% if request.user.username %} + {{request.user.username}} + {% else %} + <i class="fas fa-user"></i> + {% endif %} + <i class="fas fa-caret-down rotate"></i> + </a> + <div class="dropdown-menu dropdown-menu-right"> + {% if user.is_authenticated %} + <a href="{% url 'account:settings' %}" class="text-dark dropdown-item"> + <i class="fas fa-cog"></i> + Settings + </a> + <a href="{% url 'account:logout' %}?next={{ request.path }}" class="text-dark dropdown-item"> + <i class="fas fa-sign-out-alt"></i> + Logout + </a> + {% else %} + <a href="{% url 'account:login' %}" class="text-dark dropdown-item"> + <i class="fas fa-sign-in-alt"></i> + Login with Jira + </a> + {% endif %} + </div> <!-- End dropdown-menu --> + </li> <!-- End dropdown --> + </ul> + </div> <!-- End top right account menu --> + </div> + </nav> + <!-- /.navbar-top-links --> - <ul class="nav navbar-top-links navbar-right"> - <li class="dropdown"> - <a class="dropdown-toggle" data-toggle="dropdown" href="#"> - <i class="fa fa-user fa-fw"></i> <i class="fa fa-caret-down"></i> - </a> - <ul class="dropdown-menu dropdown-user"> - {% if user.is_authenticated %} - <li><a href="{% url 'account:settings' %}"><i - class="fa fa-gear fa-fw"></i> - Settings</a> - </li> - <li class="divider"></li> - <li><a href="{% url 'account:logout' %}?next={{ request.path }}"><i - class="fa fa-sign-out fa-fw"></i> - Logout</a> - </li> - {% else %} - <li><a href="{% url 'account:login' %}"><i - class="fa fa-sign-in fa-fw"></i> - Login with Jira</a> - <li> - {% endif %} - </ul> - <!-- /.dropdown-user --> - </li> - <!-- /.dropdown --> - </ul> - <!-- /.navbar-top-links --> - - <div class="navbar-default sidebar" role="navigation"> - <div class="sidebar-nav navbar-collapse"> - <ul class="nav" id="side-menu"> - <li> - <a href="/"><i class="fa fa-fw"></i>Home</a> - </li> - <li style="width: 100%;"> - <a href="javascript:toggle_create_drop();"><i class="fa fa-fw"></i>Create<i - class="fa fa-fw fa-caret-down"></i> + <!-- Page Content --> + <div class="container-fluid d-lg-flex flex-lg-grow-1 px-0"> + <div class="row h-100 w-100 mx-0"> + <div class="col-12 col-lg-auto px-0 border-right border-left bg-light" role="navigation"> + <nav class="navbar navbar-expand-lg border-bottom p-0 w-100"> + <div class="collapse navbar-collapse" id="sidebar"> + <div class="list-group list-group-flush w-100 bg-light"> + <a href="/" class="list-group-item list-group-item-action bg-light"> + Home + </a> + {% csrf_token %} + <a class="list-group-item list-group-item-action bg-light" data-toggle="collapse" + href="#createList" role="button"> + Create <i class="fas fa-angle-down rotate"></i> + </a> + <div class="collapse" id="createList"> + <a href="/booking/quick/" class="list-group-item list-group-item-action list-group-item-secondary"> + Express Booking + </a> + <a href="#" onclick="cwf(0)" class="list-group-item list-group-item-action list-group-item-secondary"> + Book a Pod + </a> + <a href="#" onclick="cwf(1)" class="list-group-item list-group-item-action list-group-item-secondary"> + Design a Pod + </a> + <a href="#" onclick="cwf(2)" class="list-group-item list-group-item-action list-group-item-secondary"> + Configure a Pod + </a> + <a href="#" onclick="cwf(3)" class="list-group-item list-group-item-action list-group-item-secondary"> + Create a Snapshot </a> - {% csrf_token %} - <div id="create_drop" class="create_drop" style="display:none"> - <button class="btn drop_btn" onclick="cwf(0)">Create a Booking</button> - <button class="btn drop_btn" onclick="cwf(1)">Create a Pod</button> - <button class="btn drop_btn" onclick="cwf(2)">Configure a Pod</button> - <button class="btn drop_btn" onclick="cwf(3)">Create a Snapshot</button> - </div> - </li> - <li> - <a href="{% url 'resource:hosts' %}"><i - class="fa fa-fw"></i>Hosts + <a href="#" onclick="cwf(4)" class="list-group-item list-group-item-action list-group-item-secondary"> + Configure OPNFV </a> - </li> - {% if user.is_authenticated %} - <li> - <a href="{% url 'account:users' %}"><i - class="fa fa-fw"></i>User List + </div> + <a href="{% url 'resource:hosts' %}" class="list-group-item list-group-item-action bg-light"> + Hosts </a> - </li> - {% endif %} - <li> - <a href="{% url 'booking:list' %}"><i - class="fa fa-fw"></i>Booking List + {% if user.is_authenticated %} + <a href="{% url 'account:users' %}" class="list-group-item list-group-item-action bg-light"> + User List + </a> + {% endif %} + <a href="{% url 'booking:list' %}" class="list-group-item list-group-item-action bg-light"> + Booking List </a> - </li> - <li> - <a href="{% url 'booking:stats' %}"><i - class="fa fa-fw"></i>Booking Statistics</a> - </li> - <li> - <a href="{% url 'api-root' %}"><i - class="fa fa-fw"></i>API + <a href="{% url 'booking:stats' %}" class="list-group-item list-group-item-action bg-light"> + Booking Statistics </a> - </li> - <li> - <a href="{% url 'account:my-account' %}"><i - class="fa fa-fw"></i>Account + <!-- <a href="{% url 'account:my-account' %}" class="list-group-item list-group-item-action bg-light"> + Account + </a> --> + <a class="list-group-item list-group-item-action bg-light" data-toggle="collapse" + href="#accountList" role="button"> + Account <i class="fas fa-angle-down rotate"></i> </a> - </li> - <li> - <a href="{% url 'dashboard:all_labs' %}"><i - class="fa fa-fw"></i>Lab Info + <div class="collapse" id="accountList"> + <a href="{% url 'account:my-resources' %}" class="list-group-item list-group-item-action list-group-item-secondary"> + My Resources + </a> + <a href="{% url 'account:my-bookings' %}" class="list-group-item list-group-item-action list-group-item-secondary"> + My Bookings + </a> + <a href="{% url 'account:my-configurations' %}" class="list-group-item list-group-item-action list-group-item-secondary"> + My Configurations + </a> + <a href="{% url 'account:my-images' %}" class="list-group-item list-group-item-action list-group-item-secondary"> + My Snapshots + </a> + </div> + <a href="{% url 'dashboard:all_labs' %}" class="list-group-item list-group-item-action bg-light"> + Lab Info </a> - </li> - <li> - <a href="{% url 'notifier:messages' %}"><i - class="fa fa-fw"></i>Inbox + <a href="{% url 'notifier:messages' %}" class="list-group-item list-group-item-action bg-light"> + Inbox </a> - </li> - </ul> - </div> - <!-- /.sidebar-collapse --> + </div> + </div> + </nav> + <!--/.well --> </div> - <!-- /.navbar-static-side --> - </nav> + <!--/span--> - <!-- Page Content --> - <div id="page-wrapper"> - {% if title %} - <div class="row"> - <div class="col-lg-12"> - <h1 class="page-header">{{ title }}</h1> + <div class="col flex-grow-1 d-flex flex-column"> + {% if title %} + <div class="row flex-shrink-1"> + <div class="col-lg-12"> + <h1 class="page-header">{{ title }}</h1> + </div> + <!-- /.col-lg-12 --> </div> - <!-- /.col-lg-12 --> + {% endif %} + <div id="bsm">{% bootstrap_messages %}</div> + <!-- Content block placed here --> + {% block content %} + {% endblock content %} </div> - {% endif %} - <div id="bsm">{% bootstrap_messages %}</div> + <!--/span--> - {% block content %} - {% endblock content %} </div> - <!-- /#page-wrapper --> + <!--/row--> </div> - <!-- /#wrapper --> + <!-- /#page-wrapper --> +</div> +<!-- /#wrapper --> {% endblock basecontent %} diff --git a/dashboard/src/templates/booking/booking_calendar.html b/dashboard/src/templates/booking/booking_calendar.html index 349cb0a..ddcb45d 100644 --- a/dashboard/src/templates/booking/booking_calendar.html +++ b/dashboard/src/templates/booking/booking_calendar.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block extrahead %} {{ block.super }} @@ -129,7 +129,7 @@ <div class="modal-body" id="booking_detail_content"> </div> <div class="modal-footer"> - <button type="button" class="btn btn-default" data-dismiss="modal">Close + <button type="button" class="btn btn-primary" data-dismiss="modal">Close </button> </div> </div> diff --git a/dashboard/src/templates/booking/booking_delete.html b/dashboard/src/templates/booking/booking_delete.html index 76a5634..b89eb15 100644 --- a/dashboard/src/templates/booking/booking_delete.html +++ b/dashboard/src/templates/booking/booking_delete.html @@ -1,5 +1,5 @@ {% load jira_filters %} -{% load bootstrap3 %} +{% load bootstrap4 %} <p> Really delete Booking from {{ booking.start}} to {{ booking.end }}? diff --git a/dashboard/src/templates/booking/booking_detail.html b/dashboard/src/templates/booking/booking_detail.html index cae0e25..918f5af 100644 --- a/dashboard/src/templates/booking/booking_detail.html +++ b/dashboard/src/templates/booking/booking_detail.html @@ -1,22 +1,31 @@ {% extends "base.html" %} {% load staticfiles %} +{% load bootstrap4 %} {% 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="panel panel-default"> - <div class="panel-heading clearfix"> + <div class="col-12 col-lg-5"> + <div class="card mb-4"> + <div class="card-header d-flex"> <h4 style="display: inline;">Overview</h4> - <a data-toggle="collapse" data-target="#panel_overview" class="btn pull-right" style="line-height: 1;" >Expand</a> + <button data-toggle="collapse" data-target="#panel_overview" class="btn btn-outline-secondary ml-auto">Expand</button> </div> - <div class="panel-body" id="panel_overview"> + <div class="card-body collapse show" id="panel_overview"> <table class="table"> <tr> <td>Purpose</td> @@ -50,15 +59,13 @@ </div> </div> <div class="row"> - - <div class="col-lg-6"> - - <div class="panel panel-default"> - <div class="panel-heading clearfix"> + <div class="col-lg-12"> + <div class="card"> + <div class="card-header d-flex"> <h4 style="display: inline;">Pod</h4> - <a data-toggle="collapse" data-target="#pod_panel" class="btn pull-right" style="line-height: 1;" >Expand</a> + <button data-toggle="collapse" data-target="#pod_panel" class="btn btn-outline-secondary ml-auto">Expand</button> </div> - <div class="panel-body pod_panel" id="pod_panel"> + <div class="card-body collapse show" id="pod_panel"> <table class="table"> {% for host in booking.resource.hosts.all %} <tr> @@ -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> @@ -111,15 +126,15 @@ <table class="table"> <tr> <td>Size:</td> - <td>{{host.profile.diskprofile.first.size}}GiB</td> + <td>{{host.profile.storageprofile.first.size}} GiB</td> </tr> <tr> <td>Type:</td> - <td>{{host.profile.diskprofile.first.media_type}}</td> + <td>{{host.profile.storageprofile.first.media_type}}</td> </tr> <tr> <td>Mount Point:</td> - <td>{{host.profile.diskprofile.first.name}}</td> + <td>{{host.profile.storageprofile.first.name}}</td> </tr> </table> </td> @@ -152,10 +167,7 @@ </table> </td> </tr> - - </table> - </td> {% endfor %} </tr> @@ -163,32 +175,16 @@ </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="panel panel-default"> - <div class="panel-heading clearfix"> + <div class="col"> + <div class="card mb-4"> + <div class="card-header d-flex"> <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> + <button data-toggle="collapse" data-target="#panel_tasks" class="btn btn-outline-secondary ml-auto">Expand</button> </div> - - <div class="panel-body" id="panel_tasks"> + <div class="card-body collapse show" id="panel_tasks"> <table class="table"> <style> .progress { @@ -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"> + <div class="card"> + <div class="card-header d-flex"> + <h4 style="display: inline;">PDF</h4> + <button data-toggle="collapse" data-target="#pdf_panel" class="btn btn-outline-secondary ml-auto">Expand</button> + </div> + <div class="card-body collapse show" 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/dashboard/src/templates/booking/booking_list.html b/dashboard/src/templates/booking/booking_list.html index a245450..591ecc9 100644 --- a/dashboard/src/templates/booking/booking_list.html +++ b/dashboard/src/templates/booking/booking_list.html @@ -1,44 +1,38 @@ {% extends "base.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block extrahead %} {{ block.super }} <!-- DataTables CSS --> - <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + <link href="{% static "bower_components/datatables.net-bs4/css/dataTables.bootstrap4.min.css" %}" rel="stylesheet"> <!-- DataTables Responsive CSS --> - <link href="{% static "bower_components/datatables-responsive/css/dataTables.responsive.css" %}" + <link href="{% static "bower_components/datatables.net-responsive-bs4/css/responsive.bootstrap4.min.css" %}" rel="stylesheet"> {% endblock extrahead %} {% block content %} <div class="row"> - <div class="panel-body"> - <div class="dataTables_wrapper"> - <table class="table table-striped table-bordered table-hover" id="table" - cellspacing="0" - width="100%"> - {% include "booking/booking_table.html" %} - </table> - </div> - <!-- /.table-responsive --> - <!-- /.panel-body --> - <!-- /.panel --> + <div class="col"> + <div class="panel-body"> + <div class="dataTables_wrapper"> + <table class="table table-striped table-bordered table-hover" id="table" + cellspacing="0" + width="100%"> + {% include "booking/booking_table.html" %} + </table> + </div> + </div> </div> - <!-- /.col-lg-12 --> </div> {% endblock content %} {% block extrajs %} <!-- DataTables JavaScript --> - <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" - rel="stylesheet"> - - - <script src={% static "bower_components/datatables/media/js/jquery.dataTables.min.js" %}></script> - <script src={% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.min.js" %}></script> + <script src={% static "bower_components/datatables.net/js/jquery.dataTables.min.js" %}></script> + <script src={% static "bower_components/datatables.net-bs4/js/dataTables.bootstrap4.min.js" %}></script> <script type="text/javascript"> $(document).ready(function () { diff --git a/dashboard/src/templates/booking/booking_table.html b/dashboard/src/templates/booking/booking_table.html index 5e82645..32a0146 100644 --- a/dashboard/src/templates/booking/booking_table.html +++ b/dashboard/src/templates/booking/booking_table.html @@ -5,11 +5,10 @@ <tr> <th>Owner</th> <th>Purpose</th> + <th>Project</th> <th>Start</th> <th>End</th> <th>Operating System</th> - <th>Installer</th> - <th>Scenario</th> </tr> </thead> <tbody> @@ -22,19 +21,16 @@ {{ booking.purpose }} </td> <td> - {{ booking.start }} - </td> - <td> - {{ booking.end }} + {{ booking.project }} </td> <td> - {{ booking.opsys }} + {{ booking.start }} </td> <td> - {{ booking.installer }} + {{ booking.end }} </td> <td> - {{ booking.scenario }} + {{ booking.resource.get_head_node.config.image.os.name }} </td> </tr> {% endfor %} diff --git a/dashboard/src/templates/booking/quick_deploy.html b/dashboard/src/templates/booking/quick_deploy.html new file mode 100644 index 0000000..07f3d89 --- /dev/null +++ b/dashboard/src/templates/booking/quick_deploy.html @@ -0,0 +1,187 @@ +{% extends "base.html" %} +{% load staticfiles %} +{% load bootstrap4 %} +{% block content %} +<style> + .grid_container { + display: grid; + grid-template-columns: repeat(12, 1fr); + padding: 30px; + } + .grid_element { + border-radius: 5px; + border: 1px solid #ccc; + 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; + } + .collaborator_pane { + display: flex; + flex-direction: column; + } + #id_length { + -moz-appearance: none; + border: none; + box-shadow: none; + } + + input[type=range]::-moz-range-track { + background: #cccccc; + } + + .grid_element { + overflow: hidden; + } +</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 show_label=False %} +</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_1third"> + <div class="configuration_pane grid_element"> + {% bootstrap_field form.hostname %} + {% bootstrap_field form.image %} + </div> + <div class="configuration_pane grid_element"> + <strong>OPNFV: (Optional)</strong> + {% bootstrap_field form.installer %} + {% bootstrap_field form.scenario %} + </div> +</div> +</div> +<script type="text/javascript"> + + function submit_form() + { + //formats data for form submission + multi_filter_widget.finish(); + } + + function hide_dropdown(drop_id) { + var drop = document.getElementById(drop_id); + //select 'blank' option + for( var i=0; i < drop.length; i++ ) + { + if ( drop.options[i].text == '---------' ) + drop.selectedIndex = i; + } + + //cross browser hide children + $('#id_image').children().hide(); + for( var i = 0; i < drop.childNodes.length; i++ ) + { + drop.childNodes[i].disabled = true; // closest we can get on safari to hiding it outright + } + } + + function get_selected_value(key){ + for( var attr in multi_filter_widget.result[key] ){ + if(!(attr in {}) ) + return attr; + } + return null; + } + + var sup_image_dict = {{ image_filter|safe }}; + var sup_installer_dict = {{ installer_filter|safe }}; + var sup_scenario_dict = {{ scenario_filter|safe }}; + + function imageHider() { + var drop = document.getElementById("id_image"); + + hide_dropdown("id_image"); + + var lab_pk = get_selected_value("lab"); + var host_pk = get_selected_value("host"); + + 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 + { + if( image_object.host_profile == host_pk && image_object.lab == lab_pk ) + { + drop.childNodes[i].style.display = "inherit"; + drop.childNodes[i].disabled = false; + } + } + } + } + + imageHider(); + $('#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) { + var dropdown = document.getElementById(target); + + hide_dropdown(target); + + var drop = document.getElementById(master); + var opts = target_filter[drop.options[drop.selectedIndex].value]; + if (!opts) { + opts = {}; + } + + 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 < dropdown.childNodes.length; i++) { + if (dropdown.childNodes[i].value in opts && !(dropdown.childNodes[i].value in {}) ) { + dropdown.childNodes[i].style.display = "inherit"; + dropdown.childNodes[i].disabled = false; + } + } + } +</script> + <button id="quick_booking_confirm" onclick="submit_form();" class="btn btn-success">Confirm</button> +</form> +{% endblock %} diff --git a/dashboard/src/templates/booking/stats.html b/dashboard/src/templates/booking/stats.html index abb153b..8bc68cd 100644 --- a/dashboard/src/templates/booking/stats.html +++ b/dashboard/src/templates/booking/stats.html @@ -41,15 +41,28 @@ function getData(){ {% endblock %} {% 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 id="all_graph_container"> - <div id="booking_graph_wrapper"> - <div id="booking_graph_container"/> + <div class="container-fluid"> + <div class="row"> + <div class="col"> + <p>Number of days to plot: </p> + <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> </div> - <div id="user_graph_wrapper" > - <div id="user_graph_container"/> + <div class="row"> + <div class="col-12 col-md-10"> + <!-- These graphs do NOT get redrawn when the browser size is changed --> + <div id="all_graph_container border" class="mw-100"> + <div id="booking_graph_wrapper"> + <div id="booking_graph_container"/> + </div> + <div id="user_graph_wrapper"> + <div id="user_graph_container"/> + </div> + </div> + </div> </div> </div> <script> diff --git a/dashboard/src/templates/booking/steps/booking_confirm.html b/dashboard/src/templates/booking/steps/booking_confirm.html index 9c7e951..40c30a9 100644 --- a/dashboard/src/templates/booking/steps/booking_confirm.html +++ b/dashboard/src/templates/booking/steps/booking_confirm.html @@ -1,7 +1,7 @@ {% extends "workflow/viewport-element.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} diff --git a/dashboard/src/templates/booking/steps/booking_meta.html b/dashboard/src/templates/booking/steps/booking_meta.html index a42e158..710d4ee 100644 --- a/dashboard/src/templates/booking/steps/booking_meta.html +++ b/dashboard/src/templates/booking/steps/booking_meta.html @@ -1,7 +1,7 @@ {% extends "workflow/viewport-element.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} @@ -10,6 +10,17 @@ padding: 5%; } + .bkcontrib_panel { + display: flex; + flex-direction: column; + } + + .bkcontrib_panel > .form-group { + flex: 1; + display: flex; + flex-direction: column; + } + .panel{ padding: 5%; /*border: solid 1px black;*/ @@ -21,6 +32,15 @@ grid-template-columns: 45% 10% 45%; border: none; } + + #id_length { + -moz-appearance: none; + border: none; + box-shadow: none; + } + input[type=range]::-moz-range-track { + background: #cccccc; + } </style> {% bootstrap_form_errors form type='non_fields' %} @@ -39,10 +59,11 @@ </script> {% bootstrap_field form.info_file %} <p>You must provide a url to your project's INFO.yaml file if you are a PTL and you are trying to book a POD with multiple servers in it.</p> + {% bootstrap_field form.deploy_opnfv %} </div> <div class="panel panel_center"> </div> - <div class="panel"> + <div class="panel bkcontrib_panel"> <p>You may add collaborators on your booking to share resources with coworkers.</p> {% bootstrap_field form.users label="Collaborators" %} </div> @@ -58,7 +79,6 @@ {% block onleave %} var ajaxForm = $("#booking_meta_form"); var formData = ajaxForm.serialize(); -console.log(formData); req = new XMLHttpRequest(); req.open("POST", "/wf/workflow/", false); req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); diff --git a/dashboard/src/templates/booking/steps/resource_select.html b/dashboard/src/templates/booking/steps/resource_select.html index 7ccceb3..382316f 100644 --- a/dashboard/src/templates/booking/steps/resource_select.html +++ b/dashboard/src/templates/booking/steps/resource_select.html @@ -1,7 +1,7 @@ {% extends "workflow/viewport-element.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} diff --git a/dashboard/src/templates/booking/steps/swconfig_select.html b/dashboard/src/templates/booking/steps/swconfig_select.html index 15c79d8..60a0df7 100644 --- a/dashboard/src/templates/booking/steps/swconfig_select.html +++ b/dashboard/src/templates/booking/steps/swconfig_select.html @@ -1,7 +1,7 @@ {% extends "workflow/viewport-element.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} diff --git a/dashboard/src/templates/config_bundle/steps/assign_host_roles.html b/dashboard/src/templates/config_bundle/steps/assign_host_roles.html new file mode 100644 index 0000000..b87a17f --- /dev/null +++ b/dashboard/src/templates/config_bundle/steps/assign_host_roles.html @@ -0,0 +1,22 @@ +{% extends "config_bundle/steps/table_formset.html" %} + +{% load bootstrap4 %} + +{% block table %} +<thead> + <tr> + <th>Host</th> + <th>Role</th> + </tr> +</thead> +<tbody> + {% for form in formset %} + <tr> + <td>{% bootstrap_field form.host_name show_label=False %}</td> + <td>{% bootstrap_field form.role show_label=False %}</td> + </tr> + {% endfor %} +</tbody> + +{{formset.management_form}} +{% endblock table %} diff --git a/dashboard/src/templates/config_bundle/steps/assign_network_roles.html b/dashboard/src/templates/config_bundle/steps/assign_network_roles.html new file mode 100644 index 0000000..aa1df44 --- /dev/null +++ b/dashboard/src/templates/config_bundle/steps/assign_network_roles.html @@ -0,0 +1,22 @@ +{% extends "config_bundle/steps/table_formset.html" %} + +{% load bootstrap4 %} + +{% block table %} +<thead> + <tr> + <th>Role</th> + <th>Network</th> + </tr> +</thead> +<tbody> + {% for form in formset %} + <tr> + <td>{% bootstrap_field form.role show_label=False %}</td> + <td>{% bootstrap_field form.network show_label=False %}</td> + </tr> + {% endfor %} +</tbody> + +{{formset.management_form}} +{% endblock table %} diff --git a/dashboard/src/templates/config_bundle/steps/config_software.html b/dashboard/src/templates/config_bundle/steps/config_software.html index ca15c77..68417bc 100644 --- a/dashboard/src/templates/config_bundle/steps/config_software.html +++ b/dashboard/src/templates/config_bundle/steps/config_software.html @@ -1,63 +1,19 @@ {% extends "workflow/viewport-element.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} <form action="/wf/workflow/" method="POST" id="software_config_form" class="form"> {% csrf_token %} <p>Give it a name:</p> - {{ form.name }} + {% bootstrap_field form.name %} <p>And a description:</p> - {{ form.description }} - <p>Install OPNFV?</p> - {{ form.opnfv }} - <p>Choose your:</p> - <table> - <thead> - <tr> - <th>Installer</th> - <th>Scenario</th> - </tr> - </thead> - <tbody> - <tr> - <td>{{form.installer}}</td> - <td>{{form.scenario}}</td> - </tr> - </tbody> - </table> - + {% bootstrap_field form.description %} </form> -<script> -var supported = {{supported|safe}}; -var installer_drop = document.getElementById("id_installer"); -installer_drop.addEventListener("change", filter); -var scenario_drop = document.getElementById("id_scenario"); -var scenario_options = {}; -for(var i=0; i<scenario_drop.options.length; i++){ - var option = scenario_drop.options[i]; - scenario_options[option.text] = option; -} - -scenario_drop.disabled=true; - -function filter(){ - //clear out existing options - while(scenario_drop.firstChild){ - scenario_drop.removeChild(scenario_drop.firstChild) - } - var installer = installer_drop.options[installer_drop.selectedIndex].text; - var options = supported[installer]; - for(var i=0; i<options.length; i++){ - scenario_drop.appendChild(scenario_options[options[i]]); - } - scenario_drop.disabled = false; -} -</script> {% endblock content %} diff --git a/dashboard/src/templates/config_bundle/steps/define_software.html b/dashboard/src/templates/config_bundle/steps/define_software.html index 8e7be91..87e5997 100644 --- a/dashboard/src/templates/config_bundle/steps/define_software.html +++ b/dashboard/src/templates/config_bundle/steps/define_software.html @@ -1,102 +1,55 @@ -{% extends "workflow/viewport-element.html" %} -{% load staticfiles %} +{% extends "config_bundle/steps/table_formset.html" %} + +{% load bootstrap4 %} + +{% block table %} + <thead> + <tr> + <th>Device</th> + <th>Image</th> + <th>HeadNode</th> + </tr> + </thead> + <tbody> +{% for form in formset %} + <tr> + <td>{% bootstrap_field form.host_name show_label=False %}</td> + <td>{% bootstrap_field form.image show_label=False %}</td> + <td class="table_hidden_input_parent"> + <input id="radio_{{forloop.counter}}" class="my_radio" type="radio" name="headnode" value="{{forloop.counter}}"> + {{ form.headnode }} + </td> + </tr> +{% endfor %} +{{formset.management_form}} + +{% endblock table %} + +{% block tablejs %} +<script> + + document.getElementById("radio_{{headnode}}").checked = true; + +</script> +{% endblock tablejs %} -{% load bootstrap3 %} - -{% block extrahead %} - <!-- DataTables CSS --> - <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" - rel="stylesheet"> - - <!-- DataTables Responsive CSS --> - <link href="{% static "bower_components/datatables-responsive/css/dataTables.responsive.css" %}" rel="stylesheet"> -{% endblock extrahead %} - -{% block content %} -{% if error %} - <h1 style="text-align:center;">{{ error }}</h1> -{% else %} - <form style="width: 90%; margin: 5%;" method="post" action="" class="form" id="softwaredefinitionform"> - {% csrf_token %} - - <div class="row"> - <div class="col-lg-12"> - <div class="dataTables_wrapper"> - <table class="table table-striped table-bordered table-hover" id="table" cellspacing="0" - width="100%"> - - {% block table %} - <thead> - <tr> - <th>Device</th> - <th>Role</th> - <th>Image</th> - </tr> - </thead> - <tbody> - {% for form in formset %} - <tr> - {% for field in form %} - <td>{{ field }}</td> - {% endfor %} - </tr> - {% endfor %} - {{formset.management_form}} - - {% endblock table %} - - </table> - </div> - <!-- /.table-responsive --> - <!-- /.panel-body --> - <!-- /.panel --> - </div> - <!-- /.col-lg-12 --> - </div> - </form> - - <script> -function filter_images(){ - var filter_data = {{filter_data|safe}}; - for(var key in filter_data){ - var dropdown = document.getElementById(key); - var to_remove = filter_data[key]; - for(var i=0; i<to_remove.length; i++){ - for(var j=dropdown.children.length-1; j>=0; j--){ - if(dropdown.children[j].text == to_remove[i]){ - dropdown.removeChild(dropdown.children[j]); - } - } - } +{% block onleave %} +var parents = document.getElementsByClassName("table_hidden_input_parent"); +for(var i=0; i<parents.length; i++){ + var node = parents[i]; + var radio = node.getElementsByClassName("my_radio")[0]; + var checkbox = radio.nextElementSibling; + if(radio.checked){ + checkbox.value = "True"; } } -filter_images(); - </script> -{% endif %} -{% endblock content %} - -{% block extrajs %} - {{ block.super }} - <!-- DataTables JavaScript --> - - <script src={% static "bower_components/datatables/media/js/jquery.dataTables.min.js" %}></script> - <script src={% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.min.js" %}></script> - - <script src={% static "js/dataTables-sort.js" %}></script> - - {% block tablejs %} - {% endblock tablejs %} -{% endblock extrajs %} - - -{% block onleave %} -var form = $("#softwaredefinitionform"); +var form = $("#table_formset"); var formData = form.serialize(); var req = new XMLHttpRequest(); req.open("POST", "/wf/workflow/", false); req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); req.onerror = function() { alert("problem with form submission"); } req.send(formData); -{% endblock %} +{% endblock onleave %} diff --git a/dashboard/src/templates/config_bundle/steps/pick_installer.html b/dashboard/src/templates/config_bundle/steps/pick_installer.html new file mode 100644 index 0000000..31a06de --- /dev/null +++ b/dashboard/src/templates/config_bundle/steps/pick_installer.html @@ -0,0 +1,32 @@ +{% extends "workflow/viewport-element.html" %} +{% load staticfiles %} + +{% load bootstrap4 %} + +{% block content %} + +{% if unavailable %} +<h1>Please choose a config bundle first</h1> +{% else %} + +<form id="installer_form" action="/wf/workflow/" method="POST" id="installer_config_form" class="form"> + {% csrf_token %} + <p>Choose your installer:</p> + {% bootstrap_field form.installer %} + <p>Choose your scenario:</p> + {% bootstrap_field form.scenario %} +</form> + +{% endif %} + +{% endblock content %} + +{% block onleave %} +var form = $("#installer_form"); +var formData = form.serialize(); +var req = new XMLHttpRequest(); +req.open("POST", "/wf/workflow/", false); +req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); +req.onerror = function() { alert("problem with form submission"); } +req.send(formData); +{% endblock %} diff --git a/dashboard/src/templates/config_bundle/steps/table_formset.html b/dashboard/src/templates/config_bundle/steps/table_formset.html new file mode 100644 index 0000000..18edc72 --- /dev/null +++ b/dashboard/src/templates/config_bundle/steps/table_formset.html @@ -0,0 +1,64 @@ +{% extends "workflow/viewport-element.html" %} +{% load staticfiles %} + +{% load bootstrap4 %} + +{% block extrahead %} + <!-- DataTables CSS --> + <link href="{% static "bower_components/datatables.net-bs4/css/dataTables.bootstrap4.min.css" %}" + rel="stylesheet"> + + <!-- DataTables Responsive CSS --> + <link href="{% static "bower_components/datatables.net-responsive-bs4/css/responsive.bootstrap4.min.css" %}" + rel="stylesheet"> +{% endblock extrahead %} + +{% block content %} +{% if error %} + <h1 style="text-align:center;">{{ error }}</h1> +{% else %} +<div style="padding: 5%;"> + <form method="post" action="" class="form" id="table_formset"> + {% csrf_token %} + + <div class="row"> + <div class="col-lg-12"> + <div class="dataTables_wrapper"> + <table class="table table-striped table-bordered table-hover" id="table" cellspacing="0" width="100%"> + + {% block table %} + {% endblock table %} + + </table> + </div> + </div> + </div> + </form> +</div> + +{% endif %} +{% endblock content %} + +{% block extrajs %} + {{ block.super }} + <!-- DataTables JavaScript --> + + <script src={% static "bower_components/datatables.net/js/jquery.dataTables.min.js" %}></script> + <script src={% static "bower_components/datatables.net-bs4/js/dataTables.bootstrap4.min.js" %}></script> + + <script src={% static "js/dataTables-sort.js" %}></script> + + {% block tablejs %} + {% endblock tablejs %} +{% endblock extrajs %} + + +{% block onleave %} +var form = $("#table_formset"); +var formData = form.serialize(); +var req = new XMLHttpRequest(); +req.open("POST", "/wf/workflow/", false); +req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); +req.onerror = function() { alert("problem with form submission"); } +req.send(formData); +{% endblock %} diff --git a/dashboard/src/templates/dashboard/genericselect.html b/dashboard/src/templates/dashboard/genericselect.html new file mode 100644 index 0000000..441d8dc --- /dev/null +++ b/dashboard/src/templates/dashboard/genericselect.html @@ -0,0 +1,104 @@ +{% extends "workflow/viewport-element.html" %} +{% load staticfiles %} + +{% load bootstrap4 %} + +{% block content %} + +<style> + #page-wrapper { + display: flex; + flex-direction: column; + } + + #{{select_type}}_form_div div { + } + + #{{select_type}}_form_div > * { + margin-left: 10px; + margin-right: 10px; + margin-bottom: 20px; + } + + #{{select_type}}_form_div div * { + } + + #{{select_type}}_form_div { + flex: 1; + margin: 30px; + display: flex; + flex-direction: column; + } + + #select_section { + flex: 1; + display: flex; + flex-direction: column; + } + + #{{select_type}}_select_form { + flex: 1; + display: flex; + flex-direction: column; + } + + .autocomplete { + flex: 1; + } + + #create_section { + } + + #select_header_section { + } + + h3 { + margin-top: 0; + margin-bottom: 0; + vertical-align: middle; + } + + .divider { + border-top: 1px solid #ccc; + } + + +</style> + +<div id="{{select_type}}_form_div"> + <h3 id="create_section">Create a Resource + <button class="btn btn-primary {% if disabled %} disabled {% endif %}" + {% if not disabled %}onclick="parent.add_wf({{addable_type_num}})" + {% endif %}>Here + </button> + </h3> + <div class="divider"></div> + <h3 id="select_header_section">Or select from the list below:</h3> + <div id="select_section"> + <form id="{{select_type}}_select_form" method="post" action="" class="form" id="{{select_type}}selectorform"> + {% csrf_token %} + {{ form|default:"<p>no form loaded</p>" }} + {% buttons %} + + {% endbuttons %} + </form> + </div> +</div> + +<script> + {% if disabled %} + disable(); + {% endif %} +</script> + +{% endblock content %} +{% block onleave %} +var form = $("#{{select_type}}_select_form"); +var formData = form.serialize(); +var req = new XMLHttpRequest(); +req.open("POST", "/wf/workflow/", false); +req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); +req.onerror = function() { alert("problem with form submission"); } +req.send(formData); +{% endblock %} + diff --git a/dashboard/src/templates/dashboard/idf.yaml b/dashboard/src/templates/dashboard/idf.yaml new file mode 100644 index 0000000..9e0cc26 --- /dev/null +++ b/dashboard/src/templates/dashboard/idf.yaml @@ -0,0 +1,46 @@ +--- +idf: + version: {{version|default:"0.1"}} + net_config: + oob: + ip-range: {{net_config.oob.ip_range}} + vlan: {{net_config.oob.vlan}} + admin: + interface: {{net_config.admin.interface}} + vlan: {{net_config.admin.vlan}} + network: {{net_config.admin.network}} + mask: {{net_config.admin.mask}} + mgmt: + interface: {{net_config.mgmt.interface}} + vlan: {{net_config.mgmt.vlan}} + network: {{net_config.mgmt.network}} + mask: {{net_config.mgmt.mask}} + private: + interface: {{net_config.private.interface}} + vlan: {{net_config.private.vlan}} + network: {{net_config.private.network}} + mask: {{net_config.private.mask}} + public: + interface: {{net_config.public.interface}} + vlan: {{net_config.public.vlan}} + network: {{net_config.public.network}} + mask: {{net_config.public.mask}} + ip-range: {{net_config.public.ip_range}} + mask: {{net_config.public.mask}} + gateway: {{net_config.public.gateway}} + dns: {% for serv in net_config.public.dns %} + - {{serv}}{% endfor %} + fuel: + jumphost: + bridges: + admin: {{fuel.jumphost.bridges.admin}} + mgmt: {{fuel.jumphost.bridges.mgmt}} + private: {{fuel.jumphost.bridges.private}} + public: {{fuel.jumphost.bridges.public}} + network: {% for node in fuel.network.nodes %} + node: + - interfaces: {% for iface in node.interfaces %} + - {{ iface }}{% endfor %} + - busaddr: {% for addr in node.bus_addrs %} + - {{addr}}{% endfor %} + {% endfor %} diff --git a/dashboard/src/templates/dashboard/lab_detail.html b/dashboard/src/templates/dashboard/lab_detail.html index 4c06245..336b32e 100644 --- a/dashboard/src/templates/dashboard/lab_detail.html +++ b/dashboard/src/templates/dashboard/lab_detail.html @@ -9,12 +9,12 @@ {% block content %} <div class="row"> <div class="col-lg-4"> - <div class="panel panel-default"> - <div class="panel-heading clearfix"> - <h4 style="display: inline;">Lab Profile</h4> - <a data-toggle="collapse" data-target="#panel_overview" class="btn pull-right" style="line-height: 1;" >Expand</a> + <div class="card my-2"> + <div class="card-header d-flex"> + <h4>Lab Profile</h4> + <button class="btn btn-outline-secondary ml-auto" data-toggle="collapse" data-target="#panel_overview">Expand</button> </div> - <div class="panel-body" id="panel_overview"> + <div id="panel_overview" class="card-body collapse show"> <table class="table"> <tr> <td>Lab Name: </td><td>{{lab.name}}</td> @@ -50,19 +50,18 @@ </table> </div> </div> - <div class="panel panel-default"> - <div class="panel-heading clearfix"> - <h4 style="display: inline;">Host Profiles</h4> - - <a data-toggle="collapse" data-target="#profile_panel" class="btn pull-right" style="line-height: 1;" >Expand</a> + <div class="card my-2"> + <div class="card-header d-flex"> + <h4 class="d-inline-block">Host Profiles</h4> + <button data-toggle="collapse" data-target="#profile_panel" class="btn btn-outline-secondary ml-auto" style="line-height: 1;" >Expand</button> </div> - <div class="panel-body pod_panel" id="profile_panel"> + <div id="profile_panel" class="card-body collapse show"> <table class="table"> {% for profile in hostprofiles %} <tr> <td>{{profile.name}}</td> <td>{{profile.description}}</td> - <td>{{profile.labs}}</td> + <td><a href="/resource/profiles/{{ profile.id }}" class="btn btn-info">Profile</a></td> </tr> {% endfor %} </table> @@ -70,31 +69,30 @@ </div> - <div class="panel panel-default"> - <div class="panel-heading clearfix"> + <div class="card my-2"> + <div class="card-header d-flex"> <h4 style="display: inline;">Networking Capabilities</h4> - <a data-toggle="collapse" data-target="#network_panel" class="btn pull-right" style="line-height: 1;" >Expand</a> + <button data-toggle="collapse" data-target="#network_panel" class="btn btn-outline-secondary ml-auto" style="line-height: 1;" >Expand</button> </div> - <div class="panel-body" id="network_panel"> - - <table class="table"> - <tr> - <td>Block Size: (number of VLANs allowed per deployment)</td><td>{{lab.vlan_manager.block_size}}</td> - </tr> - <tr> - <td>Overlapping Vlans Allowed (user can pick which VLANs they wish to use): </td> - <td>{{lab.vlan_manager.allow_overlapping}}</td> - </tr> - </table> + <div class="card-body collapse show" id="network_panel"> + <table class="table"> + <tr> + <td>Block Size: (number of VLANs allowed per deployment)</td><td>{{lab.vlan_manager.block_size}}</td> + </tr> + <tr> + <td>Overlapping Vlans Allowed (user can pick which VLANs they wish to use): </td> + <td>{{lab.vlan_manager.allow_overlapping}}</td> + </tr> + </table> </div> </div> - <div class="panel panel-default"> - <div class="panel-heading clearfix"> - <h4 style="display: inline;">Images</h4> - <a data-toggle="collapse" data-target="#image_panel" class="btn pull-right" style="line-height: 1;" >Expand</a> + <div class="card my-2"> + <div class="card-header d-flex"> + <h4>Images</h4> + <button data-toggle="collapse" data-target="#image_panel" class="btn btn-outline-secondary ml-auto">Expand</button> </div> - <div class="panel-body" id="image_panel"> + <div class="card-body collapse show" id="image_panel"> <table class="table"> <tr> <th>Name</th> @@ -116,14 +114,13 @@ </div> <div class="col-lg-8"> - <div class="panel panel-default"> - <div class="panel-heading clearfix"> - <h4 style="display: inline;">Lab Hosts</h4> - <p style="display: inline; margin-left: 10px;"></p> - <a data-toggle="collapse" data-target="#lab_hosts_panel" class="btn pull-right" style="line-height: 1;" >Expand</a> + <div class="card my-2"> + <div class="card-header d-flex"> + <h4>Lab Hosts</h4> + <button data-toggle="collapse" data-target="#lab_hosts_panel" class="btn btn-outline-secondary ml-auto">Expand</button> </div> - <div class="panel-body" id="lab_hosts_panel"> + <div class="card-body collapse show" id="lab_hosts_panel"> <table class="table"> <tr> <th>Name</th> diff --git a/dashboard/src/templates/dashboard/lab_list.html b/dashboard/src/templates/dashboard/lab_list.html index a86f7f4..9cde80c 100644 --- a/dashboard/src/templates/dashboard/lab_list.html +++ b/dashboard/src/templates/dashboard/lab_list.html @@ -1,87 +1,28 @@ {% extends "base.html" %} -{% load staticfiles %} - -{% block extrahead %} - {{block.super}} - <script src="https://cdn.rawgit.com/google/code-prettify/master/loader/run_prettify.js?lang=yaml"></script> -{% endblock %} - {% block content %} - <style> - .grid-item-container { - padding: 10px; - } - - .grid-item { - cursor: pointer; - border:2px; - border-style:none; - border-color:black; - border-radius: 5px; - padding: 7px; - color: inherit; - - box-shadow: 0px 0px 7px 0px rgba(0,0,0,0.75); - transition-property: box-shadow, background-color; - transition-duration: .2s; - } - - .grid-item-text - { - color: inherit; - text-decoration: none; - } - .grid-item-text:hover - { - color: #121212; - text-decoration: none; - } - - .grid-item:hover { - box-shadow: 0px 0px 14px 0px rgba(0,0,0,0.75); - transition-property: box-shadow; - transition-duration: .2s; - - } - - .selected_node { - box-shadow: 0px 0px 14px 0px rgba(0,0,0,0.75); - background-color: #CCECD7; - transition-property: background-color; - transition-duration: .2s; - } - - .disabled_node { - cursor: not-allowed; - background-color: #EFEFEF; - box-shadow: 0px 0px 1px 0px rgba(0,0,0,0.75); - transition-property: box-shadow; - transition-duration: .2s; - } - - .disabled_node:hover { - box-shadow: 0px 0px 1px 0px rgba(0,0,0,0.75); - } - - </style> - <div class="container-fluid"> - <div class="row"> - - <div class="listgrid"> - {% for lab in labs %} - <div class="grid-item-container col-lg-2 col-mid-4 col-sm-6"> - - <a href="{{ lab.name }}" class="grid-item-text"> - - <div class="grid-item"> - <h4 class="grid-item-header">{{ lab.name }}</h4> - <p class="grid-item-description">{{ lab.description }}</p> - </div> - </a> +<h2>Labs</h2> +<div class="card_container"> + {% for lab in labs %} + <div class="card"> + <div class="card-header"> + <h3 class="mt-2">{{lab.name}}</h3> + </div> + <div class="p-4"> + <ul class="list-group"> + <li class="list-group-item">name: {{lab.name}}</li> + <li class="list-group-item">description: {{lab.description}}</li> + <li class="list-group-item">location: {{lab.location}}</li> + {% if lab.status == 0 %} + <li class="list-group-item">status: Up</li> + {% elif lab.status == 100 %} + <li class="list-group-item">status: Down for Maintenance</li> + {% elif lab.status == 200 %} + <li class="list-group-item">status: Down</li> + {% endif %} + </ul> + <a class="btn btn-primary mt-4 w-100" href="/lab/{{lab.name}}/">Details</a> </div> - {% endfor %} - </div> - </div> </div> - -{% endblock content %} + {% endfor %} +</div> +{% endblock %}
\ No newline at end of file diff --git a/dashboard/src/templates/dashboard/landing.html b/dashboard/src/templates/dashboard/landing.html index 3e0aacd..e6a235f 100644 --- a/dashboard/src/templates/dashboard/landing.html +++ b/dashboard/src/templates/dashboard/landing.html @@ -2,20 +2,30 @@ {% load staticfiles %} {% block content %} - <div class=""> - <p style="text-align:center;">Welcome to the Pharos Dashboard! To get started, select one of the options below:</p> +<div class="" style="text-align: center;"> + {% if not request.user.is_anonymous %} + {% if not request.user.userprofile.ssh_public_key %} + <div class="alert alert-danger" role="alert"> + Warning: you need to upload an ssh key under <a href="/accounts/settings">account settings</a> if you wish to + log into the servers you book </div> + {% endif %} + {% else %} + {% endif %} +</div> {% csrf_token %} <style> - .wf_create{ + .wf_create { display: inline-block; text-align: center; } - .wf_create_div{ + + .wf_create_div { text-align: center; } - .hidden_form{ + + .hidden_form { display: none; } @@ -27,48 +37,122 @@ display: grid; grid-template-columns: 33% 34% 33%; } -</style> -<script type="text/javascript"> - function cwf(wf_type){ - document.getElementById('id_workflow').selectedIndex = wf_type; - document.getElementById('wf_selection_form').submit(); + + .landing_container { + display: grid; + grid-template-columns: 1fr 30px 1fr; } -</script> -<div class='wf_create_div'> -<button class="wf_create btn" onclick="cwf(0)">Create a Booking</button> -<button class="wf_create btn" onclick="cwf(1)">Create a Pod</button> -<button class="wf_create btn" onclick="cwf(2)">Configure a Pod</button> -<button class="wf_create btn" onclick="cwf(3)">Create a Snapshot</button> -{% if manager == True %} -<button class="wf_continue btn" onclick="continue_wf()">Finish Unfinished Business</button> -{% endif %} + + .grid_panel { + padding: 30px; + } + + .btn-primary { + margin: 10px; + } + + h2 { + border-bottom: 1px solid #cccccc; + } + + h1 {} +</style> +<div class="container-fluid"> + <div class="row"> + <!-- About us --> + <div class="col-12 col-lg-6 mb-4"> + <h2>About Us:</h2> + <p>The Lab as a Service (LaaS) project aims to help in the development and testing of LFN projects such as + OPNFV + by hosting hardware and providing access to the community. Currently, the only participating lab is the + University of New Hampshire Interoperability Lab (UNH-IOL).</p> + <p>To get started, you can request access to a server at the right. PTL's have the ability to design and + book a + whole block of servers with customized layer2 networks (e.g. a Pharos Pod). Read more here: <a + href="https://wiki.opnfv.org/display/INF/Lab+as+a+Service+2.0">LaaS Wiki</a></p> + </div> + <!-- Get started --> + <div class="col-12 col-lg-6 mb-4"> + <h2>Get Started:</h2> + {% if request.user.is_anonymous %} + <h4 style="text-align:center;">To get started, please log in with your <a href="/accounts/login">Linux + Foundation Jira account</a></h4> + {% else %} + <p>To get started, book a server below:</p> + <a class="wf_create btn btn-primary" + style="display: flex; flex-direction: column; justify-content: center; margin: 20px; height: 100pt; vertical-align: middle; text-align: center; color: #FFF;" + href="/booking/quick/"> + <p style="font-size: xx-large">Book a Server</p> + </a> + <p>PTLs can use our advanced options to book multi-node pods. If you are a PTL, you may use the options + below: + </p> + <div class='container'> + <div class="row"> + <div class="col-12 col-xl-4"> + <button class="wf_create btn btn-primary w-100" onclick="cwf(0)">Book a Pod</button> + </div> + <div class="col-12 col-xl-4"> + <button class="wf_create btn btn-primary w-100" onclick="cwf(1)">Design a Pod</button> + </div> + <div class="col-12 col-xl-4"> + <button class="wf_create btn btn-primary w-100" onclick="cwf(2)">Configure a Pod</button> + </div> + </div> + {% endif %} + </div> + </div> + <!-- Returning users --> + {% if not request.user.is_anonymous %} + <div class="col-12 col-lg-6 offset-lg-6 mb-4 mt-lg-4"> + <h2 class="ht-4">Returning Users:</h2> + <p>If you're a returning user, some of the following options may be of interest:</p> + <div class="container"> + <div class="row"> + <div class="col-12 col-xl-4"> + <button class="wf_create btn btn-primary w-100" onclick="cwf(3)">Snapshot a Host</button> + </div> + <div class="col-12 col-xl-4"> + <a class="wf_create btn btn-primary w-100" href="{% url 'account:my-bookings' %}">My + Bookings</a> + </div> + {% if manager == True %} + <div class="col-12 col-xl-4"> + <button class="wf_continue btn btn-primary w-100" onclick="continue_wf()">Resume + Workflow</button> + </div> + {% endif %} + </div> + </div> + </div> + {% endif %} + </div> </div> <script type="text/javascript"> - function cwf(type) - { + function cwf(type) { $.ajax({ type: "POST", url: "/", - data: {"create":type}, - beforeSend: function(request) { + data: { + "create": type + }, + beforeSend: function (request) { request.setRequestHeader("X-CSRFToken", - $('input[name="csrfmiddlewaretoken"]').val() + $('input[name="csrfmiddlewaretoken"]').val() ); } }).done(function (data) { window.location.replace("/wf/"); - }).fail(function(jqxHR, textstatus) { - alert("Something went wrong...");}); + }).fail(function (jqxHR, textstatus) { + alert("Something went wrong..."); + }); } - function continue_wf() - { + + function continue_wf() { window.location.replace("/wf/"); } - - //success: window.location.replace("/wf/") - </script> <div class="hidden_form" id="form_div"> @@ -85,4 +169,4 @@ {% block vport_comm %} {% endblock %} -{% endblock content %} +{% endblock content %}
\ No newline at end of file diff --git a/dashboard/src/templates/dashboard/multiple_select_filter_widget.html b/dashboard/src/templates/dashboard/multiple_select_filter_widget.html index 31b8f33..4302543 100644 --- a/dashboard/src/templates/dashboard/multiple_select_filter_widget.html +++ b/dashboard/src/templates/dashboard/multiple_select_filter_widget.html @@ -1,403 +1,149 @@ +<script src="/static/js/dashboard.js"> +</script> + <style> .object_class_wrapper { display: grid; grid-template-columns: 1fr 1fr 1fr; border: 0px; } + .class_grid_wrapper { border: 0px; - border-left: 1px; + text-align: center; border-right: 1px; border-style: solid; border-color: grey; - text-align: center; } + +.class_grid_wrapper:last-child { + border-right: none; +} + .grid_wrapper { display: grid; grid-template-columns: 1fr 1fr; } + .grid-item { cursor: pointer; - border:2px; - border-style:none; - border-color:black; + border: 1px solid #cccccc; border-radius: 5px; - margin:20px; + margin: 20px; height: 200px; padding: 7px; - box-shadow: 0px 0px 7px 0px rgba(0,0,0,0.75); - transition-property: box-shadow, background-color; - transition-duration: .2s; + transition: border-color ease-in-out .1s,box-shadow ease-in-out .1s; + box-shadow: 0 1px 1px rgba(0,0,0,.075); + + display: flex; + flex-direction: column; } -.grid-item:hover { - box-shadow: 0px 0px 14px 0px rgba(0,0,0,0.75); - transition-property: box-shadow; - transition-duration: .2s; +.grid-item > .btn:active, .grid-item > .btn:focus { + outline: none; !important; + box-shadow: none; +} +.grid-item-description { + flex: 1; } .selected_node { - box-shadow: 0px 0px 14px 0px rgba(0,0,0,0.75); - background-color: #CCECD7; - transition-property: background-color; - transition-duration: .2s; + border-color: #40c640; + box-shadow: 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(109, 243, 76, 0.6); + transition: border-color ease-in-out .1s,box-shadow ease-in-out .1s; +} + +.grid-item:hover:not(.selected_node):not(.disabled_node) { + box-shadow: 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(100, 100, 100, 0.3); + transition: border-color ease-in-out .1s,box-shadow ease-in-out .1s; } .disabled_node { cursor: not-allowed; background-color: #EFEFEF; - box-shadow: 0px 0px 1px 0px rgba(0,0,0,0.75); - transition-property: box-shadow; - transition-duration: .2s; } -.disabled_node:hover { - box-shadow: 0px 0px 1px 0px rgba(0,0,0,0.75); -} +.disabled_node:hover {} .cleared_node { background-color: #FFFFFF; } -.grid-item-header -{ +.grid-item-header { font-weight: bold; font-size: 20px; margin-top: 10px; } -</style> -<input name="filter_field" id="filter_field" type="hidden"/> -<div id="grid_wrapper" class="grid_wrapper"> -{% for object_class, object_list in filter_objects %} -<div class="class_grid_wrapper"> - <div style="display:inline-block;margin:auto"> - <h4>{{object_class}}</h4> - </div> - <div id="{{object_class}}" class="object_class_wrapper"> - {% for obj in object_list %} - <div id="object_parent"> - <div id="{{ obj.id|default:'not_provided' }}" class="grid-item"> - <p class="grid-item-header">{{obj.name}}</p> - <p class="grid-item-description">{{obj.description}}</p> - <button type="button" class="btn btn-success grid-item-select-btn" onclick="processClick('{{obj.id}}', {% if obj.multiple %}true{% else %}false{% endif %});">{% if obj.multiple %}Add{% else %}Select{% endif %}</button> - </div> - <input type="hidden" name="{{obj.id}}_selected" value="false"/> - </div> - {% endfor %} - </div> - </div> -{% endfor %} -</div> - -<div id="dropdown_wrapper"> -</div> - -<script> -var initialized = false; -var mapping = {{ mapping|safe }}; -var items = {{ items|safe }}; -var result = {}; -var selection = {{selection_data|default_if_none:"null"|safe}}; -var dropdown_count = 0; - -{% if selection_data %} -make_selection({{selection_data|safe}}); -{% endif %} - -function make_selection( selection_data ){ - if(!initialized) { - init(); - } - for(var k in selection_data) { - selected_items = selection_data[k]; - for( var item in selected_items ){ - var node = items[item]; - if(!node['multiple']){ - var input_value = selected_items[item]; - if( input_value != 'false' ) { - select(node); - markAndSweep(node); - } - var div = document.getElementById(item) - var input = div.parentNode.getElementsByTagName("input")[0] - input.value = input_value; - updateResult(item); - } else { - make_multiple_selection(selected_items, item); - } - } - } -} - -function make_multiple_selection(data, item_class){ - var node = items[item_class]; - select(node); - markAndSweep(node); - prepop_data = data[item_class]; - for(var i=0; i<prepop_data.length; i++){ - var div = add_item_prepopulate(node, prepop_data[i]); - updateObjectResult(div); - } -} - -function markAndSweep(root){ - for(var nodeId in items) { - node = items[nodeId]; - node['marked'] = true; //mark all nodes - //clears grey background of everything - } - - toCheck = []; - toCheck.push(root); - - while(toCheck.length > 0){ - node = toCheck.pop(); - if(!node['marked']) { - //already visited, just continue - continue; - } - node['marked'] = false; //mark as visited - if(node['follow'] || node == root){ //add neighbors if we want to follow this node (labs) - var mappingId = node.id - var neighbors = mapping[mappingId]; - for(var neighId in neighbors) { - neighId = neighbors[neighId]; - var neighbor = items[neighId]; - toCheck.push(neighbor); - } - } - } - - //now remove all nodes still marked - for(var nodeId in items){ - node = items[nodeId]; - if(node['marked']){ - disable(node); - } - } -} - -function process(node) { - if(node['selected']) { - markAndSweep(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]; - if(node['selected']) { - selected.push(node); - } - clear(node); - - } - for(var i=0; i<selected.length; i++) { - node = selected[i]; - select(node); - markAndSweep(selected[i]); - } - } -} - -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') - var input = elem.parentNode.getElementsByTagName("input")[0]; - input.disabled = false; - input.value = true; -} - -function clear(node) { - elem = document.getElementById(node['id']); - node['selected'] = false; - node['selectable'] = true; - elem.classList.add('cleared_node') - elem.classList.remove('disabled_node') - elem.classList.remove('selected_node') - elem.parentNode.getElementsByTagName("input")[0].disabled = true; -} - -function disable(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.parentNode.getElementsByTagName("input")[0].disabled = true; -} - -function processClick(id, multiple){ - if(!initialized){ - init(); - } - var element = document.getElementById(id); - var node = items[id]; - if(!node['selectable']){ - return; - } - if(multiple){ - return processClickMultipleObject(node); - } - node['selected'] = !node['selected']; //toggle on click - - if(node['selected']) { - select(node); - } - else { - clear(node); - } - process(node); - updateResult(id); +.dropdown_item { + border: 1px; + border-style: solid; + border-color: lightgray; + border-radius: 5px; + margin: 20px; + padding: 2px; + grid-column: 1; + display: grid; + grid-template-columns: 1fr 3fr 1fr; + justify-items: center; } -function processClickMultipleObject(node){ - select(node); - add_item(node); - process(node); +.dropdown_item > button { + margin: 2px; + justify-self: end; } -function add_item(node){ - return add_item_prepopulate(node, {}); +.dropdown_item > h5 { + margin: auto; } -inputs = [] - -function restrictchars(input) -{ - if( input.validity.patternMismatch ) - { - input.setCustomValidity("Only alphanumeric characters (a-z, A-Z, 0-9), underscore(_), and hyphen (-) are allowed"); - input.reportValidity(); - } - - input.value = input.value.replace(/([^A-Za-z0-9-_.])+/g, ""); - - checkunique(input); -} - -function checkunique(tocheck) -{ - val = tocheck.value; - for( var i = 0; i < inputs.length; i++ ) - { - if( inputs[i].value == val && inputs[i] != tocheck) - { - tocheck.setCustomValidity("All hostnames must be unique"); - tocheck.reportValidity(); - return; - } - } - tocheck.setCustomValidity(""); -} - -function add_item_prepopulate(node, prepopulate){ - inputs = []; - var div = document.createElement("DIV"); - div.class = node['id']; - div.id = "dropdown_" + dropdown_count; - dropdown_count++; - var label = document.createElement("H5"); - label.style['display'] = 'inline'; - label.appendChild(document.createTextNode(node['name'])); - div.appendChild(label); - for(var i=0; i<node['forms'].length; i++){ - form = node['forms'][i]; - var input = document.createElement("INPUT"); - input.type = form['type']; - input.name = form['name']; - input.pattern = "(?=^.{1,253}$)(^([A-Za-z0-9-_]{1,62}\.)*[A-Za-z0-9-_]{1,63})"; - input.title = "Only alphanumeric characters (a-z, A-Z, 0-9), underscore(_), and hyphen (-) are allowed" - input.placeholder = form['placeholder']; - inputs.push(input); - input.onchange = function() { updateObjectResult(div); restrictchars(this); }; - input.oninput = function() { restrictchars(this); }; - if(form['name'] in prepopulate){ - input.value = prepopulate[form['name']]; - } - div.appendChild(input); - } - //add class id to dropdown object - var hiddenInput = document.createElement("INPUT"); - hiddenInput.type = "hidden"; - hiddenInput.name = "class"; - hiddenInput.value = node['id']; - div.appendChild(hiddenInput); - button = document.createElement("BUTTON"); - button.onclick = function(){ - remove_dropdown(div.id); - } - button.type = "button"; - button.appendChild(document.createTextNode("Remove")); - div.appendChild(button); - document.getElementById("dropdown_wrapper").appendChild(div); - updateObjectResult(div); - return div; +.dropdown_item > input { + padding: 7px; + margin: 2px; + width: 90%; } -function remove_dropdown(id){ - var div = document.getElementById(id); - var parent = div.parentNode; - div.parentNode.removeChild(div); - //checks if we have removed last item in class - var deselect_class = true; - var div_inputs = div.getElementsByTagName("input"); - var div_class = div_inputs[div_inputs.length-1].value; - var result_class = document.getElementById(div_class).parentNode.parentNode.id; - delete result[result_class][div.id]; - for(var i=0; i<parent.children.length; i++){ - var inputs = parent.children[i].getElementsByTagName("input"); - var object_class = ""; - for(var k=0; k<inputs.length; k++){ - if(inputs[k].name == "class"){ - object_class = inputs[k].value; - } - } - if(object_class == div_class){ - deselect_class = false; - } - } - if(deselect_class){ - clear(items[div_class]); - } +#dropdown_wrapper { + display: grid; + grid-template-columns: 4fr 5fr; } +</style> -function updateResult(nodeId){ - if(!initialized){ - init(); - } - if(!items[nodeId]['multiple']){ - var node = document.getElementById(nodeId); - var value = {} - value[nodeId] = node.parentNode.getElementsByTagName("input")[0].value; - result[node.parentNode.parentNode.id][nodeId] = value; - } -} +<input name="filter_field" id="filter_field" type="hidden"/> +<div id="grid_wrapper" class="grid_wrapper"> +{% for object_class, object_list in display_objects %} + <div class="class_grid_wrapper"> + <div style="display:inline-block;margin:auto"> + <h4>{{object_class}}</h4> + </div> + <div id="{{object_class}}" class="object_class_wrapper"> + {% for obj in object_list %} + <div id="{{ obj.id|default:'not_provided' }}" class="grid-item" onclick="multi_filter_widget.processClick( + '{{obj.id}}');"> + <p class="grid-item-header">{{obj.name}}</p> + <p class="grid-item-description">{{obj.description}}</p> + <button type="button" class="btn btn-success grid-item-select-btn"> + {% if obj.multiple %}Add{% else %}Select{% endif %} + </button> + </div> + {% endfor %} + </div> + </div> +{% endfor %} +</div> -function updateObjectResult(parentElem){ - node_type = document.getElementById(parentElem.class).parentNode.parentNode.id; - input = {}; - inputs = parentElem.getElementsByTagName("input"); - for(var i in inputs){ - var e = inputs[i]; - input[e.name] = e.value; - } - result[node_type][parentElem.id] = input; -} +<div id="dropdown_wrapper"> +</div> +<script> +function multipleSelectFilterWidgetEntry() { + const graph_neighbors = {{ neighbors|safe }}; + const filter_items = {{ filter_items|safe }}; + const initial_value = {{ initial_value|default_if_none:"{}"|safe }}; -function init() { - for(nodeId in items) { - element = document.getElementById(nodeId); - node = items[nodeId]; - result[element.parentNode.parentNode.id] = {} - } - initialized = true; + //global variable + multi_filter_widget = new MultipleSelectFilterWidget(graph_neighbors, filter_items, initial_value); } +multipleSelectFilterWidgetEntry(); </script> diff --git a/dashboard/src/templates/dashboard/pdf.yaml b/dashboard/src/templates/dashboard/pdf.yaml index 297e04b..c893919 100644 --- a/dashboard/src/templates/dashboard/pdf.yaml +++ b/dashboard/src/templates/dashboard/pdf.yaml @@ -1,95 +1,92 @@ --- version: {{version|default:"1.0"}} details: - pod_owner: {{details.owner}} - contact: {{details.contact}} - lab: {{details.lab}} - location: {{details.location}} - type: {{details.type}} - link: {{details.link}} - + contact: {{details.contact}} + lab: {{details.lab}} + link: {{details.link}} + location: {{details.location}} + pod_owner: {{details.owner}} + type: {{details.type}} jumphost: - name: {{jumphost.name}} - node: - type: {{jumphost.node.type}} - vendor: {{jumphost.node.vendor}} - model: {{jumphost.node.model}} - arch: {{jumphost.node.arch}} - cpus: {{jumphost.node.cpus}} - cpu_cflags: {{jumphost.node.cpu_cflags}} - cores: {{jumphost.node.cores}} - memory: {{jumphost.node.memory}} - disks: - {% for disk in jumphost.disks %} - - name: {{disk.name}} - disk_capacity: {{disk.capacity}} - disk_type: {{disk.type}} - disk_interface: {{disk.interface}} - disk_rotation: {{disk.rotation}} - - {% endfor %} - os: {{jumphost.os}} - remote_params: - type: {{jumphost.remote.type}} - versions: - {% for version in jumphost.remote.versions %} - - {{version}} - {% endfor %} - user: {{jumphost.remote.user}} - pass: {{jumphost.remote.pass}} - remote_management: - type: {{jumphost.remote.type}} - versions: - {% for version in jumphost.remote.versions %} - - {{version}} - {% endfor %} - user: {{jumphost.remote.user}} - pass: {{jumphost.remote.pass}} - address: {{jumphost.remote.address}} - mac_address: {{jumphost.remote.mac_address}} - interfaces: - {% for interface in jumphost.interfaces %} - - name: {{interface.name}} - address: {{interface.address}} - mac_address: {{interface.mac_address}} - vlan: {{interface.vlan}} - {% endfor %} + disks: + {% for disk in jumphost.disks %} + - disk_capacity: {{disk.capacity}} + disk_interface: {{disk.interface}} + disk_rotation: {{disk.rotation}} + disk_type: {{disk.type}} + name: {{disk.name}} + {% endfor %} + interfaces: + {% for interface in jumphost.interfaces %} + - features: {{interface.features}} + mac_address: {{interface.mac_address}} + name: {{interface.name}} + speed: {{interface.speed}} + {% endfor %} + name: {{jumphost.name}} + node: + arch: {{jumphost.node.arch}} + cores: {{jumphost.node.cores}} + cpu_cflags: {{jumphost.node.cpu_cflags}} + cpus: {{jumphost.node.cpus}} + memory: {{jumphost.node.memory}} + model: {{jumphost.node.model}} + type: {{jumphost.node.type}} + vendor: {{jumphost.node.vendor}} + os: {{jumphost.os}} + remote_management: + address: {{jumphost.remote.address}} + mac_address: {{jumphost.remote.mac_address}} + pass: {{jumphost.remote.pass}} + type: {{jumphost.remote.type}} + user: {{jumphost.remote.user}} + versions: + {% for version in jumphost.remote.versions %} + - {{version}} + {% endfor %} + remote_params: + pass: {{jumphost.remote.pass}} + type: {{jumphost.remote.type}} + user: {{jumphost.remote.user}} + versions: + {% for version in jumphost.remote.versions %} + - {{version}} + {% endfor %} nodes: - {% for node in nodes %} - - name: {{node.name}} - node: - type: {{node.node.type}} - vendor: {{node.node.vendor}} - model: {{node.node.model}} - arch: {{node.node.arch}} - cpus: {{node.node.cpus}} - cpu_cflags: {{node.node.cpu_cflags}} - cores: {{node.node.cores}} - memory: {{node.node.memory}} - disks: - {% for disk in node.disks %} - - name: {{disk.name}} - disk_capacity: {{disk.capacity}} - disk_type: {{disk.type}} - disk_interface: {{disk.interface}} - disk_rotation: {{disk.rotation}} - - {% endfor %} - remote_management: - type: {{node.remote.type}} - versions: - {% for version in node.remote.versions %} - - {{version}} - {% endfor %} - user: {{node.remote.user}} - pass: {{node.remote.pass}} - address: {{node.remote.address}} - mac_address: {{node.remote.mac_address}} - interfaces: - {% for interface in node.interfaces %} - - name: {{interface.name}} - address: {{interface.address}} - mac_address: {{interface.mac_address}} - vlan: {{interface.vlan}} - {% endfor %} +{% for node in nodes %} +- disks: + {% for disk in node.disks %} + - disk_capacity: {{disk.capacity}} + disk_interface: {{disk.interface}} + disk_rotation: {{disk.rotation}} + disk_type: {{disk.type}} + name: {{disk.name}} + {% endfor %} + interfaces: + {% for interface in node.interfaces %} + - features: {{interface.features}} + mac_address: {{interface.mac_address}} + name: {{interface.name}} + speed: {{interface.speed}} {% endfor %} + name: {{node.name}} + node: + arch: {{node.node.arch}} + cores: {{node.node.cores}} + cpu_cflags: {{node.node.cpu_cflags}} + cpus: {{node.node.cpus}} + memory: {{node.node.memory}} + model: {{node.node.model}} + type: {{node.node.type}} + vendor: {{node.node.vendor}} + remote_management: + address: {{node.remote.address}} + mac_address: {{node.remote.mac_address}} + pass: {{node.remote.pass}} + type: {{node.remote.type}} + user: {{node.remote.user}} + versions: + {% for version in node.remote.versions %} + - {{version}} + {% endfor %} +{% endfor %} diff --git a/dashboard/src/templates/dashboard/resource.html b/dashboard/src/templates/dashboard/resource.html index 28e7998..f36ee7b 100644 --- a/dashboard/src/templates/dashboard/resource.html +++ b/dashboard/src/templates/dashboard/resource.html @@ -7,11 +7,11 @@ <link href="{% static "bower_components/morrisjs/morris.css" %}" rel="stylesheet"> <!-- DataTables CSS --> - <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + <link href="{% static "bower_components/datatables.net-bs4/css/dataTables.bootstrap4.min.css" %}" rel="stylesheet"> <!-- DataTables Responsive CSS --> - <link href="{% static "bower_components/datatables-responsive/css/dataTables.responsive.css" %}" + <link href="{% static "bower_components/datatables.net-responsive-bs4/css/responsive.bootstrap4.min.css" %}" rel="stylesheet"> {% endblock extrahead %} @@ -23,11 +23,11 @@ {% block extrajs %} <!-- DataTables JavaScript --> - <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + <link href="{% static "bower_components/datatables/media/css/dataTables.bootstrap.css" %}" rel="stylesheet"> - <script src={% static "bower_components/datatables/media/js/jquery.dataTables.min.js" %}></script> - <script src={% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.min.js" %}></script> + <script src={% static "bower_components/datatables.net/js/jquery.dataTables.min.js" %}></script> + <script src={% static "bower_components/datatables.net-bs4/js/dataTables.bootstrap4.min.js" %}></script> diff --git a/dashboard/src/templates/dashboard/resource_all.html b/dashboard/src/templates/dashboard/resource_all.html index 0b0d0d4..fb8cc7e 100644 --- a/dashboard/src/templates/dashboard/resource_all.html +++ b/dashboard/src/templates/dashboard/resource_all.html @@ -7,11 +7,11 @@ <link href="{% static "bower_components/morrisjs/morris.css" %}" rel="stylesheet"> <!-- DataTables CSS --> - <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + <link href="{% static "bower_components/datatables.net-bs4/css/dataTables.bootstrap4.min.css" %}" rel="stylesheet"> <!-- DataTables Responsive CSS --> - <link href="{% static "bower_components/datatables-responsive/css/dataTables.responsive.css" %}" + <link href="{% static "bower_components/datatables.net-responsive-bs4/css/responsive.bootstrap4.min.css" %}" rel="stylesheet"> {% endblock extrahead %} @@ -36,11 +36,11 @@ {% block extrajs %} <!-- DataTables JavaScript --> - <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + <link href="{% static "bower_components/datatables/media/css/dataTables.bootstrap.css" %}" rel="stylesheet"> - <script src={% static "bower_components/datatables/media/js/jquery.dataTables.min.js" %}></script> - <script src={% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.min.js" %}></script> + <script src={% static "bower_components/datatables.net/js/jquery.dataTables.min.js" %}></script> + <script src={% static "bower_components/datatables.net-bs4/js/dataTables.bootstrap4.min.js" %}></script> diff --git a/dashboard/src/templates/dashboard/resource_detail.html b/dashboard/src/templates/dashboard/resource_detail.html index 79389f0..0a443d9 100644 --- a/dashboard/src/templates/dashboard/resource_detail.html +++ b/dashboard/src/templates/dashboard/resource_detail.html @@ -101,12 +101,10 @@ {{ resource.owner.email }} </p> <p> - <a href="{% url 'booking:create' resource_id=resource.id %}" class="btn - btn-primary"> + <a href="{% url 'booking:create' resource_id=resource.id %}" class="btn btn-primary"> Booking </a> - <a href="{{ resource.url }}" class="btn - btn-primary"> + <a href="{{ resource.url }}" class="btn btn-primary"> OPNFV Wiki </a> </p> diff --git a/dashboard/src/templates/dashboard/searchable_select_multiple.html b/dashboard/src/templates/dashboard/searchable_select_multiple.html index e7128b0..8bcf890 100644 --- a/dashboard/src/templates/dashboard/searchable_select_multiple.html +++ b/dashboard/src/templates/dashboard/searchable_select_multiple.html @@ -1,35 +1,58 @@ <script src="https://code.jquery.com/jquery-2.2.4.min.js"></script> +<script src="/static/js/dashboard.js"></script> + +<div id="search_select_outer" class="autocomplete"> + <div id="warning_pane" style="background: #FFFFFF; color: #CC0000;"> + {% if incompatible == "true" %} + <h3>Warning: Incompatible Configuration</h3> + <p>Please make a different selection, as the current config conflicts with the selected pod</p> + {% endif %} + </div> + <div id="added_counter"> + <p id="added_number">0</p> + <p id="addable_limit">/ {% if selectable_limit > -1 %} {{ selectable_limit }} {% else %} ∞ {% endif %}added</p> + </div> + + <div id="added_list"> -<div class="autocomplete" style="width:400px;"> - <input id="user_field" name="ignore_this" class="form-control" autocomplete="off" type="text" placeholder="{{placeholder}}" value="{{initial.name}}" oninput="search(this.value)" + </div> + + <input id="user_field" name="ignore_this" class="form-control" autocomplete="off" type="text" placeholder="{{placeholder}}" value="" oninput="searchable_select_multiple_widget.search(this.value)" {% if disabled %} disabled {% endif %} > + </input> <input type="hidden" id="selector" name="{{ name }}" class="form-control" style="display: none;" {% if disabled %} disabled {% endif %} > </input> - <ul id="drop_results"></ul> - - - <div id="default_entry_wrap" style="display: none;"> - <div class="list_entry unremovable_list_entry"> - <p id="default_text" class="full_name"></p> - <button class="btn-remove btn disabled">remove</button> - </div> + <div id="scroll_restrictor"> + <ul id="drop_results"></ul> </div> + <style> + #scroll_restrictor { + flex: 1; + position: relative; + overflow-y: auto; + padding-bottom: 10px; + } - <div id="added_list"> + #added_list { + margin-bottom: 5px; + } - </div> - <div id="added_counter" style="text-align: center; margin: 10px;"><p id="added_number" style="display: inline;">0</p><p style="display: inline;">/ - {% if selectable_limit > -1 %} {{ selectable_limit }} {% else %} ∞ {% endif %}added</p></div> - <style> + .autocomplete { + display: flex; + flex: 1; + flex-direction: column; + } #user_field { font-size: 14pt; - width: 400px; padding: 5px; + height: 40px; + border: 1px solid #ccc; + border-radius: 5px; } @@ -37,372 +60,150 @@ list-style-type: none; padding: 0; margin: 0; - max-height: 300px; min-height: 0; - overflow-y: scroll; - overflow-x: hidden; border: solid 1px #ddd; - display: none; + border-top: none; + border-bottom: none; + visibility: inherit; + flex: 1; + + position: absolute; + width: 100%; } #drop_results li a{ font-size: 14pt; - border: 1px solid #ddd; background-color: #f6f6f6; - padding: 12px; + padding: 7px; text-decoration: none; display: block; - width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - .btn-remove { - float: right; - height: 30px; - margin: 4px; + #drop_results li a { + border-bottom: 1px solid #ddd; } .list_entry { - width: 400px; - border: 1px solid #ddd; - border-radius: 3px; + border: 1px solid #ccc; + border-radius: 5px; margin-top: 5px; vertical-align: middle; line-height: 40px; height: 40px; padding-left: 12px; + width: 100%; + display: flex; } #drop_results li a:hover{ background-color: #ffffff; } - .small_name { - display: inline-block; + .added_entry_text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline; + width: 100%; } - .full_name { - display: inline-block; + .btn-remove { + float: right; + height: 30px; + margin: 4px; + padding: 1px; + max-width: 20%; + width: 15%; + min-width: 70px; + overflow: hidden; + text-overflow: ellipsis; } - </style> -</div> - -<script type="text/javascript"> - //flags - var show_from_noentry = {{show_from_noentry|default:"false"}}; - var show_x_results = {{show_x_results|default:-1}}; - var results_scrollable = {{results_scrollable|default:"false"}}; - var selectable_limit = {{selectable_limit|default:-1}}; - var field_name = "{{name|default:"users"}}"; - var placeholder = "{{placeholder|default:"begin typing"}}"; - var default_entry = "{{default_entry}}"; - - //needed info - var items = {{items|safe}} - - //tries - var expanded_name_trie = {} - expanded_name_trie.isComplete = false; - var small_name_trie = {} - small_name_trie.isComplete = false; - var string_trie = {} - string_trie.isComplete = false; - - var added_items = []; - - var added_template = {{ added_list|default:"{}" }}; - - if( default_entry ) - { - var default_entry_div = document.getElementById("default_entry_wrap"); - default_entry_div.style.display = "inherit"; - - var entry_p = document.getElementById("default_text"); - entry_p.innerText = default_entry; - } - - init(); - - if( show_from_noentry ) - { - search(""); - } - - function disable() { - var textfield = document.getElementById("user_field"); - var drop = document.getElementById("drop_results"); - - textfield.disabled = "True"; - drop.style.display = "none"; - - var btns = document.getElementsByClassName("btn-remove"); - for( var i = 0; i < btns.length; i++ ) - { - btns[i].classList.add("disabled"); + .entry_tooltip { + display: none; } - } - - function init() { - build_all_tries(items); - var initial = {{ initial|safe }}; + #drop_results li a:hover .entry_tooltip { + position: absolute; + background: #444; + color: #ddd; + text-align: center; + font-size: 12pt; + border-radius: 3px; - for( var i = 0; i < initial.length; i++) - { - select_item(String(initial[i])); - } - if(initial.length == 1) - { - search(items[initial[0]]["small_name"]); - document.getElementById("user_field").value = items[initial[0]]["small_name"]; } - } - function build_all_tries(dict) - { - for( var i in dict ) - { - add_item(dict[i]); + #drop_results { + max-width: 100%; + display: inline-block; + list-style-type: none; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } - } - - function add_item(item) - { - var id = item['id']; - add_to_tree(item['expanded_name'], id, expanded_name_trie); - add_to_tree(item['small_name'], id, small_name_trie); - add_to_tree(item['string'], id, string_trie); - } - - function add_to_tree(str, id, trie) - { - inner_trie = trie; - while( str ) - { - if( !inner_trie[str.charAt(0)] ) - { - new_trie = {}; - inner_trie[str.charAt(0)] = new_trie; - } - else - { - new_trie = inner_trie[str.charAt(0)]; - } - if( str.length == 1 ) - { - new_trie.isComplete = true; - new_trie.itemID = id; - } - inner_trie = new_trie; - str = str.substring(1); + #drop_results li { + overflow: hidden; + text-overflow: ellipsis; } - } - function search(input) - { - if( input.length == 0 && !show_from_noentry){ - dropdown([]); - return; - } - else if( input.length == 0 && show_from_noentry) - { - dropdown(items); //show all items - } - else - { - var trees = [] - var tr1 = getSubtree(input, expanded_name_trie); - trees.push(tr1); - var tr2 = getSubtree(input, small_name_trie); - trees.push(tr2); - var tr3 = getSubtree(input, string_trie); - trees.push(tr3); - var results = collate(trees); - dropdown(results); + #added_counter { + text-align: center; } - } - function getSubtree(input, given_trie) - { - /* - recursive function to return the trie accessed at input - */ - - if( input.length == 0 ){ - return given_trie; + #added_number, #addable_limit { + display: inline; } + </style> +</div> - else{ - var substr = input.substring(0, input.length - 1); - var last_char = input.charAt(input.length-1); - var subtrie = getSubtree(substr, given_trie); - if( !subtrie ) //substr not in the trie - { - return {}; - } - var indexed_trie = subtrie[last_char]; - return indexed_trie; - } - } +<script type="text/javascript"> + function searchableSelectMultipleWidgetEntry() { + let format_vars = { + "show_from_noentry": {{show_from_noentry|yesno:"true,false"}}, + "show_x_results": {{show_x_results|default:-1}}, + "results_scrollable": {{results_scrollable|yesno:"true,false"}}, + "selectable_limit": {{selectable_limit|default:-1}}, + "placeholder": "{{placeholder|default:"begin typing"}}" + }; - function serialize(trie) - { - /* - takes in a trie and returns a list of its item id's - */ - var itemIDs = []; - if ( !trie ) - { - return itemIDs; //empty, base case - } - for( var key in trie ) - { - if(key.length > 1) - { - continue; - } - itemIDs = itemIDs.concat(serialize(trie[key])); - } - if ( trie.isComplete ) - { - itemIDs.push( trie.itemID ); - } + let field_dataset = {{items|safe}}; - return itemIDs; - } + let field_initial = {{ initial|safe }}; - function collate(trees) - { - /* - takes a list of tries - returns a list of ids of objects that are available - */ - results = []; - for( var i in trees ) - { - var available_IDs = serialize(trees[i]); - for( var j=0; j<available_IDs.length; j++){ - var itemID = available_IDs[j]; - results[itemID] = items[itemID]; - } - } - return results; + //global + searchable_select_multiple_widget = new SearchableSelectMultipleWidget(format_vars, field_dataset, field_initial); } - function dropdown(ids) - { - /* - takes in a mapping of ids to objects in items - and displays them in the dropdown - */ - var drop = document.getElementById("drop_results"); - while(drop.firstChild) - { - drop.removeChild(drop.firstChild); - } + searchableSelectMultipleWidgetEntry(); - for( var id in ids ) - { - var result_entry = document.createElement("li"); - var result_button = document.createElement("a"); - var obj = items[id]; - var result_text = document.createTextNode(obj['small_name'] + " : " + obj['expanded_name']); - result_button.appendChild(result_text); - result_button.setAttribute('onclick', 'select_item("' + obj['id'] + '")'); - result_entry.appendChild(result_button); - drop.appendChild(result_entry); - } - - if( !drop.firstChild ) - { - drop.style.display = 'none'; - } - else - { - drop.style.display = 'inherit'; - } - } + /* + var show_from_noentry = context(show_from_noentry|yesno:"true,false") // whether to show any results before user starts typing + var show_x_results = context(show_x_results|default:-1) // how many results to show at a time, -1 shows all results + var results_scrollable = {{results_scrollable|yesno:"true,false") // whether list should be scrollable + var selectable_limit = {{selectable_limit|default:-1) // how many selections can be made, -1 allows infinitely many + var placeholder = "context(placeholder|default:"begin typing")" // placeholder that goes in text box - function select_item(item_id) - { - //TODO make faster - var item = items[item_id]; - if( (selectable_limit > -1 && added_items.length < selectable_limit) || selectable_limit < 0 ) + needed info + var items = context(items|safe) // items to add to trie. Type is a dictionary of dictionaries with structure: { - if( added_items.indexOf(item) == -1 ) - { - added_items.push(item); + id# : { + "id": any, identifiable on backend + "small_name": string, displayed first (before separator), searchable (use for e.g. username) + "expanded_name": string, displayed second (after separator), searchable (use for e.g. email address) + "string": string, not displayed, still searchable } } - 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); - - update_selected_list() - document.getElementById("user_field").focus(); - } - - function edit_item(item_id){ - var wf_type = "{{wf_type}}"; - parent.add_edit_wf(wf_type, item_id); - } - - function update_selected_list() - { - document.getElementById("added_number").innerText = added_items.length; - selector = document.getElementById('selector'); - selector.value = JSON.stringify(added_items); - added_list = document.getElementById('added_list'); - - while(selector.firstChild) - { - selector.removeChild(selector.firstChild); - } - while(added_list.firstChild) - { - added_list.removeChild(added_list.firstChild); - } - - list_html = ""; - - for( var key in added_items ) - { - item = added_items[key]; - - list_html += '<div class="list_entry"><p class="full_name">' - + item["expanded_name"] - + '</p><p class="small_name">, ' - + item["small_name"] - + '</p><button onclick="remove_item(' - + Object.values(items).indexOf(item) - + ')" class="btn-remove btn">remove</button>'; - {% if edit %} - list_html += '<button onclick="edit_item(' - + item['id'] - + ')" class="btn-remove btn">edit</button>'; - {% endif %} - list_html += '</div>'; - } - - added_list.innerHTML = list_html; - } - + used later: + context(selectable_limit): changes what number displays for field + context(name): form identifiable name, relevant for backend + // when submitted, form will contain field data in post with name as the key + context(placeholder): "greyed out" contents put into search field initially to guide user as to what they're searching for + context(initial): in search_field_init(), marked safe, an array of id's each referring to an id from items + */ </script> -<style> - .full_name { - display: inline-block; - } - .small_name { - display: inline-block; - } -</style> diff --git a/dashboard/src/templates/dashboard/table.html b/dashboard/src/templates/dashboard/table.html index b3f4b5f..0a37ded 100644 --- a/dashboard/src/templates/dashboard/table.html +++ b/dashboard/src/templates/dashboard/table.html @@ -4,11 +4,12 @@ {% block extrahead %} {{ block.super }} <!-- DataTables CSS --> - <link href="{% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.css" %}" + <link href="{% static "bower_components/datatables.net-bs4/css/dataTables.bootstrap4.min.css" %}" rel="stylesheet"> <!-- DataTables Responsive CSS --> - <link href="{% static "bower_components/datatables-responsive/css/dataTables.responsive.css" %}" rel="stylesheet"> + <link href="{% static "bower_components/datatables.net-responsive-bs4/css/responsive.bootstrap4.min.css" %}" + rel="stylesheet"> {% endblock extrahead %} {% block content %} @@ -34,8 +35,8 @@ {% block extrajs %} <!-- DataTables JavaScript --> - <script src={% static "bower_components/datatables/media/js/jquery.dataTables.min.js" %}></script> - <script src={% static "bower_components/datatables-plugins/integration/bootstrap/3/dataTables.bootstrap.min.js" %}></script> + <script src={% static "bower_components/datatables.net/js/jquery.dataTables.min.js" %}></script> + <script src={% static "bower_components/datatables.net-bs4/js/dataTables.bootstrap4.min.js" %}></script> <script src={% static "js/dataTables-sort.js" %}></script> diff --git a/dashboard/src/templates/layout.html b/dashboard/src/templates/layout.html index 13f182b..d37d4f5 100644 --- a/dashboard/src/templates/layout.html +++ b/dashboard/src/templates/layout.html @@ -20,7 +20,7 @@ <link href="{% static "bower_components/metisMenu/dist/metisMenu.min.css" %}" rel="stylesheet"> <!-- Custom Fonts --> - <link href="{% static "bower_components/font-awesome/css/font-awesome.min.css" %}" + <link href="{% static "bower_components/Font-Awesome/css/all.min.css" %}" rel="stylesheet" type="text/css"> <!-- Favicon --> @@ -50,17 +50,12 @@ {#<!-- jQuery -->#} {#<script src="{% static "bower_components/jquery/dist/jquery.min.js" %}"></script>#} {#<script src="{% static "bower_components/jquery-migrate/jquery-migrate.min.js" %}"></script>#} - +<!-- Popper.js --> +<script src="{% static "bower_components/popper.js/dist/umd/popper.min.js" %}"></script> {#<script src="https://code.jquery.com/jquery-2.2.0.min.js"></script>#} <!-- Bootstrap Core JavaScript --> <script src="{% static "bower_components/bootstrap/dist/js/bootstrap.min.js" %}"></script> -<!-- Metis Menu Plugin JavaScript --> -<script src="{% static "bower_components/metisMenu/dist/metisMenu.min.js" %}"></script> - -<!-- Custom Theme JavaScript --> -<script src="{% static "bower_components/startbootstrap-sb-admin-2-blackrockdigital/dist/js/sb-admin-2.min.js" %}"></script> - {% block extrajs %} {% endblock extrajs %} </body> diff --git a/dashboard/src/templates/notifier/email_fulfilled.txt b/dashboard/src/templates/notifier/email_fulfilled.txt index d473961..65593db 100644 --- a/dashboard/src/templates/notifier/email_fulfilled.txt +++ b/dashboard/src/templates/notifier/email_fulfilled.txt @@ -3,9 +3,9 @@ The booking you requested of the OPNFV Lab as a Service has finished deploying and is ready for you to use. The lab that fulfilled your booking request has sent you the following messages: - {% for message in messages %} - {% message.title %} - {% message.content %} + {% for email_message in messages %} + {{ email_message.title }} + {{ email_message.content }} -------------------- {% endfor %} diff --git a/dashboard/src/templates/notifier/inbox.html b/dashboard/src/templates/notifier/inbox.html index 471eae4..72207ed 100644 --- a/dashboard/src/templates/notifier/inbox.html +++ b/dashboard/src/templates/notifier/inbox.html @@ -6,81 +6,124 @@ {% block content %} <style media="screen"> - - .inbox-panel { - display: grid; - grid-template-columns: 30% 70%; - } - - .section-panel { - padding: 10px; - } - - .iframe-panel { - padding: 0px; - margin-top: 0px; - } - - .card-container { - box-shadow: 0 0 5px 2px #cccccc; - } - .card { - height: 50px; - position: relative; - border-bottom: 1px solid #cccccc; - padding: 10px; - width: 100%; - background-color: #ffffff; - z-index: 5; - } - .selected-card { - background-color: #f3f3f3; - } - - .card:hover { - box-shadow: 0px 0 5px 2px #cccccc; - z-index: 6; - } - - #inbox-iframe { - height: calc(100vh - 130px); - } - - .half_width { - width: 50%; - } - .card-wrapper { - } + .inbox-panel { + display: grid; + grid-template-columns: 30% 5% 65%; + } + + .section-panel { + padding: 10px; + } + + .iframe-panel { + padding: 0px; + margin-top: 0px; + } + + .card-container { + border: 1px solid #cccccc; + border-bottom: 0px; + } + + .card { + height: 50px; + position: relative; + border-bottom: 1px solid #cccccc; + padding: 10px; + width: 100%; + background-color: #ffffff; + z-index: 5; + } + + .selected-card { + background-color: #f3f3f3; + } + + .card:hover { + box-shadow: 0px 0 5px 2px #cccccc; + z-index: 6; + } + + .half_width { + width: 50%; + } + + #page-wrapper { + padding: 0px; + } + + .read_notification { + background-color: #efefef; + } + + .scrollable { + overflow-y: auto; + } </style> - -<div class="inbox-panel"> - <div class="section-panel"> - <div class="card-container"> - {% for notification in notifications %} - <div class="inbox-entry card" onclick="showmessage({{notification.id}}); setactive(this);"> - {{ notification }} +<div class="container-fluid d-flex flex-grow-1 flex-column"> + <div class="row mt-3 mb-2"> + <div class="col-2 px-0"> + <div class="btn-group w-100" id="filterGroup"> + <button class="btn btn-secondary active" data-read="-1">All</button> + <button class="btn btn-secondary" data-read="0">Unread</button> + <button class="btn btn-secondary" data-read="1">Read</button> + </div> + </div> + </div> + <div class="row flex-grow-1" id="fixHeight"> + <!-- Notification list && Controls --> + <div class="mb-2 mb-lg-0 col-lg-2 px-0 mh-100"> + <div class="list-group rounded-0 rounded-left scrollable mh-100 notifications" id="unreadNotifications" data-read="0"> + {% for notification in unread_notifications %} + <a + href="#" + onclick="showmessage({{notification.id}}); setactive(this);" + class="list-group-item list-group-item-action notification"> + {{ notification }} + </a> + {% endfor %} + </div> + <div class="list-group rounded-0 rounded-left scrollable mh-100 notifications" id="readNotifications" data-read="1"> + {% for notification in read_notifications %} + <a + href="#" + onclick="showmessage({{notification.id}}); setactive(this);" + class="list-group-item list-group-item-action list-group-item-secondary notification"> + {{ notification }} + </a> + {% endfor %} + </div> + </div> + <!-- Content --> + <div class="col ml-lg-2 border mh-100 p-4"> + <iframe class="w-100 h-100" id="inbox-iframe" frameBorder="0" scrolling="yes">Please select a notification</iframe> </div> - {% endfor %} </div> - </div> - <div class="iframe-panel inbox-expanded-view"> - <div class="inbox-iframe-div"> - <iframe id="inbox-iframe" frameBorder="0" width="100%" height="100vh" scrolling="yes" onload="sizetoiframe(this);">Please select a notification</iframe> - </div> - </div> </div> <script type="text/javascript"> - $('#inbox-iframe').load(function() { - sizetoiframe(this); - }) - - function showmessage(msg_id) - { - iframe = document.getElementById("inbox-iframe"); - iframe.src = "notification/" + msg_id; - } - + function showmessage(msg_id) { + iframe = document.getElementById("inbox-iframe"); + iframe.src = "notification/" + msg_id; + } + + function setactive(obj) { + $(".notification").removeClass("active"); + $(obj).addClass("active"); + } + + $(document).ready(function(){ + // For all / unread / read + $("#filterGroup button").click(function(){ + let read = $(this).attr("data-read"); + $(this).siblings().removeClass("active"); + $(".notifications").addClass("d-none"); + $(this).addClass("active"); + if (read === "-1") { + return $(".notifications").removeClass("d-none"); + } + $(`.notifications[data-read="${read}"]`).removeClass("d-none"); + }); + }); </script> - -{% endblock %} +{% endblock %}
\ No newline at end of file diff --git a/dashboard/src/templates/notifier/notification.html b/dashboard/src/templates/notifier/notification.html index 65d26c9..0eafa60 100644 --- a/dashboard/src/templates/notifier/notification.html +++ b/dashboard/src/templates/notifier/notification.html @@ -2,19 +2,55 @@ {% block extrahead %} <base target="_parent"> {% endblock %} + {% block basecontent %} -<div class="card-container"> -<h3 class="msg_header">{{notification.title}}</h3> -<p class="content"></p> -<pre> -{{notification.content|safe}} -</pre> +<script> + function send_request(post_data){ + var form = $("#notification_action_form"); + var formData = form.serialize() + '&' + post_data + '=true'; + var req = new XMLHttpRequest(); + req.open("POST", ".", false); + req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.onerror = function() { alert("problem occurred while trying to cancel current workflow"); } + req.onreadystatechange = function() { if(req.readyState === 4){ + window.top.location.href += ''; + }}; + req.send(formData); + } + function delete_notification() + { + send_request("delete"); + } + function mark_unread() + { + send_request("unread"); + } +</script> +<div> + <h3 class="msg_header">{{notification.title}} + <div class="btn_group"> + <button class="btn btn-primary inbox-btn" onclick="mark_unread()">Mark Unread</button> + <button class="btn btn-danger inbox-btn" onclick="delete_notification()">Delete</button> + </div> + </h3> </div> +<p class="content-divider"></p> + +{% if not notification.is_html %} +<pre> +{% endif %} + {{notification.content|safe}} +{% if not notification.is_html %} +</pre> +{% endif %} +<form id="notification_action_form" action="." method="post"> + {% csrf_token %} +</form> + <style media="screen"> .card-container { - box-shadow: 0 0 5px 2px #cccccc; border: 1px solid #ffffff; margin-top: 11px; } @@ -28,11 +64,20 @@ background-color: #ffffff; z-index: 5; } - .sender { color: #636363; } - - + .content-divider { + border-bottom: 1px solid #cccccc; + padding-bottom: 15px; + clear: right; + } + .inbox-btn{ + display: inline; + margin: 3px; + } + .btn_group{ + float: right; + } </style> {% endblock %} diff --git a/dashboard/src/templates/resource/hostprofile_detail.html b/dashboard/src/templates/resource/hostprofile_detail.html new file mode 100644 index 0000000..dc20600 --- /dev/null +++ b/dashboard/src/templates/resource/hostprofile_detail.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} +{% load staticfiles %} + +{% block content %} +<div class="row"> + <div class="col-lg-6"> + <div class="card mb-4"> + <div class="card-header d-flex"> + <h4 style="display: inline;">Available at</h4> + <button data-toggle="collapse" data-target="#avilableAt" class="btn ml-auto btn-outline-secondary">Expand</button> + </div> + <div class="card-body collapse show" id="avilableAt"> + <ul class="list-group"> + {% for lab in hostprofile.labs.all %} + <li class="list-group-item">{{lab.name}}</li> + {% endfor %} + </ul> + </div> + </div> + <div class="card mb-4"> + <div class="card-header d-flex"> + <h4 style="display: inline;">RAM</h4> + <button data-toggle="collapse" data-target="#ramPanel" class="btn ml-auto btn-outline-secondary">Expand</button> + </div> + <div class="card-body collapse show" id="ramPanel"> + {{hostprofile.ramprofile.first.amount}}G, + {{hostprofile.ramprofile.first.channels}} channels + </div> + </div> + <div class="card mb-4"> + <div class="card-header d-flex"> + <h4 style="display: inline;">CPU</h4> + <button data-toggle="collapse" data-target="#cpuPanel" class="btn ml-auto btn-outline-secondary">Expand</button> + </div> + <div class="card-body collapse show" id="cpuPanel"> + <table class="table"> + <tr> + <td>Arch:</td> + <td>{{hostprofile.cpuprofile.first.architecture}}</td> + </tr> + <tr> + <td>Cores:</td> + <td>{{hostprofile.cpuprofile.first.cores}}</td> + </tr> + <tr> + <td>Sockets:</td> + <td>{{hostprofile.cpuprofile.first.cpus}}</td> + </tr> + </table> + </div> + </div> + <div class="card mb-4"> + <div class="card-header d-flex"> + <h4 style="display: inline;">Disk</h4> + <button data-toggle="collapse" data-target="#diskPanel" class="btn ml-auto btn-outline-secondary">Expand</button> + </div> + <div class="card-body collapse show" id="diskPanel"> + <table class="table"> + <tr> + <td>Size:</td> + <td>{{hostprofile.storageprofile.first.size}} GiB</td> + </tr> + <tr> + <td>Type:</td> + <td>{{hostprofile.storageprofile.first.media_type}}</td> + </tr> + <tr> + <td>Mount Point:</td> + <td>{{hostprofile.storageprofile.first.name}}</td> + </tr> + </table> + </div> + </div> + </div> + <div class="col-lg-6"> + <div class="card"> + <div class="card-header d-flex"> + <h4 style="display: inline;">Interfaces</h4> + <button data-toggle="collapse" data-target="#interfacePanel" class="btn ml-auto btn-outline-secondary">Expand</button> + </div> + <div class="card-body collapse show" id="interfacePanel"> + <table class="table"> + <thead> + <tr> + <th>Name</th> + <th>Speed</th> + </tr> + </thead> + <tbody> + {% for intprof in hostprofile.interfaceprofile.all %} + <tr> + <td>{{intprof.name}}</td> + <td>{{intprof.speed}}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> +</div> +{% endblock content %} diff --git a/dashboard/src/templates/resource/hosts.html b/dashboard/src/templates/resource/hosts.html index 4a53b45..69b7231 100644 --- a/dashboard/src/templates/resource/hosts.html +++ b/dashboard/src/templates/resource/hosts.html @@ -17,7 +17,7 @@ {{ host.name }} </td> <td> - {{ host.profile }} + <a href="profiles/{{ host.profile.id }}">{{ host.profile }}</a> </td> <td> {{ host.booked }} @@ -35,10 +35,7 @@ $(document).ready(function () { $('#table').DataTable({ scrollX: true, - columnDefs: [ - {type: 'status', targets: 6} - ], - "order": [[6, "asc"]] + "order": [[0, "asc"]] }); }); </script> diff --git a/dashboard/src/templates/resource/steps/define_hardware.html b/dashboard/src/templates/resource/steps/define_hardware.html index 933b4ab..57078e9 100644 --- a/dashboard/src/templates/resource/steps/define_hardware.html +++ b/dashboard/src/templates/resource/steps/define_hardware.html @@ -1,7 +1,7 @@ {% extends "workflow/viewport-element.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} <p>Note that not all labs host every kind of machine. @@ -15,20 +15,7 @@ with your current configuration will become unavailable.</p> </form> {% endblock content %} {% block onleave %} -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 data = normalize(result); -data = JSON.stringify(data); -document.getElementById("filter_field").value = data; +multi_filter_widget.finish(); var formData = $("#define_hardware_form").serialize(); req = new XMLHttpRequest(); req.open('POST', '/wf/workflow/', false); diff --git a/dashboard/src/templates/resource/steps/host_info.html b/dashboard/src/templates/resource/steps/host_info.html index 0275727..bbbafdc 100644 --- a/dashboard/src/templates/resource/steps/host_info.html +++ b/dashboard/src/templates/resource/steps/host_info.html @@ -1,7 +1,7 @@ {% extends "workflow/viewport-element.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} diff --git a/dashboard/src/templates/resource/steps/meta_info.html b/dashboard/src/templates/resource/steps/meta_info.html index 389ff6d..cebd343 100644 --- a/dashboard/src/templates/resource/steps/meta_info.html +++ b/dashboard/src/templates/resource/steps/meta_info.html @@ -1,10 +1,32 @@ {% extends "workflow/viewport-element.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} +<style> +#resource_meta_form { + padding: 80px; + display: grid; +} + +#resource_meta_form td > * { + width: 100%; + margin-bottom: 20px; + margin-top: 20px; +} + +#resource_meta_form > table > tbody > tr { + border-bottom: 1px solid #cccccc; +} + +#resource_meta_form > table > tbody > tr:last-child { + border-bottom: none; +} + +</style> + <form id="resource_meta_form" method="post" action="/wf/workflow/"> {% csrf_token %} <table> @@ -14,5 +36,11 @@ {% endblock content %} {% block onleave %} -document.getElementById("resource_meta_form").submit(); +var ajaxForm = $("#resource_meta_form"); +var formData = ajaxForm.serialize(); +req = new XMLHttpRequest(); +req.open("POST", "/wf/workflow/", false); +req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); +req.onerror = function() { alert("problem submitting form"); } +req.send(formData); {% endblock %} diff --git a/dashboard/src/templates/resource/steps/pod_definition.html b/dashboard/src/templates/resource/steps/pod_definition.html index ab9dfb3..5826ccb 100644 --- a/dashboard/src/templates/resource/steps/pod_definition.html +++ b/dashboard/src/templates/resource/steps/pod_definition.html @@ -8,609 +8,13 @@ var mxLoadStylesheets = false; </script> <script type="text/javascript" src="/static/js/mxClient.min.js" ></script> -<style> -p { - word-break: normal; - white-space: normal; -} -</style> -<script type="text/javascript"> -var currentWindow; -var currentGraph; -var netCount = 0; -var netColors = ['red', 'blue', 'purple', 'green', 'orange', '#8CCDF5', '#1E9BAC']; -var hostCount = 0; -var lastHostBottom = 100; -var networks = new Set([]); -var network_names = new Set([]); -var has_public_net = false; -var vlans = {{vlans|default:'null'}}; -var vlan_string = ""; - -function main(graphContainer, overviewContainer, toolbarContainer) { - if(vlans){ - for(var i=0; i<vlans.length-1; i++){ - vlan_string += vlans[i] + ", "; - } - if(vlans.length > 0){ - vlan_string += vlans[vlans.length-1]; - } - - var str = "Available vlans for your POD: " + vlan_string; - document.getElementById("vlan_notice").innerHTML = str; - } - //check if the browser is supported - if (!mxClient.isBrowserSupported()) { - mxUtils.error('Browser is not supported', 200, false); - return null; - } - - // Workaround for Internet Explorer ignoring certain styles - if (mxClient.IS_QUIRKS) { - document.body.style.overflow = 'hidden'; - new mxDivResizer(graphContainer); - } - var editor = new mxEditor(); - var graph = editor.graph; - var model = graph.getModel(); - editor.setGraphContainer(graphContainer); - - {% if debug %} - editor.addAction('printXML', function(editor, cell) { - mxLog.write(encodeGraph(graph)); - mxLog.show(); - }); - {% endif %} - - - doGlobalConfig(graph); - currentGraph = graph; - - {% if xml %} - restoreFromXml('{{xml|safe}}', editor); - {% elif hosts %} - {% for host in hosts %} - - var host = {{host|safe}}; - makeHost(host); - {% endfor %} - {% endif %} - {% if added_hosts %} - {% for host in added_hosts %} - var host = {{host|safe}} - makeHost(host); - {% endfor %} - updateHosts([]); - {% endif %} - - addToolbarButton(editor, toolbarContainer, 'zoomIn', '', "/static/img/mxgraph/zoom_in.png", true); - addToolbarButton(editor, toolbarContainer, 'zoomOut', '', "/static/img/mxgraph/zoom_out.png", true); - addToolbarButton(editor, toolbarContainer, 'printXML', '', '/static/img/mxgraph/fit_to_size.png', true); - var outline = new mxOutline(graph, overviewContainer); - - - var checkAllowed = function(edge, terminal, source) { - //check if other terminal is null, and that they are different - otherTerminal = edge.getTerminal(!source); - if(terminal != null && otherTerminal != null) { - if( terminal.getParent().getId().split('_')[0] == //'host' or 'network' - otherTerminal.getParent().getId().split('_')[0] ) { - //not allowed - graph.removeCells([edge]); - return false; - } - } - return true; - }; - - var colorEdge = function(edge, terminal, source) { - if(terminal.getParent().getId().indexOf('network') >= 0) { - styles = terminal.getParent().getStyle().split(';'); - color = 'black'; - for(var i=0; i<styles.length; i++){ - kvp = styles[i].split('='); - if(kvp[0] == "fillColor"){ - color = kvp[1]; - } - } - edge.setStyle('strokeColor=' + color); - } - }; - - var alertVlan = function(edge, terminal, source) { - if( terminal == null || edge.getTerminal(!source) == null) { - return; - } - var vlanHTML = '<form> <input type="radio" name="tagged" value="True" checked> Tagged<br>' - vlanHTML += '<input type="radio" name="tagged" value="False"> Untagged </form>' - vlanHTML += '<button onclick=parseVlanWindow(' + edge.getId() + ');>Okay</button>' - vlanHTML += '<button onclick=deleteVlanWindow(' + edge.getId() + ');>Cancel</button>' - content = document.createElement('div'); - content.innerHTML = vlanHTML; - showWindow(graph, "Vlan Selection", content, 200, 200); - } - - //sets the edge color to be the same as the network - graph.addListener(mxEvent.CELL_CONNECTED, function(sender, event){ - edge = event.getProperty('edge'); - terminal = event.getProperty('terminal') - source = event.getProperty('source'); - if(checkAllowed(edge, terminal, source)) { - colorEdge(edge, terminal, source); - alertVlan(edge, terminal, source); - } - }); - - graph.dblClick = function(evt, cell) { - - if( cell != null ){ - if( cell.getParent() != null && cell.getParent().getId().indexOf("network") > -1) { - cell = cell.getParent(); - } - if( cell.isEdge() || cell.getId().indexOf("network") > -1 ) { - var content = document.createElement('div'); - var innerHTML = "<button onclick=deleteCell('" + cell.getId() + "');>Remove</button>" - innerHTML += "<button onclick='currentWindow.destroy();'>Cancel</button>" - content.innerHTML = innerHTML; - showWindow(this, 'Delete?', content, 200, 200); - } - else { - showDetailWindow(cell); - } - } - }; - graph.setCellsSelectable(false); - graph.setCellsMovable(false); - - updateHosts({{ removed_hosts|default:"[]"|safe }}); - if(!has_public_net){ - addPublicNetwork(); - } -} - -function showDetailWindow(cell) { - var info = JSON.parse(cell.getValue()); - var content = document.createElement("div"); - var inner = "<pre>Name: " + info.name + "\n"; - inner += "Description:\n" + info.description + "</pre>"; - inner += '<button onclick="currentWindow.destroy();">Okay</button>' - content.innerHTML = inner - showWindow(currentGraph, 'Details', content, 400, 400); -} - -function restoreFromXml(xml, editor) { - var doc = mxUtils.parseXml(xml); - var node = doc.documentElement; - editor.readGraphModel(node); - - //Iterate over all children, and parse the networks to add them to the sidebar - var root = currentGraph.getDefaultParent(); - for( var i=0; i<root.getChildCount(); i++) { - var cell = root.getChildAt(i); - if(cell.getId().indexOf("network") > -1) { - var info = JSON.parse(cell.getValue()); - var vlan_id = info['vlan_id']; - networks.add(vlan_id); - var name = info['name']; - network_names.add(name); - var styles = cell.getStyle().split(";"); - var color = null; - for(var j=0; j< styles.length; j++){ - var kvp = styles[j].split('='); - if(kvp[0] == "fillColor") { - color = kvp[1]; - break; - } - } - if(info.public){ - vlan_id = ""; - has_public_net = true; - } - netCount++; - makeSidebarNetwork(name, vlan_id, color, cell.getId()); - } - } -} - -function deleteCell(cellId) { - var cell = currentGraph.getModel().getCell(cellId); - if( cellId.indexOf("network") > -1 ) { - elem = document.getElementById(cellId); - elem.parentElement.removeChild(elem); - } - currentGraph.removeCells([cell]); - currentWindow.destroy(); - -} - -function newNetworkWindow() { - var innerHtml = 'Name: <input type="text" name="net_name" id="net_name_input" style="margin:5px;"><br>'; - innerHtml += 'Vlan: <input type="number" step="1" name="vlan_id" id="vlan_id_input" style="margin:5px;"><br>'; - innerHtml += '<button type="button" onclick="parseNetworkWindow()">Okay</button>'; - innerHtml += '<button type="button" onclick="currentWindow.destroy();">Cancel</button><br>'; - innerHtml += '<div id="current_window_vlans"/>'; - innerHtml += '<div id="current_window_errors"/>'; - var content = document.createElement("div"); - content.innerHTML = innerHtml; - - showWindow(currentGraph, "Network Creation", content, 300, 300); - - if(vlans){ - vlan_notice = document.getElementById("current_window_vlans"); - vlan_notice.appendChild(document.createTextNode("Available Vlans: " + vlan_string)); - } -} - -function parseNetworkWindow() { - var net_name = document.getElementById("net_name_input").value - var vlan_id = document.getElementById("vlan_id_input").value - var error_div = document.getElementById("current_window_errors"); - var vlan_valid = Number.isInteger(Number(vlan_id)) && (vlan_id < 4095) && (vlan_id > 1) - if(vlans){ - vlan_valid = vlan_valid & vlans.indexOf(Number(vlan_id)) >= 0; - } - if( !vlan_valid) - { - error_div.innerHTML = "Please only enter an integer in the valid range (default 1-4095) for the VLAN ID"; - return; - } - if( networks.has(vlan_id)) - { - error_div.innerHTML = "All VLAN IDs must be unique"; - return; - } - if( network_names.has(net_name) ){ - error_div.innerHTML = "All network names must be unique"; - return; - } - addNetwork(net_name, vlan_id); - currentWindow.destroy(); -} - -function addToolbarButton(editor, toolbar, action, label, image, isTransparent) -{ - var button = document.createElement('button'); - button.style.fontSize = '10'; - if (image != null) - { - var img = document.createElement('img'); - img.setAttribute('src', image); - img.style.width = '16px'; - img.style.height = '16px'; - img.style.verticalAlign = 'middle'; - img.style.marginRight = '2px'; - button.appendChild(img); - } - if (isTransparent) - { - button.style.background = 'transparent'; - button.style.color = '#FFFFFF'; - button.style.border = 'none'; - } - mxEvent.addListener(button, 'click', function(evt) - { - editor.execute(action); - }); - mxUtils.write(button, label); - toolbar.appendChild(button); -}; - -function encodeGraph(graph) { - var encoder = new mxCodec(); - var xml = encoder.encode(graph.getModel()); - return mxUtils.getXml(xml); -} - -function doGlobalConfig(graph) { - //general graph stuff - graph.setMultigraph(false); - - //edge behavior - graph.setConnectable(true); - graph.setAllowDanglingEdges(false); - mxEdgeHandler.prototype.snapToTerminals = true; - mxConstants.MIN_HOTSPOT_SIZE = 16; - mxConstants.DEFAULT_HOTSPOT = 1; - //edge 'style' (still affects behavior greatly) - style = graph.getStylesheet().getDefaultEdgeStyle(); - style[mxConstants.STYLE_EDGE] = mxConstants.EDGESTYLE_ELBOW; - style[mxConstants.STYLE_ENDARROW] = mxConstants.NONE; - style[mxConstants.STYLE_ROUNDED] = true; - style[mxConstants.STYLE_FONTCOLOR] = 'black'; - style[mxConstants.STYLE_STROKECOLOR] = 'red'; - - style[mxConstants.STYLE_LABEL_BACKGROUNDCOLOR] = '#FFFFFF'; - style[mxConstants.STYLE_STROKEWIDTH] = '3'; - style[mxConstants.STYLE_ROUNDED] = true; - style[mxConstants.STYLE_EDGE] = mxEdgeStyle.EntityRelation; - - // TODO: Proper override - graph.convertValueToString = function(cell) { - try{ - //changes value for edges with xml value - if(cell.isEdge()) { - if(JSON.parse(cell.getValue())["tagged"]) { - return "tagged"; - } - return "untagged"; - } else{ - return JSON.parse(cell.getValue())['name']; - } - } - catch(e){ - return cell.getValue(); - } - }; -} - -function showWindow(graph, title, content, width, height) { - //create transparent black background - var background = document.createElement('div'); - background.style.position = 'absolute'; - background.style.left = '0px'; - background.style.top = '0px'; - background.style.right = '0px'; - background.style.bottom = '0px'; - background.style.background = 'black'; - mxUtils.setOpacity(background, 50); - document.body.appendChild(background); - - //deal with IE quirk - if (mxClient.IS_IE) { - new mxDivResizer(background); - } - - var x = Math.max(0, document.body.scrollWidth/2-width/2); - var y = Math.max(10, (document.body.scrollHeight || - document.documentElement.scrollHeight)/2-height*2/3); - - var wnd = new mxWindow(title, content, x, y, width, height, false, true); - wnd.setClosable(false); - - wnd.addListener(mxEvent.DESTROY, function(evt) { - graph.setEnabled(true); - mxEffects.fadeOut(background, 50, true, 10, 30, true); - }); - currentWindow = wnd; - - graph.setEnabled(false); - wnd.setVisible(true); -}; - -function closeWindow() { - //allows the current window to be destroyed - currentWindow.destroy(); -}; - -function othersUntagged(edgeID) { - var edge = currentGraph.getModel().getCell(edgeID); - var end1 = edge.getTerminal(true); - var end2 = edge.getTerminal(false); - - if( end1.getParent().getId().split('_')[0] == 'host' ) - { - var netint = end1; - } - else - { - var netint = end2; - } - - var edges = netint.edges; - - for( var i=0; i < edges.length; i++ ) - { - if( edges[i].getValue() ) - { - var tagged = JSON.parse(edges[i].getValue()).tagged; - } - else - { - var tagged = true; - } - if( !tagged ) - { - return true; - } - } - return false; -}; - - -function deleteVlanWindow(edgeID) { - var cell = currentGraph.getModel().getCell(edgeID); - currentGraph.removeCells([cell]); - currentWindow.destroy(); -} - -function parseVlanWindow(edgeID) { - //do parsing and data manipulation - var radios = document.getElementsByName("tagged"); - edge = currentGraph.getModel().getCell(edgeID); - - for(var i=0; i<radios.length; i++) { - if(radios[i].checked) { - //set edge to be tagged or untagged - //cellValue.setAttribute("tagged", radios[i].value); - if( radios[i].value == "False") - { - if( othersUntagged(edgeID) ) - { - alert("Only one untagged VLAN is allowed per interface"); - return; - } - } - edgeVal = Object(); - edgeVal['tagged'] = radios[i].value == "True"; - edge.setValue(JSON.stringify(edgeVal)); - break; - } - } - //edge.setValue(cellValue); - currentGraph.refresh(edge); - closeWindow(); -} - -function makeMxNetwork(vlan_id, net_name) { - model = currentGraph.getModel(); - width = 10; - height = 1700; - xoff = 400 + (30 * netCount); - yoff = -10; - var color = netColors[netCount]; - if( netCount > (netColors.length - 1)) { - color = Math.floor(Math.random() * 16777215); //int in possible color space - color = '#' + color.toString(16).toUpperCase(); //convert to hex - //alert(color); - } - var net_val = Object(); - net_val['vlan_id'] = vlan_id; - net_val['name'] = net_name; - net_val['public'] = vlan_id < 0; - net = currentGraph.insertVertex( - currentGraph.getDefaultParent(), - 'network_' + netCount, - JSON.stringify(net_val), - xoff, - yoff, - width, - height, - 'fillColor=' + color, - false - ); - var num_ports = 45; - for(var i=0; i<num_ports; i++){ - port = currentGraph.insertVertex( - net, - null, - '', - 0, - (1/num_ports) * i, - 10, - height / num_ports, - 'fillColor=black;opacity=0', - true - ); - } - - var retVal = Object(); - retVal['color'] = color; - retVal['element_id'] = "network_" + netCount; - - netCount++; - return retVal; -} - -function addPublicNetwork() { - var net = makeMxNetwork(-1, "public"); - makeSidebarNetwork("public", "", net['color'], net['element_id']); -} - -function addNetwork(net_name, vlan_id) { - var ret = makeMxNetwork(vlan_id, net_name); - var color = ret['color']; - var net_id = ret['element_id']; - networks.add(vlan_id); - network_names.add(net_name); - makeSidebarNetwork(net_name, vlan_id, color, net_id); -} - -function updateHosts(removed) { - for(var i=0; i < removed.length; i++) - { - var hoststring = removed[i]; - var hostid = "host_" + hoststring.split("*")[0]; - var cell = currentGraph.getModel().getCell(hostid); - currentGraph.removeCells([cell]); - } - - var hosts = currentGraph.getChildVertices(currentGraph.getDefaultParent()); - var topdist = 100; - for(var i=0; i<hosts.length; i++) - { - var host = hosts[i]; - if(!host.id.startsWith("host_")) - { - continue; - } - var geometry = host.getGeometry(); - geometry.y = topdist + 50; - topdist = geometry.y + geometry.height; - host.setGeometry(geometry); - } -} - -function makeSidebarNetwork(net_name, vlan_id, color, net_id){ - var newNet = document.createElement("li"); - newNet.id = net_id; - var text = net_name; - if(vlan_id){ - text += " : " + vlan_id; - } - var newNetValue = document.createTextNode(text); - newNet.appendChild(newNetValue); - newNet.style['background'] = color; - document.getElementById("network_list").appendChild(newNet); -} - -function makeHost(hostInfo) { - value = JSON.stringify(hostInfo['value']); - interfaces = hostInfo['interfaces']; - graph = currentGraph; - width = 100; - height = (25 * interfaces.length) + 10; - xoff = 75; - yoff = lastHostBottom + 50; - lastHostBottom = yoff + height; - host = graph.insertVertex( - graph.getDefaultParent(), - 'host_' + hostInfo['id'], - value, - xoff, - yoff, - width, - height, - 'editable=0', - false - ); - host.setConnectable(false); - hostCount++; - - for(var i=0; i<interfaces.length; i++) { - port = graph.insertVertex( - host, - null, - JSON.stringify(interfaces[i]), - 90, - (i * 25) + 5, - 20, - 20, - 'fillColor=blue;editable=0', - false - ); - } -} - -function submitForm() { - var form = document.getElementById("xml_form"); - var input_elem = document.getElementById("hidden_xml_input"); - var s = encodeGraph(currentGraph); - input_elem.value = s; - //form.submit(); - req = new XMLHttpRequest(); - req.open("POST", "/wf/workflow/", false); - req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); - req.onerror = function() { alert("problem with form submission"); } - var formData = $("#xml_form").serialize(); - req.send(formData); -} -</script> +<script type="text/javascript" src="/static/js/dashboard.js" ></script> {% endblock extrahead %} <!-- Calls the main function after the page has loaded. Container is dynamically created. --> {% block content %} <div id="graphParent" - style="position:absolute;overflow:hidden;top:0px;bottom:0px;width:65%;left:0px;"> + style="position:absolute;overflow:hidden;top:0px;bottom:0px;width:75%;left:0px;"> <div id="graphContainer" style="position:relative;overflow:hidden;top:36px;bottom:0px;left:0px;right:0px;background-image:url('/static/img/mxgraph/grid.gif');cursor:default;"> </div> @@ -618,7 +22,7 @@ function submitForm() { <!-- Creates a container for the sidebar --> <div id="toolbarContainer" - style="position:absolute;white-space:nowrap;overflow:hidden;top:0px;left:0px;max-height:24px;height:36px;right:0px;padding:6px;background-image:url('/static/img/mxgraph/toolbar_bg.gif');"> + style="position:absolute;white-space:nowrap;overflow:hidden;top:0px;left:0px;right:0px;padding:6px;"> </div> <!-- Creates a container for the outline --> @@ -627,12 +31,67 @@ function submitForm() { </div> </div> - <div id="network_select" style="position:absolute;top:0px;bottom:0px;width:35%;right:0px;left:auto;background:grey"> - <button type="button" onclick="newNetworkWindow();">Add Network</button> + <style> + p { + word-break: normal; + white-space: normal; + } + #network_select { + background: inherit; + padding: 0px; + padding-top: 0px; + } + #toolbarContainer { + background: #DDDDDD; + height: 36px; + } + #toolbar_extension { + height: 36px; + background: #DDDDDD; + } + #btn_add_network { + width: 100%; + } + #vlan_notice { + margin: 20px; + } + #network_list li { + border-radius: 2px; + margin: 5px; + padding: 5px; + vertical-align: middle; + background: #DDDDDD; + } + #network_list { + list-style-type: none; + padding: 0; + } + .colorblob { + width: 20px; + height: 20px; + border-radius: 50%; + display: inline-block; + vertical-align: middle; + } + .network_innertext { + display: inline-block; + padding-left: 10px; + vertical-align: middle; + padding-bottom: 0px; + margin-bottom: 2px; + } + .mxWindow { + background: #FFFFFF; + } + </style> + + <div id="network_select" style="position:absolute;top:0px;bottom:0px;width:25%;right:0px;left:auto;"> + <div id="toolbar_extension"> + <button id="btn_add_network" type="button" class="btn btn-primary" onclick="network_step.newNetworkWindow();">Add Network</button> + </div> <ul id="network_list"> </ul> - <p id="vlan_notice"></p> - <button type="button" style="display: none" onclick="submitForm();">Submit</button> + <button type="button" style="display: none" onclick="network_step.submitForm();">Submit</button> </div> <form id="xml_form" method="post" action="/wf/workflow/"> {% csrf_token %} @@ -640,14 +99,42 @@ function submitForm() { </form> <script> - main( + //gather context data + let debug = false; + {% if debug %} + debug = true; + {% endif %} + + let xml = ''; + {% if xml %} + xml = '{{xml|safe}}'; + {% endif %} + + let hosts = []; + {% for host in hosts %} + hosts.push({{host|safe}}); + {% endfor %} + + let added_hosts = []; + {% for host in added_hosts %} + added_hosts.push({{host|safe}}); + {% endfor %} + + let removed_host_ids = {{removed_hosts|safe}}; + + network_step = new NetworkStep( + debug, + xml, + hosts, + added_hosts, + removed_host_ids, document.getElementById('graphContainer'), document.getElementById('outlineContainer'), document.getElementById('toolbarContainer'), document.getElementById('sidebarContainer') - ) + ); </script> {% endblock content %} {% block onleave %} -submitForm(); +network_step.submitForm(); {% endblock %} diff --git a/dashboard/src/templates/snapshot_workflow/steps/meta.html b/dashboard/src/templates/snapshot_workflow/steps/meta.html index 2e767cc..bea475d 100644 --- a/dashboard/src/templates/snapshot_workflow/steps/meta.html +++ b/dashboard/src/templates/snapshot_workflow/steps/meta.html @@ -1,19 +1,24 @@ {% extends "workflow/viewport-element.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} - - <style> +.meta_container { + padding: 50px; +} </style> - {% bootstrap_form_errors form type='non_fields' %} -<form id="meta_form" action="/wf/workflow/" method="POST" class="form"> -{% csrf_token %} -{{form}} -</form> +<div class="meta_container"> + <form id="meta_form" action="/wf/workflow/" method="POST" class="form"> + {% csrf_token %} + <div class="form-group"> + {% bootstrap_field form.name %} + {% bootstrap_field form.description %} + </div> + </form> +</div> {% endblock content %} {% block onleave %} diff --git a/dashboard/src/templates/snapshot_workflow/steps/select_host.html b/dashboard/src/templates/snapshot_workflow/steps/select_host.html index 16dd5d4..f438bac 100644 --- a/dashboard/src/templates/snapshot_workflow/steps/select_host.html +++ b/dashboard/src/templates/snapshot_workflow/steps/select_host.html @@ -1,47 +1,77 @@ {% extends "workflow/viewport-element.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} - <style> .booking { - border-style: solid; + border-style: none; border-color: black; - border-width: 2px; - display: inline-block; - padding: 3px; + border: 2px; + border-radius: 5px; + margin: 20px; + padding-left: 25px; + padding-right: 25px; + padding-bottom: 25px; + box-shadow: 0px 0px 7px 0px rgba(0,0,0,0.75); + transition-property: box-shadow; + transition-duration: 0.1s; + float: left; + } + .booking:hover { + box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.75); + transition-property: box-shadow; + transition-duration: 0.1s; } .host { + cursor: pointer; border-style: solid; border-color: black; border-width: 1px; - margin: 2px; + border-radius: 5px; + margin: 5px; + padding: 5px; + text-align: center; + box-shadow: 0px 0px 2px 0px rgba(0,0,0,0.75); + transition-property: box-shadow; + transition-duration: 0.1s; + } + .host:hover { + box-shadow: 0px 0px 4px 0px rgba(0,0,0,0.75); + transition-property: box-shadow; + transition-duration: 0.1s; + background-color: rgba(144,238,144,0.3); + } + .selected { + background-color: lightgreen !important; + } + .booking_container { + overflow: auto; + padding: 30px; } </style> - {% bootstrap_form_errors form type='non_fields' %} <form id="host_select_form" action="/wf/workflow/" method="POST" class="form"> {% csrf_token %} <input type="hidden" id="hidden_json_input", name="host"/> </form> -<div id="host_select_container"> +<div id="host_select_container" class="booking_container"> </div> <script> var selected_host = null; -var initial = {{chosen|default:'null'}}; +var initial = {{chosen|safe|default:'null'}}; function select(booking_id, host_name){ var input = document.getElementById("hidden_json_input"); input.value = JSON.stringify({"booking": booking_id, "name": host_name}); // clear out and highlist host if(selected_host){ - selected_host.style['background-color'] = "white"; + selected_host.classList.remove("selected"); } selected_host = document.getElementById("booking_" + booking_id + "_host_" + host_name); - selected_host.style['background-color'] = "lightgrey"; + selected_host.classList.add("selected"); } function draw_bookings(){ @@ -53,17 +83,20 @@ function draw_bookings(){ var heading = document.createElement("H3"); heading.appendChild(document.createTextNode("Booking " + booking_id)); booking.appendChild(heading); - var desc = "start: " + booking_hosts[booking_id].start + - " end: " + booking_hosts[booking_id].end + - " purpose: " + booking_hosts[booking_id].purpose; - booking.appendChild(document.createTextNode(desc)); + booking.appendChild(document.createTextNode("start: " + booking_hosts[booking_id].start)); + booking.appendChild(document.createElement("BR")); + booking.appendChild(document.createTextNode("end: " + booking_hosts[booking_id].end)); + booking.appendChild(document.createElement("BR")); + booking.appendChild(document.createTextNode("purpose: " + booking_hosts[booking_id].purpose)); + booking.appendChild(document.createElement("BR")); + booking.appendChild(document.createTextNode("hosts:")); booking.id = "booking_" + booking_id; booking.className = "booking"; var hosts = booking_hosts[booking_id].hosts; for(var i=0; i<hosts.length; i++){ var host = document.createElement("DIV"); host.id = "booking_" + booking_id + "_host_" + hosts[i].name; - host.className = "host"; + host.classList.add("host"); host.appendChild(document.createTextNode(hosts[i].name)); var hostname = hosts[i].name; host.booking = booking_id; diff --git a/dashboard/src/templates/workflow/confirm.html b/dashboard/src/templates/workflow/confirm.html index 555fa56..c1f3440 100644 --- a/dashboard/src/templates/workflow/confirm.html +++ b/dashboard/src/templates/workflow/confirm.html @@ -1,7 +1,7 @@ {% extends "workflow/viewport-element.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} @@ -58,6 +58,17 @@ <script> var select = document.getElementById("id_confirm"); + function processResponseText(json) + { + var dict = JSON.parse(json); + + if( !dict["redir_url"] ) { + window.top.refresh_iframe(); + } else { + top.window.location.href = dict["redir_url"]; + } + } + function delete_manager() { var form = $("#manager_delete_form"); @@ -66,19 +77,34 @@ req.open("POST", "/wf/workflow/finish/", false); req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); req.onerror = function() { alert("problem with cleaning up session"); } - req.onreadystatechange = function() { if(req.readyState === 4 ) { parent.redirect_root(); } } + req.onreadystatechange = function() { if(req.readyState === 4 ) { + processResponseText(req.responseText); + }} req.send(formData); } + function submitForm() + { + var form = $("#confirmation_form"); + var formData = form.serialize(); + var req = new XMLHttpRequest(); + req.open("POST", "/wf/workflow/", false); + req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.onerror = function() { alert("problem submitting confirmation"); } + req.onreadystatechange = function() { if(req.readyState === 4 ) { delete_manager(); } } + req.send(formData); + } + + function formconfirm() { select.value = "True"; - document.getElementById("confirmation_form").submit(); + submitForm(); } function formcancel() { select.value = "False"; - document.getElementById("confirmation_form").submit(); + submitForm(); } var confirmed = {{bypassed|default:"false"}}; @@ -91,7 +117,20 @@ function fixVlans() { document.getElementById("vlan_input").value = "True"; - document.getElementById("vlan_form").submit(); + var form = $("#vlan_form"); + var formData = form.serialize(); + var req = new XMLHttpRequest(); + req.open("POST", "/wf/workflow/", false); + req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.onerror = function() { alert("problem submitting form"); } + req.onreadystatechange = function() { //replaces current page with response + if(req.readyState === 4 ) { + var d = document.getElementById("vlan_warning").innerHTML = ""; + document.getElementById("confirm_button").disabled = false; + document.getElementById("cancel_button").disabled = false; + } + } + req.send(formData); } var problem = {{vlan_warning|default:'false'}}; if(problem){ @@ -121,5 +160,4 @@ if(problem){ {% endblock element_messages %} {% endblock content %} {% block onleave %} -//document.getElementById("confirmation_form").submit(); {% endblock %} diff --git a/dashboard/src/templates/workflow/no_workflow.html b/dashboard/src/templates/workflow/no_workflow.html index ff8aab3..0ac6549 100644 --- a/dashboard/src/templates/workflow/no_workflow.html +++ b/dashboard/src/templates/workflow/no_workflow.html @@ -1,7 +1,3 @@ -{% extends "base.html" %} -{% load staticfiles %} - -{% block content %} -<h3>If you would like to create a booking or a resource, please use the links on the sidebar or from the homepage</h3> -<a href="/">Go Home</a> -{% endblock content %} +<script> + top.window.location.href='/'; +</script> diff --git a/dashboard/src/templates/workflow/resource_select.html b/dashboard/src/templates/workflow/resource_select.html index c319ff5..cd04137 100644 --- a/dashboard/src/templates/workflow/resource_select.html +++ b/dashboard/src/templates/workflow/resource_select.html @@ -1,7 +1,7 @@ {% extends "workflow/viewport-element.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} diff --git a/dashboard/src/templates/workflow/viewport-base.html b/dashboard/src/templates/workflow/viewport-base.html index 37eff27..aa01d7e 100644 --- a/dashboard/src/templates/workflow/viewport-base.html +++ b/dashboard/src/templates/workflow/viewport-base.html @@ -1,12 +1,12 @@ {% extends "base.html" %} {% load staticfiles %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% block content %} <style> - .go_btn{ + .go_btn { position: absolute; width: 100px; @@ -14,118 +14,256 @@ height: calc(100% - 170px); } - .go_btn_disabled{ - background-color: #ffffff; + + .go_btn_disabled { + background-color: #ffffff; } - .go_forward{ + + .go_forward { right: 0px; border-left: none; } - .go_back{ + .go_back { left: 251px; border-right: none; } - .btn_wrapper{ + .btn_wrapper { text-align: center; margin-bottom: 5px; } {% if DEBUG %} + .add_btn_wrapper { + right: 130px; + top: 10px; + position: absolute; + } + {% endif %} - .add_btn_wrapper{ - right: 130px; - top: 10px; - position: absolute; + #breadcrumbs { + margin-bottom: 0; } - {% endif %} + .btn_wrapper { + margin: 0; + } + .step { + display: inline; + padding: 7px; + margin: 1px; + font-size: 14pt; + cursor: default; + } - .options{ - position: absolute; - top: 60px; - right: 20px; + .step:active { + -webkit-box-shadow: inherit; + box-shadow: inherit; } - #breadcrumbs { - padding: 4px; + .step_active:active { + -webkit-box-shadow: inherit; + box-shadow: inherit; } - .step{ - background: #DEEED3; + + .step_active { display: inline; - padding: 5px; + padding: 7px; margin: 1px; + cursor: default; + font-size: 14pt; + padding-bottom: 4px !important; + border-bottom: 4px solid #41ba78 !important; } - .step_active{ - background: #5EC392; - display: inline; - padding: 5px; - margin: 1px; - font-weight: bold; + .step_hidden { + background: #EFEFEF; + color: #999999; + } + + .step_invalid::after { + content: " \2612"; + color: #CC3300; } - .step_untouched - { - background: #98B0AF; + .step_valid::after { + content: " \2611"; + color: #41ba78; } - .step_invalid - { - background: #CC3300; + .step_untouched::after { + content: " \2610"; } - .step_valid - { - background: #0FD57D; + .iframe_div { + width: calc(100% - 450px); + margin-left: 70px; + height: calc(100vh - 155px); + position: absolute; + border: none; } - #viewport-iframe - { - height: calc(100vh - 450); - } + .iframe_elem { + width: 100%; + height: calc(100vh - 155px); + border: none; + } + #breadcrumbs { + background-color: inherit; + } -</style> + #breadcrumbs.breadcrumb>li { + border: 1px solid #cccccc; + border-left: none; + } + + #breadcrumbs.breadcrumb>li:first-child { + border-left: 1px solid #cccccc; + } + + #breadcrumbs.breadcrumb>li+li:before { + content: ""; + width: 0; + margin: 0; + padding: 0; + } + + #topPagination .topcrumb { + flex: 1 1 0; + display: flex; + align-content: center; + justify-content: center; + border: 1px solid #dee2e6; + border-left: none; + } + + .topcrumb > span { + color: #343a40; + cursor: default; + } -<button id="gof" onclick="go(step+1)" class="btn go_btn go_forward">Go Forward</button> -<button id="gob" onclick="go(step-1)" class="btn btn go_btn go_back">Go Back</button> + .topcrumb.active > span { + background: #007bff; + color: white; + } -<div class="options"> - <button class="btn" onclick="cancel_wf()">Cancel</button> + .topcrumb.disabled > span { + color: #6c757d; + background: #f8f9fa; + } +</style> +<!-- Pagination --> +<div class="row mt-3"> + <div class="col"> + <nav> + <ul class="pagination d-flex flex-row" id="topPagination"> + <li class="page-item flex-shrink-1 page-control"> + <a class="page-link" href="#" id="gob" onclick="go('prev')"> + <i class="fas fa-backward"></i> Back + </a> + </li> + <li class="page-item flex-grow-1 active"> + <a class="page-link disabled" href="#"> + Select <i class="far fa-check-square"></i> + </a> + </li> + <li class="page-item flex-grow-1"> + <a class="page-link disabled" href="#"> + Configure <i class="far fa-square"></i> + </a> + </li> + <li class="page-item flex-grow-1"> + <a class="page-link disabled" href="#"> + Information <i class="far fa-square"></i> + </a> + </li> + <li class="page-item flex-grow-1"> + <a class="page-link disabled" href="#"> + OPNFV <i class="far fa-square"></i> + </a> + </li> + <li class="page-item flex-grow-1"> + <a class="page-link disabled" href="#"> + Confirm <i class="far fa-square"></i> + </a> + </li> + <li class="page-item flex-shrink-1 page-control"> + <a class="page-link text-right" href="#" id="gof" onclick="go('next')"> + Next <i class="fas fa-forward"></i> + </a> + </li> + </ul> + </nav> + </div> </div> -<div class="btn_wrapper"> -<div id="breadcrumbs"> +<!-- Top header --> +<div class="row px-4"> + <div class="col"> + <div id="iframe_header" class="row view-header"> + <div class="col-lg-12 step_header"> + <h1 class="step_title d-inline-block" id="view_title"></h1> + <span class="description text-muted" id="view_desc"></span> + <p class="step_message" id="view_message"></p> + </div> + <script> + function update_description(title, desc) { + document.getElementById("view_title").innerText = title; + document.getElementById("view_desc").innerText = desc; + } + + function update_message(message, stepstatus) { + document.getElementById("view_message").innerText = message; + document.getElementById("view_message").className = "step_message"; + document.getElementById("view_message").classList.add("message_" + stepstatus); + } + </script> + <!-- /.col-lg-12 --> + </div> + </div> + <div class="col-auto align-self-center d-flex"> + <button id="cancel_btn" class="btn btn-danger ml-auto" onclick="cancel_wf()">Cancel</button> + </div> +</div> +<!-- Content here --> +<div class="row d-flex flex-column flex-grow-1"> + <div class="container-fluid d-flex flex-column h-100"> + <div class="row d-flex flex-grow-1 p-4"> + <!-- iframe workflow --> + <div class="col-12 d-flex border flex-grow-1"> + <!-- This was where the iframe went --> + <iframe src="/wf/workflow" class="w-100 h-100" scrolling="yes" id="viewport-iframe" + frameBorder="0"></iframe> + </div> + </div> + </div> </div> +<div class="btn_wrapper"> </div> {% csrf_token %} <script type="text/javascript"> - - update_context(); var step = 0; var page_count = 0; var context_data = false; - function go(to) - { + function go(to) { step_on_leave(); request_leave(to); } - function request_leave(to) - { + function request_leave(to) { $.ajax({ type: "GET", url: "/wf/manager/", - beforeSend: function(request) { + beforeSend: function (request) { request.setRequestHeader("X-CSRFToken", - $('input[name="csrfmiddlewaretoken"]').val()); + $('input[name="csrfmiddlewaretoken"]').val()); }, success: function (data) { confirm_permission(to, data); @@ -134,56 +272,44 @@ }); } - function confirm_permission(to, data) - { - if( errors_exist(data) ) - { - var continueanyway = confirm("The current step has errors that will prevent it from saving. Continue anyway?"); - if( !continueanyway ) - { + function confirm_permission(to, data) { + if (errors_exist(data)) { + if (to != "prev") { return; } } - if( to >= page_count ) - { - to = page_count-1; - } - else if( to < 0 ) - { - to = 0; - } - var problem = function() { + + var problem = function () { alert("There was a problem"); } //makes an asynch request req = new XMLHttpRequest(); url = "/wf/workflow/?step=" + to; req.open("GET", url, true); - req.onload = function(e) { - if(req.readyState === 4){ - if(req.status < 300){ + req.onload = function (e) { + if (req.readyState === 4) { + if (req.status < 300) { document.getElementById("viewport-iframe").srcdoc = this.responseText; - } else { problem(); } - } else { problem(); } + } else { + problem(); + } + } else { + problem(); + } } req.onerror = problem; req.send(); } - function step_on_leave() - { + function step_on_leave() { document.getElementById("viewport-iframe").contentWindow.step_on_leave(); } - function errors_exist(data) - { + function errors_exist(data) { var stat = data['steps'][data['active']]['valid']; - if( stat >= 100 && stat < 200 ) - { + if (stat >= 100 && stat < 200) { return true; - } - else - { + } else { return false; } } @@ -192,9 +318,9 @@ $.ajax({ type: "GET", url: "/wf/manager/", - beforeSend: function(request) { + beforeSend: function (request) { request.setRequestHeader("X-CSRFToken", - $('input[name="csrfmiddlewaretoken"]').val()); + $('input[name="csrfmiddlewaretoken"]').val()); }, success: function (data) { update_page(data); @@ -202,183 +328,146 @@ }); } - function update_page(data) - { + function update_page(data) { context_data = data; update_breadcrumbs(data); + if (data["workflow_count"] == 1) { + document.getElementById("cancel_btn").innerText = "Exit Workflow"; + } else { + document.getElementById("cancel_btn").innerText = "Return to Parent"; + } } function update_breadcrumbs(meta_json) { step = meta_json['active']; page_count = meta_json['steps'].length; - if( step == 0 ) - { - var btn = document.getElementById("gob"); - btn.classList.add("go_btn_disabled"); - btn.disabled = true; - } - else - { - var btn = document.getElementById("gob"); - btn.classList.remove("go_btn_disabled"); - btn.disabled = false; - } - if( step == page_count - 1 ) - { - var btn = document.getElementById("gof"); - btn.classList.add("go_btn_disabled"); - btn.disabled = true; + if (step == 0) { + var btn = document.getElementById("gob"); + btn.classList.add("invisible"); + btn.disabled = true; + } else { + var btn = document.getElementById("gob"); + btn.classList.remove("invisible"); + btn.disabled = false; } - else - { - var btn = document.getElementById("gof"); - btn.classList.remove("go_btn_disabled"); - btn.disabled = false; + if (step == page_count - 1) { + var btn = document.getElementById("gof"); + btn.classList.add("invisible"); + btn.disabled = true; + } else { + var btn = document.getElementById("gof"); + btn.classList.remove("invisible"); + btn.disabled = false; } //remove all children of breadcrumbs so we can redraw - var container = document.getElementById("breadcrumbs"); - while(container.firstChild){ - container.removeChild(container.firstChild); - } - //draw enough rows for all steps - var depth = meta_json['max_depth']; - for(var i=0; i<=depth; i++){ - var div = document.createElement("DIV"); - div.id = "row"+i; - if(i<depth){ - div.style['margin-bottom'] = "7px"; - } - if(i>0){ - div.style['margin-top'] = "7px"; - } - container.appendChild(div); - } + $("#topPagination").children().not(".page-control").remove(); draw_steps(meta_json); } - function draw_steps(meta_json){ - var all_relations = meta_json['relations']; - var relations = []; - var active_steps = []; - var active_step = step; - while(active_step < meta_json['steps'].length){ - active_steps.push(active_step); - var index = meta_json['parents'][active_step]; - var relation = all_relations[index]; - relations.push(relation); - active_step = relation['parent']; - } - var child_index = meta_json['children'][step]; - var my_children = all_relations[child_index]; - if(my_children){ - relations.push(my_children); - } - draw_relations(relations, meta_json, active_steps); - } - - function draw_relations(relations, meta_json, active_steps){ - for(var i=0; i<relations.length; i++){ - var relation = relations[i]; - var children_container = document.createElement("DIV"); - children_container.style['display'] = "inline"; - children_container.style['margin'] = "3px"; - children_container.style['padding'] = "3px"; - console.log("meta_json: "); - console.log(meta_json); - for(var j=0; j<relation['children'].length; j++){ - var step_json = meta_json['steps'][relation['children'][j]]; - step_json['index'] = relation['children'][j]; - var active = active_steps.indexOf(step_json['index']) > -1; - var step_button = create_step(meta_json['steps'][relation['children'][j]], active); - children_container.appendChild(step_button); - } - var parent_div = document.getElementById("row" + relation['depth']); - parent_div.appendChild(children_container); + function draw_steps(meta_json) { + for (var i = 0; i < meta_json["steps"].length; i++) { + meta_json["steps"][i]["index"] = i; + var step_btn = create_step(meta_json["steps"][i], i == meta_json["active"]); + $("#topPagination li:last-child").before(step_btn); } } - function create_step(step_json, active){ - var step_dom = document.createElement("DIV"); - if(active){ - step_dom.className = "step_active"; - console.log(step_json['message']); - - } else{ - step_dom.className = "step"; + function create_step(step_json, active) { + var step_dom = document.createElement("li"); + // First create the dom object depending on active or not + if (active) { + step_dom.className = "topcrumb active"; + } else { + step_dom.className = "topcrumb"; } - step_dom.appendChild(document.createTextNode(step_json['title'])); + $(step_dom).html(`<span class="d-flex align-items-center justify-content-center text-capitalize w-100">${step_json['title']}</span>`) var code = step_json['valid']; stat = ""; msg = ""; - if( code < 100 ) - { - step_dom.classList.add("step_untouched"); - + if (code < 100) { + $(step_dom).children().first().append("<i class='ml-2 far fa-square'></i>") stat = ""; msg = ""; - } - else if( code < 200 ) - { - step_dom.classList.add("step_invalid"); + } else if (code < 200) { + $(step_dom).children().first().append("<i class='ml-2 fas fa-minus-square'></i>") stat = "invalid"; msg = step_json['message']; - } - else if( code < 300 ) - { - step_dom.classList.add("step_valid"); + } else if (code < 300) { + $(step_dom).children().first().append("<i class='ml-2 far fa-check-square'></i>") stat = "valid"; msg = step_json['message']; } - if(active) - { + if (step_json['enabled'] == false) { + step_dom.classList.add("disabled"); + } + if (active) { update_message(msg, stat); } var step_number = step_json['index']; - step_dom.onclick = function(){ go(step_number); } - //TODO: background color and other style return step_dom; } - function cancel_wf(){ - $.ajax({ - type: "POST", - url: "/wf/manager/", - data: {"cancel":"",}, - beforeSend: function(request) { - request.setRequestHeader("X-CSRFToken", - $('input[name="csrfmiddlewaretoken"]').val() - ); - }, - success: redirect_root() - }); + function cancel_wf() { + var form = $("#workflow_pop_form"); + var formData = form.serialize(); + var req = new XMLHttpRequest(); + req.open("POST", "/wf/workflow/finish/", false); + req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + req.onerror = function () { + alert("problem occurred while trying to cancel current workflow"); + } + req.onreadystatechange = function () { + if (req.readyState === 4) { + refresh_iframe(); + } + }; + req.send(formData); + } + + function refresh_iframe() { + req = new XMLHttpRequest(); + url = "/wf/workflow/"; + req.open("GET", url, true); + req.onload = function (e) { + var doc = document.getElementById("viewport-iframe").contentWindow.document; + doc.open(); + doc.write(this.responseText); + doc.close(); + } + req.send(); } - function redirect_root() - { - window.location.replace('/'); + function write_iframe(contents) { + document.getElementById("viewport-iframe").contentWindow.document.innerHTML = contents; } - function add_wf(type){ + function redirect_root() { + window.location.replace('/wf/'); + } + + function add_wf(type) { add_wf_internal(type, false); } - function add_edit_wf(type, target){ + function add_edit_wf(type, target) { add_wf_internal(type, target); } - function add_wf_internal(type, itemid){ - data = {"add": type}; - if(itemid){ + function add_wf_internal(type, itemid) { + data = { + "add": type + }; + if (itemid) { data['target'] = itemid; } $.ajax({ type: "POST", url: "/wf/manager/", data: data, - beforeSend: function(request) { + beforeSend: function (request) { request.setRequestHeader("X-CSRFToken", - $('input[name="csrfmiddlewaretoken"]').val() + $('input[name="csrfmiddlewaretoken"]').val() ); }, success: refresh_wf_iframe() @@ -386,68 +475,12 @@ } function refresh_wf_iframe() { - window.location=window.location; + window.location = window.location; } </script> -<div id="iframe_header" class="row view-header"> - <div class="col-lg-12 step_header"> - <h1 class="step_title" id="view_title"></h1> - <p class="description" id="view_desc"></p> - <p class="step_message" id="view_message"></p> - </div> - <style> - #view_desc{ - margin-bottom: 15px; - margin-top: 5px; - margin-left: 30px; - display: inline; - } - #view_title{ - margin-top: 5px; - margin-bottom: 0px; - display: inline; - } - #view_message{ - margin-top: 10px; - margin-bottom: 5px; - float: right; - } - .message_invalid{ - color: #ff4400; - } - .message_valid{ - color: #44cc00; - } - .step_header{ - border-bottom: 1px solid #eee; - border-top: 1px solid #eee; - left: 101px; - width: calc(100% - 202px); - } - </style> - <script> - function update_description(title, desc){ - document.getElementById("view_title").innerText = title; - document.getElementById("view_desc").innerText = desc; - } - function update_message(message, stepstatus){ - document.getElementById("view_message").innerText = message; - document.getElementById("view_message").className = "step_message"; - document.getElementById("view_message").classList.add("message_" + stepstatus); - } - function resize_iframe(){ - var page_rect = document.getElementById("wrapper").getBoundingClientRect(); - var title_rect = document.getElementById("iframe_header").getBoundingClientRect(); - var iframe_height = page_rect.bottom - title_rect.bottom; - console.log("setting height to " + iframe_height); - document.getElementById("viewport-iframe").height = iframe_height; - - } - window.addEventListener('load', resize_iframe); - window.addEventListener('resize', resize_iframe); - </script> - <!-- /.col-lg-12 --> +<div style="display: none;" id="workflow_pop_form_div"> + <form id="workflow_pop_form" action="/wf/workflow/finish/" method="post"> + {% csrf_token %} + </form> </div> - -<iframe src="/wf/workflow" style="position: absolute; left: 351px; right: 105px; width: calc(100% - 450px); border-style: none; border-width: 1px; border-color: #888888;" scrolling="yes" id="viewport-iframe" onload="resize_iframe();"></iframe> -{% endblock content %} +{% endblock content %}
\ No newline at end of file diff --git a/dashboard/src/templates/workflow/viewport-element.html b/dashboard/src/templates/workflow/viewport-element.html index f25e644..7a7165a 100644 --- a/dashboard/src/templates/workflow/viewport-element.html +++ b/dashboard/src/templates/workflow/viewport-element.html @@ -1,5 +1,5 @@ {% extends "layout.html" %} -{% load bootstrap3 %} +{% load bootstrap4 %} {% load staticfiles %} {% block basecontent %} diff --git a/dashboard/src/workflow/booking_workflow.py b/dashboard/src/workflow/booking_workflow.py index cd12ab6..42372ce 100644 --- a/dashboard/src/workflow/booking_workflow.py +++ b/dashboard/src/workflow/booking_workflow.py @@ -8,209 +8,136 @@ ############################################################################## from django.contrib import messages -from django.shortcuts import render -from django.contrib.auth.models import User from django.utils import timezone -import json from datetime import timedelta from booking.models import Booking -from workflow.models import WorkflowStep -from workflow.forms import ResourceSelectorForm, SWConfigSelectorForm, BookingMetaForm -from resource_inventory.models import GenericResourceBundle, ResourceBundle, ConfigBundle +from workflow.models import WorkflowStep, AbstractSelectOrCreate +from workflow.forms import ResourceSelectorForm, SWConfigSelectorForm, BookingMetaForm, OPNFVSelectForm +from resource_inventory.models import GenericResourceBundle, ConfigBundle, OPNFVConfig -class Resource_Select(WorkflowStep): - template = 'booking/steps/resource_select.html' +""" +subclassing notes: + subclasses have to define the following class attributes: + self.repo_key: main output of step, where the selected/created single selector + result is placed at the end + self.confirm_key: +""" + + +class Abstract_Resource_Select(AbstractSelectOrCreate): + form = ResourceSelectorForm + template = 'dashboard/genericselect.html' title = "Select Resource" description = "Select a resource template to use for your deployment" short_title = "pod select" def __init__(self, *args, **kwargs): - super(Resource_Select, self).__init__(*args, **kwargs) - self.repo_key = self.repo.SELECTED_GRESOURCE_BUNDLE - self.repo_check_key = False - self.confirm_key = "booking" + super().__init__(*args, **kwargs) + self.select_repo_key = self.repo.SELECTED_GRESOURCE_BUNDLE + self.confirm_key = self.workflow_type - def get_default_entry(self): - return None + def alert_bundle_missing(self): + self.set_invalid("Please select a valid resource bundle") - def get_context(self): - context = super(Resource_Select, self).get_context() - default = [] - chosen_bundle = None - default_bundle = self.get_default_entry() - if default_bundle: - context['disabled'] = True - chosen_bundle = default_bundle - if chosen_bundle.id: - default.append(chosen_bundle.id) - else: - default.append("repo bundle") - else: - chosen_bundle = self.repo_get(self.repo_key, False) - if chosen_bundle: - if chosen_bundle.id: - default.append(chosen_bundle.id) - else: - default.append("repo bundle") - - bundle = default_bundle - if not bundle: - bundle = chosen_bundle - edit = self.repo_get(self.repo.EDIT, False) + def get_form_queryset(self): user = self.repo_get(self.repo.SESSION_USER) - context['form'] = ResourceSelectorForm( - data={"user": user}, - chosen_resource=default, - bundle=bundle, - edit=edit - ) - return context - - def post_render(self, request): - form = ResourceSelectorForm(request.POST) - context = self.get_context() - if form.is_valid(): - data = form.cleaned_data['generic_resource_bundle'] - data = data[2:-2] - if not data: - self.metastep.set_invalid("Please select a valid bundle") - return render(request, self.template, context) - selected_bundle = json.loads(data) - selected_id = selected_bundle[0]['id'] - gresource_bundle = None - try: - selected_id = int(selected_id) - gresource_bundle = GenericResourceBundle.objects.get(id=selected_id) - except ValueError: - # we want the bundle in the repo - gresource_bundle = self.repo_get( - self.repo.GRESOURCE_BUNDLE_MODELS, - {} - ).get("bundle", GenericResourceBundle()) - self.repo_put( - self.repo_key, - gresource_bundle - ) - confirm = self.repo_get(self.repo.CONFIRMATION) - if self.confirm_key not in confirm: - confirm[self.confirm_key] = {} - confirm[self.confirm_key]["resource name"] = gresource_bundle.name - self.repo_put(self.repo.CONFIRMATION, confirm) - messages.add_message(request, messages.SUCCESS, 'Form Validated Successfully', fail_silently=True) - self.metastep.set_valid("Step Completed") - return render(request, self.template, context) - else: - messages.add_message(request, messages.ERROR, "Form Didn't Validate", fail_silently=True) - self.metastep.set_invalid("Please complete the fields highlighted in red to continue") - return render(request, self.template, context) + qs = GenericResourceBundle.objects.filter(owner=user) + return qs + def get_page_context(self): + return { + 'select_type': 'resource', + 'select_type_title': 'Resource Bundle', + 'addable_type_num': 1 + } -class Booking_Resource_Select(Resource_Select): + def put_confirm_info(self, bundle): + confirm_dict = self.repo_get(self.repo.CONFIRMATION) + if self.confirm_key not in confirm_dict: + confirm_dict[self.confirm_key] = {} + confirm_dict[self.confirm_key]["Resource Template"] = bundle.name + self.repo_put(self.repo.CONFIRMATION, confirm_dict) - def __init__(self, *args, **kwargs): - super(Booking_Resource_Select, self).__init__(*args, **kwargs) - self.repo_key = self.repo.BOOKING_SELECTED_GRB - self.confirm_key = "booking" - def get_default_entry(self): - default = self.repo_get(self.repo.GRESOURCE_BUNDLE_MODELS, {}).get("bundle") - mine = self.repo_get(self.repo_key) - if mine: - return None - try: - config_bundle = self.repo_get(self.repo.BOOKING_MODELS)['booking'].config_bundle - if default: - return default # select created grb, even if preselected config bundle - return config_bundle.bundle - except: - pass - return default +class Booking_Resource_Select(Abstract_Resource_Select): + workflow_type = "booking" - def get_context(self): - context = super(Booking_Resource_Select, self).get_context() - return context - def post_render(self, request): - response = super(Booking_Resource_Select, self).post_render(request) - models = self.repo_get(self.repo.BOOKING_MODELS, {}) - if "booking" not in models: - models['booking'] = Booking() - booking = models['booking'] - resource = self.repo_get(self.repo_key, False) - if resource: - try: - booking.resource.template = resource - except: - booking.resource = ResourceBundle(template=resource) - models['booking'] = booking - self.repo_put(self.repo.BOOKING_MODELS, models) - return response - - -class SWConfig_Select(WorkflowStep): - template = 'booking/steps/swconfig_select.html' +class SWConfig_Select(AbstractSelectOrCreate): title = "Select Software Configuration" description = "Choose the software and related configurations you want to have used for your deployment" short_title = "pod config" + form = SWConfigSelectorForm - def post_render(self, request): - form = SWConfigSelectorForm(request.POST) - if form.is_valid(): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.select_repo_key = self.repo.SELECTED_CONFIG_BUNDLE + self.confirm_key = "booking" - bundle_json = form.cleaned_data['software_bundle'] - bundle_json = bundle_json[2:-2] # Stupid django string bug - if not bundle_json: - self.metastep.set_invalid("Please select a valid config") - return self.render(request) - bundle_json = json.loads(bundle_json) - bundle = None - try: - id = int(bundle_json[0]['id']) - bundle = ConfigBundle.objects.get(id=id) - except ValueError: - bundle = self.repo_get(self.repo.CONFIG_MODELS).get("bundle") + def alert_bundle_missing(self): + self.set_invalid("Please select a valid pod config") - models = self.repo_get(self.repo.BOOKING_MODELS, {}) - if "booking" not in models: - models['booking'] = Booking() - models['booking'].config_bundle = bundle - self.repo_put(self.repo.BOOKING_MODELS, models) - confirm = self.repo_get(self.repo.CONFIRMATION) - if "booking" not in confirm: - confirm['booking'] = {} - confirm['booking']["configuration name"] = bundle.name - self.repo_put(self.repo.CONFIRMATION, confirm) - self.metastep.set_valid("Step Completed") - messages.add_message(request, messages.SUCCESS, 'Form Validated Successfully', fail_silently=True) - else: - self.metastep.set_invalid("Please select or create a valid config") - messages.add_message(request, messages.ERROR, "Form Didn't Validate", fail_silently=True) + def get_form_queryset(self): + user = self.repo_get(self.repo.SESSION_USER) + grb = self.repo_get(self.repo.SELECTED_GRESOURCE_BUNDLE) + qs = ConfigBundle.objects.filter(owner=user).filter(bundle=grb) + return qs - return self.render(request) + def put_confirm_info(self, bundle): + confirm_dict = self.repo_get(self.repo.CONFIRMATION) + if self.confirm_key not in confirm_dict: + confirm_dict[self.confirm_key] = {} + confirm_dict[self.confirm_key]["Software Configuration"] = bundle.name + self.repo_put(self.repo.CONFIRMATION, confirm_dict) - def get_context(self): - context = super(SWConfig_Select, self).get_context() - default = [] - bundle = None - chosen_bundle = None - created_bundle = self.repo_get(self.repo.CONFIG_MODELS, {}).get("bundle", False) - booking = self.repo_get(self.repo.BOOKING_MODELS, {}).get("booking", False) - try: - chosen_bundle = booking.config_bundle - default.append(chosen_bundle.id) - bundle = chosen_bundle - except: - if created_bundle: - default.append("repo bundle") - bundle = created_bundle - context['disabled'] = True - edit = self.repo_get(self.repo.EDIT, False) - grb = self.repo_get(self.repo.BOOKING_SELECTED_GRB) - context['form'] = SWConfigSelectorForm(chosen_software=default, bundle=bundle, edit=edit, resource=grb) - return context + def get_page_context(self): + return { + 'select_type': 'swconfig', + 'select_type_title': 'Software Config', + 'addable_type_num': 2 + } + + +class OPNFV_EnablePicker(object): + pass + + +class OPNFV_Select(AbstractSelectOrCreate, OPNFV_EnablePicker): + title = "Choose an OPNFV Config" + description = "Choose or create a description of how you want to deploy OPNFV" + short_title = "opnfv config" + form = OPNFVSelectForm + enabled = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.select_repo_key = self.repo.SELECTED_OPNFV_CONFIG + self.confirm_key = "booking" + + def alert_bundle_missing(self): + self.set_invalid("Please select a valid OPNFV config") + + def get_form_queryset(self): + cb = self.repo_get(self.repo.SELECTED_CONFIG_BUNDLE) + qs = OPNFVConfig.objects.filter(bundle=cb) + return qs + + def put_confirm_info(self, config): + confirm_dict = self.repo_get(self.repo.CONFIRMATION) + if self.confirm_key not in confirm_dict: + confirm_dict[self.confirm_key] = {} + confirm_dict[self.confirm_key]["OPNFV Configuration"] = config.name + self.repo_put(self.repo.CONFIRMATION, confirm_dict) + + def get_page_context(self): + return { + 'select_type': 'opnfv', + 'select_type_title': 'OPNFV Config', + 'addable_type_num': 4 + } class Booking_Meta(WorkflowStep): @@ -235,23 +162,17 @@ class Booking_Meta(WorkflowStep): initial['info_file'] = info users = models.get("collaborators", []) for user in users: - default.append(user.id) + default.append(user.userprofile) except Exception: pass - default_user = self.repo_get(self.repo.SESSION_USER) - if default_user is None: - # TODO: error - default_user = "you" - else: - default_user = default_user.username + owner = self.repo_get(self.repo.SESSION_USER) - context['form'] = BookingMetaForm(initial=initial, chosen_users=default, default_user=default_user) + context['form'] = BookingMetaForm(initial=initial, user_initial=default, owner=owner) return context def post_render(self, request): - form = BookingMetaForm(data=request.POST) - context = self.get_context() + form = BookingMetaForm(data=request.POST, owner=request.user) forms = self.repo_get(self.repo.BOOKING_FORMS, {}) @@ -274,15 +195,16 @@ class Booking_Meta(WorkflowStep): for key in ['length', 'project', 'purpose']: confirm['booking'][key] = form.cleaned_data[key] - user_data = form.cleaned_data['users'] + if form.cleaned_data["deploy_opnfv"]: + self.repo_get(self.repo.SESSION_MANAGER).set_step_statuses(OPNFV_EnablePicker, desired_enabled=True) + else: + self.repo_get(self.repo.SESSION_MANAGER).set_step_statuses(OPNFV_EnablePicker, desired_enabled=False) + + userprofile_list = form.cleaned_data['users'] confirm['booking']['collaborators'] = [] - user_data = user_data[2:-2] # fixes malformed string from querydict - if user_data: - form_users = json.loads(user_data) - for user_json in form_users: - user = User.objects.get(pk=user_json['id']) - models['collaborators'].append(user) - confirm['booking']['collaborators'].append(user.username) + for userprofile in userprofile_list: + models['collaborators'].append(userprofile.user) + confirm['booking']['collaborators'].append(userprofile.user.username) info_file = form.cleaned_data.get("info_file", False) if info_file: @@ -291,9 +213,8 @@ class Booking_Meta(WorkflowStep): self.repo_put(self.repo.BOOKING_MODELS, models) self.repo_put(self.repo.CONFIRMATION, confirm) messages.add_message(request, messages.SUCCESS, 'Form Validated', fail_silently=True) - self.metastep.set_valid("Step Completed") + self.set_valid("Step Completed") else: messages.add_message(request, messages.ERROR, "Form didn't validate", fail_silently=True) - self.metastep.set_invalid("Please complete the fields highlighted in red to continue") - context['form'] = form # TODO: store this form - return render(request, self.template, context) + self.set_invalid("Please complete the fields highlighted in red to continue") + return self.render(request) diff --git a/dashboard/src/workflow/forms.py b/dashboard/src/workflow/forms.py index feb32f2..ee44ecd 100644 --- a/dashboard/src/workflow/forms.py +++ b/dashboard/src/workflow/forms.py @@ -9,40 +9,37 @@ import django.forms as forms -from django.forms import widgets +from django.forms import widgets, ValidationError from django.utils.safestring import mark_safe from django.template.loader import render_to_string from django.forms.widgets import NumberInput +import json + from account.models import Lab from account.models import UserProfile from resource_inventory.models import ( - GenericResourceBundle, - ConfigBundle, OPNFVRole, - Image, Installer, - Scenario + Scenario, ) +from booking.lib import get_user_items, get_user_field_opts class SearchableSelectMultipleWidget(widgets.SelectMultiple): template_name = 'dashboard/searchable_select_multiple.html' def __init__(self, attrs=None): - self.items = attrs['set'] + self.items = attrs['items'] self.show_from_noentry = attrs['show_from_noentry'] self.show_x_results = attrs['show_x_results'] - self.results_scrollable = attrs['scrollable'] + self.results_scrollable = attrs['results_scrollable'] self.selectable_limit = attrs['selectable_limit'] self.placeholder = attrs['placeholder'] self.name = attrs['name'] - self.initial = attrs.get("initial", "") - self.default_entry = attrs.get("default_entry", "") - self.edit = attrs.get("edit", False) - self.wf_type = attrs.get("wf_type") + self.initial = attrs.get("initial", []) - super(SearchableSelectMultipleWidget, self).__init__(attrs) + super(SearchableSelectMultipleWidget, self).__init__() def render(self, name, value, attrs=None, renderer=None): @@ -59,127 +56,160 @@ class SearchableSelectMultipleWidget(widgets.SelectMultiple): 'selectable_limit': self.selectable_limit, 'placeholder': self.placeholder, 'initial': self.initial, - 'default_entry': self.default_entry, - 'edit': self.edit, - 'wf_type': self.wf_type } -class ResourceSelectorForm(forms.Form): +class SearchableSelectMultipleField(forms.Field): + def __init__(self, *args, required=True, widget=None, label=None, disabled=False, + items=None, queryset=None, show_from_noentry=True, show_x_results=-1, + results_scrollable=False, selectable_limit=-1, placeholder="search here", + name="searchable_select", initial=[], **kwargs): + """from the documentation: + # required -- Boolean that specifies whether the field is required. + # True by default. + # widget -- A Widget class, or instance of a Widget class, that should + # be used for this Field when displaying it. Each Field has a + # default Widget that it'll use if you don't specify this. In + # most cases, the default widget is TextInput. + # label -- A verbose name for this field, for use in displaying this + # field in a form. By default, Django will use a "pretty" + # version of the form field name, if the Field is part of a + # Form. + # initial -- A value to use in this Field's initial display. This value + # is *not* used as a fallback if data isn't given. + # help_text -- An optional string to use as "help text" for this Field. + # error_messages -- An optional dictionary to override the default + # messages that the field will raise. + # show_hidden_initial -- Boolean that specifies if it is needed to render a + # hidden widget with initial value after widget. + # validators -- List of additional validators to use + # localize -- Boolean that specifies if the field should be localized. + # disabled -- Boolean that specifies whether the field is disabled, that + # is its widget is shown in the form but not editable. + # label_suffix -- Suffix to be added to the label. Overrides + # form's label_suffix. + """ - def __init__(self, data=None, **kwargs): - chosen_resource = "" - bundle = None - edit = False - if "chosen_resource" in kwargs: - chosen_resource = kwargs.pop("chosen_resource") - if "bundle" in kwargs: - bundle = kwargs.pop("bundle") - if "edit" in kwargs: - edit = kwargs.pop("edit") - super(ResourceSelectorForm, self).__init__(data=data, **kwargs) - queryset = GenericResourceBundle.objects.select_related("owner").all() - if data and 'user' in data: - queryset = queryset.filter(owner=data['user']) + self.widget = widget + if self.widget is None: + self.widget = SearchableSelectMultipleWidget( + attrs={ + 'items': items, + 'initial': [obj.id for obj in initial], + 'show_from_noentry': show_from_noentry, + 'show_x_results': show_x_results, + 'results_scrollable': results_scrollable, + 'selectable_limit': selectable_limit, + 'placeholder': placeholder, + 'name': name, + 'disabled': disabled + } + ) + self.disabled = disabled + self.queryset = queryset + self.selectable_limit = selectable_limit - attrs = self.build_search_widget_attrs(chosen_resource, bundle, edit, queryset) + super().__init__(disabled=disabled, **kwargs) - self.fields['generic_resource_bundle'] = forms.CharField( - widget=SearchableSelectMultipleWidget(attrs=attrs) - ) + self.required = required - def build_search_widget_attrs(self, chosen_resource, bundle, edit, queryset): - resources = {} - for res in queryset: - displayable = {} - displayable['small_name'] = res.name - if res.owner: - displayable['expanded_name'] = res.owner.username + def clean(self, data): + data = data[0] + if not data: + if self.required: + raise ValidationError("Nothing was selected") else: - displayable['expanded_name'] = "" - displayable['string'] = res.description - displayable['id'] = res.id - resources[res.id] = displayable - - if bundle: - displayable = {} - displayable['small_name'] = bundle.name - displayable['expanded_name'] = "Current bundle" - displayable['string'] = bundle.description - displayable['id'] = "repo bundle" - resources["repo bundle"] = displayable - attrs = { - 'set': resources, - 'show_from_noentry': "true", + return [] + data_as_list = json.loads(data) + if self.selectable_limit != -1: + if len(data_as_list) > self.selectable_limit: + raise ValidationError("Too many items were selected") + + items = [] + for elem in data_as_list: + items.append(self.queryset.get(id=elem)) + + return items + + +class SearchableSelectAbstractForm(forms.Form): + def __init__(self, *args, queryset=None, initial=[], **kwargs): + self.queryset = queryset + items = self.generate_items(self.queryset) + options = self.generate_options() + + super(SearchableSelectAbstractForm, self).__init__(*args, **kwargs) + self.fields['searchable_select'] = SearchableSelectMultipleField( + initial=initial, + items=items, + queryset=self.queryset, + **options + ) + + def get_validated_bundle(self): + bundles = self.cleaned_data['searchable_select'] + if len(bundles) < 1: # don't need to check for >1, as field does that for us + raise ValidationError("No bundle was selected") + return bundles[0] + + def generate_items(self, queryset): + raise Exception("SearchableSelectAbstractForm does not implement concrete generate_items()") + + def generate_options(self, disabled=False): + return { + 'show_from_noentry': True, 'show_x_results': -1, - 'scrollable': "true", + 'results_scrollable': True, 'selectable_limit': 1, - 'name': "generic_resource_bundle", - 'placeholder': "resource", - 'initial': chosen_resource, - 'edit': edit, - 'wf_type': 1 + 'placeholder': 'Search for a Bundle', + 'name': 'searchable_select', + 'disabled': False } - return attrs -class SWConfigSelectorForm(forms.Form): +class SWConfigSelectorForm(SearchableSelectAbstractForm): + def generate_items(self, queryset): + items = {} - def __init__(self, *args, **kwargs): - chosen_software = "" - bundle = None - edit = False - resource = None - if "chosen_software" in kwargs: - chosen_software = kwargs.pop("chosen_software") - - if "bundle" in kwargs: - bundle = kwargs.pop("bundle") - if "edit" in kwargs: - edit = kwargs.pop("edit") - if "resource" in kwargs: - resource = kwargs.pop("resource") - super(SWConfigSelectorForm, self).__init__(*args, **kwargs) - attrs = self.build_search_widget_attrs(chosen_software, bundle, edit, resource) - self.fields['software_bundle'] = forms.CharField( - widget=SearchableSelectMultipleWidget(attrs=attrs) - ) + for bundle in queryset: + items[bundle.id] = { + 'expanded_name': bundle.name, + 'small_name': bundle.owner.username, + 'string': bundle.description, + 'id': bundle.id + } - def build_search_widget_attrs(self, chosen, bundle, edit, resource): - configs = {} - queryset = ConfigBundle.objects.select_related('owner').all() - if resource: - queryset = queryset.filter(bundle=resource) + return items + + +class OPNFVSelectForm(SearchableSelectAbstractForm): + def generate_items(self, queryset): + items = {} for config in queryset: - displayable = {} - displayable['small_name'] = config.name - displayable['expanded_name'] = config.owner.username - displayable['string'] = config.description - displayable['id'] = config.id - configs[config.id] = displayable - - if bundle: - displayable = {} - displayable['small_name'] = bundle.name - displayable['expanded_name'] = "Current configuration" - displayable['string'] = bundle.description - displayable['id'] = "repo bundle" - configs['repo bundle'] = displayable - - attrs = { - 'set': configs, - 'show_from_noentry': "true", - 'show_x_results': -1, - 'scrollable': "true", - 'selectable_limit': 1, - 'name': "software_bundle", - 'placeholder': "config", - 'initial': chosen, - 'edit': edit, - 'wf_type': 2 - } - return attrs + items[config.id] = { + 'expanded_name': config.name, + 'small_name': config.bundle.owner.username, + 'string': config.description, + 'id': config.id + } + + return items + + +class ResourceSelectorForm(SearchableSelectAbstractForm): + def generate_items(self, queryset): + items = {} + + for bundle in queryset: + items[bundle.id] = { + 'expanded_name': bundle.name, + 'small_name': bundle.owner.username, + 'string': bundle.description, + 'id': bundle.id + } + + return items class BookingMetaForm(forms.Form): @@ -188,195 +218,126 @@ class BookingMetaForm(forms.Form): widget=NumberInput( attrs={ "type": "range", - 'min': "0", + 'min': "1", "max": "21", - "value": "0" + "value": "1" } ) ) purpose = forms.CharField(max_length=1000) project = forms.CharField(max_length=400) info_file = forms.CharField(max_length=1000, required=False) + deploy_opnfv = forms.BooleanField(required=False) - def __init__(self, data=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") - else: - pass + def __init__(self, *args, user_initial=[], owner=None, **kwargs): + super(BookingMetaForm, self).__init__(**kwargs) - super(BookingMetaForm, 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 + self.fields['users'] = SearchableSelectMultipleField( + queryset=UserProfile.objects.select_related('user').exclude(user=owner), + initial=user_initial, + items=get_user_items(exclude=owner), + required=False, + **get_user_field_opts() ) - 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 MultipleSelectFilterWidget(forms.Widget): - def __init__(self, attrs=None): - super(MultipleSelectFilterWidget, self).__init__(attrs) - self.attrs = attrs + def __init__(self, *args, display_objects=None, filter_items=None, neighbors=None, **kwargs): + super(MultipleSelectFilterWidget, self).__init__(*args, **kwargs) + self.display_objects = display_objects + self.filter_items = filter_items + self.neighbors = neighbors self.template_name = "dashboard/multiple_select_filter_widget.html" def render(self, name, value, attrs=None, renderer=None): - attrs = self.attrs - self.context = self.get_context(name, value, attrs) - html = render_to_string(self.template_name, context=self.context) + context = self.get_context(name, value, attrs) + html = render_to_string(self.template_name, context=context) return mark_safe(html) def get_context(self, name, value, attrs): - return attrs + return { + 'display_objects': self.display_objects, + 'neighbors': self.neighbors, + 'filter_items': self.filter_items, + 'initial_value': value + } class MultipleSelectFilterField(forms.Field): - def __init__(self, required=True, widget=None, label=None, initial=None, - help_text='', error_messages=None, show_hidden_initial=False, - validators=(), localize=False, disabled=False, label_suffix=None): - """from the documentation: - # required -- Boolean that specifies whether the field is required. - # True by default. - # widget -- A Widget class, or instance of a Widget class, that should - # be used for this Field when displaying it. Each Field has a - # default Widget that it'll use if you don't specify this. In - # most cases, the default widget is TextInput. - # label -- A verbose name for this field, for use in displaying this - # field in a form. By default, Django will use a "pretty" - # version of the form field name, if the Field is part of a - # Form. - # initial -- A value to use in this Field's initial display. This value - # is *not* used as a fallback if data isn't given. - # help_text -- An optional string to use as "help text" for this Field. - # error_messages -- An optional dictionary to override the default - # messages that the field will raise. - # show_hidden_initial -- Boolean that specifies if it is needed to render a - # hidden widget with initial value after widget. - # validators -- List of additional validators to use - # localize -- Boolean that specifies if the field should be localized. - # disabled -- Boolean that specifies whether the field is disabled, that - # is its widget is shown in the form but not editable. - # label_suffix -- Suffix to be added to the label. Overrides - # form's label_suffix. - """ - # this is bad, but django forms are annoying - self.widget = widget - if self.widget is None: - self.widget = MultipleSelectFilterWidget() - super(MultipleSelectFilterField, self).__init__( - required=required, - widget=self.widget, - label=label, - initial=None, - help_text=help_text, - error_messages=error_messages, - show_hidden_initial=show_hidden_initial, - validators=validators, - localize=localize, - disabled=disabled, - label_suffix=label_suffix - ) + def __init__(self, **kwargs): + self.initial = kwargs.get("initial") + super().__init__(**kwargs) - def clean(data): - """ - This method will raise a django.forms.ValidationError or return clean data - """ - return data + def to_python(self, value): + return json.loads(value) class FormUtils: @staticmethod - def getLabData(): + def getLabData(multiple_hosts=False): """ 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 - Should be moved outside of global scope """ + # javascript truthy variables + true = 1 + false = 0 + if multiple_hosts: + multiple_hosts = true + else: + multiple_hosts = false labs = {} hosts = {} items = {} - mapping = {} + neighbors = {} for lab in Lab.objects.all(): - slab = {} - slab['id'] = "lab_" + str(lab.lab_user.id) - slab['name'] = lab.name - slab['description'] = lab.description - slab['selected'] = 0 - slab['selectable'] = 1 - slab['follow'] = 1 - slab['multiple'] = 0 - items[slab['id']] = slab - mapping[slab['id']] = [] - labs[slab['id']] = slab + lab_node = { + 'id': "lab_" + str(lab.lab_user.id), + 'model_id': lab.lab_user.id, + 'name': lab.name, + 'description': lab.description, + 'selected': false, + 'selectable': true, + 'follow': false, + 'multiple': false, + 'class': 'lab' + } + if multiple_hosts: + # "follow" this lab node to discover more hosts if allowed + lab_node['follow'] = true + items[lab_node['id']] = lab_node + neighbors[lab_node['id']] = [] + labs[lab_node['id']] = lab_node + for host in lab.hostprofiles.all(): - shost = {} - shost['forms'] = [{"name": "host_name", "type": "text", "placeholder": "hostname"}] - shost['id'] = "host_" + str(host.id) - shost['name'] = host.name - shost['description'] = host.description - shost['selected'] = 0 - shost['selectable'] = 1 - shost['follow'] = 0 - shost['multiple'] = 1 - items[shost['id']] = shost - mapping[slab['id']].append(shost['id']) - if shost['id'] not in mapping: - mapping[shost['id']] = [] - mapping[shost['id']].append(slab['id']) - hosts[shost['id']] = shost - - filter_objects = [("labs", labs.values()), ("hosts", hosts.values())] + host_node = { + 'form': {"name": "host_name", "type": "text", "placeholder": "hostname"}, + 'id': "host_" + str(host.id), + 'model_id': host.id, + 'name': host.name, + 'description': host.description, + 'selected': false, + 'selectable': true, + 'follow': false, + 'multiple': multiple_hosts, + 'class': 'host' + } + if multiple_hosts: + host_node['values'] = [] # place to store multiple values + items[host_node['id']] = host_node + neighbors[lab_node['id']].append(host_node['id']) + if host_node['id'] not in neighbors: + neighbors[host_node['id']] = [] + neighbors[host_node['id']].append(lab_node['id']) + hosts[host_node['id']] = host_node + + display_objects = [("lab", labs.values()), ("host", hosts.values())] context = { - 'filter_objects': filter_objects, - 'mapping': mapping, - 'items': items + 'display_objects': display_objects, + 'neighbors': neighbors, + 'filter_items': items } return context @@ -384,14 +345,10 @@ class FormUtils: 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['selection_data'] = selection_data + attrs = FormUtils.getLabData(multiple_hosts=True) self.fields['filter_field'] = MultipleSelectFilterField( - widget=MultipleSelectFilterWidget( - attrs=attrs - ) + widget=MultipleSelectFilterWidget(**attrs) ) @@ -424,20 +381,14 @@ class NetworkConfigurationForm(forms.Form): class HostSoftwareDefinitionForm(forms.Form): - fields = ["host_name", "role", "image"] host_name = forms.CharField(max_length=200, disabled=True, required=False) - role = forms.ModelChoiceField(queryset=OPNFVRole.objects.all()) - image = forms.ModelChoiceField(queryset=Image.objects.all()) + headnode = forms.BooleanField(required=False, widget=forms.HiddenInput) - -class SoftwareConfigurationForm(forms.Form): - - name = forms.CharField(max_length=200) - description = forms.CharField(widget=forms.Textarea) - opnfv = forms.BooleanField(disabled=True, required=False) - installer = forms.ModelChoiceField(queryset=Installer.objects.all(), disabled=True, required=False) - scenario = forms.ModelChoiceField(queryset=Scenario.objects.all(), disabled=True, required=False) + def __init__(self, *args, **kwargs): + imageQS = kwargs.pop("imageQS") + super(HostSoftwareDefinitionForm, self).__init__(*args, **kwargs) + self.fields['image'] = forms.ModelChoiceField(queryset=imageQS) class WorkflowSelectionForm(forms.Form): @@ -461,9 +412,9 @@ class SnapshotHostSelectForm(forms.Form): host = forms.CharField() -class SnapshotMetaForm(forms.Form): +class BasicMetaForm(forms.Form): name = forms.CharField() - description = forms.CharField() + description = forms.CharField(widget=forms.Textarea) class ConfirmationForm(forms.Form): @@ -475,3 +426,23 @@ class ConfirmationForm(forms.Form): (False, "Cancel") ) ) + + +class OPNFVSelectionForm(forms.Form): + installer = forms.ModelChoiceField(queryset=Installer.objects.all(), required=True) + scenario = forms.ModelChoiceField(queryset=Scenario.objects.all(), required=True) + + +class OPNFVNetworkRoleForm(forms.Form): + role = forms.CharField(max_length=200, disabled=True, required=False) + + def __init__(self, *args, config_bundle, **kwargs): + super(OPNFVNetworkRoleForm, self).__init__(*args, **kwargs) + self.fields['network'] = forms.ModelChoiceField( + queryset=config_bundle.bundle.networks.all() + ) + + +class OPNFVHostRoleForm(forms.Form): + host_name = forms.CharField(max_length=200, disabled=True, required=False) + role = forms.ModelChoiceField(queryset=OPNFVRole.objects.all().order_by("name").distinct("name")) diff --git a/dashboard/src/workflow/models.py b/dashboard/src/workflow/models.py index 966582c..6c6bd9a 100644 --- a/dashboard/src/workflow/models.py +++ b/dashboard/src/workflow/models.py @@ -10,6 +10,8 @@ from django.shortcuts import render from django.contrib import messages +from django.http import HttpResponse +from django.utils import timezone import yaml import requests @@ -17,8 +19,9 @@ import requests from workflow.forms import ConfirmationForm from api.models import JobFactory from dashboard.exceptions import ResourceAvailabilityException, ModelValidationException -from resource_inventory.models import Image, GenericInterface +from resource_inventory.models import Image, GenericInterface, OPNFVConfig, HostOPNFVConfig, NetworkRole from resource_inventory.resource_manager import ResourceManager +from resource_inventory.pdf_templater import PDFTemplater from notifier.manager import NotificationHandler from booking.models import Booking @@ -26,13 +29,11 @@ from booking.models import Booking class BookingAuthManager(): LFN_PROJECTS = ["opnfv"] # TODO - def parse_url(self, info_url): - """ - will return the PTL in the INFO file on success, or None - """ + def parse_github_url(self, url): + project_leads = [] try: - parts = info_url.split("/") - if parts[0].find("http") > -1: # the url include http(s):// + parts = url.split("/") + if "http" in parts[0]: # the url include http(s):// parts = parts[2:] if parts[-1] != "INFO.yaml": return None @@ -47,13 +48,94 @@ class BookingAuthManager(): info_file = requests.get(url, timeout=15).text info_parsed = yaml.load(info_file) ptl = info_parsed.get('project_lead') - if not ptl: + if ptl: + project_leads.append(ptl) + sub_ptl = info_parsed.get("subproject_lead") + if sub_ptl: + project_leads.append(sub_ptl) + + except Exception: + pass + + return project_leads + + def parse_gerrit_url(self, url): + project_leads = [] + try: + halfs = url.split("?") + parts = halfs[0].split("/") + args = halfs[1].split(";") + if "http" in parts[0]: # the url include http(s):// + parts = parts[2:] + if "f=INFO.yaml" not in args: + return None + if "gerrit.opnfv.org" not in parts[0]: return None - return ptl + try: + i = args.index("a=blob") + args[i] = "a=blob_plain" + except ValueError: + pass + # recreate url + halfs[1] = ";".join(args) + halfs[0] = "/".join(parts) + # now to download and parse file + url = "https://" + "?".join(halfs) + info_file = requests.get(url, timeout=15).text + info_parsed = yaml.load(info_file) + ptl = info_parsed.get('project_lead') + if ptl: + project_leads.append(ptl) + sub_ptl = info_parsed.get("subproject_lead") + if sub_ptl: + project_leads.append(sub_ptl) except Exception: return None + return project_leads + + def parse_opnfv_git_url(self, url): + project_leads = [] + try: + parts = url.split("/") + if "http" in parts[0]: # the url include http(s):// + parts = parts[2:] + if "INFO.yaml" not in parts[-1]: + return None + if "git.opnfv.org" not in parts[0]: + return None + if parts[-2] == "tree": + parts[-2] = "plain" + # now to download and parse file + url = "https://" + "/".join(parts) + info_file = requests.get(url, timeout=15).text + info_parsed = yaml.load(info_file) + ptl = info_parsed.get('project_lead') + if ptl: + project_leads.append(ptl) + sub_ptl = info_parsed.get("subproject_lead") + if sub_ptl: + project_leads.append(sub_ptl) + + except Exception: + return None + + return project_leads + + def parse_url(self, info_url): + """ + will return the PTL in the INFO file on success, or None + """ + if "github" in info_url: + return self.parse_github_url(info_url) + + if "gerrit.opnfv.org" in info_url: + return self.parse_gerrit_url(info_url) + + if "git.opnfv.org" in info_url: + return self.parse_opnfv_git_url(info_url) + def booking_allowed(self, booking, repo): """ This is the method that will have to change whenever the booking policy changes in the Infra @@ -61,23 +143,67 @@ class BookingAuthManager(): currently checks if the booking uses multiple servers. if it does, then the owner must be a PTL, which is checked using the provided info file """ - if len(booking.resource.template.getHosts()) < 2: - return True # if they only have one server, we dont care if booking.owner.userprofile.booking_privledge: return True # admin override for this user + if Booking.objects.filter(owner=booking.owner, end__gt=timezone.now()).count() >= 3: + return False + if len(booking.resource.template.getHosts()) < 2: + return True # if they only have one server, we dont care if repo.BOOKING_INFO_FILE not in repo.el: return False # INFO file not provided - ptl_info = self.parse_url(repo.BOOKING_INFO_FILE) - return ptl_info and ptl_info == booking.owner.userprofile.email_addr + ptl_info = self.parse_url(repo.el.get(repo.BOOKING_INFO_FILE)) + for ptl in ptl_info: + if ptl['email'] == booking.owner.userprofile.email_addr: + return True + return False -class WorkflowStep(object): +class WorkflowStepStatus(object): + UNTOUCHED = 0 + INVALID = 100 + VALID = 200 + +class WorkflowStep(object): template = 'bad_request.html' title = "Generic Step" description = "You were led here by mistake" short_title = "error" metastep = None + # phasing out metastep: + + valid = WorkflowStepStatus.UNTOUCHED + message = "" + + enabled = True + + def cleanup(self): + raise Exception("WorkflowStep subclass of type " + str(type(self)) + " has no concrete implemented cleanup() method") + + def enable(self): + if not self.enabled: + self.enabled = True + + def disable(self): + if self.enabled: + self.cleanup() + self.enabled = False + + def set_invalid(self, message, code=WorkflowStepStatus.INVALID): + self.valid = code + self.message = message + + def set_valid(self, message, code=WorkflowStepStatus.VALID): + self.valid = code + self.message = message + + def to_json(self): + return { + 'title': self.short_title, + 'enabled': self.enabled, + 'valid': self.valid, + 'message': self.message, + } def __init__(self, id, repo=None): self.repo = repo @@ -114,29 +240,73 @@ class WorkflowStep(object): return self.repo.put(key, value, self.id) +""" +subclassing notes: + subclasses have to define the following class attributes: + self.select_repo_key: where the selected "object" or "bundle" is to be placed in the repo + self.form: the form to be used + alert_bundle_missing(): what message to display if a user does not select/selects an invalid object + get_form_queryset(): generate a queryset to be used to filter available items for the field + get_page_context(): return simple context such as page header and other info +""" + + +class AbstractSelectOrCreate(WorkflowStep): + template = 'dashboard/genericselect.html' + title = "Select a Bundle" + short_title = "select" + description = "Generic bundle selector step" + + select_repo_key = None + form = None # subclasses are expected to use a form that is a subclass of SearchableSelectGenericForm + + def alert_bundle_missing(self): # override in subclasses to change message if field isn't filled out + self.set_invalid("Please select a valid bundle") + + def post_render(self, request): + context = self.get_context() + form = self.form(request.POST, queryset=self.get_form_queryset()) + if form.is_valid(): + bundle = form.get_validated_bundle() + if not bundle: + self.alert_bundle_missing() + return render(request, self.template, context) + self.repo_put(self.select_repo_key, bundle) + self.put_confirm_info(bundle) + self.set_valid("Step Completed") + else: + self.alert_bundle_missing() + messages.add_message(request, messages.ERROR, "Form Didn't Validate", fail_silently=True) + + return self.render(request) + + def get_context(self): + default = [] + + bundle = self.repo_get(self.select_repo_key, False) + if bundle: + default.append(bundle) + + form = self.form(queryset=self.get_form_queryset(), initial=default) + + context = {'form': form, **self.get_page_context()} + context.update(super().get_context()) + + return context + + def get_page_context(): + return { + 'select_type': 'generic', + 'select_type_title': 'Generic Bundle' + } + + class Confirmation_Step(WorkflowStep): template = 'workflow/confirm.html' title = "Confirm Changes" description = "Does this all look right?" - def get_vlan_warning(self): - grb = self.repo_get(self.repo.BOOKING_SELECTED_GRB, False) - if not grb: - return 0 - vlan_manager = grb.lab.vlan_manager - if vlan_manager is None: - return 0 - hosts = grb.getHosts() - for host in hosts: - for interface in host.generic_interfaces.all(): - for vlan in interface.vlans.all(): - if vlan.public: - if not vlan_manager.public_vlan_is_available(vlan.vlan_id): - return 1 - else: - if not vlan_manager.is_available(vlan.vlan_id): - return 1 # There is a problem with these vlans - return 0 + short_title = "confirm" def get_context(self): context = super(Confirmation_Step, self).get_context() @@ -145,7 +315,6 @@ class Confirmation_Step(WorkflowStep): self.repo_get(self.repo.CONFIRMATION), default_flow_style=False ).strip() - context['vlan_warning'] = self.get_vlan_warning() return context @@ -164,9 +333,10 @@ class Confirmation_Step(WorkflowStep): errors = self.flush_to_db() if errors: messages.add_message(request, messages.ERROR, "ERROR OCCURRED: " + errors) - return render(request, self.template, context) - messages.add_message(request, messages.SUCCESS, "Confirmed") - return render(request, self.template, context) + else: + messages.add_message(request, messages.SUCCESS, "Confirmed") + + return HttpResponse('') elif data == "False": context["bypassed"] = "true" messages.add_message(request, messages.SUCCESS, "Canceled") @@ -175,39 +345,8 @@ class Confirmation_Step(WorkflowStep): pass else: - if "vlan_input" in request.POST: - if request.POST.get("vlan_input") == "True": - self.translate_vlans() - return self.render(request) pass - def translate_vlans(self): - grb = self.repo_get(self.repo.BOOKING_SELECTED_GRB, False) - if not grb: - return 0 - vlan_manager = grb.lab.vlan_manager - if vlan_manager is None: - return 0 - hosts = grb.getHosts() - for host in hosts: - for interface in host.generic_interfaces.all(): - for vlan in interface.vlans.all(): - if not vlan.public: - if not vlan_manager.is_available(vlan.vlan_id): - vlan.vlan_id = vlan_manager.get_vlan() - vlan.save() - else: - if not vlan_manager.public_vlan_is_available(vlan.vlan_id): - pub_vlan = vlan_manager.get_public_vlan() - vlan.vlan_id = pub_vlan.vlan - vlan.save() - - -class Workflow(): - - steps = [] - active_index = 0 - class Repository(): @@ -216,6 +355,8 @@ class Repository(): RESOURCE_SELECT = "resource_select" CONFIRMATION = "confirmation" SELECTED_GRESOURCE_BUNDLE = "selected generic bundle pk" + SELECTED_CONFIG_BUNDLE = "selected config bundle pk" + SELECTED_OPNFV_CONFIG = "selected opnfv deployment config" GRESOURCE_BUNDLE_MODELS = "generic_resource_bundle_models" GRESOURCE_BUNDLE_INFO = "generic_resource_bundle_info" BOOKING = "booking" @@ -223,11 +364,11 @@ class Repository(): GRB_LAST_HOSTLIST = "grb_network_previous_hostlist" BOOKING_FORMS = "booking_forms" SWCONF_HOSTS = "swconf_hosts" - SWCONF_SELECTED_GRB = "swconf_selected_grb_pk" - BOOKING_SELECTED_GRB = "booking_selected_grb_pk" BOOKING_MODELS = "booking models" CONFIG_MODELS = "configuration bundle models" + OPNFV_MODELS = "opnfv configuration models" SESSION_USER = "session owner user account" + SESSION_MANAGER = "session manager for current session" VALIDATED_MODEL_GRB = "valid grb config model instance in db" VALIDATED_MODEL_CONFIG = "valid config model instance in db" VALIDATED_MODEL_BOOKING = "valid booking model instance in db" @@ -238,7 +379,24 @@ class Repository(): SNAPSHOT_DESC = "description of the snapshot" BOOKING_INFO_FILE = "the INFO.yaml file for this user's booking" + # migratory elements of segmented workflow + # each of these is the end result of a different workflow. + HAS_RESULT = "whether or not workflow has a result" + RESULT_KEY = "key for target index that result will be put into in parent" + RESULT = "result object from workflow" + + def get_child_defaults(self): + return_tuples = [] + for key in [self.SELECTED_GRESOURCE_BUNDLE, self.SESSION_USER]: + return_tuples.append((key, self.el.get(key))) + return return_tuples + + def set_defaults(self, defaults): + for key, value in defaults: + self.el[key] = value + def get(self, key, default, id): + self.add_get_history(key, id) return self.el.get(key, default) @@ -263,16 +421,33 @@ class Repository(): errors = self.make_snapshot() if errors: return errors + # if GRB WF, create it if self.GRESOURCE_BUNDLE_MODELS in self.el: errors = self.make_generic_resource_bundle() if errors: return errors + else: + self.el[self.HAS_RESULT] = True + self.el[self.RESULT_KEY] = self.SELECTED_GRESOURCE_BUNDLE + return if self.CONFIG_MODELS in self.el: errors = self.make_software_config_bundle() if errors: return errors + else: + self.el[self.HAS_RESULT] = True + self.el[self.RESULT_KEY] = self.SELECTED_CONFIG_BUNDLE + return + + if self.OPNFV_MODELS in self.el: + errors = self.make_opnfv_config() + if errors: + return errors + else: + self.el[self.HAS_RESULT] = True + self.el[self.RESULT_KEY] = self.SELECTED_OPNFV_CONFIG if self.BOOKING_MODELS in self.el: errors = self.make_booking() @@ -290,6 +465,8 @@ class Repository(): if not booking_id: return "SNAP, No booking ID provided" booking = Booking.objects.get(pk=booking_id) + if booking.start > timezone.now() or booking.end < timezone.now(): + return "Booking is not active" name = self.el.get(self.SNAPSHOT_NAME) if not name: return "SNAP, no name provided" @@ -305,6 +482,16 @@ class Repository(): image.owner = owner image.host_type = host.profile image.save() + try: + current_image = host.config.image + image.os = current_image.os + image.save() + except Exception: + pass + JobFactory.makeSnapshotTask(image, booking, host) + + self.el[self.RESULT] = image + self.el[self.HAS_RESULT] = True def make_generic_resource_bundle(self): owner = self.el[self.SESSION_USER] @@ -334,45 +521,52 @@ class Repository(): except Exception as e: return "GRB, saving hosts generated exception: " + str(e) + " CODE:0x0005" + if 'networks' in models: + for net in models['networks'].values(): + net.bundle = bundle + net.save() + if 'interfaces' in models: for interface_set in models['interfaces'].values(): for interface in interface_set: try: interface.host = interface.host interface.save() - except Exception as e: + except Exception: return "GRB, saving interface " + str(interface) + " failed. CODE:0x0019" else: return "GRB, no interface set provided. CODE:0x001a" - if 'vlans' in models: - for resource_name, mapping in models['vlans'].items(): - for profile_name, vlan_set in mapping.items(): + if 'connections' in models: + for resource_name, mapping in models['connections'].items(): + for profile_name, connection_set in mapping.items(): interface = GenericInterface.objects.get( profile__name=profile_name, host__resource__name=resource_name, host__resource__bundle=models['bundle'] ) - for vlan in vlan_set: + for connection in connection_set: try: - vlan.save() - interface.vlans.add(vlan) + connection.network = connection.network + connection.save() + interface.connections.add(connection) except Exception as e: - return "GRB, saving vlan " + str(vlan) + " failed. Exception: " + str(e) + ". CODE:0x0017" + return "GRB, saving vlan " + str(connection) + " failed. Exception: " + str(e) + ". CODE:0x0017" else: return "GRB, no vlan set provided. CODE:0x0018" else: return "GRB no models given. CODE:0x0001" - self.el[self.VALIDATED_MODEL_GRB] = bundle + self.el[self.RESULT] = bundle + self.el[self.HAS_RESULT] = True return False def make_software_config_bundle(self): models = self.el[self.CONFIG_MODELS] if 'bundle' in models: bundle = models['bundle'] - bundle.bundle = bundle.bundle + bundle.bundle = self.el[self.SELECTED_GRESOURCE_BUNDLE] try: bundle.save() except Exception as e: @@ -403,26 +597,30 @@ class Repository(): else: pass - self.el[self.VALIDATED_MODEL_CONFIG] = bundle + self.el[self.RESULT] = bundle return False def make_booking(self): models = self.el[self.BOOKING_MODELS] owner = self.el[self.SESSION_USER] - if self.BOOKING_SELECTED_GRB in self.el: - selected_grb = self.el[self.BOOKING_SELECTED_GRB] - else: - return "BOOK, no selected resource. CODE:0x000e" - - if not self.reserve_vlans(selected_grb): - return "BOOK, vlans not available" - if 'booking' in models: booking = models['booking'] else: return "BOOK, no booking model exists. CODE:0x000f" + selected_grb = None + + if self.SELECTED_GRESOURCE_BUNDLE in self.el: + selected_grb = self.el[self.SELECTED_GRESOURCE_BUNDLE] + else: + return "BOOK, no selected resource. CODE:0x000e" + + if self.SELECTED_CONFIG_BUNDLE not in self.el: + return "BOOK, no selected config bundle. CODE:0x001f" + + booking.config_bundle = self.el[self.SELECTED_CONFIG_BUNDLE] + if not booking.start: return "BOOK, booking has no start. CODE:0x0010" if not booking.end: @@ -443,7 +641,6 @@ class Repository(): booking.resource = resource_bundle booking.owner = owner - booking.config_bundle = booking.config_bundle booking.lab = selected_grb.lab is_allowed = BookingAuthManager().booking_allowed(booking, self) @@ -459,6 +656,12 @@ class Repository(): booking.collaborators.add(collaborator) try: + booking.pdf = PDFTemplater.makePDF(booking) + booking.save() + except Exception as e: + return "BOOK, failed to create Pod Desriptor File: " + str(e) + + try: JobFactory.makeCompleteJob(booking) except Exception as e: return "BOOK, serializing for api generated exception: " + str(e) + " CODE:0xFFFF" @@ -468,32 +671,62 @@ class Repository(): except Exception as e: return "BOOK, saving booking generated exception: " + str(e) + " CODE:0x0016" - def reserve_vlans(self, grb): - """ - True is success - """ - vlans = [] - public_vlan = None - vlan_manager = grb.lab.vlan_manager - if vlan_manager is None: - return True - for host in grb.getHosts(): - for interface in host.generic_interfaces.all(): - for vlan in interface.vlans.all(): - if vlan.public: - public_vlan = vlan - else: - vlans.append(vlan.vlan_id) - - try: - vlan_manager.reserve_vlans(vlans) - vlan_manager.reserve_public_vlan(public_vlan.vlan_id) - return True - except Exception: - return False + self.el[self.RESULT] = booking + self.el[self.HAS_RESULT] = True + + def make_opnfv_config(self): + opnfv_models = self.el[self.OPNFV_MODELS] + config_bundle = self.el[self.SELECTED_CONFIG_BUNDLE] + if not config_bundle: + return "No Configuration bundle selected" + info = opnfv_models.get("meta", {}) + name = info.get("name", False) + desc = info.get("description", False) + if not (name and desc): + return "No name or description given" + installer = opnfv_models['installer_chosen'] + if not installer: + return "No OPNFV Installer chosen" + scenario = opnfv_models['scenario_chosen'] + if not scenario: + return "No OPNFV Scenario chosen" + + opnfv_config = OPNFVConfig.objects.create( + bundle=config_bundle, + name=name, + description=desc, + installer=installer, + scenario=scenario + ) + + network_roles = opnfv_models['network_roles'] + for net_role in network_roles: + opnfv_config.networks.add( + NetworkRole.objects.create( + name=net_role['role'], + network=net_role['network'] + ) + ) + + host_roles = opnfv_models['host_roles'] + for host_role in host_roles: + config = config_bundle.hostConfigurations.get( + host__resource__name=host_role['host_name'] + ) + HostOPNFVConfig.objects.create( + role=host_role['role'], + host_config=config, + opnfv_config=opnfv_config + ) + + self.el[self.RESULT] = opnfv_config + self.el[self.HAS_RESULT] = True def __init__(self): self.el = {} self.el[self.CONFIRMATION] = {} + self.el["active_step"] = 0 + self.el[self.HAS_RESULT] = False + self.el[self.RESULT] = None self.get_history = {} self.put_history = {} diff --git a/dashboard/src/workflow/opnfv_workflow.py b/dashboard/src/workflow/opnfv_workflow.py new file mode 100644 index 0000000..7d499ec --- /dev/null +++ b/dashboard/src/workflow/opnfv_workflow.py @@ -0,0 +1,299 @@ +############################################################################## +# 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.forms import formset_factory + +from workflow.models import WorkflowStep, AbstractSelectOrCreate +from resource_inventory.models import ConfigBundle, OPNFV_SETTINGS +from workflow.forms import OPNFVSelectionForm, OPNFVNetworkRoleForm, OPNFVHostRoleForm, SWConfigSelectorForm, BasicMetaForm + + +class OPNFV_Resource_Select(AbstractSelectOrCreate): + title = "Select Software Configuration" + description = "Choose the software bundle you wish to use as a base for your OPNFV configuration" + short_title = "software config" + form = SWConfigSelectorForm + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.select_repo_key = self.repo.SELECTED_CONFIG_BUNDLE + + def get_form_queryset(self): + user = self.repo_get(self.repo.SESSION_USER) + qs = ConfigBundle.objects.filter(owner=user) + return qs + + def put_confirm_info(self, bundle): + confirm_dict = self.repo_get(self.repo.CONFIRMATION) + confirm_dict['software bundle'] = bundle.name + confirm_dict['hardware POD'] = bundle.bundle.name + self.repo_put(self.repo.CONFIRMATION, confirm_dict) + + def get_page_context(self): + return { + 'select_type': 'swconfig', + 'select_type_title': 'Software Config', + 'addable_type_num': 2 + } + + +class Pick_Installer(WorkflowStep): + template = 'config_bundle/steps/pick_installer.html' + title = 'Pick OPNFV Installer' + description = 'Choose which OPNFV installer to use' + short_title = "opnfv installer" + modified_key = "installer_step" + + def update_confirmation(self): + confirm = self.repo_get(self.repo.CONFIRMATION, {}) + models = self.repo_get(self.repo.OPNFV_MODELS, {}) + installer = models.get("installer_chosen") + scenario = models.get("scenario_chosen") + if not (installer and scenario): + return + confirm['installer'] = installer.name + confirm['scenario'] = scenario.name + self.repo_put(self.repo.CONFIRMATION, confirm) + + def get_context(self): + context = super(Pick_Installer, self).get_context() + + models = self.repo_get(self.repo.OPNFV_MODELS, {}) + initial = { + "installer": models.get("installer_chosen"), + "scenario": models.get("scenario_chosen") + } + + context["form"] = OPNFVSelectionForm(initial=initial) + return context + + def post_render(self, request): + form = OPNFVSelectionForm(request.POST) + if form.is_valid(): + installer = form.cleaned_data['installer'] + scenario = form.cleaned_data['scenario'] + models = self.repo_get(self.repo.OPNFV_MODELS, {}) + models['installer_chosen'] = installer + models['scenario_chosen'] = scenario + self.repo_put(self.repo.OPNFV_MODELS, models) + self.update_confirmation() + self.set_valid("Step Completed") + else: + self.set_invalid("Please select an Installer and Scenario") + + return self.render(request) + + +class Assign_Network_Roles(WorkflowStep): + template = 'config_bundle/steps/assign_network_roles.html' + title = 'Pick Network Roles' + description = 'Choose what role each network should get' + short_title = "network roles" + modified_key = "net_roles_step" + + """ + to do initial filling, repo should have a "network_roles" array with the following structure for each element: + { + "role": <NetworkRole object ref>, + "network": <Network object ref> + } + """ + def create_netformset(self, roles, config_bundle, data=None): + roles_initial = [] + set_roles = self.repo_get(self.repo.OPNFV_MODELS, {}).get("network_roles") + if set_roles: + roles_initial = set_roles + else: + for role in OPNFV_SETTINGS.NETWORK_ROLES: + roles_initial.append({"role": role}) + + Formset = formset_factory(OPNFVNetworkRoleForm, extra=0) + kwargs = { + "initial": roles_initial, + "form_kwargs": {"config_bundle": config_bundle} + } + formset = None + if data: + formset = Formset(data, **kwargs) + else: + formset = Formset(**kwargs) + return formset + + def get_context(self): + context = super(Assign_Network_Roles, self).get_context() + config_bundle = self.repo_get(self.repo.SELECTED_CONFIG_BUNDLE) + if config_bundle is None: + context["unavailable"] = True + return context + + roles = OPNFV_SETTINGS.NETWORK_ROLES + formset = self.create_netformset(roles, config_bundle) + context['formset'] = formset + + return context + + def update_confirmation(self): + confirm = self.repo_get(self.repo.CONFIRMATION, {}) + models = self.repo_get(self.repo.OPNFV_MODELS, {}) + roles = models.get("network_roles") + if not roles: + return + confirm['network roles'] = {} + for role in roles: + confirm['network roles'][role['role']] = role['network'].name + self.repo_put(self.repo.CONFIRMATION, confirm) + + def post_render(self, request): + models = self.repo_get(self.repo.OPNFV_MODELS, {}) + config_bundle = self.repo_get(self.repo.SELECTED_CONFIG_BUNDLE) + roles = OPNFV_SETTINGS.NETWORK_ROLES + net_role_formset = self.create_netformset(roles, config_bundle, data=request.POST) + if net_role_formset.is_valid(): + results = [] + for form in net_role_formset: + results.append({ + "role": form.cleaned_data['role'], + "network": form.cleaned_data['network'] + }) + models['network_roles'] = results + self.set_valid("Completed") + self.repo_put(self.repo.OPNFV_MODELS, models) + self.update_confirmation() + else: + self.set_invalid("Please complete all fields") + return self.render(request) + + +class Assign_Host_Roles(WorkflowStep): # taken verbatim from Define_Software in sw workflow, merge the two? + template = 'config_bundle/steps/assign_host_roles.html' + title = 'Pick Host Roles' + description = "Choose the role each machine will have in your OPNFV pod" + short_title = "host roles" + modified_key = "host_roles_step" + + def create_host_role_formset(self, hostlist=[], data=None): + models = self.repo_get(self.repo.OPNFV_MODELS, {}) + host_roles = models.get("host_roles", []) + if not host_roles: + for host in hostlist: + initial = {"host_name": host.resource.name} + host_roles.append(initial) + models['host_roles'] = host_roles + self.repo_put(self.repo.OPNFV_MODELS, models) + + HostFormset = formset_factory(OPNFVHostRoleForm, extra=0) + + kwargs = {"initial": host_roles} + formset = None + if data: + formset = HostFormset(data, **kwargs) + else: + formset = HostFormset(**kwargs) + + return formset + + def get_context(self): + context = super(Assign_Host_Roles, self).get_context() + config = self.repo_get(self.repo.SELECTED_CONFIG_BUNDLE) + if config is None: + context['error'] = "Please select a Configuration on the first step" + + formset = self.create_host_role_formset(hostlist=config.bundle.getHosts()) + context['formset'] = formset + + return context + + def get_host_role_mapping(self, host_roles, hostname): + for obj in host_roles: + if hostname == obj['host_name']: + return obj + return None + + def update_confirmation(self): + confirm = self.repo_get(self.repo.CONFIRMATION, {}) + models = self.repo_get(self.repo.OPNFV_MODELS, {}) + roles = models.get("host_roles") + if not roles: + return + confirm['host roles'] = {} + for role in roles: + confirm['host roles'][role['host_name']] = role['role'].name + self.repo_put(self.repo.CONFIRMATION, confirm) + + def post_render(self, request): + formset = self.create_host_role_formset(data=request.POST) + + models = self.repo_get(self.repo.OPNFV_MODELS, {}) + host_roles = models.get("host_roles", []) + + has_jumphost = False + if formset.is_valid(): + for form in formset: + hostname = form.cleaned_data['host_name'] + role = form.cleaned_data['role'] + mapping = self.get_host_role_mapping(host_roles, hostname) + mapping['role'] = role + if "jumphost" in role.name.lower(): + has_jumphost = True + + models['host_roles'] = host_roles + self.repo_put(self.repo.OPNFV_MODELS, models) + self.update_confirmation() + + if not has_jumphost: + self.set_invalid('Must have at least one "Jumphost" per POD') + else: + self.set_valid("Completed") + else: + self.set_invalid("Please complete all fields") + + return self.render(request) + + +class MetaInfo(WorkflowStep): + template = 'config_bundle/steps/config_software.html' + title = "Other Info" + description = "Give your software config a name, description, and other stuff" + short_title = "config info" + + def get_context(self): + context = super(MetaInfo, self).get_context() + + initial = self.repo_get(self.repo.OPNFV_MODELS, {}).get("meta", {}) + context["form"] = BasicMetaForm(initial=initial) + return context + + def update_confirmation(self): + confirm = self.repo_get(self.repo.CONFIRMATION, {}) + models = self.repo_get(self.repo.OPNFV_MODELS, {}) + meta = models.get("meta") + if not meta: + return + confirm['name'] = meta['name'] + confirm['description'] = meta['description'] + self.repo_put(self.repo.CONFIRMATION, confirm) + + def post_render(self, request): + models = self.repo_get(self.repo.OPNFV_MODELS, {}) + info = models.get("meta", {}) + + form = BasicMetaForm(request.POST) + if form.is_valid(): + info['name'] = form.cleaned_data['name'] + info['description'] = form.cleaned_data['description'] + models['meta'] = info + self.repo_put(self.repo.OPNFV_MODELS, models) + self.update_confirmation() + self.set_valid("Complete") + else: + self.set_invalid("Please correct the errors shown below") + + self.repo_put(self.repo.OPNFV_MODELS, models) + return self.render(request) diff --git a/dashboard/src/workflow/resource_bundle_workflow.py b/dashboard/src/workflow/resource_bundle_workflow.py index 712c92b..06737d2 100644 --- a/dashboard/src/workflow/resource_bundle_workflow.py +++ b/dashboard/src/workflow/resource_bundle_workflow.py @@ -10,6 +10,7 @@ from django.shortcuts import render from django.forms import formset_factory +from django.conf import settings import json import re @@ -25,11 +26,12 @@ from workflow.forms import ( ) from resource_inventory.models import ( GenericResourceBundle, - Vlan, GenericInterface, GenericHost, GenericResource, - HostProfile + HostProfile, + Network, + NetworkConnection ) from dashboard.exceptions import ( InvalidVlanConfigurationException, @@ -50,65 +52,47 @@ class Define_Hardware(WorkflowStep): description = "Choose the type and amount of machines you want" short_title = "hosts" + def __init__(self, *args, **kwargs): + self.form = None + super().__init__(*args, **kwargs) + def get_context(self): context = super(Define_Hardware, self).get_context() - selection_data = {"hosts": {}, "labs": {}} - models = self.repo_get(self.repo.GRESOURCE_BUNDLE_MODELS, {}) - hosts = models.get("hosts", []) - for host in hosts: - profile_id = "host_" + str(host.profile.id) - if profile_id not in selection_data['hosts']: - selection_data['hosts'][profile_id] = [] - selection_data['hosts'][profile_id].append({"host_name": host.resource.name, "class": profile_id}) - - if models.get("bundle", GenericResourceBundle()).lab: - selection_data['labs'] = {"lab_" + str(models.get("bundle").lab.lab_user.id): "true"} - - form = HardwareDefinitionForm( - selection_data=selection_data - ) - context['form'] = form + context['form'] = self.form or HardwareDefinitionForm() return context - def render(self, request): - self.context = self.get_context() - return render(request, self.template, self.context) - def update_models(self, data): - data = json.loads(data['filter_field']) + data = data['filter_field'] models = self.repo_get(self.repo.GRESOURCE_BUNDLE_MODELS, {}) models['hosts'] = [] # This will always clear existing data when this step changes models['interfaces'] = {} if "bundle" not in models: models['bundle'] = GenericResourceBundle(owner=self.repo_get(self.repo.SESSION_USER)) - host_data = data['hosts'] + host_data = data['host'] names = {} - for host_dict in host_data: - id = host_dict['class'] - # bit of formatting - id = int(id.split("_")[-1]) + for host_profile_dict in host_data.values(): + id = host_profile_dict['id'] profile = HostProfile.objects.get(id=id) # instantiate genericHost and store in repo - name = host_dict['host_name'] - if not re.match(r"(?=^.{1,253}$)(^([A-Za-z0-9-_]{1,62}\.)*[A-Za-z0-9-_]{1,63})", name): - raise InvalidHostnameException("Hostname must comply to RFC 952 and all extensions to it until this point") - if name in names: - raise NonUniqueHostnameException("All hosts must have unique names") - names[name] = True - genericResource = GenericResource(bundle=models['bundle'], name=name) - genericHost = GenericHost(profile=profile, resource=genericResource) - models['hosts'].append(genericHost) - for interface_profile in profile.interfaceprofile.all(): - genericInterface = GenericInterface(profile=interface_profile, host=genericHost) - if genericHost.resource.name not in models['interfaces']: - models['interfaces'][genericHost.resource.name] = [] - models['interfaces'][genericHost.resource.name].append(genericInterface) + for name in host_profile_dict['values'].values(): + if not re.match(r"(?=^.{1,253}$)(^([A-Za-z0-9-_]{1,62}\.)*[A-Za-z0-9-_]{1,63})", name): + raise InvalidHostnameException("Invalid hostname: '" + name + "'") + if name in names: + raise NonUniqueHostnameException("All hosts must have unique names") + names[name] = True + genericResource = GenericResource(bundle=models['bundle'], name=name) + genericHost = GenericHost(profile=profile, resource=genericResource) + models['hosts'].append(genericHost) + for interface_profile in profile.interfaceprofile.all(): + genericInterface = GenericInterface(profile=interface_profile, host=genericHost) + if genericHost.resource.name not in models['interfaces']: + models['interfaces'][genericHost.resource.name] = [] + models['interfaces'][genericHost.resource.name].append(genericInterface) # add selected lab to models - for lab_dict in data['labs']: - if list(lab_dict.values())[0]: # True for lab the user selected - lab_user_id = int(list(lab_dict.keys())[0].split("_")[-1]) - models['bundle'].lab = Lab.objects.get(lab_user__id=lab_user_id) + for lab_dict in data['lab'].values(): + if lab_dict['selected']: + models['bundle'].lab = Lab.objects.get(lab_user__id=lab_dict['id']) break # if somehow we get two 'true' labs, we only use one # return to repo @@ -133,12 +117,11 @@ class Define_Hardware(WorkflowStep): if self.form.is_valid(): self.update_models(self.form.cleaned_data) self.update_confirmation() - self.metastep.set_valid("Step Completed") + self.set_valid("Step Completed") else: - self.metastep.set_invalid("Please complete the fields highlighted in red to continue") - pass + self.set_invalid("Please complete the fields highlighted in red to continue") except Exception as e: - self.metastep.set_invalid(str(e)) + self.set_invalid(str(e)) self.context = self.get_context() return render(request, self.template, self.context) @@ -168,53 +151,55 @@ class Define_Nets(WorkflowStep): except Exception: return None + def make_mx_host_dict(self, generic_host): + host = { + 'id': generic_host.resource.name, + 'interfaces': [], + 'value': { + "name": generic_host.resource.name, + "description": generic_host.profile.description + } + } + for iface in generic_host.profile.interfaceprofile.all(): + host['interfaces'].append({ + "name": iface.name, + "description": "speed: " + str(iface.speed) + "M\ntype: " + iface.nic_type + }) + return host + def get_context(self): - # TODO: render *primarily* on hosts in repo models context = super(Define_Nets, self).get_context() - context['form'] = NetworkDefinitionForm() + context.update({ + 'form': NetworkDefinitionForm(), + 'debug': settings.DEBUG, + 'hosts': [], + 'added_hosts': [], + 'removed_hosts': [] + }) + vlans = self.get_vlans() + if vlans: + context['vlans'] = vlans try: - context['hosts'] = [] models = self.repo_get(self.repo.GRESOURCE_BUNDLE_MODELS, {}) - vlans = self.get_vlans() - if vlans: - context['vlans'] = vlans hosts = models.get("hosts", []) - hostlist = self.repo_get(self.repo.GRB_LAST_HOSTLIST, None) - added_list = [] - added_dict = {} - context['added_hosts'] = [] - if hostlist is not None: - new_hostlist = [] - for host in models['hosts']: - intcount = host.profile.interfaceprofile.count() - new_hostlist.append(str(host.resource.name) + "*" + str(host.profile) + "&" + str(intcount)) - context['removed_hosts'] = list(set(hostlist) - set(new_hostlist)) - added_list = list(set(new_hostlist) - set(hostlist)) - for hoststr in added_list: - key = hoststr.split("*")[0] - added_dict[key] = hoststr + # calculate if the selected hosts have changed + added_hosts = set() + host_set = set(self.repo_get(self.repo.GRB_LAST_HOSTLIST, [])) + if len(host_set): + new_host_set = set([h.resource.name + "*" + h.profile.name for h in models['hosts']]) + context['removed_hosts'] = [h.split("*")[0] for h in (host_set - new_host_set)] + added_hosts.update([h.split("*")[0] for h in (new_host_set - host_set)]) + + # add all host info to context for generic_host in hosts: - host_profile = generic_host.profile - host = {} - host['id'] = generic_host.resource.name - host['interfaces'] = [] - for iface in host_profile.interfaceprofile.all(): - host['interfaces'].append( - { - "name": iface.name, - "description": "speed: " + str(iface.speed) + "M\ntype: " + iface.nic_type - } - ) - host['value'] = {"name": generic_host.resource.name} - host['value']['description'] = generic_host.profile.description - context['hosts'].append(json.dumps(host)) - if host['id'] in added_dict: - context['added_hosts'].append(json.dumps(host)) + host = self.make_mx_host_dict(generic_host) + host_serialized = json.dumps(host) + context['hosts'].append(host_serialized) + if host['id'] in added_hosts: + context['added_hosts'].append(host_serialized) bundle = models.get("bundle", False) - if bundle and bundle.xml: - context['xml'] = bundle.xml - else: - context['xml'] = False + if bundle: + context['xml'] = bundle.xml or False except Exception: pass @@ -224,27 +209,24 @@ class Define_Nets(WorkflowStep): def post_render(self, request): models = self.repo_get(self.repo.GRESOURCE_BUNDLE_MODELS, {}) if 'hosts' in models: - hostlist = [] - for host in models['hosts']: - intcount = host.profile.interfaceprofile.count() - hostlist.append(str(host.resource.name) + "*" + str(host.profile) + "&" + str(intcount)) - self.repo_put(self.repo.GRB_LAST_HOSTLIST, hostlist) + host_set = set([h.resource.name + "*" + h.profile.name for h in models['hosts']]) + self.repo_put(self.repo.GRB_LAST_HOSTLIST, host_set) try: xmlData = request.POST.get("xml") self.updateModels(xmlData) # update model with xml - self.metastep.set_valid("Networks applied successfully") + self.set_valid("Networks applied successfully") except ResourceAvailabilityException: - self.metastep.set_invalid("Public network not availble") - except Exception: - self.metastep.set_invalid("An error occurred when applying networks") + self.set_invalid("Public network not availble") + except Exception as e: + self.set_invalid("An error occurred when applying networks: " + str(e)) return self.render(request) def updateModels(self, xmlData): models = self.repo_get(self.repo.GRESOURCE_BUNDLE_MODELS, {}) - models["vlans"] = {} - given_hosts, interfaces = self.parseXml(xmlData) - vlan_manager = models['bundle'].lab.vlan_manager + models["connections"] = {} + models['networks'] = {} + given_hosts, interfaces, networks = self.parseXml(xmlData) existing_host_list = models.get("hosts", []) existing_hosts = {} # maps id to host for host in existing_host_list: @@ -252,104 +234,133 @@ class Define_Nets(WorkflowStep): bundle = models.get("bundle", GenericResourceBundle(owner=self.repo_get(self.repo.SESSION_USER))) + for net_id, net in networks.items(): + network = Network() + network.name = net['name'] + network.bundle = bundle + network.is_public = net['public'] + models['networks'][net_id] = network + for hostid, given_host in given_hosts.items(): existing_host = existing_hosts[hostid[5:]] for ifaceId in given_host['interfaces']: iface = interfaces[ifaceId] - if existing_host.resource.name not in models['vlans']: - models['vlans'][existing_host.resource.name] = {} - models['vlans'][existing_host.resource.name][iface['profile_name']] = [] - for network in iface['networks']: - vlan_id = network['network']['vlan'] - is_public = network['network']['public'] - if is_public: - public_net = vlan_manager.get_public_vlan() - if public_net is None: - raise ResourceAvailabilityException("No public networks available") - vlan_id = vlan_manager.get_public_vlan().vlan - vlan = Vlan(vlan_id=vlan_id, tagged=network['tagged'], public=is_public) - models['vlans'][existing_host.resource.name][iface['profile_name']].append(vlan) + if existing_host.resource.name not in models['connections']: + models['connections'][existing_host.resource.name] = {} + models['connections'][existing_host.resource.name][iface['profile_name']] = [] + for connection in iface['connections']: + network_id = connection['network'] + net = models['networks'][network_id] + connection = NetworkConnection(vlan_is_tagged=connection['tagged'], network=net) + models['connections'][existing_host.resource.name][iface['profile_name']].append(connection) bundle.xml = xmlData self.repo_put(self.repo.GRESOURCE_BUNDLE_MODELS, models) - # serialize and deserialize xml from mxGraph - def parseXml(self, xmlString): - parent_nets = {} # map network ports to networks - networks = {} # maps net id to network object - hosts = {} # cotains id -> hosts, each containing interfaces, referencing networks - interfaces = {} # maps id -> interface + def decomposeXml(self, xmlString): + """ + This function takes in an xml doc from our front end + and returns dictionaries that map cellIds to the xml + nodes themselves. There is no unpacking of the + xml objects, just grouping and organizing + """ + + connections = {} + networks = {} + hosts = {} + interfaces = {} + network_ports = {} + xmlDom = minidom.parseString(xmlString) root = xmlDom.documentElement.firstChild - netids = {} - untagged_ints = {} for cell in root.childNodes: cellId = cell.getAttribute('id') + group = cellId.split("_")[0] + parentGroup = cell.getAttribute("parent").split("_")[0] + # place cell into correct group if cell.getAttribute("edge"): - # cell is a network connection - escaped_json_str = cell.getAttribute("value") - json_str = escaped_json_str.replace('"', '"') - attributes = json.loads(json_str) - tagged = attributes['tagged'] - interface = None - network = None - src = cell.getAttribute("source") - tgt = cell.getAttribute("target") - if src in parent_nets: - # src is a network port - network = networks[parent_nets[src]] - if tgt in untagged_ints and not tagged: - raise InvalidVlanConfigurationException("More than one untagged vlan on an interface") - interface = interfaces[tgt] - untagged_ints[tgt] = True - else: - network = networks[parent_nets[tgt]] - if src in untagged_ints and not tagged: - raise InvalidVlanConfigurationException("More than one untagged vlan on an interface") - interface = interfaces[src] - untagged_ints[src] = True - interface['networks'].append({"network": network, "tagged": tagged}) - - elif "network" in cellId: # cell is a network - escaped_json_str = cell.getAttribute("value") - json_str = escaped_json_str.replace('"', '"') - net_info = json.loads(json_str) - nid = net_info['vlan_id'] - public = net_info['public'] - try: - int_netid = int(nid) - assert public or int_netid > 1, "Net id is 1 or lower" - assert int_netid < 4095, "Net id is 4095 or greater" - except Exception: - raise InvalidVlanConfigurationException("VLAN ID is not an integer more than 1 and less than 4095") - if nid in netids: - raise NetworkExistsException("Non unique network id found") - else: - pass - network = {"name": net_info['name'], "vlan": net_info['vlan_id'], "public": public} - netids[net_info['vlan_id']] = True - networks[cellId] = network - - elif "host" in cellId: # cell is a host/machine - # TODO gather host info - cell_json_str = cell.getAttribute("value") - cell_json = json.loads(cell_json_str) - host = {"interfaces": [], "name": cellId, "profile_name": cell_json['name']} - hosts[cellId] = host - - elif cell.hasAttribute("parent"): - parentId = cell.getAttribute('parent') - if "network" in parentId: - parent_nets[cellId] = parentId - elif "host" in parentId: - # TODO gather iface info - cell_json_str = cell.getAttribute("value") - cell_json = json.loads(cell_json_str) - iface = {"name": cellId, "networks": [], "profile_name": cell_json['name']} - hosts[parentId]['interfaces'].append(cellId) - interfaces[cellId] = iface - return hosts, interfaces + connections[cellId] = cell + + elif "network" in group: + networks[cellId] = cell + + elif "host" in group: + hosts[cellId] = cell + + elif "host" in parentGroup: + interfaces[cellId] = cell + + # make network ports also map to thier network + elif "network" in parentGroup: + network_ports[cellId] = cell.getAttribute("parent") # maps port ID to net ID + + return connections, networks, hosts, interfaces, network_ports + + # serialize and deserialize xml from mxGraph + def parseXml(self, xmlString): + networks = {} # maps net name to network object + hosts = {} # cotains id -> hosts, each containing interfaces, referencing networks + interfaces = {} # maps id -> interface + untagged_ifaces = set() # used to check vlan config + network_names = set() # used to check network names + xml_connections, xml_nets, xml_hosts, xml_ifaces, xml_ports = self.decomposeXml(xmlString) + + # parse Hosts + for cellId, cell in xml_hosts.items(): + cell_json_str = cell.getAttribute("value") + cell_json = json.loads(cell_json_str) + host = {"interfaces": [], "name": cellId, "profile_name": cell_json['name']} + hosts[cellId] = host + + # parse networks + for cellId, cell in xml_nets.items(): + escaped_json_str = cell.getAttribute("value") + json_str = escaped_json_str.replace('"', '"') + net_info = json.loads(json_str) + net_name = net_info['name'] + public = net_info['public'] + if net_name in network_names: + raise NetworkExistsException("Non unique network name found") + network = {"name": net_name, "public": public, "id": cellId} + networks[cellId] = network + network_names.add(net_name) + + # parse interfaces + for cellId, cell in xml_ifaces.items(): + parentId = cell.getAttribute('parent') + cell_json_str = cell.getAttribute("value") + cell_json = json.loads(cell_json_str) + iface = {"name": cellId, "connections": [], "profile_name": cell_json['name']} + hosts[parentId]['interfaces'].append(cellId) + interfaces[cellId] = iface + + # parse connections + for cellId, cell in xml_connections.items(): + escaped_json_str = cell.getAttribute("value") + json_str = escaped_json_str.replace('"', '"') + attributes = json.loads(json_str) + tagged = attributes['tagged'] + interface = None + network = None + src = cell.getAttribute("source") + tgt = cell.getAttribute("target") + if src in interfaces: + interface = interfaces[src] + network = networks[xml_ports[tgt]] + else: + interface = interfaces[tgt] + network = networks[xml_ports[src]] + + if not tagged: + if interface['name'] in untagged_ifaces: + raise InvalidVlanConfigurationException("More than one untagged vlan on an interface") + untagged_ifaces.add(interface['name']) + + # add connection to interface + interface['connections'].append({"tagged": tagged, "network": network['id']}) + + return hosts, interfaces, networks class Resource_Meta_Info(WorkflowStep): @@ -390,10 +401,10 @@ class Resource_Meta_Info(WorkflowStep): tmp = tmp[:60] + "..." confirm_info["description"] = tmp self.repo_put(self.repo.CONFIRMATION, confirm) - self.metastep.set_valid("Step Completed") + self.set_valid("Step Completed") else: - self.metastep.set_invalid("Please correct the fields highlighted in red to continue") + self.set_invalid("Please correct the fields highlighted in red to continue") pass return self.render(request) diff --git a/dashboard/src/workflow/snapshot_workflow.py b/dashboard/src/workflow/snapshot_workflow.py index 4ddc397..5414784 100644 --- a/dashboard/src/workflow/snapshot_workflow.py +++ b/dashboard/src/workflow/snapshot_workflow.py @@ -8,13 +8,13 @@ ############################################################################## -import datetime +from django.utils import timezone import json from booking.models import Booking from resource_inventory.models import Host, Image from workflow.models import WorkflowStep -from workflow.forms import SnapshotMetaForm, SnapshotHostSelectForm +from workflow.forms import BasicMetaForm, SnapshotHostSelectForm class Select_Host_Step(WorkflowStep): @@ -27,7 +27,7 @@ class Select_Host_Step(WorkflowStep): context = super(Select_Host_Step, self).get_context() context['form'] = SnapshotHostSelectForm() booking_hosts = {} - now = datetime.datetime.now() + now = timezone.now() user = self.repo_get(self.repo.SESSION_USER) bookings = Booking.objects.filter(start__lt=now, end__gt=now, owner=user) for booking in bookings: @@ -52,11 +52,11 @@ class Select_Host_Step(WorkflowStep): def post_render(self, request): host_data = request.POST.get("host") if not host_data: - self.metastep.set_invalid("Please select a host") + self.set_invalid("Please select a host") return self.render(request) host = json.loads(host_data) if 'name' not in host or 'booking' not in host: - self.metastep.set_invalid("Invalid host selected") + self.set_invalid("Invalid host selected") return self.render(request) name = host['name'] booking_id = host['booking'] @@ -75,7 +75,7 @@ class Select_Host_Step(WorkflowStep): snap_confirm['host'] = name confirm['snapshot'] = snap_confirm self.repo_put(self.repo.CONFIRMATION, confirm) - self.metastep.set_valid("Success") + self.set_valid("Success") return self.render(request) @@ -87,11 +87,18 @@ class Image_Meta_Step(WorkflowStep): def get_context(self): context = super(Image_Meta_Step, self).get_context() - context['form'] = SnapshotMetaForm() + name = self.repo_get(self.repo.SNAPSHOT_NAME, False) + desc = self.repo_get(self.repo.SNAPSHOT_DESC, False) + form = None + if name and desc: + form = BasicMetaForm(initial={"name": name, "description": desc}) + else: + form = BasicMetaForm() + context['form'] = form return context def post_render(self, request): - form = SnapshotMetaForm(request.POST) + form = BasicMetaForm(request.POST) if form.is_valid(): name = form.cleaned_data['name'] self.repo_put(self.repo.SNAPSHOT_NAME, name) @@ -105,8 +112,8 @@ class Image_Meta_Step(WorkflowStep): confirm['snapshot'] = snap_confirm self.repo_put(self.repo.CONFIRMATION, confirm) - self.metastep.set_valid("Success") + self.set_valid("Success") else: - self.metastep.set_invalid("Please Fill out the Form") + self.set_invalid("Please Fill out the Form") return self.render(request) diff --git a/dashboard/src/workflow/sw_bundle_workflow.py b/dashboard/src/workflow/sw_bundle_workflow.py index 26ade22..0c558fc 100644 --- a/dashboard/src/workflow/sw_bundle_workflow.py +++ b/dashboard/src/workflow/sw_bundle_workflow.py @@ -11,33 +11,13 @@ from django.forms import formset_factory from workflow.models import WorkflowStep -from workflow.forms import SoftwareConfigurationForm, HostSoftwareDefinitionForm -from workflow.booking_workflow import Resource_Select -from resource_inventory.models import Image, GenericHost, ConfigBundle, HostConfiguration, Installer, OPNFVConfig +from workflow.forms import BasicMetaForm, HostSoftwareDefinitionForm +from workflow.booking_workflow import Abstract_Resource_Select +from resource_inventory.models import Image, GenericHost, ConfigBundle, HostConfiguration -# resource selection step is reused from Booking workflow -class SWConf_Resource_Select(Resource_Select): - def __init__(self, *args, **kwargs): - super(SWConf_Resource_Select, self).__init__(*args, **kwargs) - self.repo_key = self.repo.SWCONF_SELECTED_GRB - self.confirm_key = "configuration" - - def get_default_entry(self): - booking_grb = self.repo_get(self.repo.BOOKING_SELECTED_GRB) - if booking_grb: - return booking_grb - created_grb = self.repo_get(self.repo.GRESOURCE_BUNDLE_MODELS, {}).get("bundle", None) - return created_grb - - def post_render(self, request): - response = super(SWConf_Resource_Select, self).post_render(request) - models = self.repo_get(self.repo.CONFIG_MODELS, {}) - bundle = models.get("bundle", ConfigBundle(owner=self.repo_get(self.repo.SESSION_USER))) - bundle.bundle = self.repo_get(self.repo_key) # super put grb here - models['bundle'] = bundle - self.repo_put(self.repo.CONFIG_MODELS, models) - return response +class SWConf_Resource_Select(Abstract_Resource_Select): + workflow_type = "configuration" class Define_Software(WorkflowStep): @@ -46,52 +26,61 @@ class Define_Software(WorkflowStep): description = "Choose the opnfv and image of your machines" short_title = "host config" - def create_hostformset(self, hostlist): + def build_filter_data(self, hosts_data): + """ + returns a 2D array of images to exclude + based on the ordering of the passed + hosts_data + """ + filter_data = [] + user = self.repo_get(self.repo.SESSION_USER) + lab = self.repo_get(self.repo.SELECTED_GRESOURCE_BUNDLE).lab + for i, host_data in enumerate(hosts_data): + host = GenericHost.objects.get(pk=host_data['host_id']) + wrong_owner = Image.objects.exclude(owner=user).exclude(public=True) + wrong_host = Image.objects.exclude(host_type=host.profile) + wrong_lab = Image.objects.exclude(from_lab=lab) + excluded_images = wrong_owner | wrong_host | wrong_lab + filter_data.append([]) + for image in excluded_images: + filter_data[i].append(image.pk) + return filter_data + + def create_hostformset(self, hostlist, data=None): hosts_initial = [] host_configs = self.repo_get(self.repo.CONFIG_MODELS, {}).get("host_configs", False) if host_configs: for config in host_configs: - host_initial = {'host_id': config.host.id, 'host_name': config.host.resource.name} - host_initial['role'] = config.opnfvRole - host_initial['image'] = config.image - hosts_initial.append(host_initial) - + hosts_initial.append({ + 'host_id': config.host.id, + 'host_name': config.host.resource.name, + 'headnode': config.is_head_node, + 'image': config.image + }) else: for host in hostlist: - host_initial = {'host_id': host.id, 'host_name': host.resource.name} - - hosts_initial.append(host_initial) + hosts_initial.append({ + 'host_id': host.id, + 'host_name': host.resource.name + }) HostFormset = formset_factory(HostSoftwareDefinitionForm, extra=0) - host_formset = HostFormset(initial=hosts_initial) + filter_data = self.build_filter_data(hosts_initial) - filter_data = {} - user = self.repo_get(self.repo.SESSION_USER) - i = 0 - for host_data in hosts_initial: - host_profile = None - try: - host = GenericHost.objects.get(pk=host_data['host_id']) - host_profile = host.profile - except Exception: - for host in hostlist: - if host.resource.name == host_data['host_name']: - host_profile = host.profile - break - excluded_images = Image.objects.exclude(owner=user).exclude(public=True) - excluded_images = excluded_images | Image.objects.exclude(host_type=host.profile) - lab = self.repo_get(self.repo.SWCONF_SELECTED_GRB).lab - excluded_images = excluded_images | Image.objects.exclude(from_lab=lab) - filter_data["id_form-" + str(i) + "-image"] = [] - for image in excluded_images: - filter_data["id_form-" + str(i) + "-image"].append(image.name) - i += 1 + class SpecialHostFormset(HostFormset): + def get_form_kwargs(self, index): + kwargs = super(SpecialHostFormset, self).get_form_kwargs(index) + if index is not None: + kwargs['imageQS'] = Image.objects.exclude(pk__in=filter_data[index]) + return kwargs - return host_formset, filter_data + if data: + return SpecialHostFormset(data, initial=hosts_initial) + return SpecialHostFormset(initial=hosts_initial) def get_host_list(self, grb=None): if grb is None: - grb = self.repo_get(self.repo.SWCONF_SELECTED_GRB, False) + grb = self.repo_get(self.repo.SELECTED_GRESOURCE_BUNDLE, False) if not grb: return [] if grb.id: @@ -102,16 +91,16 @@ class Define_Software(WorkflowStep): def get_context(self): context = super(Define_Software, self).get_context() - grb = self.repo_get(self.repo.SWCONF_SELECTED_GRB, False) + grb = self.repo_get(self.repo.SELECTED_GRESOURCE_BUNDLE, False) if grb: context["grb"] = grb - formset, filter_data = self.create_hostformset(self.get_host_list(grb)) + formset = self.create_hostformset(self.get_host_list(grb)) context["formset"] = formset - context["filter_data"] = filter_data + context['headnode'] = self.repo_get(self.repo.CONFIG_MODELS, {}).get("headnode_index", 1) else: context["error"] = "Please select a resource first" - self.metastep.set_invalid("Step requires information that is not yet provided by previous step") + self.set_invalid("Step requires information that is not yet provided by previous step") return context @@ -122,58 +111,51 @@ class Define_Software(WorkflowStep): confirm = self.repo_get(self.repo.CONFIRMATION, {}) - HostFormset = formset_factory(HostSoftwareDefinitionForm, extra=0) - formset = HostFormset(request.POST) hosts = self.get_host_list() + models['headnode_index'] = request.POST.get("headnode", 1) + formset = self.create_hostformset(hosts, data=request.POST) + has_headnode = False if formset.is_valid(): models['host_configs'] = [] - i = 0 confirm_hosts = [] - for form in formset: + for i, form in enumerate(formset): host = hosts[i] - i += 1 image = form.cleaned_data['image'] - # checks image compatability - grb = self.repo_get(self.repo.SWCONF_SELECTED_GRB) - lab = None - if grb: - lab = grb.lab - try: - owner = self.repo_get(self.repo.SESSION_USER) - q = Image.objects.filter(owner=owner) | Image.objects.filter(public=True) - q.filter(host_type=host.profile) - q.filter(from_lab=lab) - q.get(id=image.id) # will throw exception if image is not in q - except: - self.metastep.set_invalid("Image " + image.name + " is not compatible with host " + host.resource.name) - role = form.cleaned_data['role'] + headnode = form.cleaned_data['headnode'] + if headnode: + has_headnode = True bundle = models['bundle'] hostConfig = HostConfiguration( host=host, image=image, bundle=bundle, - opnfvRole=role + is_head_node=headnode ) models['host_configs'].append(hostConfig) - confirm_host = {"name": host.resource.name, "image": image.name, "role": role.name} - confirm_hosts.append(confirm_host) + confirm_hosts.append({ + "name": host.resource.name, + "image": image.name, + "headnode": headnode + }) + + if not has_headnode: + self.set_invalid('Must have one "Headnode" per POD') + return self.render(request) self.repo_put(self.repo.CONFIG_MODELS, models) if "configuration" not in confirm: confirm['configuration'] = {} confirm['configuration']['hosts'] = confirm_hosts self.repo_put(self.repo.CONFIRMATION, confirm) - self.metastep.set_valid("Completed") + self.set_valid("Completed") else: - self.metastep.set_invalid("Please complete all fields") + self.set_invalid("Please complete all fields") return self.render(request) class Config_Software(WorkflowStep): template = 'config_bundle/steps/config_software.html' - form = SoftwareConfigurationForm - context = {'workspace_form': form} title = "Other Info" description = "Give your software config a name, description, and other stuff" short_title = "config info" @@ -187,58 +169,30 @@ class Config_Software(WorkflowStep): if bundle: initial['name'] = bundle.name initial['description'] = bundle.description - opnfv = models.get("opnfv", False) - if opnfv: - initial['installer'] = opnfv.installer - initial['scenario'] = opnfv.scenario - else: - initial['opnfv'] = False - supported = {} - for installer in Installer.objects.all(): - supported[str(installer)] = [] - for scenario in installer.sup_scenarios.all(): - supported[str(installer)].append(str(scenario)) - - context["form"] = SoftwareConfigurationForm(initial=initial) - context['supported'] = supported - + context["form"] = BasicMetaForm(initial=initial) return context def post_render(self, request): - try: - models = self.repo_get(self.repo.CONFIG_MODELS, {}) - if "bundle" not in models: - models['bundle'] = ConfigBundle(owner=self.repo_get(self.repo.SESSION_USER)) + models = self.repo_get(self.repo.CONFIG_MODELS, {}) + if "bundle" not in models: + models['bundle'] = ConfigBundle(owner=self.repo_get(self.repo.SESSION_USER)) - confirm = self.repo_get(self.repo.CONFIRMATION, {}) - if "configuration" not in confirm: - confirm['configuration'] = {} + confirm = self.repo_get(self.repo.CONFIRMATION, {}) + if "configuration" not in confirm: + confirm['configuration'] = {} - form = self.form(request.POST) - if form.is_valid(): - models['bundle'].name = form.cleaned_data['name'] - models['bundle'].description = form.cleaned_data['description'] - if form.cleaned_data['opnfv']: - installer = form.cleaned_data['installer'] - scenario = form.cleaned_data['scenario'] - opnfv = OPNFVConfig( - bundle=models['bundle'], - installer=installer, - scenario=scenario - ) - models['opnfv'] = opnfv - confirm['configuration']['installer'] = form.cleaned_data['installer'].name - confirm['configuration']['scenario'] = form.cleaned_data['scenario'].name - - confirm['configuration']['name'] = form.cleaned_data['name'] - confirm['configuration']['description'] = form.cleaned_data['description'] - self.metastep.set_valid("Complete") - else: - self.metastep.set_invalid("Please correct the errors shown below") + form = BasicMetaForm(request.POST) + if form.is_valid(): + models['bundle'].name = form.cleaned_data['name'] + models['bundle'].description = form.cleaned_data['description'] - self.repo_put(self.repo.CONFIG_MODELS, models) - self.repo_put(self.repo.CONFIRMATION, confirm) + confirm['configuration']['name'] = form.cleaned_data['name'] + confirm['configuration']['description'] = form.cleaned_data['description'] + self.set_valid("Complete") + else: + self.set_invalid("Please correct the errors shown below") + + self.repo_put(self.repo.CONFIG_MODELS, models) + self.repo_put(self.repo.CONFIRMATION, confirm) - except Exception: - pass return self.render(request) diff --git a/dashboard/src/workflow/urls.py b/dashboard/src/workflow/urls.py index b131d84..5a97904 100644 --- a/dashboard/src/workflow/urls.py +++ b/dashboard/src/workflow/urls.py @@ -14,7 +14,7 @@ from django.conf import settings from workflow.views import step_view, delete_session, manager_view, viewport_view from workflow.models import Repository from workflow.resource_bundle_workflow import Define_Hardware, Define_Nets, Resource_Meta_Info -from workflow.booking_workflow import SWConfig_Select, Resource_Select, Booking_Meta +from workflow.booking_workflow import SWConfig_Select, Booking_Resource_Select, Booking_Meta app_name = 'workflow' urlpatterns = [ @@ -31,4 +31,4 @@ if settings.TESTING: urlpatterns.append(url(r'^workflow/step/resource_meta$', Resource_Meta_Info("", Repository()).test_render)) urlpatterns.append(url(r'^workflow/step/booking_meta$', Booking_Meta("", Repository()).test_render)) urlpatterns.append(url(r'^workflow/step/software_select$', SWConfig_Select("", Repository()).test_render)) - urlpatterns.append(url(r'^workflow/step/resource_select$', Resource_Select("", Repository()).test_render)) + urlpatterns.append(url(r'^workflow/step/resource_select$', Booking_Resource_Select("", Repository()).test_render)) diff --git a/dashboard/src/workflow/views.py b/dashboard/src/workflow/views.py index e5ef5c6..7ed9031 100644 --- a/dashboard/src/workflow/views.py +++ b/dashboard/src/workflow/views.py @@ -8,12 +8,14 @@ ############################################################################## -from django.http import HttpResponse, HttpResponseGone +from django.http import HttpResponseGone, JsonResponse from django.shortcuts import render +from django.urls import reverse import uuid from workflow.workflow_manager import ManagerTracker, SessionManager +from booking.models import Booking import logging logger = logging.getLogger(__name__) @@ -29,12 +31,33 @@ def attempt_auth(request): return None +def get_redirect_response(result): + if not result: + return {} + + # need to get type of result, and switch on the type + # since has_result, result must be populated with a valid object + if isinstance(result, Booking): + return { + 'redir_url': reverse('booking:booking_detail', kwargs={'booking_id': result.id}) + } + else: + return {} + + def delete_session(request): - try: + manager = attempt_auth(request) + + if not manager: + return HttpResponseGone("No session found that relates to current request") + + not_last_workflow, result = manager.pop_workflow() + + if not_last_workflow: # this was not the last workflow, so don't redirect away + return JsonResponse({}) + else: del ManagerTracker.managers[request.session['manager_session']] - return HttpResponse('') - except Exception: - return None + return JsonResponse(get_redirect_response(result)) def step_view(request): @@ -43,7 +66,12 @@ def step_view(request): # no manager found, redirect to "lost" page return no_workflow(request) if request.GET.get('step') is not None: - manager.goto(int(request.GET.get('step'))) + if request.GET.get('step') == 'next': + manager.go_next() + elif request.GET.get('step') == 'prev': + manager.go_prev() + else: + raise Exception("requested action for new step had malformed contents: " + request.GET.get('step')) return manager.render(request) @@ -70,7 +98,8 @@ def manager_view(request): logger.debug("edit found") manager.add_workflow(workflow_type=request.POST.get('edit'), edit_object=int(request.POST.get('edit_id'))) elif request.POST.get('cancel') is not None: - del ManagerTracker.managers[request.session['manager_session']] + if not manager.pop_workflow(): + del ManagerTracker.managers[request.session['manager_session']] return manager.status(request) diff --git a/dashboard/src/workflow/workflow_factory.py b/dashboard/src/workflow/workflow_factory.py index 9a42d86..03c8126 100644 --- a/dashboard/src/workflow/workflow_factory.py +++ b/dashboard/src/workflow/workflow_factory.py @@ -8,10 +8,12 @@ ############################################################################## -from workflow.booking_workflow import Booking_Resource_Select, SWConfig_Select, Booking_Meta +from workflow.booking_workflow import Booking_Resource_Select, SWConfig_Select, Booking_Meta, OPNFV_Select from workflow.resource_bundle_workflow import Define_Hardware, Define_Nets, Resource_Meta_Info from workflow.sw_bundle_workflow import Config_Software, Define_Software, SWConf_Resource_Select from workflow.snapshot_workflow import Select_Host_Step, Image_Meta_Step +from workflow.opnfv_workflow import Pick_Installer, Assign_Network_Roles, Assign_Host_Roles, OPNFV_Resource_Select, MetaInfo +from workflow.models import Confirmation_Step import uuid @@ -19,38 +21,6 @@ import logging logger = logging.getLogger(__name__) -class BookingMetaWorkflow(object): - workflow_type = 0 - color = "#0099ff" - is_child = False - - -class ResourceMetaWorkflow(object): - workflow_type = 1 - color = "#ff6600" - - -class ConfigMetaWorkflow(object): - workflow_type = 2 - color = "#00ffcc" - - -class MetaRelation(object): - def __init__(self, *args, **kwargs): - self.color = "#cccccc" - self.parent = 0 - self.children = [] - self.depth = -1 - - def to_json(self): - return { - 'color': self.color, - 'parent': self.parent, - 'children': self.children, - 'depth': self.depth, - } - - class MetaStep(object): UNTOUCHED = 0 @@ -69,6 +39,7 @@ class MetaStep(object): self.short_title = "error" self.skip_step = 0 self.valid = 0 + self.hidden = False self.message = "" self.id = uuid.uuid4() @@ -93,11 +64,19 @@ class MetaStep(object): return self.id.int != other.id.int +class Workflow(object): + def __init__(self, steps, repository): + self.repository = repository + self.steps = steps + self.active_index = 0 + + class WorkflowFactory(): booking_steps = [ Booking_Resource_Select, SWConfig_Select, - Booking_Meta + Booking_Meta, + OPNFV_Select, ] resource_steps = [ @@ -114,7 +93,15 @@ class WorkflowFactory(): snapshot_steps = [ Select_Host_Step, - Image_Meta_Step + Image_Meta_Step, + ] + + opnfv_steps = [ + OPNFV_Resource_Select, + Pick_Installer, + Assign_Network_Roles, + Assign_Host_Roles, + MetaInfo ] def conjure(self, workflow_type=None, repo=None): @@ -123,14 +110,19 @@ class WorkflowFactory(): self.resource_steps, self.config_steps, self.snapshot_steps, + self.opnfv_steps, ] steps = self.make_steps(workflow_types[workflow_type], repository=repo) - meta_steps = self.metaize(steps=steps, wf_type=workflow_type) - return steps, meta_steps + return steps + + def create_workflow(self, workflow_type=None, repo=None): + steps = self.conjure(workflow_type, repo) + c_step = self.make_step(Confirmation_Step, repo) + steps.append(c_step) + return Workflow(steps, repo) def make_steps(self, step_types, repository): - repository.el['steps'] += len(step_types) steps = [] for step_type in step_types: steps.append(self.make_step(step_type, repository)) @@ -140,13 +132,3 @@ class WorkflowFactory(): def make_step(self, step_type, repository): iden = step_type.description + step_type.title + step_type.template return step_type(iden, repository) - - def metaize(self, steps, wf_type): - meta_dict = [] - for step in steps: - meta_step = MetaStep() - meta_step.short_title = step.short_title - meta_dict.append(meta_step) - step.metastep = meta_step - - return meta_dict diff --git a/dashboard/src/workflow/workflow_manager.py b/dashboard/src/workflow/workflow_manager.py index 95fefbf..80b8a67 100644 --- a/dashboard/src/workflow/workflow_manager.py +++ b/dashboard/src/workflow/workflow_manager.py @@ -10,11 +10,9 @@ from django.http import JsonResponse -import random - from booking.models import Booking -from workflow.workflow_factory import WorkflowFactory, MetaStep, MetaRelation -from workflow.models import Repository, Confirmation_Step +from workflow.workflow_factory import WorkflowFactory +from workflow.models import Repository from resource_inventory.models import ( GenericResourceBundle, ConfigBundle, @@ -27,86 +25,64 @@ logger = logging.getLogger(__name__) class SessionManager(): + def active_workflow(self): + return self.workflows[-1] def __init__(self, request=None): - self.repository = Repository() - self.repository.el[self.repository.SESSION_USER] = request.user - self.repository.el['active_step'] = 0 - self.steps = [] + self.workflows = [] + + self.owner = request.user + self.factory = WorkflowFactory() - c_step = WorkflowFactory().make_step(Confirmation_Step, self.repository) - self.steps.append(c_step) - metaconfirm = MetaStep() - metaconfirm.index = 0 - metaconfirm.short_title = "confirm" - self.repository.el['steps'] = 1 - self.metaworkflow = None - self.metaworkflows = [] - self.metarelations = [] - self.relationreverselookup = {} - self.initialized = False - self.active_index = 0 - self.step_meta = [metaconfirm] - self.relation_depth = 0 + + def set_step_statuses(self, superclass_type, desired_enabled=True): + workflow = self.active_workflow() + steps = workflow.steps + for step in steps: + if isinstance(step, superclass_type): + if desired_enabled: + step.enable() + else: + step.disable() def add_workflow(self, workflow_type=None, target_id=None, **kwargs): if target_id is not None: self.prefill_repo(target_id, workflow_type) - factory_steps, meta_info = self.factory.conjure(workflow_type=workflow_type, repo=self.repository) - offset = len(meta_info) - for relation in self.metarelations: - if relation.depth > self.relation_depth: - self.relation_depth = relation.depth - if relation.parent >= self.repository.el['active_step']: - relation.parent += offset - for i in range(0, len(relation.children)): - if relation.children[i] >= self.repository.el['active_step']: - relation.children[i] += offset - self.step_meta[self.active_index:self.active_index] = meta_info - self.steps[self.active_index:self.active_index] = factory_steps - - if self.initialized: - relation = MetaRelation() - relation.parent = self.repository.el['active_step'] + offset - relation.depth = self.relationreverselookup[self.step_meta[relation.parent]].depth + 1 - if relation.depth > self.relation_depth: - self.relation_depth = relation.depth - for i in range(self.repository.el['active_step'], offset + self.repository.el['active_step']): - relation.children.append(i) - self.relationreverselookup[self.step_meta[i]] = relation - relation.color = "#%06x" % random.randint(0, 0xFFFFFF) - self.metarelations.append(relation) - else: - relation = MetaRelation() - relation.depth = 0 - relation.parent = 500000000000 - for i in range(0, len(self.step_meta)): - relation.children.append(i) - self.relationreverselookup[self.step_meta[i]] = relation - self.metarelations.append(relation) - self.initialized = True + + repo = Repository() + if(len(self.workflows) >= 1): + defaults = self.workflows[-1].repository.get_child_defaults() + repo.set_defaults(defaults) + repo.el[repo.HAS_RESULT] = False + repo.el[repo.SESSION_USER] = self.owner + repo.el[repo.SESSION_MANAGER] = self + self.workflows.append( + self.factory.create_workflow( + workflow_type=workflow_type, + repo=repo + ) + ) + + def pop_workflow(self): + multiple_wfs = len(self.workflows) > 1 + if multiple_wfs: + if self.workflows[-1].repository.el[Repository.RESULT]: # move result + key = self.workflows[-1].repository.el[Repository.RESULT_KEY] + result = self.workflows[-1].repository.el[Repository.RESULT] + self.workflows[-2].repository.el[key] = result + self.workflows.pop() + current_repo = self.workflows[-1].repository + return (multiple_wfs, current_repo.el[current_repo.RESULT]) def status(self, request): try: - steps = [] - for step in self.step_meta: - steps.append(step.to_json()) - parents = {} - children = {} + meta_json = [] + for step in self.active_workflow().steps: + meta_json.append(step.to_json()) responsejson = {} - responsejson["steps"] = steps - responsejson["active"] = self.repository.el['active_step'] - responsejson["relations"] = [] - i = 0 - for relation in self.metarelations: - responsejson["relations"].append(relation.to_json()) - children[relation.parent] = i - for child in relation.children: - parents[child] = i - i += 1 - responsejson['max_depth'] = self.relation_depth - responsejson['parents'] = parents - responsejson['children'] = children + responsejson["steps"] = meta_json + responsejson["active"] = self.active_workflow().repository.el['active_step'] + responsejson["workflow_count"] = len(self.workflows) return JsonResponse(responsejson, safe=False) except Exception: pass @@ -115,16 +91,35 @@ class SessionManager(): # filter out when a step needs to handle post/form data # if 'workflow' in post data, this post request was meant for me, not step if request.method == 'POST' and request.POST.get('workflow', None) is None: - return self.steps[self.active_index].post_render(request) - return self.steps[self.active_index].render(request) + return self.active_workflow().steps[self.active_workflow().active_index].post_render(request) + return self.active_workflow().steps[self.active_workflow().active_index].render(request) def post_render(self, request): - return self.steps[self.active_index].post_render(request) - - def goto(self, num, **kwargs): - self.repository.el['active_step'] = int(num) - self.active_index = int(num) - # TODO: change to include some checking + return self.active_workflow().steps[self.active_workflow().active_index].post_render(request) + + def get_active_step(self): + return self.active_workflow().steps[self.active_workflow().active_index] + + def go_next(self, **kwargs): + # need to verify current step is valid to allow this + if self.get_active_step().valid < 200: + return + next_step = self.active_workflow().active_index + 1 + if next_step >= len(self.active_workflow().steps): + raise Exception("Out of bounds request for step") + while not self.active_workflow().steps[next_step].enabled: + next_step += 1 + self.active_workflow().repository.el['active_step'] = next_step + self.active_workflow().active_index = next_step + + def go_prev(self, **kwargs): + prev_step = self.active_workflow().active_index - 1 + if prev_step < 0: + raise Exception("Out of bounds request for step") + while not self.active_workflow().steps[prev_step].enabled: + prev_step -= 1 + self.active_workflow().repository.el['active_step'] = prev_step + self.active_workflow().active_index = prev_step def prefill_repo(self, target_id, workflow_type): self.repository.el[self.repository.EDIT] = True @@ -142,29 +137,28 @@ class SessionManager(): def prefill_booking(self, booking): models = self.make_booking_models(booking) confirmation = self.make_booking_confirm(booking) - self.repository.el[self.repository.BOOKING_MODELS] = models - self.repository.el[self.repository.CONFIRMATION] = confirmation - self.repository.el[self.repository.GRESOURCE_BUNDLE_MODELS] = self.make_grb_models(booking.resource.template) - self.repository.el[self.repository.BOOKING_SELECTED_GRB] = self.make_grb_models(booking.resource.template)['bundle'] - self.repository.el[self.repository.CONFIG_MODELS] = self.make_config_models(booking.config_bundle) + self.active_workflow().repository.el[self.active_workflow().repository.BOOKING_MODELS] = models + self.active_workflow().repository.el[self.active_workflow().repository.CONFIRMATION] = confirmation + self.active_workflow().repository.el[self.active_workflow().repository.GRESOURCE_BUNDLE_MODELS] = self.make_grb_models(booking.resource.template) + self.active_workflow().repository.el[self.active_workflow().repository.SELECTED_GRESOURCE_BUNDLE] = self.make_grb_models(booking.resource.template)['bundle'] + self.active_workflow().repository.el[self.active_workflow().repository.CONFIG_MODELS] = self.make_config_models(booking.config_bundle) def prefill_resource(self, resource): models = self.make_grb_models(resource) confirm = self.make_grb_confirm(resource) - self.repository.el[self.repository.GRESOURCE_BUNDLE_MODELS] = models - self.repository.el[self.repository.CONFIRMATION] = confirm + self.active_workflow().repository.el[self.active_workflow().repository.GRESOURCE_BUNDLE_MODELS] = models + self.active_workflow().repository.el[self.active_workflow().repository.CONFIRMATION] = confirm def prefill_config(self, config): models = self.make_config_models(config) confirm = self.make_config_confirm(config) - self.repository.el[self.repository.CONFIG_MODELS] = models - self.repository.el[self.repository.CONFIRMATION] = confirm + self.active_workflow().repository.el[self.active_workflow().repository.CONFIG_MODELS] = models + self.active_workflow().repository.el[self.active_workflow().repository.CONFIRMATION] = confirm grb_models = self.make_grb_models(config.bundle) - self.repository.el[self.repository.GRESOURCE_BUNDLE_MODELS] = grb_models - self.repository.el[self.repository.SWCONF_SELECTED_GRB] = config.bundle + self.active_workflow().repository.el[self.active_workflow().repository.GRESOURCE_BUNDLE_MODELS] = grb_models def make_grb_models(self, resource): - models = self.repository.el.get(self.repository.GRESOURCE_BUNDLE_MODELS, {}) + models = self.active_workflow().repository.el.get(self.active_workflow().repository.GRESOURCE_BUNDLE_MODELS, {}) models['hosts'] = [] models['bundle'] = resource models['interfaces'] = {} @@ -181,7 +175,7 @@ class SessionManager(): return models def make_grb_confirm(self, resource): - confirm = self.repository.el.get(self.repository.CONFIRMATION, {}) + confirm = self.active_workflow().repository.el.get(self.active_workflow().repository.CONFIRMATION, {}) confirm['resource'] = {} confirm['resource']['hosts'] = [] confirm['resource']['lab'] = resource.lab.lab_user.username @@ -190,7 +184,7 @@ class SessionManager(): return confirm def make_config_models(self, config): - models = self.repository.el.get(self.repository.CONFIG_MODELS, {}) + models = self.active_workflow().repository.el.get(self.active_workflow().repository.CONFIG_MODELS, {}) models['bundle'] = config models['host_configs'] = [] for host_conf in HostConfiguration.objects.filter(bundle=config): @@ -199,7 +193,7 @@ class SessionManager(): return models def make_config_confirm(self, config): - confirm = self.repository.el.get(self.repository.CONFIRMATION, {}) + confirm = self.active_workflow().repository.el.get(self.active_workflow().repository.CONFIRMATION, {}) confirm['configuration'] = {} confirm['configuration']['hosts'] = [] confirm['configuration']['name'] = config.name @@ -213,7 +207,7 @@ class SessionManager(): return confirm def make_booking_models(self, booking): - models = self.repository.el.get(self.repository.BOOKING_MODELS, {}) + models = self.active_workflow().repository.el.get(self.active_workflow().repository.BOOKING_MODELS, {}) models['booking'] = booking models['collaborators'] = [] for user in booking.collaborators.all(): @@ -221,7 +215,7 @@ class SessionManager(): return models def make_booking_confirm(self, booking): - confirm = self.repository.el.get(self.repository.CONFIRMATION, {}) + confirm = self.active_workflow().repository.el.get(self.active_workflow().repository.CONFIRMATION, {}) confirm['booking'] = {} confirm['booking']['length'] = (booking.end - booking.start).days confirm['booking']['project'] = booking.project diff --git a/dashboard/test.sh b/dashboard/test.sh index 7931cf0..0fbfd0e 100755 --- a/dashboard/test.sh +++ b/dashboard/test.sh @@ -13,4 +13,4 @@ find . -type f -name "*.py" -not -name "manage.py" | xargs flake8 --count --igno # this file should be executed from the dir it is in -docker exec -it dg01 python manage.py test -t ../src/ +docker exec -it dg01 python manage.py test diff --git a/dashboard/tox.ini b/dashboard/tox.ini new file mode 100644 index 0000000..3d01e10 --- /dev/null +++ b/dashboard/tox.ini @@ -0,0 +1,21 @@ +[tox] +envlist = flake8 +skipsdist = True + +[testenv] +deps = -rrequirements.txt +basepython = python3 + +# Store flake8 config here intead of .flake8 +[flake8] +ignore = + # Ignore 'line-too-long' warnings + E501 +exclude = + src/manage.py + +[testenv:flake8] +deps = + -rrequirements.txt + flake8 +commands = flake8 src/ |