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
* [#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

View File

@ -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 *
@ -231,7 +230,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'),

View File

@ -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 = []

View File

@ -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

View File

@ -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'),

View File

@ -488,6 +488,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
return {
'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",
'active_tab': 'ip-ranges',
'first_available_ip': instance.get_first_available_ip(),
}
@ -1035,19 +1036,6 @@ class ServiceEditView(generic.ObjectEditView):
model_form = forms.ServiceForm
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):
queryset = Service.objects.all()

View File

@ -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'),
),
),
),

View File

@ -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>

View File

@ -2,13 +2,13 @@
{% if show_assigned or show_available is not None %}
<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
</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
</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
</a>
</div>

View File

@ -3,7 +3,7 @@
{% block extra_controls %}
{% 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
</a>
{% endif %}

View File

@ -1,6 +1,14 @@
{% extends 'ipam/prefix/base.html' %}
{% 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 %}
<form method="post">
{% csrf_token %}

View File

@ -4,7 +4,7 @@
{% block extra_controls %}
{% include 'ipam/inc/toggle_available.html' %}
{% 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
</a>
{% endif %}

View File

@ -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>

View File

@ -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>

View File

@ -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'),

View File

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