Merge branch 'develop' into fix/generic_prefetch_4.2

This commit is contained in:
Andrey Tikhonov 2025-03-06 16:02:32 +01:00
commit d226af420b
20 changed files with 81 additions and 72 deletions

View File

@ -34,10 +34,9 @@ body:
label: Python Version label: Python Version
description: What version of Python are you currently running? description: What version of Python are you currently running?
options: options:
- "3.8"
- "3.9"
- "3.10" - "3.10"
- "3.11" - "3.11"
- "3.12"
validations: validations:
required: true required: true
- type: textarea - type: textarea

View File

@ -1,5 +1,9 @@
# NetBox v4.0 # NetBox v4.0
## v4.0.1 (FUTURE)
---
## v4.0.0 (2024-05-06) ## v4.0.0 (2024-05-06)
!!! tip "Plugin Maintainers" !!! tip "Plugin Maintainers"

View File

@ -48,7 +48,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
class CircuitSerializer(NetBoxModelSerializer): class CircuitSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
provider = ProviderSerializer(nested=True) provider = ProviderSerializer(nested=True)
provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True) provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
status = ChoiceField(choices=CircuitStatusChoices, required=False) status = ChoiceField(choices=CircuitStatusChoices, required=False)
type = CircuitTypeSerializer(nested=True) type = CircuitTypeSerializer(nested=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)

View File

@ -45,6 +45,7 @@ class ProviderSerializer(NetBoxModelSerializer):
class ProviderAccountSerializer(NetBoxModelSerializer): class ProviderAccountSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail')
provider = ProviderSerializer(nested=True) provider = ProviderSerializer(nested=True)
name = serializers.CharField(allow_blank=True, max_length=100, required=False, default='')
class Meta: class Meta:
model = ProviderAccount model = ProviderAccount

View File

@ -141,7 +141,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
{ {
'cid': 'Circuit 6', 'cid': 'Circuit 6',
'provider': providers[1].pk, 'provider': providers[1].pk,
'provider_account': provider_accounts[1].pk, # Omit provider account to test uniqueness constraint
'type': circuit_types[1].pk, 'type': circuit_types[1].pk,
}, },
] ]
@ -237,7 +237,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
'account': '5678', 'account': '5678',
}, },
{ {
'name': 'Provider Account 6', # Omit name to test uniqueness constraint
'provider': providers[0].pk, 'provider': providers[0].pk,
'account': '6789', 'account': '6789',
}, },

View File

@ -122,6 +122,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class VirtualDeviceContextSerializer(NetBoxModelSerializer): class VirtualDeviceContextSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
identifier = serializers.IntegerField(allow_null=True, max_value=32767, min_value=0, required=False, default=None)
tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None) tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None)
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True) primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True) primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)

View File

