Przeglądaj źródła

Closes #8081: Allow creating services directly from navigation menu

jeremystretch 4 lat temu
rodzic
commit
8dbd3f332b

+ 1 - 0
docs/release-notes/version-3.1.md

@@ -7,6 +7,7 @@
 * [#7665](https://github.com/netbox-community/netbox/issues/7665) - Add toggle to show only available child prefixes
 * [#8057](https://github.com/netbox-community/netbox/issues/8057) - Dynamic object tables using HTMX
 * [#8080](https://github.com/netbox-community/netbox/issues/8080) - Link to NAT IPs for device/VM primary IPs
+* [#8081](https://github.com/netbox-community/netbox/issues/8081) - Allow creating services directly from navigation menu
 
 ### Bug Fixes
 

+ 0 - 2
netbox/dcim/urls.py

@@ -1,7 +1,6 @@
 from django.urls import path
 
 from extras.views import ObjectChangeLogView, ObjectJournalView
-from ipam.views import ServiceEditView
 from utilities.views import SlugRedirectView
 from . import views
 from .models import *
@@ -233,7 +232,6 @@ urlpatterns = [
     path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
     path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
-    path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
 
     # Console ports
     path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),

+ 18 - 16
netbox/ipam/forms/models.py

@@ -809,6 +809,14 @@ class VLANForm(TenancyForm, CustomFieldModelForm):
 
 
 class ServiceForm(CustomFieldModelForm):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False
+    )
+    virtual_machine = DynamicModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False
+    )
     ports = NumericArrayField(
         base_field=forms.IntegerField(
             min_value=SERVICE_PORT_MIN,
@@ -816,6 +824,15 @@ class ServiceForm(CustomFieldModelForm):
         ),
         help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
     )
+    ipaddresses = DynamicModelMultipleChoiceField(
+        queryset=IPAddress.objects.all(),
+        required=False,
+        label='IP Addresses',
+        query_params={
+            'device_id': '$device',
+            'virtual_machine_id': '$virtual_machine',
+        }
+    )
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
@@ -824,7 +841,7 @@ class ServiceForm(CustomFieldModelForm):
     class Meta:
         model = Service
         fields = [
-            'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags',
+            'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags',
         ]
         help_texts = {
             'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be "
@@ -834,18 +851,3 @@ class ServiceForm(CustomFieldModelForm):
             'protocol': StaticSelect(),
             'ipaddresses': StaticSelectMultiple(),
         }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit IP address choices to those assigned to interfaces of the parent device/VM
-        if self.instance.device:
-            self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
-                interface__in=self.instance.device.vc_interfaces().values_list('id', flat=True)
-            )
-        elif self.instance.virtual_machine:
-            self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
-                vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
-            )
-        else:
-            self.fields['ipaddresses'].choices = []

+ 1 - 12
netbox/ipam/tests/test_views.py

@@ -562,18 +562,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
 
-# TODO: Update base class to PrimaryObjectViewTestCase
-# Blocked by absence of standard creation view
-class ServiceTestCase(
-    ViewTestCases.GetObjectViewTestCase,
-    ViewTestCases.GetObjectChangelogViewTestCase,
-    ViewTestCases.EditObjectViewTestCase,
-    ViewTestCases.DeleteObjectViewTestCase,
-    ViewTestCases.ListObjectsViewTestCase,
-    ViewTestCases.BulkImportObjectsViewTestCase,
-    ViewTestCases.BulkEditObjectsViewTestCase,
-    ViewTestCases.BulkDeleteObjectsViewTestCase
-):
+class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Service
 
     @classmethod

+ 1 - 0
netbox/ipam/urls.py

@@ -164,6 +164,7 @@ urlpatterns = [
 
     # Services
     path('services/', views.ServiceListView.as_view(), name='service_list'),
+    path('services/add/', views.ServiceEditView.as_view(), name='service_add'),
     path('services/import/', views.ServiceBulkImportView.as_view(), name='service_import'),
     path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
     path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),

+ 0 - 13
netbox/ipam/views.py

@@ -1036,19 +1036,6 @@ class ServiceEditView(generic.ObjectEditView):
     model_form = forms.ServiceForm
     template_name = 'ipam/service_edit.html'
 
-    def alter_obj(self, obj, request, url_args, url_kwargs):
-        if 'device' in url_kwargs:
-            obj.device = get_object_or_404(
-                Device.objects.restrict(request.user),
-                pk=url_kwargs['device']
-            )
-        elif 'virtualmachine' in url_kwargs:
-            obj.virtual_machine = get_object_or_404(
-                VirtualMachine.objects.restrict(request.user),
-                pk=url_kwargs['virtualmachine']
-            )
-        return obj
-
 
 class ServiceBulkImportView(generic.BulkImportView):
     queryset = Service.objects.all()

