diff options
author | Parker Berberian <pberberian@iol.unh.edu> | 2020-04-14 18:15:55 +0000 |
---|---|---|
committer | Gerrit Code Review <gerrit@opnfv.org> | 2020-04-14 18:15:55 +0000 |
commit | 6bf37e9864787e0398a1d2e1cdd10b40a8ebc6e6 (patch) | |
tree | 18396967cc32110ae5455827e612e2846e55392c | |
parent | d5b4f4c77534824430fd8e2d3f15e5f7ed6303f5 (diff) | |
parent | 557659acfd97ddaacdb89192ff6a575691b39093 (diff) |
Merge "Add user + user list to API"
-rw-r--r-- | src/api/models.py | 44 | ||||
-rw-r--r-- | src/api/urls.py | 4 | ||||
-rw-r--r-- | src/api/views.py | 10 | ||||
-rw-r--r-- | src/booking/quick_deployer.py | 100 | ||||
-rw-r--r-- | src/dashboard/tasks.py | 40 | ||||
-rw-r--r-- | src/dashboard/testing_utils.py | 3 | ||||
-rw-r--r-- | src/dashboard/utils.py | 2 | ||||
-rw-r--r-- | src/notifier/manager.py | 4 | ||||
-rw-r--r-- | src/resource_inventory/migrations/0013_auto_20200218_1536.py | 5 | ||||
-rw-r--r-- | src/resource_inventory/migrations/0014_auto_20200305_1415.py | 18 | ||||
-rw-r--r-- | src/resource_inventory/models.py | 9 | ||||
-rw-r--r-- | src/resource_inventory/pdf_templater.py | 11 | ||||
-rw-r--r-- | src/resource_inventory/resource_manager.py | 21 | ||||
-rw-r--r-- | src/templates/base/booking/quick_deploy.html | 8 | ||||
-rw-r--r-- | src/workflow/forms.py | 2 |
15 files changed, 195 insertions, 86 deletions
diff --git a/src/api/models.py b/src/api/models.py index de73a7a..e41a44d 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -33,7 +33,7 @@ from resource_inventory.models import ( ) from resource_inventory.idf_templater import IDFTemplater from resource_inventory.pdf_templater import PDFTemplater -from account.models import Downtime +from account.models import Downtime, UserProfile from dashboard.utils import AbstractModelQuery @@ -174,6 +174,26 @@ class LabManager(object): for profile in ResourceProfile.objects.filter(labs=self.lab)] return prof + def format_user(self, userprofile): + return { + "id": userprofile.user.id, + "username": userprofile.user.username, + "email": userprofile.email_addr, + "first_name": userprofile.user.first_name, + "last_name": userprofile.user.last_name, + "company": userprofile.company + } + + def get_users(self): + userlist = [self.format_user(profile) for profile in UserProfile.objects.select_related("user").all()] + + return json.dumps({"users": userlist}) + + def get_user(self, user_id): + profile = get_object_or_404(UserProfile, pk=user_id) + + return json.dumps(self.format_user(profile)) + def get_inventory(self): inventory = {} resources = ResourceQuery.filter(lab=self.lab) @@ -331,7 +351,7 @@ class Job(models.Model): return {"id": self.id, "payload": d} def get_tasklist(self, status="all"): - if status == "all": + if status != "all": return JobTaskQuery.filter(job=self, status=status) return JobTaskQuery.filter(job=self) @@ -408,7 +428,7 @@ class BridgeConfig(models.Model): def to_dict(self): d = {} - hid = self.interfaces.first().host.labid + hid = ResourceQuery.get(interface__pk=self.interfaces.first().pk).labid d[hid] = {} for interface in self.interfaces.all(): d[hid][interface.mac_address] = [] @@ -611,7 +631,7 @@ class HardwareConfig(TaskConfig): def get_delta(self): return self.format_delta( - self.hosthardwarerelation.host.get_configuration(self.state), + self.hosthardwarerelation.get_resource().get_configuration(self.state), self.hosthardwarerelation.lab_token) @@ -623,7 +643,7 @@ class NetworkConfig(TaskConfig): def to_dict(self): d = {} - hid = self.hostnetworkrelation.host.labid + hid = self.hostnetworkrelation.resource_id d[hid] = {} for interface in self.interfaces.all(): d[hid][interface.mac_address] = [] @@ -652,7 +672,7 @@ class NetworkConfig(TaskConfig): def add_interface(self, interface): self.interfaces.add(interface) d = json.loads(self.delta) - hid = self.hostnetworkrelation.host.labid + hid = self.hostnetworkrelation.resource_id if hid not in d: d[hid] = {} d[hid][interface.mac_address] = [] @@ -809,6 +829,9 @@ class HostHardwareRelation(TaskRelation): raise ValidationError("resource_id " + str(self.resource_id) + " does not refer to a single resource") super().save(*args, **kwargs) + def get_resource(self): + return ResourceQuery.get(labid=self.resource_id) + class HostNetworkRelation(TaskRelation): resource_id = models.CharField(max_length=200, default="default_id") @@ -827,6 +850,9 @@ class HostNetworkRelation(TaskRelation): raise ValidationError("resource_id " + str(self.resource_id) + " does not refer to a single resource") super().save(*args, **kwargs) + def get_resource(self): + return ResourceQuery.get(labid=self.resource_id) + class SnapshotRelation(TaskRelation): snapshot = models.ForeignKey(Image, on_delete=models.CASCADE) @@ -941,7 +967,7 @@ class JobFactory(object): for res in resources: hardware_config = None try: - hardware_config = HardwareConfig.objects.get(relation__host=res) + hardware_config = HardwareConfig.objects.get(relation__resource_id=res.labid) except Exception: hardware_config = HardwareConfig() @@ -1011,7 +1037,7 @@ class JobFactory(object): @classmethod def make_bridge_config(cls, booking): - if booking.resource.hosts.count() < 2: + if len(booking.resource.get_resources()) < 2: return None try: jumphost_config = ResourceOPNFVConfig.objects.filter( @@ -1049,7 +1075,7 @@ class JobFactory(object): opnfv_api_config.set_xdf(booking, False) opnfv_api_config.save() - for host in booking.resource.hosts.all(): + for host in booking.resource.get_resources(): 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) diff --git a/src/api/urls.py b/src/api/urls.py index 39f07df..0005d34 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -42,6 +42,8 @@ from api.views import ( lab_host, get_pdf, get_idf, + lab_users, + lab_user, GenerateTokenView ) @@ -59,5 +61,7 @@ urlpatterns = [ path('labs/<slug:lab_name>/jobs/new', new_jobs), path('labs/<slug:lab_name>/jobs/current', current_jobs), path('labs/<slug:lab_name>/jobs/done', done_jobs), + path('labs/<slug:lab_name>/users', lab_users), + path('labs/<slug:lab_name>/users/<int:user_id>', lab_user), url(r'^token$', GenerateTokenView.as_view(), name='generate_token'), ] diff --git a/src/api/views.py b/src/api/views.py index bc01562..4e0d058 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -97,6 +97,16 @@ def lab_status(request, lab_name=""): return JsonResponse(lab_manager.set_status(request.POST), safe=False) return JsonResponse(lab_manager.get_status(), safe=False) +def lab_users(request, lab_name=""): + lab_token = request.META.get('HTTP_AUTH_TOKEN') + lab_manager = LabManagerTracker.get(lab_name, lab_token) + return HttpResponse(lab_manager.get_users(), content_type="text/plain") + +def lab_user(request, lab_name="", user_id=-1): + lab_token = request.META.get('HTTP_AUTH_TOKEN') + lab_manager = LabManagerTracker.get(lab_name, lab_token) + return HttpResponse(lab_manager.get_user(user_id), content_type="text/plain") + @csrf_exempt def update_host_bmc(request, lab_name="", host_id=""): diff --git a/src/booking/quick_deployer.py b/src/booking/quick_deployer.py index 917f578..951ff47 100644 --- a/src/booking/quick_deployer.py +++ b/src/booking/quick_deployer.py @@ -22,6 +22,10 @@ from resource_inventory.models import ( OPNFVRole, OPNFVConfig, ResourceOPNFVConfig, + ResourceConfiguration, + NetworkConnection, + InterfaceConfiguration, + Network, ) from resource_inventory.resource_manager import ResourceManager from resource_inventory.pdf_templater import PDFTemplater @@ -56,13 +60,73 @@ def parse_resource_field(resource_json): return lab, template -def update_template(template, image, lab, hostname): +def update_template(old_template, image, hostname, user): """ - Update and copy a resource template to the user's profile. + Duplicate a template to the users account and update configured fields. - TODO: How, why, should we? + The dashboard presents users with preconfigured resource templates, + but the user might want to make small modifications, e.g hostname and + linux distro. So we copy the public template and create a private version + to the user's profile, and mark it temporary. When the booking ends the + new template is deleted """ - pass + name = user.username + "'s Copy of '" + old_template.name + "'" + num_copies = ResourceTemplate.objects.filter(name__startswith=name).count() + template = ResourceTemplate.objects.create( + name=name if num_copies == 0 else name + " (" + str(num_copies) + ")", + xml=old_template.xml, + owner=user, + lab=old_template.lab, + description=old_template.description, + public=False, + temporary=True + ) + + for old_network in old_template.networks.all(): + Network.objects.create( + name=old_network.name, + bundle=old_template, + is_public=False + ) + # We are assuming there is only one opnfv config per public resource template + old_opnfv = template.opnfv_config.first() + if old_opnfv: + opnfv_config = OPNFVConfig.objects.create( + installer=old_opnfv.installer, + scenario=old_opnfv.installer, + template=template, + name=old_opnfv.installer, + ) + # I am explicitly leaving opnfv_config.networks empty to avoid + # problems with duplicated / shared networks. In the quick deploy, + # there is never multiple networks anyway. This may have to change in the future + + for old_config in old_template.getConfigs(): + config = ResourceConfiguration.objects.create( + profile=old_config.profile, + image=image, + template=template + ) + + for old_iface_config in old_config.interface_configs.all(): + iface_config = InterfaceConfiguration.objects.create( + profile=old_iface_config.profile, + resource_config=config + ) + + for old_connection in old_iface_config.connections.all(): + iface_config.connections.add(NetworkConnection.objects.create( + network=template.networks.get(name=old_connection.network.name), + vlan_is_tagged=old_connection.vlan_is_tagged + )) + + for old_res_opnfv in old_config.resource_opnfv_config.all(): + if old_opnfv: + ResourceOPNFVConfig.objects.create( + role=old_opnfv.role, + resource_config=config, + opnfv_config=opnfv_config + ) def generate_opnfvconfig(scenario, installer, template): @@ -91,7 +155,7 @@ def generate_hostopnfv(hostconfig, opnfvconfig): def generate_resource_bundle(template): resource_manager = ResourceManager.getInstance() - resource_bundle = resource_manager.convertResourceBundle(template) + resource_bundle = resource_manager.instantiateTemplate(template) return resource_bundle @@ -101,7 +165,7 @@ def check_invariants(request, **kwargs): image = kwargs['image'] scenario = kwargs['scenario'] lab = kwargs['lab'] - host_profile = kwargs['host_profile'] + resource_template = kwargs['resource_template'] length = kwargs['length'] # check that image os is compatible with installer if installer in image.os.sup_installers.all(): @@ -112,8 +176,9 @@ def check_invariants(request, **kwargs): raise ValidationError("The chosen installer does not support the chosen scenario") if image.from_lab != lab: raise ValidationError("The chosen image is not available at the chosen hosting lab") - if image.host_type != host_profile: - raise ValidationError("The chosen image is not available for the chosen host type") + #TODO + #if image.host_type != host_profile: + # raise ValidationError("The chosen image is not available for the chosen host type") if not image.public and image.owner != request.user: raise ValidationError("You are not the owner of the chosen private image") if length < 1 or length > 21: @@ -152,7 +217,7 @@ def create_from_form(form, request): ResourceManager.getInstance().templateIsReservable(resource_template) - hconf = update_template(resource_template, image, lab, hostname) + hconf = update_template(resource_template, image, hostname, request.user) # if no installer provided, just create blank host opnfv_config = None @@ -213,10 +278,19 @@ def drop_filter(user): for image in images: image_filter[image.id] = { 'lab': 'lab_' + str(image.from_lab.lab_user.id), - 'host_profile': 'host_' + str(image.host_type.id), + 'host_profile': str(image.host_type.id), 'name': image.name } - return {'installer_filter': json.dumps(installer_filter), - 'scenario_filter': json.dumps(scenario_filter), - 'image_filter': json.dumps(image_filter)} + resource_filter = {} + templates = ResourceTemplate.objects.filter(Q(public=True) | Q(owner=user)) + for rt in templates: + profiles = [conf.profile for conf in rt.getConfigs()] + resource_filter["resource_" + str(rt.id)] = [str(p.id) for p in profiles] + + return { + 'installer_filter': json.dumps(installer_filter), + 'scenario_filter': json.dumps(scenario_filter), + 'image_filter': json.dumps(image_filter), + 'resource_profile_map': json.dumps(resource_filter), + } diff --git a/src/dashboard/tasks.py b/src/dashboard/tasks.py index ac4d36f..b980799 100644 --- a/src/dashboard/tasks.py +++ b/src/dashboard/tasks.py @@ -15,43 +15,15 @@ from booking.models import Booking from notifier.manager import NotificationHandler from api.models import Job, JobStatus, SoftwareRelation, HostHardwareRelation, HostNetworkRelation, AccessRelation from resource_inventory.resource_manager import ResourceManager +from resource_inventory.models import ConfigState @shared_task def booking_poll(): - def cleanup_hardware(qs): + def cleanup_resource_task(qs): for hostrelation in qs: - config = hostrelation.config - config.clear_delta() - config.power = "off" - config.save() - hostrelation.status = JobStatus.NEW - hostrelation.save() - - def cleanup_network(qs): - for hostrelation in qs: - network = hostrelation.config - network.interfaces.clear() - host = hostrelation.host - network.clear_delta() - vlans = [] - for interface in host.interfaces.all(): - for vlan in interface.config.all(): - if vlan.public: - try: - host.lab.vlan_manager.release_public_vlan(vlan.vlan_id) - except Exception: # will fail if we already released in this loop - pass - else: - vlans.append(vlan.vlan_id) - - # release all vlans - if len(vlans) > 0: - host.lab.vlan_manager.release_vlans(vlans) - - interface.config.clear() - network.add_interface(interface) - network.save() + hostrelation.config.state = ConfigState.CLEAN + hostrelation.config.save() hostrelation.status = JobStatus.NEW hostrelation.save() @@ -78,8 +50,8 @@ def booking_poll(): if not booking.job.complete: job = booking.job cleanup_software(SoftwareRelation.objects.filter(job=job)) - cleanup_hardware(HostHardwareRelation.objects.filter(job=job)) - cleanup_network(HostNetworkRelation.objects.filter(job=job)) + cleanup_resource_task(HostHardwareRelation.objects.filter(job=job)) + cleanup_resource_task(HostNetworkRelation.objects.filter(job=job)) cleanup_access(AccessRelation.objects.filter(job=job)) job.complete = True job.save() diff --git a/src/dashboard/testing_utils.py b/src/dashboard/testing_utils.py index 506e998..b7272ea 100644 --- a/src/dashboard/testing_utils.py +++ b/src/dashboard/testing_utils.py @@ -277,7 +277,7 @@ def make_os(installers=None, name="test OS"): def make_server(host_profile, lab, labid="test_host", name="test_host", - booked=False, working=True, config=None, template=None, + booked=False, working=True, config=None, bundle=None, model="Model 1", vendor="ACME"): return Server.objects.create( lab=lab, @@ -286,7 +286,6 @@ def make_server(host_profile, lab, labid="test_host", name="test_host", booked=booked, working=working, config=config, - template=template, bundle=bundle, model=model, vendor=vendor diff --git a/src/dashboard/utils.py b/src/dashboard/utils.py index 3d63366..d6b697a 100644 --- a/src/dashboard/utils.py +++ b/src/dashboard/utils.py @@ -34,6 +34,8 @@ class AbstractModelQuery(): for model in cls.model_list: result += list(model.objects.filter(*args, **kwargs)) + return result + @classmethod def get(cls, *args, **kwargs): try: diff --git a/src/notifier/manager.py b/src/notifier/manager.py index a5b7b9a..6d75a79 100644 --- a/src/notifier/manager.py +++ b/src/notifier/manager.py @@ -110,7 +110,7 @@ class NotificationHandler(object): @classmethod def email_booking_over(cls, booking): template_name = "notifier/email_ended.txt" - hostnames = [host.template.resource.name for host in booking.resource.hosts.all()] + hostnames = [host.name for host in booking.resource.getResources()] users = list(booking.collaborators.all()) users.append(booking.owner) for user in users: @@ -134,7 +134,7 @@ class NotificationHandler(object): @classmethod def email_booking_expiring(cls, booking): template_name = "notifier/email_expiring.txt" - hostnames = [host.template.resource.name for host in booking.resource.hosts.all()] + hostnames = [host.name for host in booking.resource.getResources()] users = list(booking.collaborators.all()) users.append(booking.owner) for user in users: diff --git a/src/resource_inventory/migrations/0013_auto_20200218_1536.py b/src/resource_inventory/migrations/0013_auto_20200218_1536.py index 014cb2f..d9dcbd6 100644 --- a/src/resource_inventory/migrations/0013_auto_20200218_1536.py +++ b/src/resource_inventory/migrations/0013_auto_20200218_1536.py @@ -25,7 +25,7 @@ def populate_servers(apps, schema_editor): ResourceProfile = apps.get_model('resource_inventory', 'ResourceProfile') for h in Host.objects.all(): rp = ResourceProfile.objects.get(id=h.profile.id) - Server.objects.create( + server = Server.objects.create( working=h.working, vendor=h.vendor, labid=h.labid, @@ -35,6 +35,9 @@ def populate_servers(apps, schema_editor): profile=rp ) + for iface in h.interfaces.all(): + server.interfaces.add(iface) + def populate_resource_templates(apps, schema_editor): """ diff --git a/src/resource_inventory/migrations/0014_auto_20200305_1415.py b/src/resource_inventory/migrations/0014_auto_20200305_1415.py new file mode 100644 index 0000000..6fcf4a6 --- /dev/null +++ b/src/resource_inventory/migrations/0014_auto_20200305_1415.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2 on 2020-03-05 14:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('resource_inventory', '0013_auto_20200218_1536'), + ] + + operations = [ + migrations.RenameField( + model_name='resourcetemplate', + old_name='hidden', + new_name='temporary', + ), + ] diff --git a/src/resource_inventory/models.py b/src/resource_inventory/models.py index d11f71b..7115ece 100644 --- a/src/resource_inventory/models.py +++ b/src/resource_inventory/models.py @@ -161,7 +161,7 @@ class ResourceTemplate(models.Model): lab = models.ForeignKey(Lab, null=True, on_delete=models.SET_NULL, related_name="resourcetemplates") description = models.CharField(max_length=1000, default="") public = models.BooleanField(default=False) - hidden = models.BooleanField(default=False) + temporary = models.BooleanField(default=False) def getConfigs(self): return list(self.resourceConfigurations.all()) @@ -307,11 +307,12 @@ class Server(Resource): def get_configuration(self, state): ipmi = state == ConfigState.NEW power = "off" if state == ConfigState.CLEAN else "on" + image = self.config.image.lab_id if self.config else "unknown" return { "id": self.labid, - "image": self.config.image.lab_id, - "hostname": self.template.resource.name, + "image": image, + "hostname": self.name, "power": power, "ipmi_create": str(ipmi) } @@ -498,7 +499,7 @@ class Interface(models.Model): profile = models.ForeignKey(InterfaceProfile, on_delete=models.CASCADE) def __str__(self): - return self.mac_address + " on host " + str(self.host) + return self.mac_address + " on host " + str(self.profile.host.name) """ diff --git a/src/resource_inventory/pdf_templater.py b/src/resource_inventory/pdf_templater.py index 6844b09..367ba43 100644 --- a/src/resource_inventory/pdf_templater.py +++ b/src/resource_inventory/pdf_templater.py @@ -101,7 +101,7 @@ class PDFTemplater: returns a dictionary """ host_info = {} - host_info['name'] = host.template.resource.name + host_info['name'] = host.name host_info['node'] = cls.get_pdf_host_node(host) host_info['disks'] = [] for disk in host.profile.storageprofile.all(): @@ -153,13 +153,8 @@ class PDFTemplater: 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['name'] = interface.profile.name + speed = str(int(interface.profile.speed / 1000)) + "gb" iface_info['speed'] = speed return iface_info diff --git a/src/resource_inventory/resource_manager.py b/src/resource_inventory/resource_manager.py index c8b2b05..4310f8c 100644 --- a/src/resource_inventory/resource_manager.py +++ b/src/resource_inventory/resource_manager.py @@ -35,7 +35,7 @@ class ResourceManager: def getAvailableResourceTemplates(self, lab, user): templates = ResourceTemplate.objects.filter(lab=lab) - templates.filter(Q(owner=user) | Q(public=True)) + templates = templates.filter(Q(owner=user) | Q(public=True)).filter(temporary=False) return templates def templateIsReservable(self, resource_template): @@ -65,10 +65,10 @@ class ResourceManager: resource.release() resourceBundle.delete() - def get_vlans(self, genericResourceBundle): + def get_vlans(self, resourceTemplate): networks = {} - vlan_manager = genericResourceBundle.lab.vlan_manager - for network in genericResourceBundle.networks.all(): + vlan_manager = resourceTemplate.lab.vlan_manager + for network in resourceTemplate.networks.all(): if network.is_public: public_net = vlan_manager.get_public_vlan() vlan_manager.reserve_public_vlan(public_net.vlan) @@ -108,12 +108,13 @@ class ResourceManager: return resource_bundle - 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] + def configureNetworking(self, resource, vlan_map): + for physical_interface in resource.interfaces.all(): + iface_config = physical_interface.acts_as + if not iface_config: + continue physical_interface.config.clear() - for connection in generic_interface.connections.all(): + for connection in iface_config.connections.all(): physicalNetwork = PhysicalNetwork.objects.create( vlan_id=vlan_map[connection.network.name], generic_network=connection.network @@ -129,7 +130,7 @@ class ResourceManager: # private interface def acquireHost(self, resource_config): - resources = resource_config.profile.get_resources(lab=resource_config.lab, unreserved=True) + resources = resource_config.profile.get_resources(lab=resource_config.template.lab, unreserved=True) try: resource = resources[0] # TODO: should we randomize and 'load balance' the servers? resource.config = resource_config diff --git a/src/templates/base/booking/quick_deploy.html b/src/templates/base/booking/quick_deploy.html index 42148c8..ad9adf2 100644 --- a/src/templates/base/booking/quick_deploy.html +++ b/src/templates/base/booking/quick_deploy.html @@ -81,17 +81,21 @@ var sup_image_dict = {{image_filter | safe}}; var sup_installer_dict = {{installer_filter | safe}}; var sup_scenario_dict = {{scenario_filter | safe}}; + var resource_profile_map = {{resource_profile_map | safe}}; function imageFilter() { var drop = document.getElementById("id_image"); var lab_pk = get_selected_value("lab"); - var host_pk = get_selected_value("host"); + var host_pk = get_selected_value("resource"); for (const childNode of drop.childNodes) { var image_object = sup_image_dict[childNode.value]; if (image_object) //weed out empty option { - childNode.disabled = !(image_object.host_profile == host_pk && image_object.lab == lab_pk); + const img_at_lab = image_object.lab == lab_pk; + const profiles = resource_profile_map[host_pk]; + const img_in_template = profiles && profiles.indexOf(image_object.host_profile) > -1 + childNode.disabled = !img_at_lab || !img_in_template; } } } diff --git a/src/workflow/forms.py b/src/workflow/forms.py index 37bc390..a8d3413 100644 --- a/src/workflow/forms.py +++ b/src/workflow/forms.py @@ -330,7 +330,7 @@ class FormUtils: 'selectable': true, 'follow': false, 'multiple': multiple_hosts, - 'class': 'host' + 'class': 'resource' } if multiple_hosts: resource_node['values'] = [] # place to store multiple values |