nautobot 2.4.12__py3-none-any.whl → 2.4.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nautobot might be problematic. Click here for more details.
- nautobot/core/templates/inc/footer.html +4 -4
- nautobot/dcim/migrations/0071_alter_consoleport_options_and_more.py +42 -0
- nautobot/dcim/models/device_components.py +10 -2
- nautobot/dcim/models/devices.py +18 -0
- nautobot/dcim/templates/dcim/device.html +1 -34
- nautobot/dcim/templates/dcim/rack_elevation_list.html +4 -1
- nautobot/dcim/tests/test_models.py +1 -0
- nautobot/dcim/urls.py +1 -1
- nautobot/dcim/views.py +35 -2
- nautobot/extras/views.py +3 -2
- nautobot/ipam/migrations/0052_alter_ipaddress_index_together_and_more.py +28 -0
- nautobot/ipam/models.py +13 -1
- nautobot/ipam/tests/test_api.py +1 -3
- nautobot/ipam/utils/testing.py +76 -29
- nautobot/project-static/docs/code-reference/nautobot/apps/views.html +2 -0
- nautobot/project-static/docs/release-notes/version-2.4.html +100 -0
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +299 -299
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- {nautobot-2.4.12.dist-info → nautobot-2.4.13.dist-info}/METADATA +1 -1
- {nautobot-2.4.12.dist-info → nautobot-2.4.13.dist-info}/RECORD +25 -23
- {nautobot-2.4.12.dist-info → nautobot-2.4.13.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.12.dist-info → nautobot-2.4.13.dist-info}/NOTICE +0 -0
- {nautobot-2.4.12.dist-info → nautobot-2.4.13.dist-info}/WHEEL +0 -0
- {nautobot-2.4.12.dist-info → nautobot-2.4.13.dist-info}/entry_points.txt +0 -0
|
@@ -36,16 +36,16 @@
|
|
|
36
36
|
<a href="#theme_modal" data-toggle="modal" data-target="#theme_modal" id="btn-theme-modal"><i class="mdi mdi-theme-light-dark text-primary"></i>Theme</a> ·
|
|
37
37
|
<i class="mdi mdi-book-open-page-variant text-primary"></i>
|
|
38
38
|
{% if settings.BRANDING_URLS.docs %}
|
|
39
|
-
<a href="{{ settings.BRANDING_URLS.docs }}">Docs</a>
|
|
39
|
+
<a href="{{ settings.BRANDING_URLS.docs }}" target="_blank">Docs</a>
|
|
40
40
|
{% else %}
|
|
41
|
-
<a href="{% static 'docs/index.html' %}">Docs</a>
|
|
41
|
+
<a href="{% static 'docs/index.html' %}" target="_blank">Docs</a>
|
|
42
42
|
{% endif %}
|
|
43
43
|
·
|
|
44
44
|
<img src="{% static 'img/jinja_logo.svg' %}" style="height:20px"> <a href="{% url 'render_jinja_template' %}">Jinja Renderer</a> ·
|
|
45
45
|
<i class="mdi mdi-cloud-braces text-primary"></i> <a href="{% url 'api_docs' %}">API</a> ·
|
|
46
46
|
<i class="mdi mdi-graphql text-primary"></i> <a href="{% url 'graphql' %}">GraphQL</a> ·
|
|
47
|
-
<i class="mdi mdi-xml text-primary"></i> <a href="{{ settings.BRANDING_URLS.code }}">Code</a> ·
|
|
48
|
-
<i class="mdi mdi-lifebuoy text-primary"></i> <a href="{{ settings.BRANDING_URLS.help }}">Help</a>
|
|
47
|
+
<i class="mdi mdi-xml text-primary"></i> <a href="{{ settings.BRANDING_URLS.code }}" target="_blank">Code</a> ·
|
|
48
|
+
<i class="mdi mdi-lifebuoy text-primary"></i> <a href="{{ settings.BRANDING_URLS.help }}" target="_blank">Help</a>
|
|
49
49
|
</p>
|
|
50
50
|
{% endif %}
|
|
51
51
|
</div>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Generated by Django 4.2.20 on 2025-07-17 20:24
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
import nautobot.core.models.query_functions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
dependencies = [
|
|
10
|
+
("dcim", "0070_modulefamily_models"),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AlterModelOptions(
|
|
15
|
+
name="consoleport",
|
|
16
|
+
options={"ordering": ("device", "module__id", "_name")},
|
|
17
|
+
),
|
|
18
|
+
migrations.AlterModelOptions(
|
|
19
|
+
name="consoleserverport",
|
|
20
|
+
options={"ordering": ("device", "module__id", "_name")},
|
|
21
|
+
),
|
|
22
|
+
migrations.AlterModelOptions(
|
|
23
|
+
name="frontport",
|
|
24
|
+
options={"ordering": ("device", "module__id", "_name")},
|
|
25
|
+
),
|
|
26
|
+
migrations.AlterModelOptions(
|
|
27
|
+
name="interface",
|
|
28
|
+
options={"ordering": ("device", "module__id", nautobot.core.models.query_functions.CollateAsChar("_name"))},
|
|
29
|
+
),
|
|
30
|
+
migrations.AlterModelOptions(
|
|
31
|
+
name="poweroutlet",
|
|
32
|
+
options={"ordering": ("device", "module__id", "_name")},
|
|
33
|
+
),
|
|
34
|
+
migrations.AlterModelOptions(
|
|
35
|
+
name="powerport",
|
|
36
|
+
options={"ordering": ("device", "module__id", "_name")},
|
|
37
|
+
),
|
|
38
|
+
migrations.AlterModelOptions(
|
|
39
|
+
name="rearport",
|
|
40
|
+
options={"ordering": ("device", "module__id", "_name")},
|
|
41
|
+
),
|
|
42
|
+
]
|
|
@@ -2,6 +2,7 @@ import re
|
|
|
2
2
|
|
|
3
3
|
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
|
4
4
|
from django.contrib.contenttypes.models import ContentType
|
|
5
|
+
from django.core.cache import cache
|
|
5
6
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
|
6
7
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
7
8
|
from django.db import models, transaction
|
|
@@ -123,7 +124,7 @@ class ModularComponentModel(ComponentModel):
|
|
|
123
124
|
|
|
124
125
|
class Meta:
|
|
125
126
|
abstract = True
|
|
126
|
-
ordering = ("device", "
|
|
127
|
+
ordering = ("device", "module__id", "_name") # Module.ordering is complex/expensive so don't order by module
|
|
127
128
|
constraints = [
|
|
128
129
|
models.UniqueConstraint(
|
|
129
130
|
fields=("device", "name"),
|
|
@@ -646,7 +647,7 @@ class Interface(ModularComponentModel, CableTermination, PathEndpoint, BaseInter
|
|
|
646
647
|
)
|
|
647
648
|
|
|
648
649
|
class Meta(ModularComponentModel.Meta):
|
|
649
|
-
ordering = ("device", "
|
|
650
|
+
ordering = ("device", "module__id", CollateAsChar("_name")) # Module.ordering is complex; don't order by module
|
|
650
651
|
|
|
651
652
|
def clean(self):
|
|
652
653
|
super().clean()
|
|
@@ -1311,3 +1312,10 @@ class ModuleBay(PrimaryModel):
|
|
|
1311
1312
|
self.position = self.name
|
|
1312
1313
|
|
|
1313
1314
|
clean.alters_data = True
|
|
1315
|
+
|
|
1316
|
+
def save(self, *args, **kwargs):
|
|
1317
|
+
super().save(*args, **kwargs)
|
|
1318
|
+
|
|
1319
|
+
if self.parent_device is not None:
|
|
1320
|
+
# Set the has_module_bays cache key on the parent device - see Device.has_module_bays()
|
|
1321
|
+
cache.set(f"nautobot.dcim.device.{self.parent_device.pk}.has_module_bays", True, timeout=5)
|
nautobot/dcim/models/devices.py
CHANGED
|
@@ -2,6 +2,7 @@ from collections import OrderedDict
|
|
|
2
2
|
|
|
3
3
|
from django.contrib.contenttypes.fields import GenericRelation
|
|
4
4
|
from django.contrib.contenttypes.models import ContentType
|
|
5
|
+
from django.core.cache import cache
|
|
5
6
|
from django.core.exceptions import ValidationError
|
|
6
7
|
from django.core.serializers.json import DjangoJSONEncoder
|
|
7
8
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
@@ -896,6 +897,8 @@ class Device(PrimaryModel, ConfigContextModel):
|
|
|
896
897
|
instantiated_components = []
|
|
897
898
|
for model, templates in component_models:
|
|
898
899
|
model.objects.bulk_create([x.instantiate(device=self) for x in templates])
|
|
900
|
+
cache_key = f"nautobot.dcim.device.{self.pk}.has_module_bays"
|
|
901
|
+
cache.delete(cache_key)
|
|
899
902
|
return instantiated_components
|
|
900
903
|
|
|
901
904
|
create_components.alters_data = True
|
|
@@ -988,6 +991,18 @@ class Device(PrimaryModel, ConfigContextModel):
|
|
|
988
991
|
"""
|
|
989
992
|
return Device.objects.filter(parent_bay__device=self.pk)
|
|
990
993
|
|
|
994
|
+
@property
|
|
995
|
+
def has_module_bays(self) -> bool:
|
|
996
|
+
"""
|
|
997
|
+
Cacheable property for determining whether this Device has any ModuleBays, and therefore may contain Modules.
|
|
998
|
+
"""
|
|
999
|
+
cache_key = f"nautobot.dcim.device.{self.pk}.has_module_bays"
|
|
1000
|
+
module_bays_exists = cache.get(cache_key)
|
|
1001
|
+
if module_bays_exists is None:
|
|
1002
|
+
module_bays_exists = self.module_bays.exists()
|
|
1003
|
+
cache.set(cache_key, module_bays_exists, timeout=5)
|
|
1004
|
+
return module_bays_exists
|
|
1005
|
+
|
|
991
1006
|
@property
|
|
992
1007
|
def all_modules(self):
|
|
993
1008
|
"""
|
|
@@ -998,6 +1013,9 @@ class Device(PrimaryModel, ConfigContextModel):
|
|
|
998
1013
|
# We artificially limit the recursion to 4 levels or we would be stuck in an infinite loop.
|
|
999
1014
|
recursion_depth = MODULE_RECURSION_DEPTH_LIMIT
|
|
1000
1015
|
qs = Module.objects.all()
|
|
1016
|
+
if not self.has_module_bays:
|
|
1017
|
+
# Short-circuit to avoid an expensive nested query
|
|
1018
|
+
return qs.none()
|
|
1001
1019
|
query = Q()
|
|
1002
1020
|
for level in range(recursion_depth):
|
|
1003
1021
|
recursive_query = "parent_module_bay__parent_module__" * level
|
|
@@ -398,40 +398,7 @@
|
|
|
398
398
|
</div>
|
|
399
399
|
</div>
|
|
400
400
|
{% endif %}
|
|
401
|
-
{%
|
|
402
|
-
<div id="dynamic_groups" role="tabpanel" class="tab-pane {% if request.GET.tab == 'dynamic_groups' %}active{% else %}fade{% endif %}">
|
|
403
|
-
<div class="row">
|
|
404
|
-
<div class="col-md-12">
|
|
405
|
-
<div class="alert alert-warning">
|
|
406
|
-
Dynamic group membership is cached for performance reasons,
|
|
407
|
-
therefore this table may not always be up-to-date.
|
|
408
|
-
<br>You can refresh the membership of any specific group by navigating to it from the list below
|
|
409
|
-
or from the <a href="{% url 'extras:dynamicgroup_list' %}">Dynamic Groups list view</a>.
|
|
410
|
-
<br>You can also refresh the membership of all groups by running the
|
|
411
|
-
<a href="{% url 'extras:job_run_by_class_path' class_path='nautobot.core.jobs.groups.RefreshDynamicGroupCaches' %}">Refresh Dynamic Group Caches job</a>.
|
|
412
|
-
</div>
|
|
413
|
-
</div>
|
|
414
|
-
</div>
|
|
415
|
-
<div class="row">
|
|
416
|
-
<div class="col-md-12">
|
|
417
|
-
<form method="post">
|
|
418
|
-
{% csrf_token %}
|
|
419
|
-
<div class="panel panel-default">
|
|
420
|
-
<div class="panel-heading">
|
|
421
|
-
<strong>Dynamic Groups</strong>
|
|
422
|
-
<div class="pull-right noprint">
|
|
423
|
-
<!-- Insert table config button here -->
|
|
424
|
-
</div>
|
|
425
|
-
</div>
|
|
426
|
-
<div class="table-responsive">
|
|
427
|
-
{% render_table associated_dynamic_groups_table 'inc/table.html' %}
|
|
428
|
-
</div>
|
|
429
|
-
</div>
|
|
430
|
-
</form>
|
|
431
|
-
</div>
|
|
432
|
-
</div>
|
|
433
|
-
</div>
|
|
434
|
-
{% endif %}
|
|
401
|
+
{% comment %}The dynamic_groups tab is intentionally omitted here for performance reasons.{% endcomment %}
|
|
435
402
|
{% if object.is_metadata_associable_model and perms.extras.view_objectmetadata %}
|
|
436
403
|
<div id="object_metadata" role="tabpanel" class="tab-pane {% if request.GET.tab == 'object_metadata' %}active{% else %}fade{% endif %}">
|
|
437
404
|
<div class="row">
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
{% load static %}
|
|
4
4
|
|
|
5
5
|
{% block buttons %}
|
|
6
|
+
<button class="btn btn-default toggle-fullname" selected="selected">
|
|
7
|
+
<span class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></span> Show Device Full Name
|
|
8
|
+
</button>
|
|
6
9
|
<button class="btn btn-default toggle-images" selected="selected">
|
|
7
|
-
<span class="mdi mdi
|
|
10
|
+
<span class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></span> Show Images
|
|
8
11
|
</button>
|
|
9
12
|
<div class="btn-group" role="group">
|
|
10
13
|
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
|
|
@@ -1778,6 +1778,7 @@ class DeviceTestCase(ModelTestCases.BaseModelTestCase):
|
|
|
1778
1778
|
self.device.validated_save()
|
|
1779
1779
|
|
|
1780
1780
|
def test_all_x_properties(self):
|
|
1781
|
+
self.assertTrue(self.device.has_module_bays)
|
|
1781
1782
|
self.assertEqual(self.device.all_modules.count(), 0)
|
|
1782
1783
|
self.assertEqual(self.device.all_module_bays.count(), 1)
|
|
1783
1784
|
self.assertEqual(self.device.all_console_server_ports.count(), 1)
|
nautobot/dcim/urls.py
CHANGED
|
@@ -493,7 +493,7 @@ urlpatterns = [
|
|
|
493
493
|
name="device_notes",
|
|
494
494
|
kwargs={"model": Device},
|
|
495
495
|
),
|
|
496
|
-
path(
|
|
496
|
+
path(
|
|
497
497
|
"devices/<uuid:pk>/dynamic-groups/",
|
|
498
498
|
views.DeviceDynamicGroupsView.as_view(),
|
|
499
499
|
name="device_dynamicgroups",
|
nautobot/dcim/views.py
CHANGED
|
@@ -59,6 +59,7 @@ from nautobot.core.views.viewsets import NautobotUIViewSet
|
|
|
59
59
|
from nautobot.dcim.choices import LocationDataToContactActionChoices
|
|
60
60
|
from nautobot.dcim.forms import LocationMigrateDataToContactForm
|
|
61
61
|
from nautobot.extras.models import Contact, ContactAssociation, Role, Status, Team
|
|
62
|
+
from nautobot.extras.tables import DynamicGroupTable
|
|
62
63
|
from nautobot.extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectDynamicGroupsView
|
|
63
64
|
from nautobot.ipam.models import IPAddress, Prefix, Service, VLAN
|
|
64
65
|
from nautobot.ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable, VRFDeviceAssignmentTable, VRFTable
|
|
@@ -1701,7 +1702,39 @@ class DeviceView(generic.ObjectView):
|
|
|
1701
1702
|
"tenant__tenant_group",
|
|
1702
1703
|
).prefetch_related("images", "software_image_files")
|
|
1703
1704
|
|
|
1704
|
-
|
|
1705
|
+
class DeviceDetailContent(object_detail.ObjectDetailContent):
|
|
1706
|
+
"""
|
|
1707
|
+
Override base ObjectDetailContent to render dynamic-groups table as a separate view/tab instead of inline.
|
|
1708
|
+
"""
|
|
1709
|
+
|
|
1710
|
+
def __init__(self, **kwargs):
|
|
1711
|
+
super().__init__(**kwargs)
|
|
1712
|
+
# Remove inline tab definition
|
|
1713
|
+
for tab in list(self._tabs):
|
|
1714
|
+
if isinstance(tab, object_detail._ObjectDetailGroupsTab):
|
|
1715
|
+
self._tabs.remove(tab)
|
|
1716
|
+
# Add distinct-view tab definition
|
|
1717
|
+
self._tabs.append(
|
|
1718
|
+
object_detail.DistinctViewTab(
|
|
1719
|
+
weight=object_detail.Tab.WEIGHT_GROUPS_TAB,
|
|
1720
|
+
tab_id="dynamic_groups",
|
|
1721
|
+
label="Dynamic Groups",
|
|
1722
|
+
url_name="dcim:device_dynamicgroups",
|
|
1723
|
+
related_object_attribute="dynamic_groups",
|
|
1724
|
+
panels=(
|
|
1725
|
+
object_detail.ObjectsTablePanel(
|
|
1726
|
+
weight=100,
|
|
1727
|
+
table_class=DynamicGroupTable,
|
|
1728
|
+
table_attribute="dynamic_groups",
|
|
1729
|
+
exclude_columns=["content_type"],
|
|
1730
|
+
add_button_route=None,
|
|
1731
|
+
related_field_name="member_id",
|
|
1732
|
+
),
|
|
1733
|
+
),
|
|
1734
|
+
)
|
|
1735
|
+
)
|
|
1736
|
+
|
|
1737
|
+
object_detail_content = DeviceDetailContent(
|
|
1705
1738
|
extra_buttons=(
|
|
1706
1739
|
object_detail.DropdownButton(
|
|
1707
1740
|
weight=100,
|
|
@@ -2188,7 +2221,7 @@ class DeviceChangeLogView(ObjectChangeLogView):
|
|
|
2188
2221
|
base_template = "dcim/device/base.html"
|
|
2189
2222
|
|
|
2190
2223
|
|
|
2191
|
-
class DeviceDynamicGroupsView(ObjectDynamicGroupsView):
|
|
2224
|
+
class DeviceDynamicGroupsView(ObjectDynamicGroupsView):
|
|
2192
2225
|
base_template = "dcim/device/base.html"
|
|
2193
2226
|
|
|
2194
2227
|
|
nautobot/extras/views.py
CHANGED
|
@@ -945,11 +945,13 @@ class DynamicGroupBulkDeleteView(generic.BulkDeleteView):
|
|
|
945
945
|
filterset = filters.DynamicGroupFilterSet
|
|
946
946
|
|
|
947
947
|
|
|
948
|
-
# 3.0 TODO: remove, deprecated since 2.3 (#5845)
|
|
949
948
|
class ObjectDynamicGroupsView(generic.GenericView):
|
|
950
949
|
"""
|
|
951
950
|
Present a list of dynamic groups associated to a particular object.
|
|
952
951
|
|
|
952
|
+
Note that this isn't currently widely used, as most object detail views currently render the table inline
|
|
953
|
+
rather than using this separate view. This may change in the future.
|
|
954
|
+
|
|
953
955
|
base_template: Specify to explicitly identify the base object detail template to render.
|
|
954
956
|
If not provided, "<app>/<model>.html", "<app>/<model>_retrieve.html", or "generic/object_retrieve.html"
|
|
955
957
|
will be used, as per `get_base_template()`.
|
|
@@ -969,7 +971,6 @@ class ObjectDynamicGroupsView(generic.GenericView):
|
|
|
969
971
|
data=obj.dynamic_groups.restrict(request.user, "view"), orderable=False
|
|
970
972
|
)
|
|
971
973
|
dynamicgroups_table.columns.hide("content_type")
|
|
972
|
-
dynamicgroups_table.columns.hide("members")
|
|
973
974
|
|
|
974
975
|
# Apply the request context
|
|
975
976
|
paginate = {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by Django 4.2.20 on 2025-07-17 16:10
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
dependencies = [
|
|
8
|
+
("ipam", "0051_added_optional_vrf_relationship_to_vdc"),
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
operations = [
|
|
12
|
+
migrations.AlterIndexTogether(
|
|
13
|
+
name="ipaddress",
|
|
14
|
+
index_together={("ip_version", "host", "mask_length")},
|
|
15
|
+
),
|
|
16
|
+
migrations.AlterIndexTogether(
|
|
17
|
+
name="prefix",
|
|
18
|
+
index_together={
|
|
19
|
+
("namespace", "ip_version", "network", "prefix_length"),
|
|
20
|
+
("namespace", "network", "broadcast", "prefix_length"),
|
|
21
|
+
("network", "broadcast", "prefix_length"),
|
|
22
|
+
},
|
|
23
|
+
),
|
|
24
|
+
migrations.AlterIndexTogether(
|
|
25
|
+
name="vrf",
|
|
26
|
+
index_together={("namespace", "name", "rd")},
|
|
27
|
+
),
|
|
28
|
+
]
|
nautobot/ipam/models.py
CHANGED
|
@@ -170,6 +170,9 @@ class VRF(PrimaryModel):
|
|
|
170
170
|
# where multiple different-RD VRFs with the same name may already exist.
|
|
171
171
|
# ["namespace", "name"],
|
|
172
172
|
]
|
|
173
|
+
index_together = [
|
|
174
|
+
["namespace", "name", "rd"],
|
|
175
|
+
]
|
|
173
176
|
verbose_name = "VRF"
|
|
174
177
|
verbose_name_plural = "VRFs"
|
|
175
178
|
|
|
@@ -409,7 +412,12 @@ class VRFPrefixAssignment(BaseModel):
|
|
|
409
412
|
super().clean()
|
|
410
413
|
|
|
411
414
|
if self.prefix.namespace != self.vrf.namespace:
|
|
412
|
-
raise ValidationError(
|
|
415
|
+
raise ValidationError(
|
|
416
|
+
{
|
|
417
|
+
"prefix": f"Prefix (namespace {self.prefix.namespace}) must be in same namespace as "
|
|
418
|
+
"VRF (namespace {self.vrf.namespace})"
|
|
419
|
+
}
|
|
420
|
+
)
|
|
413
421
|
|
|
414
422
|
|
|
415
423
|
@extras_features(
|
|
@@ -589,6 +597,7 @@ class Prefix(PrimaryModel):
|
|
|
589
597
|
index_together = [
|
|
590
598
|
["network", "broadcast", "prefix_length"],
|
|
591
599
|
["namespace", "network", "broadcast", "prefix_length"],
|
|
600
|
+
["namespace", "ip_version", "network", "prefix_length"],
|
|
592
601
|
]
|
|
593
602
|
unique_together = ["namespace", "network", "prefix_length"]
|
|
594
603
|
verbose_name_plural = "prefixes"
|
|
@@ -1250,6 +1259,9 @@ class IPAddress(PrimaryModel):
|
|
|
1250
1259
|
verbose_name = "IP address"
|
|
1251
1260
|
verbose_name_plural = "IP addresses"
|
|
1252
1261
|
unique_together = ["parent", "host"]
|
|
1262
|
+
index_together = [
|
|
1263
|
+
["ip_version", "host", "mask_length"],
|
|
1264
|
+
]
|
|
1253
1265
|
|
|
1254
1266
|
def __init__(self, *args, address=None, namespace=None, **kwargs):
|
|
1255
1267
|
super().__init__(*args, **kwargs)
|
nautobot/ipam/tests/test_api.py
CHANGED
|
@@ -316,9 +316,7 @@ class VRFPrefixAssignmentTest(APIViewTestCases.APIViewTestCase):
|
|
|
316
316
|
response, "The fields vrf, prefix must make a unique set.", status_code=status.HTTP_400_BAD_REQUEST
|
|
317
317
|
)
|
|
318
318
|
response = self.client.post(self._get_list_url(), wrong_namespace_create_data, format="json", **self.header)
|
|
319
|
-
self.assertContains(
|
|
320
|
-
response, "Prefix must be in same namespace as VRF", status_code=status.HTTP_400_BAD_REQUEST
|
|
321
|
-
)
|
|
319
|
+
self.assertContains(response, "must be in same namespace as", status_code=status.HTTP_400_BAD_REQUEST)
|
|
322
320
|
response = self.client.post(self._get_list_url(), missing_field_create_data, format="json", **self.header)
|
|
323
321
|
self.assertContains(response, "This field may not be null.", status_code=status.HTTP_400_BAD_REQUEST)
|
|
324
322
|
|
nautobot/ipam/utils/testing.py
CHANGED
|
@@ -5,6 +5,8 @@ import random
|
|
|
5
5
|
from django.apps import apps
|
|
6
6
|
from netaddr import IPNetwork
|
|
7
7
|
|
|
8
|
+
from nautobot.ipam.models import get_default_namespace
|
|
9
|
+
|
|
8
10
|
# Calculate the probabilities to use for the maybe_subdivide() function defined below.
|
|
9
11
|
|
|
10
12
|
# Frequency of IPv4 (leaf, network) Prefixes by each given mask length in a "realistic" data set.
|
|
@@ -113,40 +115,73 @@ def create_prefixes_and_ips(initial_subnet: str, apps=apps, seed="Nautobot"): #
|
|
|
113
115
|
status_active, _ = Status.objects.get_or_create(name="Active", defaults={"slug": "active"})
|
|
114
116
|
|
|
115
117
|
for i in range(1, 11):
|
|
116
|
-
Tenant.objects.
|
|
117
|
-
|
|
118
|
+
Tenant.objects.get_or_create(name=f"{initial_subnet} Tenant {i}")
|
|
119
|
+
if hasattr(VRF, "enforce_unique"):
|
|
120
|
+
VRF.objects.get_or_create(
|
|
121
|
+
name=f"{initial_subnet} VRF {i}",
|
|
122
|
+
enforce_unique=False, # TODO should some enforce_unique?
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
VRF.objects.get_or_create(name=f"{initial_subnet} VRF {i}")
|
|
118
126
|
|
|
119
127
|
all_tenants = list(Tenant.objects.all())
|
|
120
|
-
|
|
128
|
+
if hasattr(VRF, "namespace"):
|
|
129
|
+
all_vrfs = list(VRF.objects.filter(namespace=get_default_namespace()))
|
|
130
|
+
else:
|
|
131
|
+
all_vrfs = list(VRF.objects.all())
|
|
132
|
+
|
|
133
|
+
create_prefixes(initial_subnet, all_tenants, all_vrfs, status_active, Prefix)
|
|
134
|
+
create_ips(initial_subnet, all_tenants, all_vrfs, status_active, IPAddress)
|
|
121
135
|
|
|
136
|
+
|
|
137
|
+
def create_prefixes(initial_subnet, all_tenants, all_vrfs, status_active, Prefix):
|
|
122
138
|
print(f"Creating Prefixes to subdivide {initial_subnet}")
|
|
123
139
|
unique_prefix_count = 0
|
|
124
140
|
duplicate_prefix_count = 0
|
|
125
141
|
for subnet in maybe_subdivide(IPNetwork(initial_subnet)):
|
|
126
142
|
if random.random() < 0.95: # noqa: S311 # suspicious-non-cryptographic-random-usage
|
|
127
143
|
# 95% chance to create any given Prefix
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
prefix_length=subnet.prefixlen,
|
|
132
|
-
status=status_active,
|
|
133
|
-
tenant=maybe_random_instance(all_tenants),
|
|
134
|
-
vrf=maybe_random_instance(all_vrfs),
|
|
135
|
-
)
|
|
136
|
-
unique_prefix_count += 1
|
|
137
|
-
while random.random() < 0.1: # noqa: S311 # suspicious-non-cryptographic-random-usage
|
|
138
|
-
# 10% repeating chance to create a duplicate(s) of this Prefix
|
|
139
|
-
Prefix.objects.create(
|
|
144
|
+
vrf = maybe_random_instance(all_vrfs)
|
|
145
|
+
if hasattr(Prefix, "vrf"):
|
|
146
|
+
Prefix.objects.get_or_create(
|
|
140
147
|
network=str(subnet.network),
|
|
141
148
|
broadcast=str(subnet.broadcast if subnet.broadcast else subnet[-1]),
|
|
142
149
|
prefix_length=subnet.prefixlen,
|
|
143
150
|
status=status_active,
|
|
144
151
|
tenant=maybe_random_instance(all_tenants),
|
|
145
|
-
vrf=
|
|
152
|
+
vrf=vrf,
|
|
146
153
|
)
|
|
147
|
-
|
|
154
|
+
else:
|
|
155
|
+
prefix, _ = Prefix.objects.get_or_create(
|
|
156
|
+
network=str(subnet.network),
|
|
157
|
+
broadcast=str(subnet.broadcast if subnet.broadcast else subnet[-1]),
|
|
158
|
+
prefix_length=subnet.prefixlen,
|
|
159
|
+
status=status_active,
|
|
160
|
+
tenant=maybe_random_instance(all_tenants),
|
|
161
|
+
)
|
|
162
|
+
if vrf is not None:
|
|
163
|
+
vrf.add_prefix(prefix)
|
|
164
|
+
|
|
165
|
+
unique_prefix_count += 1
|
|
166
|
+
if hasattr(Prefix, "vrf"):
|
|
167
|
+
while random.random() < 0.1: # noqa: S311 # suspicious-non-cryptographic-random-usage
|
|
168
|
+
# 10% repeating chance to create a duplicate(s) of this Prefix
|
|
169
|
+
Prefix.objects.create(
|
|
170
|
+
network=str(subnet.network),
|
|
171
|
+
broadcast=str(subnet.broadcast if subnet.broadcast else subnet[-1]),
|
|
172
|
+
prefix_length=subnet.prefixlen,
|
|
173
|
+
status=status_active,
|
|
174
|
+
tenant=maybe_random_instance(all_tenants),
|
|
175
|
+
vrf=maybe_random_instance(all_vrfs),
|
|
176
|
+
)
|
|
177
|
+
duplicate_prefix_count += 1
|
|
178
|
+
else:
|
|
179
|
+
# TODO: create prefixes in different namespaces?
|
|
180
|
+
pass
|
|
148
181
|
print(f"Created {unique_prefix_count} unique Prefixes and {duplicate_prefix_count} duplicates")
|
|
149
182
|
|
|
183
|
+
|
|
184
|
+
def create_ips(initial_subnet, all_tenants, all_vrfs, status_active, IPAddress):
|
|
150
185
|
print(f"Creating IPAddresses within {initial_subnet}")
|
|
151
186
|
unique_ip_count = 0
|
|
152
187
|
duplicate_ip_count = 0
|
|
@@ -154,17 +189,7 @@ def create_prefixes_and_ips(initial_subnet: str, apps=apps, seed="Nautobot"): #
|
|
|
154
189
|
if random.random() < 0.05: # noqa: S311 # suspicious-non-cryptographic-random-usage
|
|
155
190
|
# 5% chance to create any given IP address
|
|
156
191
|
network = IPNetwork(ip)
|
|
157
|
-
IPAddress
|
|
158
|
-
host=str(network.ip),
|
|
159
|
-
broadcast=str(network.broadcast if network.broadcast else network[-1]),
|
|
160
|
-
prefix_length=network.prefixlen,
|
|
161
|
-
status=status_active,
|
|
162
|
-
tenant=maybe_random_instance(all_tenants),
|
|
163
|
-
vrf=maybe_random_instance(all_vrfs),
|
|
164
|
-
)
|
|
165
|
-
unique_ip_count += 1
|
|
166
|
-
while random.random() < 0.1: # noqa: S311 # suspicious-non-cryptographic-random-usage
|
|
167
|
-
# 10% repeating chance to create a duplicate(s) of this IP
|
|
192
|
+
if hasattr(IPAddress, "prefix_length"):
|
|
168
193
|
IPAddress.objects.create(
|
|
169
194
|
host=str(network.ip),
|
|
170
195
|
broadcast=str(network.broadcast if network.broadcast else network[-1]),
|
|
@@ -173,5 +198,27 @@ def create_prefixes_and_ips(initial_subnet: str, apps=apps, seed="Nautobot"): #
|
|
|
173
198
|
tenant=maybe_random_instance(all_tenants),
|
|
174
199
|
vrf=maybe_random_instance(all_vrfs),
|
|
175
200
|
)
|
|
176
|
-
|
|
201
|
+
else:
|
|
202
|
+
IPAddress.objects.create(
|
|
203
|
+
host=str(network.ip),
|
|
204
|
+
mask_length=network.prefixlen,
|
|
205
|
+
status=status_active,
|
|
206
|
+
tenant=maybe_random_instance(all_tenants),
|
|
207
|
+
)
|
|
208
|
+
unique_ip_count += 1
|
|
209
|
+
if hasattr(IPAddress, "prefix_length"):
|
|
210
|
+
while random.random() < 0.1: # noqa: S311 # suspicious-non-cryptographic-random-usage
|
|
211
|
+
# 10% repeating chance to create a duplicate(s) of this IP
|
|
212
|
+
IPAddress.objects.create(
|
|
213
|
+
host=str(network.ip),
|
|
214
|
+
broadcast=str(network.broadcast if network.broadcast else network[-1]),
|
|
215
|
+
prefix_length=network.prefixlen,
|
|
216
|
+
status=status_active,
|
|
217
|
+
tenant=maybe_random_instance(all_tenants),
|
|
218
|
+
vrf=maybe_random_instance(all_vrfs),
|
|
219
|
+
)
|
|
220
|
+
duplicate_ip_count += 1
|
|
221
|
+
else:
|
|
222
|
+
# TODO: create duplicate IPs in other namespaces?
|
|
223
|
+
pass
|
|
177
224
|
print(f"Created {unique_ip_count} unique IPAddresses and {duplicate_ip_count} duplicates")
|
|
@@ -13617,6 +13617,8 @@ Override to add more variables to Response</p>
|
|
|
13617
13617
|
|
|
13618
13618
|
|
|
13619
13619
|
<p>Present a list of dynamic groups associated to a particular object.</p>
|
|
13620
|
+
<p>Note that this isn't currently widely used, as most object detail views currently render the table inline
|
|
13621
|
+
rather than using this separate view. This may change in the future.</p>
|
|
13620
13622
|
|
|
13621
13623
|
|
|
13622
13624
|
<details class="base_template" open>
|