Merge branch 'develop' into art-10070

This commit is contained in:
jeremystretch 2022-08-24 17:00:59 -04:00
commit 77278d1835
41 changed files with 269 additions and 55 deletions

View File

@ -4,12 +4,28 @@
### Enhancements
* [#6454](https://github.com/netbox-community/netbox/issues/6454) - Include contextual help when creating first objects in UI
* [#9935](https://github.com/netbox-community/netbox/issues/9935) - Add 802.11ay and "other" wireless interface types
* [#10031](https://github.com/netbox-community/netbox/issues/10031) - Enforce `application/json` content type for REST API requests
* [#10033](https://github.com/netbox-community/netbox/issues/10033) - Disable "add termination" button for point-to-point L2VPNs with two terminations
* [#10037](https://github.com/netbox-community/netbox/issues/10037) - Add link to create child interface to interface context menu
* [#10061](https://github.com/netbox-community/netbox/issues/10061) - Replicate type when cloning L2VPN instances
* [#10066](https://github.com/netbox-community/netbox/issues/10066) - Use fixed column widths for custom field values in UI
* [#10133](https://github.com/netbox-community/netbox/issues/10133) - Enable nullifying device location during bulk edit
### Bug Fixes
* [#10040](https://github.com/netbox-community/netbox/issues/10040) - Fix exception when ordering prefixes by flat representation
* [#10053](https://github.com/netbox-community/netbox/issues/10053) - Custom fields header should not be displayed when editing circuit terminations with no custom fields
* [#10055](https://github.com/netbox-community/netbox/issues/10055) - Fix extraneous NAT indicator by device primary IP
* [#10057](https://github.com/netbox-community/netbox/issues/10057) - Fix AttributeError exception when global search results include rack reservations
* [#10059](https://github.com/netbox-community/netbox/issues/10059) - Add identifier column to L2VPN table
* [#10089](https://github.com/netbox-community/netbox/issues/10089) - `linkify` template filter should escape object representation
* [#10094](https://github.com/netbox-community/netbox/issues/10094) - Fix 404 when using "create and add another" to add contact assignments
* [#10108](https://github.com/netbox-community/netbox/issues/10108) - Linkify inside NAT IPs for primary device IPs in UI
* [#10109](https://github.com/netbox-community/netbox/issues/10109) - Fix available prefixes calculation for container prefixes in the global table
* [#10111](https://github.com/netbox-community/netbox/issues/10111) - Wrap search QS to catch ValueError on identifier field
* [#10134](https://github.com/netbox-community/netbox/issues/10134) - Custom fields data serializer should return a 400 response for invalid data
---

View File

@ -1,3 +1,4 @@
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
@ -136,6 +137,10 @@ class Circuit(NetBoxModel):
def __str__(self):
return self.cid
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('circuits.Provider'), CircuitType]
def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk])

View File

@ -790,7 +790,9 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_80211AC = 'ieee802.11ac'
TYPE_80211AD = 'ieee802.11ad'
TYPE_80211AX = 'ieee802.11ax'
TYPE_80211AY = 'ieee802.11ay'
TYPE_802151 = 'ieee802.15.1'
TYPE_OTHER_WIRELESS = 'other-wireless'
# Cellular
TYPE_GSM = 'gsm'
@ -918,7 +920,9 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_80211AC, 'IEEE 802.11ac'),
(TYPE_80211AD, 'IEEE 802.11ad'),
(TYPE_80211AX, 'IEEE 802.11ax'),
(TYPE_80211AY, 'IEEE 802.11ay'),
(TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
(TYPE_OTHER_WIRELESS, 'Other (Wireless)'),
)
),
(

View File

@ -45,6 +45,9 @@ WIRELESS_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_80211AC,
InterfaceTypeChoices.TYPE_80211AD,
InterfaceTypeChoices.TYPE_80211AX,
InterfaceTypeChoices.TYPE_80211AY,
InterfaceTypeChoices.TYPE_802151,
InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
]
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES

View File

@ -480,7 +480,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')),
)
nullable_fields = (
'tenant', 'platform', 'serial', 'airflow',
'location', 'tenant', 'platform', 'serial', 'airflow',
)

View File

@ -1,6 +1,8 @@
import decimal
import yaml
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@ -159,6 +161,10 @@ class DeviceType(NetBoxModel):
self._original_front_image = self.front_image
self._original_rear_image = self.rear_image
@classmethod
def get_prerequisite_models(cls):
return [Manufacturer, ]
def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk])
@ -338,6 +344,10 @@ class ModuleType(NetBoxModel):
def __str__(self):
return self.model
@classmethod
def get_prerequisite_models(cls):
return [Manufacturer, ]
def get_absolute_url(self):
return reverse('dcim:moduletype', args=[self.pk])
@ -658,6 +668,10 @@ class Device(NetBoxModel, ConfigContextModel):
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
return super().__str__()
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), DeviceRole, DeviceType, ]
def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk])

View File

@ -1,3 +1,4 @@
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@ -54,6 +55,10 @@ class PowerPanel(NetBoxModel):
def __str__(self):
return self.name
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), ]
def get_absolute_url(self):
return reverse('dcim:powerpanel', args=[self.pk])
@ -138,6 +143,10 @@ class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
def __str__(self):
return self.name
@classmethod
def get_prerequisite_models(cls):
return [PowerPanel, ]
def get_absolute_url(self):
return reverse('dcim:powerfeed', args=[self.pk])

View File

@ -1,5 +1,6 @@
import decimal
from django.apps import apps
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
@ -201,6 +202,10 @@ class Rack(NetBoxModel):
return f'{self.name} ({self.facility_id})'
return self.name
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), ]
def get_absolute_url(self):
return reverse('dcim:rack', args=[self.pk])
@ -477,6 +482,10 @@ class RackReservation(NetBoxModel):
def __str__(self):
return "Reservation for rack {}".format(self.rack)
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), Rack, ]
def get_absolute_url(self):
return reverse('dcim:rackreservation', args=[self.pk])

View File

@ -411,6 +411,10 @@ class Location(NestedGroupModel):
super().validate_unique(exclude=exclude)
@classmethod
def get_prerequisite_models(cls):
return [Site, ]
def get_absolute_url(self):
return reverse('dcim:location', args=[self.pk])

View File

@ -238,6 +238,9 @@ INTERFACE_BUTTONS = """
{% if perms.dcim.add_inventoryitem %}
<li><a class="dropdown-item" href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Inventory Item</a></li>
{% endif %}
{% if perms.dcim.add_interface %}
<li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ record.device_id }}&parent={{ record.pk }}&name_pattern={{ record.name }}.&type=virtual&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Child Interface</a></li>
{% endif %}
</ul>
</span>
{% endif %}

View File

@ -1,5 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from rest_framework.fields import Field
from rest_framework.serializers import ValidationError
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
@ -62,6 +63,12 @@ class CustomFieldsDataField(Field):
return data
def to_internal_value(self, data):
if type(data) is not dict:
raise ValidationError(
"Invalid data format. Custom field data must be passed as a dictionary mapping field names to their "
"values."
)
# If updating an existing instance, start with existing custom_field_data
if self.parent.instance:
data = {**self.parent.instance.custom_field_data, **data}

View File

@ -965,7 +965,11 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(identifier=value) | Q(name__icontains=value) | Q(description__icontains=value)
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
try:
qs_filter |= Q(identifier=int(value))
except ValueError:
pass
return queryset.filter(qs_filter)
@ -1071,6 +1075,12 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
qs_filter = Q(l2vpn__name__icontains=value)
return queryset.filter(qs_filter)
def filter_assigned_object(self, queryset, name, value):
qs = queryset.filter(
Q(**{'{}__in'.format(name): value})
)
return qs
def filter_site(self, queryset, name, value):
qs = queryset.filter(
Q(

View File

@ -35,13 +35,16 @@ class GetAvailablePrefixesMixin:
def get_available_prefixes(self):
"""
Return all available Prefixes within this aggregate as an IPSet.
Return all available prefixes within this Aggregate or Prefix as an IPSet.
"""
prefix = netaddr.IPSet(self.prefix)
child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()])
available_prefixes = prefix - child_prefixes
params = {
'prefix__net_contained': str(self.prefix)
}
if hasattr(self, 'vrf'):
params['vrf'] = self.vrf
return available_prefixes
child_prefixes = Prefix.objects.filter(**params).values_list('prefix', flat=True)
return netaddr.IPSet(self.prefix) - netaddr.IPSet(child_prefixes)
def get_first_available_prefix(self):
"""
@ -124,6 +127,10 @@ class ASN(NetBoxModel):
def __str__(self):
return f'AS{self.asn_with_asdot}'
@classmethod
def get_prerequisite_models(cls):
return [RIR, ]
def get_absolute_url(self):
return reverse('ipam:asn', args=[self.pk])
@ -185,6 +192,10 @@ class Aggregate(GetAvailablePrefixesMixin, NetBoxModel):
def __str__(self):
return str(self.prefix)
@classmethod
def get_prerequisite_models(cls):
return [RIR, ]
def get_absolute_url(self):
return reverse('ipam:aggregate', args=[self.pk])

View File

@ -1,8 +1,10 @@
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.functional import cached_property
from ipam.choices import L2VPNTypeChoices
from ipam.constants import L2VPN_ASSIGNMENT_MODELS
@ -70,6 +72,13 @@ class L2VPN(NetBoxModel):
def get_absolute_url(self):
return reverse('ipam:l2vpn', args=[self.pk])
@cached_property
def can_add_termination(self):
if self.type in L2VPNTypeChoices.P2P and self.terminations.count() >= 2:
return False
else:
return True
class L2VPNTermination(NetBoxModel):
l2vpn = models.ForeignKey(
@ -106,6 +115,10 @@ class L2VPNTermination(NetBoxModel):
return f'{self.assigned_object} <> {self.l2vpn}'
return super().__str__()
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('ipam.L2VPN'), ]
def get_absolute_url(self):
return reverse('ipam:l2vpntermination', args=[self.pk])

View File

@ -360,8 +360,8 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
orderable=False,
verbose_name='NAT (Inside)'
)
nat_outside = tables.Column(
linkify=True,
nat_outside = tables.ManyToManyColumn(
linkify_item=True,
orderable=False,
verbose_name='NAT (Outside)'
)

View File

@ -33,10 +33,10 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = L2VPN
fields = (
'pk', 'name', 'slug', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'tenant_group',
'pk', 'name', 'slug', 'identifier', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'tenant_group',
'actions',
)
default_columns = ('pk', 'name', 'type', 'description', 'actions')
default_columns = ('pk', 'name', 'identifier', 'type', 'description', 'actions')
class L2VPNTerminationTable(NetBoxTable):

View File

@ -390,7 +390,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
self.assertEqual(response.data['description'], data['description'])
# Try to create one more IP
response = self.client.post(url, {}, **self.header)
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data)
@ -487,7 +487,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
self.assertEqual(response.data['description'], data['description'])
# Try to create one more IP
response = self.client.post(url, {}, **self.header)
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data)
@ -973,9 +973,9 @@ class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase):
VLAN.objects.bulk_create(vlans)
l2vpns = (
L2VPN(name='L2VPN 1', type='vxlan', identifier=650001),
L2VPN(name='L2VPN 2', type='vpws', identifier=650002),
L2VPN(name='L2VPN 3', type='vpls'), # No RD
L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD
)
L2VPN.objects.bulk_create(l2vpns)

