Merge branch 'develop' into 10087-connections-tables

This commit is contained in:
Jeremy Stretch 2022-08-25 08:45:12 -04:00 committed by GitHub
commit 277a400c27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 141 additions and 24 deletions

View File

@ -54,6 +54,12 @@ NetBox ships with a [git pre-commit hook](https://githooks.com/) script that aut
cd .git/hooks/
ln -s ../../scripts/git-hooks/pre-commit
```
For the pre-commit hooks to work, you will also need to install the pycodestyle package:
```no-highlight
python -m pip install pycodestyle
```
...and setup the yarn packages as shown in the [Web UI Development Guide](web-ui.md)
### 3. Create a Python Virtual Environment
@ -118,6 +124,10 @@ This ensures that your development environment is now complete and operational.
!!! tip "IDE Integration"
Some IDEs, such as the highly-recommended [PyCharm](https://www.jetbrains.com/pycharm/), will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment.
## UI Development
For UI development you will need to review the [Web UI Development Guide](web-ui.md)
## Populating Demo Data
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)

View File

@ -5,8 +5,13 @@
### 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
@ -15,11 +20,15 @@
* [#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
* [#10070](https://github.com/netbox-community/netbox/issues/10070) - Add unique constraint for L2VPN slug
* [#10087](https://github.com/netbox-community/netbox/issues/10087) - Correct display of far end in console/power/interface connections tables
* [#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
* [#10147](https://github.com/netbox-community/netbox/issues/10147) - Permit the creation of 0U device types via REST API
---

View File

@ -310,7 +310,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
max_digits=4,
decimal_places=1,
label='Position (U)',
min_value=decimal.Decimal(0.5),
min_value=0,
default=1.0
)
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)

View File

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

View File

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

View File

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

View File

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

View File

@ -461,16 +461,19 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
'manufacturer': manufacturers[1].pk,
'model': 'Device Type 4',
'slug': 'device-type-4',
'u_height': 0,
},
{
'manufacturer': manufacturers[1].pk,
'model': 'Device Type 5',
'slug': 'device-type-5',
'u_height': 0.5,
},
{
'manufacturer': manufacturers[1].pk,
'model': 'Device Type 6',
'slug': 'device-type-6',
'u_height': 1,
},
]

View File

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

View File

@ -0,0 +1,18 @@
# Generated by Django 4.0.7 on 2022-08-22 15:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0059_l2vpn'),
]
operations = [
migrations.AlterField(
model_name='l2vpn',
name='slug',
field=models.SlugField(max_length=100, unique=True),
),
]

View File

@ -4,6 +4,7 @@ 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
@ -20,7 +21,10 @@ class L2VPN(NetBoxModel):
max_length=100,
unique=True
)
slug = models.SlugField()
slug = models.SlugField(
max_length=100,
unique=True
)
type = models.CharField(
max_length=50,
choices=L2VPNTypeChoices
@ -68,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(

View File

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

View File

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

View File

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

View File

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

View File

@ -328,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
#
@ -403,6 +409,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
# If cloning is supported, pre-populate a new instance of the form
params = prepare_cloned_fields(obj)
params.update(self.get_extra_addanother_params(request))
if params:
if 'return_url' in request.GET:
params['return_url'] = request.GET.get('return_url')

View File

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

View File

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

View File

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

View File

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

View File

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

@ -24,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,
@ -127,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,