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 ### 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 * [#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 ### Bug Fixes
* [#10040](https://github.com/netbox-community/netbox/issues/10040) - Fix exception when ordering prefixes by flat representation * [#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 * [#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.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@ -136,6 +137,10 @@ class Circuit(NetBoxModel):
def __str__(self): def __str__(self):
return self.cid return self.cid
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('circuits.Provider'), CircuitType]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk]) return reverse('circuits:circuit', args=[self.pk])

View File

@ -790,7 +790,9 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_80211AC = 'ieee802.11ac' TYPE_80211AC = 'ieee802.11ac'
TYPE_80211AD = 'ieee802.11ad' TYPE_80211AD = 'ieee802.11ad'
TYPE_80211AX = 'ieee802.11ax' TYPE_80211AX = 'ieee802.11ax'
TYPE_80211AY = 'ieee802.11ay'
TYPE_802151 = 'ieee802.15.1' TYPE_802151 = 'ieee802.15.1'
TYPE_OTHER_WIRELESS = 'other-wireless'
# Cellular # Cellular
TYPE_GSM = 'gsm' TYPE_GSM = 'gsm'
@ -918,7 +920,9 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_80211AC, 'IEEE 802.11ac'), (TYPE_80211AC, 'IEEE 802.11ac'),
(TYPE_80211AD, 'IEEE 802.11ad'), (TYPE_80211AD, 'IEEE 802.11ad'),
(TYPE_80211AX, 'IEEE 802.11ax'), (TYPE_80211AX, 'IEEE 802.11ax'),
(TYPE_80211AY, 'IEEE 802.11ay'),
(TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'), (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_80211AC,
InterfaceTypeChoices.TYPE_80211AD, InterfaceTypeChoices.TYPE_80211AD,
InterfaceTypeChoices.TYPE_80211AX, InterfaceTypeChoices.TYPE_80211AX,
InterfaceTypeChoices.TYPE_80211AY,
InterfaceTypeChoices.TYPE_802151,
InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
] ]
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES

View File

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

View File

@ -1,6 +1,8 @@
import decimal import decimal
import yaml import yaml
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@ -159,6 +161,10 @@ class DeviceType(NetBoxModel):
self._original_front_image = self.front_image self._original_front_image = self.front_image
self._original_rear_image = self.rear_image self._original_rear_image = self.rear_image
@classmethod
def get_prerequisite_models(cls):
return [Manufacturer, ]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk]) return reverse('dcim:devicetype', args=[self.pk])
@ -338,6 +344,10 @@ class ModuleType(NetBoxModel):
def __str__(self): def __str__(self):
return self.model return self.model
@classmethod
def get_prerequisite_models(cls):
return [Manufacturer, ]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:moduletype', args=[self.pk]) 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 f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
return super().__str__() return super().__str__()
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), DeviceRole, DeviceType, ]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk]) 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.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@ -54,6 +55,10 @@ class PowerPanel(NetBoxModel):
def __str__(self): def __str__(self):
return self.name return self.name
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), ]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:powerpanel', args=[self.pk]) return reverse('dcim:powerpanel', args=[self.pk])
@ -138,6 +143,10 @@ class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
def __str__(self): def __str__(self):
return self.name return self.name
@classmethod
def get_prerequisite_models(cls):
return [PowerPanel, ]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:powerfeed', args=[self.pk]) return reverse('dcim:powerfeed', args=[self.pk])

View File

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

View File

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

View File

