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
description: What version of Python are you currently running?
options:
- "3.8"
- "3.9"
- "3.10"
- "3.11"
- "3.12"
validations:
required: true
- type: textarea

View File

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

View File

@ -48,7 +48,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
class CircuitSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
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)
type = CircuitTypeSerializer(nested=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)

View File

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

View File

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

View File

@ -122,6 +122,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
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)
primary_ip = IPAddressSerializer(nested=True, read_only=True, 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)
region = RegionSerializer(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)
asns = SerializedPKRelatedField(
queryset=ASN.objects.all(),
@ -83,7 +83,7 @@ class SiteSerializer(NetBoxModelSerializer):
class LocationSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
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)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True)

View File

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

View File

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

View File

@ -371,6 +371,7 @@ ADMIN_MENU = Menu(
MenuItem(
link=f'users:user_list',
link_text=_('Users'),
auth_required=True,
permissions=[f'auth.view_user'],
buttons=(
MenuItemButton(
@ -390,6 +391,7 @@ ADMIN_MENU = Menu(
MenuItem(
link=f'users:group_list',
link_text=_('Groups'),
auth_required=True,
permissions=[f'auth.view_group'],
buttons=(
MenuItemButton(
@ -409,12 +411,14 @@ ADMIN_MENU = Menu(
MenuItem(
link=f'users:token_list',
link_text=_('API Tokens'),
auth_required=True,
permissions=[f'users.view_token'],
buttons=get_model_buttons('users', 'token')
),
MenuItem(
link=f'users:objectpermission_list',
link_text=_('Permissions'),
auth_required=True,
permissions=[f'users.view_objectpermission'],
buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
),
@ -425,16 +429,19 @@ ADMIN_MENU = Menu(
items=(
MenuItem(
link='core:system',
link_text=_('System')
link_text=_('System'),
auth_required=True
),
MenuItem(
link='core:configrevision_list',
link_text=_('Configuration History'),
auth_required=True,
permissions=['core.view_configrevision']
),
MenuItem(
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
#
VERSION = '4.0.0'
VERSION = '4.0.1-dev'
HOSTNAME = platform.node()
# Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -522,7 +522,6 @@ if SENTRY_ENABLED:
sentry_sdk.init(
dsn=SENTRY_DSN,
release=VERSION,
integrations=[sentry_sdk.integrations.django.DjangoIntegration()],
sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
send_default_pii=True,

View File

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

View File

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

View File

@ -12,37 +12,33 @@
{% endblock %}
{% block form %}
<form action="{% querystring request %}" method="post" class="form form-horizontal">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="row mb-3">
<div class="col col-md-8 offset-md-2">
<div class="field-group">
<h6>{% trans "Select IP Address" %}</h6>
{% render_field form.vrf_id %}
{% render_field form.q %}
</div>
</div>
<form action="{% querystring request %}" method="post" class="form form-horizontal">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="field-group my-5">
<div class="row">
<h5 class="col-9 offset-3">{% trans "Select IP Address" %}</h5>
</div>
{% render_field form.vrf_id %}
{% render_field form.q %}
</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 class="row mb-3">
<div class="col col-md-8 offset-md-2 text-end">
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
<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 %}
</div>
</div>
{% endif %}
{% endblock form %}
{% 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):
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
circuit_count = RelatedObjectCountField('circuits')

View File

@ -26,6 +26,8 @@ def nav(context):
for group in menu.groups:
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):
continue
if item.staff_only and not user.is_staff:

View File

@ -31,11 +31,11 @@ __all__ = (
class VirtualMachineSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
site = SiteSerializer(nested=True, required=False, allow_null=True)
cluster = ClusterSerializer(nested=True, required=False, allow_null=True)
device = DeviceSerializer(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, default=None)
device = DeviceSerializer(nested=True, required=False, allow_null=True, default=None)
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)
primary_ip = IPAddressSerializer(nested=True, read_only=True, 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',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
validators = []
class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):

View File

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