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
|
### 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -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])
|
||||||
|
|
||||||
|
@ -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)'),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -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
|
||||||
|
@ -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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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])
|
||||||
|
|
||||||
|
@ -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])
|
||||||
|
|
||||||
|
@ -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])
|
||||||
|
|
||||||
|
@ -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])
|
||||||
|
|
||||||
|
@ -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 %}
|
||||||
|
@ -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}
|
||||||
|
@ -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(
|
||||||
|
@ -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])
|
||||||
|
|
||||||
|
@ -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])
|
||||||
|
|
||||||
|
@ -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)'
|
||||||
)
|
)
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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])
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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',
|
||||||
|
@ -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',
|
||||||
|
@ -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):
|
||||||
|
@ -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')
|
||||||
|
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>
|
||||||
<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 }}
|
||||||
|
@ -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>
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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' %}
|
||||||
|
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">
|
<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>
|
||||||
|
@ -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 %}
|
||||||
|
@ -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>
|
||||||
|
@ -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 }}
|
||||||
|
@ -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()
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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])
|
||||||
|
|
||||||
|
@ -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.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])
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user