View File

@ -1485,9 +1485,9 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
RouteTarget.objects.bulk_create(route_targets)
l2vpns = (
L2VPN(name='L2VPN 1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001),
L2VPN(name='L2VPN 2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002),
L2VPN(name='L2VPN 3', type=L2VPNTypeChoices.TYPE_VPLS),
L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001),
L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002),
L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VPLS),
)
L2VPN.objects.bulk_create(l2vpns)
l2vpns[0].import_targets.add(route_targets[0])

View File

@ -581,9 +581,9 @@ class TestL2VPNTermination(TestCase):
VLAN.objects.bulk_create(vlans)
l2vpns = (
L2VPN(name='L2VPN 1', type='vxlan', identifier=650001),
L2VPN(name='L2VPN 2', type='vpws', identifier=650002),
L2VPN(name='L2VPN 3', type='vpls'), # No RD
L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD
)
L2VPN.objects.bulk_create(l2vpns)

View File

@ -28,6 +28,14 @@ class NetBoxFeatureSet(
class Meta:
abstract = True
@classmethod
def get_prerequisite_models(cls):
"""
Return a list of model types that are required to create this model or empty list if none. This is used for
showing prequisite warnings in the UI on the list and detail views.
"""
return []
#
# Base model classes

View File

@ -62,7 +62,7 @@ DCIM_TYPES = {
'url': 'dcim:rack_list',
},
'rackreservation': {
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
'queryset': RackReservation.objects.prefetch_related('rack', 'user'),
'filterset': dcim.filtersets.RackReservationFilterSet,
'table': dcim.tables.RackReservationTable,
'url': 'dcim:rackreservation_list',

View File

@ -533,6 +533,9 @@ REST_FRAMEWORK = {
),
'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata',
'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination',
'DEFAULT_PARSER_CLASSES': (
'rest_framework.parsers.JSONParser',
),
'DEFAULT_PERMISSION_CLASSES': (
'netbox.api.authentication.TokenPermissions',
),
@ -542,7 +545,6 @@ REST_FRAMEWORK = {
),
'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
# 'PAGE_SIZE': PAGINATE_COUNT,
'SCHEMA_COERCE_METHOD_NAMES': {
# Default mappings
'retrieve': 'read',

View File

@ -26,6 +26,7 @@ from utilities.permissions import get_permission_for_model
from utilities.views import GetReturnURLMixin
from .base import BaseMultiObjectView
from .mixins import ActionsMixin, TableMixin
from .utils import get_prerequisite_model
__all__ = (
'BulkComponentCreateView',
@ -165,13 +166,16 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
'table': table,
})
return render(request, self.template_name, {
context = {
'model': model,
'table': table,
'actions': actions,
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
'prerequisite_model': get_prerequisite_model(self.queryset),
**self.get_extra_context(request),
})
}
return render(request, self.template_name, context)
class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):

View File

@ -21,6 +21,7 @@ from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fi
from utilities.views import GetReturnURLMixin
from .base import BaseObjectView
from .mixins import ActionsMixin, TableMixin
from .utils import get_prerequisite_model
__all__ = (
'ComponentCreateView',
@ -327,6 +328,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
"""
return obj
def get_extra_addanother_params(self, request):
"""
Return a dictionary of extra parameters to use on the Add Another button.
"""
return {}
#
# Request handlers
#
@ -340,15 +347,18 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
"""
obj = self.get_object(**kwargs)
obj = self.alter_object(obj, request, args, kwargs)
model = self.queryset.model
initial_data = normalize_querydict(request.GET)
form = self.form(instance=obj, initial=initial_data)
restrict_form_fields(form, request.user)
return render(request, self.template_name, {
'model': model,
'object': obj,
'form': form,
'return_url': self.get_return_url(request, obj),
'prerequisite_model': get_prerequisite_model(self.queryset),
**self.get_extra_context(request, obj),
})
@ -399,6 +409,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
# If cloning is supported, pre-populate a new instance of the form
params = prepare_cloned_fields(obj)
params.update(self.get_extra_addanother_params(request))
if params:
if 'return_url' in request.GET:
params['return_url'] = request.GET.get('return_url')

View File

@ -0,0 +1,12 @@
def get_prerequisite_model(queryset):
model = queryset.model
if not queryset.exists():
if hasattr(model, 'get_prerequisite_models'):
prerequisites = model.get_prerequisite_models()
if prerequisites:
for prereq in prerequisites:
if not prereq.objects.exists():
return prereq
return None

View File

@ -155,9 +155,7 @@
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Management
</h5>
<h5 class="card-header">Management</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
@ -178,9 +176,9 @@
{% if object.primary_ip4 %}
<a href="{{ object.primary_ip4.get_absolute_url }}">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %}
(NAT for {{ object.primary_ip4.nat_inside.address.ip|linkify }})
{% elif object.primary_ip4.nat_outside %}
(NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }})
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
{% elif object.primary_ip4.nat_outside.exists %}
(NAT for {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% else %}
{{ ''|placeholder }}
@ -193,9 +191,9 @@
{% if object.primary_ip6 %}
<a href="{{ object.primary_ip6.get_absolute_url }}">{{ object.primary_ip6.address.ip }}</a>
{% if object.primary_ip6.nat_inside %}
(NAT for {{ object.primary_ip6.nat_inside.address.ip|linkify }})
{% elif object.primary_ip6.nat_outside %}
(NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }})
(NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
{% elif object.primary_ip6.nat_outside.exists %}
(NAT for {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% else %}
{{ ''|placeholder }}

View File

@ -74,7 +74,7 @@
<td>{{ object.get_poe_mode_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">PoE Mode</th>
<th scope="row">PoE Type</th>
<td>{{ object.get_poe_type_display|placeholder }}</td>
</tr>
<tr>

View File

@ -40,6 +40,10 @@ Context:
</div>
{% endif %}
{% if prerequisite_model %}
{% include 'inc/missing_prerequisites.html' %}
{% endif %}
<form action="" method="post" enctype="multipart/form-data" class="form-object-edit mt-5">
{% csrf_token %}

View File

@ -100,6 +100,11 @@ Context:
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
{# Object table #}
{% if prerequisite_model %}
{% include 'inc/missing_prerequisites.html' %}
{% endif %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}

View File

@ -0,0 +1,6 @@
{% load buttons %}
<div class="alert alert-warning" role="alert">
<i class="mdi mdi-alert"></i> Before you can add a {{ model|meta:"verbose_name" }} you must first create a
<strong>{{ prerequisite_model|meta:"verbose_name"|title }}</strong> which can be added here: {% add_button prerequisite_model %}
</div>

View File

@ -12,9 +12,9 @@
<table class="table table-hover attr-table">
{% for field, value in fields.items %}
<tr>
<td>
<th scope="row">
<span title="{{ field.description|escape }}">{{ field }}</span>
</td>
</th>
<td>
{% customfield_value field value %}
</td>

View File

@ -91,10 +91,13 @@
</td>
</tr>
<tr>
<th scope="row">Outside NAT IPs</th>
<th scope="row">NAT (Outside)</th>
<td>
{% for ip in object.nat_outside.all %}
{{ ip|linkify }}<br/>
{{ ip|linkify }}
{% if ip.assigned_object %}
({{ ip.assigned_object.parent_object|linkify }})
{% endif %}<br/>
{% empty %}
{{ ''|placeholder }}
{% endfor %}

View File

@ -59,7 +59,7 @@
</div>
{% if perms.ipam.add_l2vpntermination %}
<div class="card-footer text-end noprint">
<a href="{% url 'ipam:l2vpntermination_add' %}?l2vpn={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
<a href="{% url 'ipam:l2vpntermination_add' %}?l2vpn={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm{% if not object.can_add_termination %} disabled" aria-disabled="true{% endif %}">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a Termination
</a>
</div>

View File

@ -45,8 +45,8 @@
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %}
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
{% elif object.primary_ip4.nat_outside %}
(NAT: <a href="{{ object.primary_ip4.nat_outside.get_absolute_url }}">{{ object.primary_ip4.nat_outside.address.ip }}</a>)
{% elif object.primary_ip4.nat_outside.exists %}
(NAT for {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% else %}
{{ ''|placeholder }}
@ -60,8 +60,8 @@
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}">{{ object.primary_ip6.address.ip }}</a>
{% if object.primary_ip6.nat_inside %}
(NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
{% elif object.primary_ip6.nat_outside %}
(NAT: <a href="{{ object.primary_ip6.nat_outside.get_absolute_url }}">{{ object.primary_ip6.nat_outside.address.ip }}</a>)
{% elif object.primary_ip6.nat_outside.exists %}
(NAT for {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% else %}
{{ ''|placeholder }}

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.http import QueryDict
from django.shortcuts import get_object_or_404
from circuits.models import Circuit
@ -365,6 +366,12 @@ class ContactAssignmentEditView(generic.ObjectEditView):
instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
return instance
def get_extra_addanother_params(self, request):
return {
'content_type': request.GET.get('content_type'),
'object_id': request.GET.get('object_id'),
}
class ContactAssignmentDeleteView(generic.ObjectDeleteView):
queryset = ContactAssignment.objects.all()

View File

@ -124,7 +124,7 @@ class TokenTest(
user = User.objects.create_user(**data)
url = reverse('users-api:token_provision')
response = self.client.post(url, **self.header, data=data)
response = self.client.post(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 201)
self.assertIn('key', response.data)
self.assertEqual(len(response.data['key']), 40)
@ -141,7 +141,7 @@ class TokenTest(
}
url = reverse('users-api:token_provision')
response = self.client.post(url, **self.header, data=data)
response = self.client.post(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 403)

View File

@ -5,7 +5,7 @@ import re
import yaml
from django import template
from django.contrib.contenttypes.models import ContentType
from django.utils.html import strip_tags
from django.utils.html import escape
from django.utils.safestring import mark_safe
from markdown import markdown
@ -35,7 +35,7 @@ def linkify(instance, attr=None):
text = getattr(instance, attr) if attr is not None else str(instance)
try:
url = instance.get_absolute_url()
return mark_safe(f'<a href="{url}">{text}</a>')
return mark_safe(f'<a href="{url}">{escape(text)}</a>')
except (AttributeError, TypeError):
return text

View File

@ -285,7 +285,7 @@ def prepare_cloned_fields(instance):
"""
# Generate the clone attributes from the instance
if not hasattr(instance, 'clone'):
return QueryDict()
return QueryDict(mutable=True)
attrs = instance.clone()
# Prepare querydict parameters

View File

@ -167,6 +167,10 @@ class Cluster(NetBoxModel):
def __str__(self):
return self.name
@classmethod
def get_prerequisite_models(cls):
return [ClusterType, ]
def get_absolute_url(self):
return reverse('virtualization:cluster', args=[self.pk])
@ -312,6 +316,10 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
def __str__(self):
return self.name
@classmethod
def get_prerequisite_models(cls):
return [Cluster, ]
def get_absolute_url(self):
return reverse('virtualization:virtualmachine', args=[self.pk])

View File

@ -0,0 +1,24 @@
from django.db import migrations, models
import django.db.models.deletion
import wireless.models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0161_cabling_cleanup'),
('wireless', '0004_wireless_tenancy'),
]
operations = [
migrations.AlterField(
model_name='wirelesslink',
name='interface_a',
field=models.ForeignKey(limit_choices_to=wireless.models.get_wireless_interface_types, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface'),
),
migrations.AlterField(
model_name='wirelesslink',
name='interface_b',
field=models.ForeignKey(limit_choices_to=wireless.models.get_wireless_interface_types, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface'),
),
]

View File

@ -1,3 +1,4 @@
from django.apps import apps
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
@ -23,7 +24,8 @@ class WirelessAuthenticationBase(models.Model):
auth_type = models.CharField(
max_length=50,
choices=WirelessAuthTypeChoices,
blank=True
blank=True,
verbose_name="Auth Type",
)
auth_cipher = models.CharField(
max_length=50,
@ -126,21 +128,29 @@ class WirelessLAN(WirelessAuthenticationBase, NetBoxModel):
return reverse('wireless:wirelesslan', args=[self.pk])
def get_wireless_interface_types():
# Wrap choices in a callable to avoid generating dummy migrations
# when the choices are updated.
return {'type__in': WIRELESS_IFACE_TYPES}
class WirelessLink(WirelessAuthenticationBase, NetBoxModel):
"""
A point-to-point connection between two wireless Interfaces.
"""
interface_a = models.ForeignKey(
to='dcim.Interface',
limit_choices_to={'type__in': WIRELESS_IFACE_TYPES},
limit_choices_to=get_wireless_interface_types,
on_delete=models.PROTECT,
related_name='+'
related_name='+',
verbose_name="Interface A",
)
interface_b = models.ForeignKey(
to='dcim.Interface',
limit_choices_to={'type__in': WIRELESS_IFACE_TYPES},
limit_choices_to=get_wireless_interface_types,
on_delete=models.PROTECT,
related_name='+'
related_name='+',
verbose_name="Interface B",
)
ssid = models.CharField(
max_length=SSID_MAX_LENGTH,
@ -190,6 +200,10 @@ class WirelessLink(WirelessAuthenticationBase, NetBoxModel):
def __str__(self):
return f'#{self.pk}'
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Interface'), ]
def get_absolute_url(self):
return reverse('wireless:wirelesslink', args=[self.pk])