Closes #8081: Allow creating services directly from navigation menu

This commit is contained in:
jeremystretch 2021-12-15 11:55:27 -05:00
parent f43ec7c05d
commit 8dbd3f332b
11 changed files with 51 additions and 63 deletions

View File

@ -7,6 +7,7 @@
* [#7665](https://github.com/netbox-community/netbox/issues/7665) - Add toggle to show only available child prefixes * [#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 * [#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 * [#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 ### Bug Fixes

View File

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

View File

@ -809,6 +809,14 @@ class VLANForm(TenancyForm, CustomFieldModelForm):
class ServiceForm(CustomFieldModelForm): class ServiceForm(CustomFieldModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False
)
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False
)
ports = NumericArrayField( ports = NumericArrayField(
base_field=forms.IntegerField( base_field=forms.IntegerField(
min_value=SERVICE_PORT_MIN, 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." 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( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
required=False required=False
@ -824,7 +841,7 @@ class ServiceForm(CustomFieldModelForm):
class Meta: class Meta:
model = Service model = Service
fields = [ fields = [
'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags',
] ]
help_texts = { help_texts = {
'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " '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(), 'protocol': StaticSelect(),
'ipaddresses': StaticSelectMultiple(), '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 = []

View File

@ -562,18 +562,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
# TODO: Update base class to PrimaryObjectViewTestCase class ServiceTestCase(ViewTestCases.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
):
model = Service model = Service
@classmethod @classmethod

View File

@ -164,6 +164,7 @@ urlpatterns = [
# Services # Services
path('services/', views.ServiceListView.as_view(), name='service_list'), 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/import/', views.ServiceBulkImportView.as_view(), name='service_import'),
path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),

View File

@ -1036,19 +1036,6 @@ class ServiceEditView(generic.ObjectEditView):
model_form = forms.ServiceForm model_form = forms.ServiceForm
template_name = 'ipam/service_edit.html' 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): class ServiceBulkImportView(generic.BulkImportView):
queryset = Service.objects.all() queryset = Service.objects.all()

View File

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

View File

@ -290,7 +290,7 @@
</div> </div>
{% if perms.ipam.add_service %} {% if perms.ipam.add_service %}
<div class="card-footer text-end noprint"> <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 <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Assign Service
</a> </a>
</div> </div>

View File

@ -6,21 +6,33 @@
<div class="row mb-2"> <div class="row mb-2">
<h5 class="offset-sm-3">Service</h5> <h5 class="offset-sm-3">Service</h5>
</div> </div>
{% if obj.device %}
<div class="row mb-3"> <div class="row mb-2">
<label class="col-sm-3 col-form-label text-lg-end">Device</label> <div class="offset-sm-3">
<div class="col"> <ul class="nav nav-pills" role="tablist">
<input class="form-control" value="{{ obj.device }}" disabled /> <li role="presentation" class="nav-item">
</div> <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 %}">
</div> Device
{% else %} </button>
<div class="row mb-3"> </li>
<label class="col-sm-3 col-form-label text-lg-end">Virtual Machine</label> <li role="presentation" class="nav-item">
<div class="col"> <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 %}">
<input class="form-control" value="{{ obj.virtual_machine }}" disabled /> Virtual Machine
</div> </button>
</div> </li>
{% endif %} </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 %} {% render_field form.name %}
<div class="row"> <div class="row">
<label class="col-sm-3 col-form-label text-lg-end">Port(s)</label> <label class="col-sm-3 col-form-label text-lg-end">Port(s)</label>

View File

@ -167,7 +167,7 @@
</div> </div>
{% if perms.ipam.add_service %} {% if perms.ipam.add_service %}
<div class="card-footer text-end noprint"> <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 <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Assign Service
</a> </a>
</div> </div>

View File

@ -1,7 +1,6 @@
from django.urls import path from django.urls import path
from extras.views import ObjectChangeLogView, ObjectJournalView from extras.views import ObjectChangeLogView, ObjectJournalView
from ipam.views import ServiceEditView
from . import views from . import views
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface 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>/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>/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: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 # VM interfaces
path('interfaces/', views.VMInterfaceListView.as_view(), name='vminterface_list'), path('interfaces/', views.VMInterfaceListView.as_view(), name='vminterface_list'),