+ 1 - 1
netbox/netbox/navigation_menu.py

@@ -260,7 +260,7 @@ IPAM_MENU = Menu(
             label='Other',
             items=(
                 get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'),
-                get_model_item('ipam', 'service', 'Services', actions=['import']),
+                get_model_item('ipam', 'service', 'Services'),
             ),
         ),
     ),

+ 1 - 1
netbox/templates/dcim/device.html

@@ -290,7 +290,7 @@
                 </div>
                 {% if perms.ipam.add_service %}
                 <div class="card-footer text-end noprint">
-                    <a href="{% url 'dcim:device_service_assign' device=object.pk %}" class="btn btn-sm btn-primary">
+                    <a href="{% url 'ipam:service_add' %}?device={{ object.pk }}" class="btn btn-sm btn-primary">
                         <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Assign Service
                     </a>
                 </div>

+ 27 - 15
netbox/templates/ipam/service_edit.html

@@ -6,21 +6,33 @@
     <div class="row mb-2">
       <h5 class="offset-sm-3">Service</h5>
     </div>
-    {% if obj.device %}
-        <div class="row mb-3">
-            <label class="col-sm-3 col-form-label text-lg-end">Device</label>
-            <div class="col">
-                <input class="form-control" value="{{ obj.device }}" disabled />
-            </div>
-        </div>
-    {% else %}
-        <div class="row mb-3">
-            <label class="col-sm-3 col-form-label text-lg-end">Virtual Machine</label>
-            <div class="col">
-                <input class="form-control" value="{{ obj.virtual_machine }}" disabled />
-            </div>
-        </div>
-    {% endif %}
+
+    <div class="row mb-2">
+      <div class="offset-sm-3">
+        <ul class="nav nav-pills" role="tablist">
+          <li role="presentation" class="nav-item">
+            <button role="tab" type="button" id="device_tab" data-bs-toggle="tab" aria-controls="device" data-bs-target="#device" class="nav-link {% if not form.initial.virtual_machine %}active{% endif %}">
+              Device
+            </button>
+          </li>
+          <li role="presentation" class="nav-item">
+            <button role="tab" type="button" id="vm_tab" data-bs-toggle="tab" aria-controls="vm" data-bs-target="#vm" class="nav-link {% if form.initial.virtual_machine %}active{% endif %}">
+              Virtual Machine
+            </button>
+          </li>
+        </ul>
+      </div>
+    </div>
+    <div class="tab-content p-0 border-0">
+    {{ form.initial.device }}
+    {{ form.initial.virtual_machine }}
+      <div class="tab-pane {% if not form.initial.virtual_machine %}active{% endif %}" id="device" role="tabpanel" aria-labeled-by="device_tab">
+        {% render_field form.device %}
+      </div>
+      <div class="tab-pane {% if form.initial.virtual_machine %}active{% endif %}" id="vm" role="tabpanel" aria-labeled-by="vm_tab">
+        {% render_field form.virtual_machine %}
+      </div>
+    </div>
     {% render_field form.name %}
     <div class="row">
         <label class="col-sm-3 col-form-label text-lg-end">Port(s)</label>

+ 1 - 1
netbox/templates/virtualization/virtualmachine.html

@@ -167,7 +167,7 @@
             </div>
             {% if perms.ipam.add_service %}
                 <div class="card-footer text-end noprint">
-                    <a href="{% url 'virtualization:virtualmachine_service_assign' virtualmachine=object.pk %}" class="btn btn-sm btn-primary">
+                    <a href="{% url 'ipam:service_add' %}?virtual_machine={{ object.pk }}" class="btn btn-sm btn-primary">
                         <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Assign Service
                     </a>
                 </div>

+ 0 - 2
netbox/virtualization/urls.py

@@ -1,7 +1,6 @@
 from django.urls import path
 
 from extras.views import ObjectChangeLogView, ObjectJournalView
-from ipam.views import ServiceEditView
 from . import views
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
@@ -59,7 +58,6 @@ urlpatterns = [
     path('virtual-machines/<int:pk>/config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
     path('virtual-machines/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
     path('virtual-machines/<int:pk>/journal/', ObjectJournalView.as_view(), name='virtualmachine_journal', kwargs={'model': VirtualMachine}),
-    path('virtual-machines/<int:virtualmachine>/services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'),
 
     # VM interfaces
     path('interfaces/', views.VMInterfaceListView.as_view(), name='vminterface_list'),