@ -51,7 +51,7 @@ class SiteSerializer(NetBoxModelSerializer):
status = ChoiceField(choices=SiteStatusChoices, required=False) status = ChoiceField(choices=SiteStatusChoices, required=False)
region = RegionSerializer(nested=True, required=False, allow_null=True) region = RegionSerializer(nested=True, required=False, allow_null=True)
group = SiteGroupSerializer(nested=True, required=False, allow_null=True) group = SiteGroupSerializer(nested=True, required=False, allow_null=True)
tenant = TenantSerializer(required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)
time_zone = TimeZoneSerializerField(required=False, allow_null=True) time_zone = TimeZoneSerializerField(required=False, allow_null=True)
asns = SerializedPKRelatedField( asns = SerializedPKRelatedField(
queryset=ASN.objects.all(), queryset=ASN.objects.all(),
@ -83,7 +83,7 @@ class SiteSerializer(NetBoxModelSerializer):
class LocationSerializer(NestedGroupModelSerializer): class LocationSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
site = SiteSerializer(nested=True) site = SiteSerializer(nested=True)
parent = NestedLocationSerializer(required=False, allow_null=True) parent = NestedLocationSerializer(required=False, allow_null=True, default=None)
status = ChoiceField(choices=LocationStatusChoices, required=False) status = ChoiceField(choices=LocationStatusChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True) rack_count = serializers.IntegerField(read_only=True)

View File

@ -10,6 +10,7 @@ from dcim.models import *
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.models import ASN, RIR, VLAN, VRF from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer from netbox.api.serializers import GenericObjectSerializer
from tenancy.models import Tenant
from utilities.testing import APITestCase, APIViewTestCases, create_test_device from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from virtualization.models import Cluster, ClusterType from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices from wireless.choices import WirelessChannelChoices
@ -152,6 +153,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
Site.objects.bulk_create(sites) Site.objects.bulk_create(sites)
rir = RIR.objects.create(name='RFC 6996', is_private=True) rir = RIR.objects.create(name='RFC 6996', is_private=True)
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
asns = [ asns = [
ASN(asn=65000 + i, rir=rir) for i in range(8) ASN(asn=65000 + i, rir=rir) for i in range(8)
@ -166,6 +168,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
'group': groups[1].pk, 'group': groups[1].pk,
'status': SiteStatusChoices.STATUS_ACTIVE, 'status': SiteStatusChoices.STATUS_ACTIVE,
'asns': [asns[0].pk, asns[1].pk], 'asns': [asns[0].pk, asns[1].pk],
'tenant': tenant.pk,
}, },
{ {
'name': 'Site 5', 'name': 'Site 5',
@ -230,7 +233,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
'name': 'Test Location 6', 'name': 'Test Location 6',
'slug': 'test-location-6', 'slug': 'test-location-6',
'site': sites[1].pk, 'site': sites[1].pk,
'parent': parent_locations[1].pk, # Omit parent to test uniqueness constraint
'status': LocationStatusChoices.STATUS_PLANNED, 'status': LocationStatusChoices.STATUS_PLANNED,
}, },
] ]
@ -2307,6 +2310,6 @@ class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
'device': devices[1].pk, 'device': devices[1].pk,
'status': 'active', 'status': 'active',
'name': 'VDC 3', 'name': 'VDC 3',
'identifier': 3, # Omit identifier to test uniqueness constraint
}, },
] ]

View File

@ -781,6 +781,7 @@ class IPAddressView(generic.ObjectView):
class IPAddressEditView(generic.ObjectEditView): class IPAddressEditView(generic.ObjectEditView):
queryset = IPAddress.objects.all() queryset = IPAddress.objects.all()
form = forms.IPAddressForm form = forms.IPAddressForm
template_name = 'ipam/ipaddress_edit.html'
def alter_object(self, obj, request, url_args, url_kwargs): def alter_object(self, obj, request, url_args, url_kwargs):

View File

@ -32,6 +32,7 @@ class MenuItem:
link: str link: str
link_text: str link_text: str
permissions: Optional[Sequence[str]] = () permissions: Optional[Sequence[str]] = ()
auth_required: Optional[bool] = False
staff_only: Optional[bool] = False staff_only: Optional[bool] = False
buttons: Optional[Sequence[MenuItemButton]] = () buttons: Optional[Sequence[MenuItemButton]] = ()

View File

