mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-23 07:56:44 -06:00
Merge branch 'develop' into art-10070
This commit is contained in:
commit
77278d1835
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
|
@ -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
|
||||
|
@ -480,7 +480,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'tenant', 'platform', 'serial', 'airflow',
|
||||
'location', 'tenant', 'platform', 'serial', 'airflow',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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}
|
||||
|
@ -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(
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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)'
|
||||
)
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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])
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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):
|
||||
|
@ -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')
|
||||
|
12
netbox/netbox/views/generic/utils.py
Normal file
12
netbox/netbox/views/generic/utils.py
Normal 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
|
@ -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 }}
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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' %}
|
||||
|
6
netbox/templates/inc/missing_prerequisites.html
Normal file
6
netbox/templates/inc/missing_prerequisites.html
Normal 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>
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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 }}
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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])
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user