@ -238,6 +238,9 @@ INTERFACE_BUTTONS = """
{% if perms.dcim.add_inventoryitem %} {% 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> <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 %} {% 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> </ul>
</span> </span>
{% endif %} {% endif %}

View File

@ -1,5 +1,6 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from rest_framework.fields import Field from rest_framework.fields import Field
from rest_framework.serializers import ValidationError
from extras.choices import CustomFieldTypeChoices from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField from extras.models import CustomField
@ -62,6 +63,12 @@ class CustomFieldsDataField(Field):
return data return data
def to_internal_value(self, 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 updating an existing instance, start with existing custom_field_data
if self.parent.instance: if self.parent.instance:
data = {**self.parent.instance.custom_field_data, **data} data = {**self.parent.instance.custom_field_data, **data}

View File

@ -965,7 +965,11 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset 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) return queryset.filter(qs_filter)
@ -1071,6 +1075,12 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
qs_filter = Q(l2vpn__name__icontains=value) qs_filter = Q(l2vpn__name__icontains=value)
return queryset.filter(qs_filter) 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): def filter_site(self, queryset, name, value):
qs = queryset.filter( qs = queryset.filter(
Q( Q(

View File

@ -35,13 +35,16 @@ class GetAvailablePrefixesMixin:
def get_available_prefixes(self): 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) params = {
child_prefixes = netaddr.IPSet([child.prefix for child in self.get_child_prefixes()]) 'prefix__net_contained': str(self.prefix)
available_prefixes = prefix - child_prefixes }
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): def get_first_available_prefix(self):
""" """
@ -124,6 +127,10 @@ class ASN(NetBoxModel):
def __str__(self): def __str__(self):
return f'AS{self.asn_with_asdot}' return f'AS{self.asn_with_asdot}'
@classmethod
def get_prerequisite_models(cls):
return [RIR, ]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:asn', args=[self.pk]) return reverse('ipam:asn', args=[self.pk])
@ -185,6 +192,10 @@ class Aggregate(GetAvailablePrefixesMixin, NetBoxModel):
def __str__(self): def __str__(self):
return str(self.prefix) return str(self.prefix)
@classmethod
def get_prerequisite_models(cls):
return [RIR, ]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:aggregate', args=[self.pk]) 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.fields import GenericRelation, GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property
from ipam.choices import L2VPNTypeChoices from ipam.choices import L2VPNTypeChoices
from ipam.constants import L2VPN_ASSIGNMENT_MODELS from ipam.constants import L2VPN_ASSIGNMENT_MODELS
@ -70,6 +72,13 @@ class L2VPN(NetBoxModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:l2vpn', args=[self.pk]) 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): class L2VPNTermination(NetBoxModel):
l2vpn = models.ForeignKey( l2vpn = models.ForeignKey(
@ -106,6 +115,10 @@ class L2VPNTermination(NetBoxModel):
return f'{self.assigned_object} <> {self.l2vpn}' return f'{self.assigned_object} <> {self.l2vpn}'
return super().__str__() return super().__str__()
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('ipam.L2VPN'), ]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:l2vpntermination', args=[self.pk]) return reverse('ipam:l2vpntermination', args=[self.pk])

View File

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

View File

@ -33,10 +33,10 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = L2VPN model = L2VPN
fields = ( 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', 'actions',
) )
default_columns = ('pk', 'name', 'type', 'description', 'actions') default_columns = ('pk', 'name', 'identifier', 'type', 'description', 'actions')
class L2VPNTerminationTable(NetBoxTable): class L2VPNTerminationTable(NetBoxTable):

View File

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

View File

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

View File

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

View File

@ -28,6 +28,14 @@ class NetBoxFeatureSet(
class Meta: class Meta:
abstract = True 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 # Base model classes

View File

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

View File

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

View File

@ -26,6 +26,7 @@ from utilities.permissions import get_permission_for_model
from utilities.views import GetReturnURLMixin from utilities.views import GetReturnURLMixin
from .base import BaseMultiObjectView from .base import BaseMultiObjectView
from .mixins import ActionsMixin, TableMixin from .mixins import ActionsMixin, TableMixin
from .utils import get_prerequisite_model
__all__ = ( __all__ = (
'BulkComponentCreateView', 'BulkComponentCreateView',
@ -165,13 +166,16 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
'table': table, 'table': table,
}) })
return render(request, self.template_name, { context = {
'model': model, 'model': model,
'table': table, 'table': table,
'actions': actions, 'actions': actions,
'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, '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), **self.get_extra_context(request),
}) }
return render(request, self.template_name, context)
class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): 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 utilities.views import GetReturnURLMixin
from .base import BaseObjectView from .base import BaseObjectView
from .mixins import ActionsMixin, TableMixin from .mixins import ActionsMixin, TableMixin
from .utils import get_prerequisite_model
__all__ = ( __all__ = (
'ComponentCreateView', 'ComponentCreateView',
@ -327,6 +328,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
""" """
return obj 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 # Request handlers
# #
@ -340,15 +347,18 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
""" """
obj = self.get_object(**kwargs) obj = self.get_object(**kwargs)
obj = self.alter_object(obj, request, args, kwargs) obj = self.alter_object(obj, request, args, kwargs)
model = self.queryset.model
initial_data = normalize_querydict(request.GET) initial_data = normalize_querydict(request.GET)
form = self.form(instance=obj, initial=initial_data) form = self.form(instance=obj, initial=initial_data)
restrict_form_fields(form, request.user) restrict_form_fields(form, request.user)
return render(request, self.template_name, { return render(request, self.template_name, {
'model': model,
'object': obj, 'object': obj,
'form': form, 'form': form,
'return_url': self.get_return_url(request, obj), 'return_url': self.get_return_url(request, obj),
'prerequisite_model': get_prerequisite_model(self.queryset),
**self.get_extra_context(request, obj), **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 # If cloning is supported, pre-populate a new instance of the form
params = prepare_cloned_fields(obj) params = prepare_cloned_fields(obj)
params.update(self.get_extra_addanother_params(request))
if params: if params:
if 'return_url' in request.GET: if 'return_url' in request.GET:
params['return_url'] = request.GET.get('return_url') 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>
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Management</h5>
Management
</h5>
<div class="card-body"> <div class="card-body">
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
@ -178,9 +176,9 @@
{% if object.primary_ip4 %} {% if object.primary_ip4 %}
<a href="{{ object.primary_ip4.get_absolute_url }}">{{ object.primary_ip4.address.ip }}</a> <a href="{{ object.primary_ip4.get_absolute_url }}">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %} {% if object.primary_ip4.nat_inside %}
(NAT for {{ object.primary_ip4.nat_inside.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 %} {% elif object.primary_ip4.nat_outside.exists %}
(NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }}) (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 %} {% endif %}
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}
@ -193,9 +191,9 @@
{% if object.primary_ip6 %} {% if object.primary_ip6 %}
<a href="{{ object.primary_ip6.get_absolute_url }}">{{ object.primary_ip6.address.ip }}</a> <a href="{{ object.primary_ip6.get_absolute_url }}">{{ object.primary_ip6.address.ip }}</a>
{% if object.primary_ip6.nat_inside %} {% if object.primary_ip6.nat_inside %}
(NAT for {{ object.primary_ip6.nat_inside.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 %} {% elif object.primary_ip6.nat_outside.exists %}
(NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }}) (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 %} {% endif %}
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}

View File

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

View File

@ -40,6 +40,10 @@ Context:
</div> </div>
{% endif %} {% endif %}
{% if prerequisite_model %}
{% include 'inc/missing_prerequisites.html' %}
{% endif %}
<form action="" method="post" enctype="multipart/form-data" class="form-object-edit mt-5"> <form action="" method="post" enctype="multipart/form-data" class="form-object-edit mt-5">
{% csrf_token %} {% 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 %}" /> <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 #} {# Object table #}
{% if prerequisite_model %}
{% include 'inc/missing_prerequisites.html' %}
{% endif %}
<div class="card"> <div class="card">
<div class="card-body" id="object_list"> <div class="card-body" id="object_list">
{% include 'htmx/table.html' %} {% 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"> <table class="table table-hover attr-table">
{% for field, value in fields.items %} {% for field, value in fields.items %}
<tr> <tr>
<td> <th scope="row">
<span title="{{ field.description|escape }}">{{ field }}</span> <span title="{{ field.description|escape }}">{{ field }}</span>
</td> </th>
<td> <td>
{% customfield_value field value %} {% customfield_value field value %}
</td> </td>

View File

@ -91,10 +91,13 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">Outside NAT IPs</th> <th scope="row">NAT (Outside)</th>
<td> <td>
{% for ip in object.nat_outside.all %} {% 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 %} {% empty %}
{{ ''|placeholder }} {{ ''|placeholder }}
{% endfor %} {% endfor %}

View File

@ -59,7 +59,7 @@
</div> </div>
{% if perms.ipam.add_l2vpntermination %} {% if perms.ipam.add_l2vpntermination %}
<div class="card-footer text-end noprint"> <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 <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a Termination
</a> </a>
</div> </div>

View File

@ -45,8 +45,8 @@
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a> <a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %} {% 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>) (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 %} {% elif object.primary_ip4.nat_outside.exists %}
(NAT: <a href="{{ object.primary_ip4.nat_outside.get_absolute_url }}">{{ object.primary_ip4.nat_outside.address.ip }}</a>) (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 %} {% endif %}
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}
@ -60,8 +60,8 @@
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}">{{ object.primary_ip6.address.ip }}</a> <a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}">{{ object.primary_ip6.address.ip }}</a>
{% if object.primary_ip6.nat_inside %} {% 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>) (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 %} {% elif object.primary_ip6.nat_outside.exists %}
(NAT: <a href="{{ object.primary_ip6.nat_outside.get_absolute_url }}">{{ object.primary_ip6.nat_outside.address.ip }}</a>) (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 %} {% endif %}
{% else %} {% else %}
{{ ''|placeholder }} {{ ''|placeholder }}

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.http import QueryDict
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from circuits.models import Circuit 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')) instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
return instance 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): class ContactAssignmentDeleteView(generic.ObjectDeleteView):
queryset = ContactAssignment.objects.all() queryset = ContactAssignment.objects.all()

View File

@ -124,7 +124,7 @@ class TokenTest(
user = User.objects.create_user(**data) user = User.objects.create_user(**data)
url = reverse('users-api:token_provision') 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.assertEqual(response.status_code, 201)
self.assertIn('key', response.data) self.assertIn('key', response.data)
self.assertEqual(len(response.data['key']), 40) self.assertEqual(len(response.data['key']), 40)
@ -141,7 +141,7 @@ class TokenTest(
} }
url = reverse('users-api:token_provision') 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) self.assertEqual(response.status_code, 403)

View File

@ -5,7 +5,7 @@ import re
import yaml import yaml
from django import template from django import template
from django.contrib.contenttypes.models import ContentType 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 django.utils.safestring import mark_safe
from markdown import markdown 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) text = getattr(instance, attr) if attr is not None else str(instance)
try: try:
url = instance.get_absolute_url() 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): except (AttributeError, TypeError):
return text return text

View File

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

View File

@ -167,6 +167,10 @@ class Cluster(NetBoxModel):
def __str__(self): def __str__(self):
return self.name return self.name
@classmethod
def get_prerequisite_models(cls):
return [ClusterType, ]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('virtualization:cluster', args=[self.pk]) return reverse('virtualization:cluster', args=[self.pk])
@ -312,6 +316,10 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
def __str__(self): def __str__(self):
return self.name return self.name
@classmethod
def get_prerequisite_models(cls):
return [Cluster, ]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('virtualization:virtualmachine', args=[self.pk]) 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.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -23,7 +24,8 @@ class WirelessAuthenticationBase(models.Model):
auth_type = models.CharField( auth_type = models.CharField(
max_length=50, max_length=50,
choices=WirelessAuthTypeChoices, choices=WirelessAuthTypeChoices,
blank=True blank=True,
verbose_name="Auth Type",
) )
auth_cipher = models.CharField( auth_cipher = models.CharField(
max_length=50, max_length=50,
@ -126,21 +128,29 @@ class WirelessLAN(WirelessAuthenticationBase, NetBoxModel):
return reverse('wireless:wirelesslan', args=[self.pk]) 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): class WirelessLink(WirelessAuthenticationBase, NetBoxModel):
""" """
A point-to-point connection between two wireless Interfaces. A point-to-point connection between two wireless Interfaces.
""" """
interface_a = models.ForeignKey( interface_a = models.ForeignKey(
to='dcim.Interface', to='dcim.Interface',
limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, limit_choices_to=get_wireless_interface_types,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+' related_name='+',
verbose_name="Interface A",
) )
interface_b = models.ForeignKey( interface_b = models.ForeignKey(
to='dcim.Interface', to='dcim.Interface',
limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, limit_choices_to=get_wireless_interface_types,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+' related_name='+',
verbose_name="Interface B",
) )
ssid = models.CharField( ssid = models.CharField(
max_length=SSID_MAX_LENGTH, max_length=SSID_MAX_LENGTH,
@ -190,6 +200,10 @@ class WirelessLink(WirelessAuthenticationBase, NetBoxModel):
def __str__(self): def __str__(self):
return f'#{self.pk}' return f'#{self.pk}'
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Interface'), ]
def get_absolute_url(self): def get_absolute_url(self):
return reverse('wireless:wirelesslink', args=[self.pk]) return reverse('wireless:wirelesslink', args=[self.pk])