@ -371,6 +371,7 @@ ADMIN_MENU = Menu(
MenuItem( MenuItem(
link=f'users:user_list', link=f'users:user_list',
link_text=_('Users'), link_text=_('Users'),
auth_required=True,
permissions=[f'auth.view_user'], permissions=[f'auth.view_user'],
buttons=( buttons=(
MenuItemButton( MenuItemButton(
@ -390,6 +391,7 @@ ADMIN_MENU = Menu(
MenuItem( MenuItem(
link=f'users:group_list', link=f'users:group_list',
link_text=_('Groups'), link_text=_('Groups'),
auth_required=True,
permissions=[f'auth.view_group'], permissions=[f'auth.view_group'],
buttons=( buttons=(
MenuItemButton( MenuItemButton(
@ -409,12 +411,14 @@ ADMIN_MENU = Menu(
MenuItem( MenuItem(
link=f'users:token_list', link=f'users:token_list',
link_text=_('API Tokens'), link_text=_('API Tokens'),
auth_required=True,
permissions=[f'users.view_token'], permissions=[f'users.view_token'],
buttons=get_model_buttons('users', 'token') buttons=get_model_buttons('users', 'token')
), ),
MenuItem( MenuItem(
link=f'users:objectpermission_list', link=f'users:objectpermission_list',
link_text=_('Permissions'), link_text=_('Permissions'),
auth_required=True,
permissions=[f'users.view_objectpermission'], permissions=[f'users.view_objectpermission'],
buttons=get_model_buttons('users', 'objectpermission', actions=['add']) buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
), ),
@ -425,16 +429,19 @@ ADMIN_MENU = Menu(
items=( items=(
MenuItem( MenuItem(
link='core:system', link='core:system',
link_text=_('System') link_text=_('System'),
auth_required=True
), ),
MenuItem( MenuItem(
link='core:configrevision_list', link='core:configrevision_list',
link_text=_('Configuration History'), link_text=_('Configuration History'),
auth_required=True,
permissions=['core.view_configrevision'] permissions=['core.view_configrevision']
), ),
MenuItem( MenuItem(
link='core:background_queue_list', link='core:background_queue_list',
link_text=_('Background Tasks') link_text=_('Background Tasks'),
auth_required=True
), ),
), ),
), ),

View File

@ -25,7 +25,7 @@ from utilities.string import trailing_slash
# Environment setup # Environment setup
# #
VERSION = '4.0.0' VERSION = '4.0.1-dev'
HOSTNAME = platform.node() HOSTNAME = platform.node()
# Set the base directory two levels up # Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -522,7 +522,6 @@ if SENTRY_ENABLED:
sentry_sdk.init( sentry_sdk.init(
dsn=SENTRY_DSN, dsn=SENTRY_DSN,
release=VERSION, release=VERSION,
integrations=[sentry_sdk.integrations.django.DjangoIntegration()],
sample_rate=SENTRY_SAMPLE_RATE, sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE, traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
send_default_pii=True, send_default_pii=True,

View File

@ -20,7 +20,7 @@
{# Initialize color mode #} {# Initialize color mode #}
<script <script
type="text/javascript" type="text/javascript"
src="{% static 'setmode.js' %}" src="{% static 'setmode.js' %}?v={{ settings.VERSION }}"
onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'"> onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'">
</script> </script>
<script type="text/javascript"> <script type="text/javascript">

View File

@ -3,30 +3,21 @@
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
<li class="nav-item"> <li class="nav-item">
<a <a href="{% url 'ipam:ipaddress_add' %}{% querystring request %}" class="nav-link {% if active_tab == 'add' %}active{% endif %}">
class="nav-link {% if active_tab == 'add' %}active{% endif %}" {% if object.pk %}{% trans "Edit" %}{% else %}{% trans "Create" %}{% endif %}
href="{% url 'ipam:ipaddress_add' %}{% querystring request %}" </a>
>
{% if obj.pk %}{% trans "Edit" %}{% else %}{% trans "Create" %}{% endif %}
</a>
</li> </li>
{% if 'interface' in request.GET or 'vminterface' in request.GET %} {% if 'interface' in request.GET or 'vminterface' in request.GET %}
<li class="nav-item"> <li class="nav-item">
<a <a href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}" class="nav-link {% if active_tab == 'assign' %}active{% endif %}">
class="nav-link {% if active_tab == 'assign' %}active{% endif %}" {% trans "Assign IP" %}
href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}"
>
{% trans "Assign IP" %}
</a> </a>
</li> </li>
{% else %} {% elif not object.pk %}
<li class="nav-item"> <li class="nav-item">
<a <a href="{% url 'ipam:ipaddress_bulk_add' %}{% querystring request %}" class="nav-link {% if active_tab == 'bulk_add' %}active{% endif %}">
class="nav-link {% if active_tab == 'bulk_add' %}active{% endif %}" {% trans "Bulk Create" %}
href="{% url 'ipam:ipaddress_bulk_add' %}{% querystring request %}"
>
{% trans "Bulk Create" %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -12,37 +12,33 @@
{% endblock %} {% endblock %}
{% block form %} {% block form %}
<form action="{% querystring request %}" method="post" class="form form-horizontal"> <form action="{% querystring request %}" method="post" class="form form-horizontal">
{% csrf_token %} {% csrf_token %}
{% for field in form.hidden_fields %} {% for field in form.hidden_fields %}
{{ field }} {{ field }}
{% endfor %} {% endfor %}
<div class="row mb-3"> <div class="field-group my-5">
<div class="col col-md-8 offset-md-2"> <div class="row">
<div class="field-group"> <h5 class="col-9 offset-3">{% trans "Select IP Address" %}</h5>
<h6>{% trans "Select IP Address" %}</h6> </div>
{% render_field form.vrf_id %} {% render_field form.vrf_id %}
{% render_field form.q %} {% render_field form.q %}
</div> </div>
</div> <div class="text-end my-3">
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% trans "Search" %}</button>
</div>
</form>
{% if table %}
<div class="row mb-3">
<div class="col col-md-12">
<h3>{% trans "Search Results" %}</h3>
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div> </div>
<div class="row mb-3"> </div>
<div class="col col-md-8 offset-md-2 text-end"> </div>
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a> {% endif %}
<button type="submit" class="btn btn-primary">{% trans "Search" %}</button>
</div>
</div>
</form>
{% if table %}
<div class="row mb-3">
<div class="col col-md-12">
<h3>{% trans "Search Results" %}</h3>
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
</div>
</div>
{% endif %}
{% endblock form %} {% endblock form %}
{% block buttons %} {% block buttons %}

View File

@ -0,0 +1,5 @@
{% extends 'generic/object_edit.html' %}
{% block tabs %}
{% include 'ipam/inc/ipaddress_edit_header.html' with active_tab='add' %}
{% endblock %}

View File

@ -27,7 +27,7 @@ class TenantGroupSerializer(NestedGroupModelSerializer):
class TenantSerializer(NetBoxModelSerializer): class TenantSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
group = TenantGroupSerializer(nested=True, required=False, allow_null=True) group = TenantGroupSerializer(nested=True, required=False, allow_null=True, default=None)
# Related object counts # Related object counts
circuit_count = RelatedObjectCountField('circuits') circuit_count = RelatedObjectCountField('circuits')

View File

@ -26,6 +26,8 @@ def nav(context):
for group in menu.groups: for group in menu.groups:
items = [] items = []
for item in group.items: for item in group.items:
if getattr(item, 'auth_required', False) and not user.is_authenticated:
continue
if not user.has_perms(item.permissions): if not user.has_perms(item.permissions):
continue continue
if item.staff_only and not user.is_staff: if item.staff_only and not user.is_staff:

View File

@ -31,11 +31,11 @@ __all__ = (
class VirtualMachineSerializer(NetBoxModelSerializer): class VirtualMachineSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
site = SiteSerializer(nested=True, required=False, allow_null=True) site = SiteSerializer(nested=True, required=False, allow_null=True, default=None)
cluster = ClusterSerializer(nested=True, required=False, allow_null=True) cluster = ClusterSerializer(nested=True, required=False, allow_null=True, default=None)
device = DeviceSerializer(nested=True, required=False, allow_null=True) device = DeviceSerializer(nested=True, required=False, allow_null=True, default=None)
role = DeviceRoleSerializer(nested=True, required=False, allow_null=True) role = DeviceRoleSerializer(nested=True, required=False, allow_null=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None)
platform = PlatformSerializer(nested=True, required=False, allow_null=True) platform = PlatformSerializer(nested=True, required=False, allow_null=True)
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True) primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True) primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
@ -55,7 +55,6 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
'interface_count', 'virtual_disk_count', 'interface_count', 'virtual_disk_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
validators = []
class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):

View File

@ -1,4 +1,4 @@
Django==5.0.5 Django==5.0.6
django-cors-headers==4.3.1 django-cors-headers==4.3.1
django-debug-toolbar==4.3.0 django-debug-toolbar==4.3.0
django-filter==24.2 django-filter==24.2