Merge branch 'develop' into feature

This commit is contained in:
jeremystretch 2021-12-15 13:19:17 -05:00
commit 28f577738a
16 changed files with 66 additions and 69 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 . import views from . import views
from .models import * from .models import *
@ -231,7 +230,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

@ -488,6 +488,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
return { return {
'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}", 'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",
'active_tab': 'ip-ranges', 'active_tab': 'ip-ranges',
'first_available_ip': instance.get_first_available_ip(),
} }
@ -1035,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_object(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

@ -2,13 +2,13 @@
{% if show_assigned or show_available is not None %} {% if show_assigned or show_available is not None %}
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<a href="{{ request.path }}{% querystring request show_assigned='true' show_available='false' %}" class="btn btn-sm {% if show_assigned and not show_available %}btn-primary active{% else %}btn-outline-primary{% endif %}"> <a href="{{ request.path }}{% querystring request show_assigned='true' show_available='false' %}" class="btn btn-sm {% if show_assigned and not show_available %}btn-secondary active{% else %}btn-outline-secondary{% endif %}">
Show Assigned Show Assigned
</a> </a>
<a href="{{ request.path }}{% querystring request show_assigned='false' show_available='true' %}" class="btn btn-sm {% if show_available and not show_assigned %}btn-primary active{% else %}btn-outline-primary{% endif %}"> <a href="{{ request.path }}{% querystring request show_assigned='false' show_available='true' %}" class="btn btn-sm {% if show_available and not show_assigned %}btn-secondary active{% else %}btn-outline-secondary{% endif %}">
Show Available Show Available
</a> </a>
<a href="{{ request.path }}{% querystring request show_assigned='true' show_available='true' %}" class="btn btn-sm {% if show_available and show_assigned %}btn-primary active{% else %}btn-outline-primary{% endif %}"> <a href="{{ request.path }}{% querystring request show_assigned='true' show_available='true' %}" class="btn btn-sm {% if show_available and show_assigned %}btn-secondary active{% else %}btn-outline-secondary{% endif %}">
Show All Show All
</a> </a>
</div> </div>

View File

@ -3,7 +3,7 @@
{% block extra_controls %} {% block extra_controls %}
{% if perms.ipam.add_ipaddress and first_available_ip %} {% if perms.ipam.add_ipaddress and first_available_ip %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}&return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-sm btn-success"> <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}&return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add IP Address <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add IP Address
</a> </a>
{% endif %} {% endif %}

View File

@ -1,6 +1,14 @@
{% extends 'ipam/prefix/base.html' %} {% extends 'ipam/prefix/base.html' %}
{% load helpers %} {% load helpers %}
{% block extra_controls %}
{% if perms.ipam.add_iprange and first_available_ip %}
<a href="{% url 'ipam:iprange_add' %}?start_address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}&return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add IP Range
</a>
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}

View File

@ -4,7 +4,7 @@
{% block extra_controls %} {% block extra_controls %}
{% include 'ipam/inc/toggle_available.html' %} {% include 'ipam/inc/toggle_available.html' %}
{% if perms.ipam.add_prefix and first_available_prefix %} {% if perms.ipam.add_prefix and first_available_prefix %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-success"> <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Prefix <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Prefix
</a> </a>
{% endif %} {% endif %}

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">
<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> </div>
{% else %} <div class="tab-content p-0 border-0">
<div class="row mb-3"> {{ form.initial.device }}
<label class="col-sm-3 col-form-label text-lg-end">Virtual Machine</label> {{ form.initial.virtual_machine }}
<div class="col"> <div class="tab-pane {% if not form.initial.virtual_machine %}active{% endif %}" id="device" role="tabpanel" aria-labeled-by="device_tab">
<input class="form-control" value="{{ obj.virtual_machine }}" disabled /> {% 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>
</div> </div>
{% endif %}
{% 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'),

View File

@ -347,7 +347,7 @@ class VirtualMachineView(generic.ObjectView):
class VirtualMachineInterfacesView(generic.ObjectChildrenView): class VirtualMachineInterfacesView(generic.ObjectChildrenView):
queryset = VirtualMachine.objects.all() queryset = VirtualMachine.objects.all()
child_model = VMInterface child_model = VMInterface
table = tables.VMInterfaceTable table = tables.VirtualMachineVMInterfaceTable
filterset = filtersets.VMInterfaceFilterSet filterset = filtersets.VMInterfaceFilterSet
template_name = 'virtualization/virtualmachine/interfaces.html' template_name = 'virtualization/virtualmachine/interfaces.html'