mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-23 16:06:43 -06:00
Merge branch 'develop' into 10087-connections-tables
This commit is contained in:
commit
277a400c27
@ -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>.)
|
||||
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
@ -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}
|
||||
|
18
netbox/ipam/migrations/0060_alter_l2vpn_slug.py
Normal file
18
netbox/ipam/migrations/0060_alter_l2vpn_slug.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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(
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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')
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user