nautobot 2.3.0__py3-none-any.whl → 2.3.1__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/cloud/forms.py +1 -1
- nautobot/cloud/tests/test_views.py +17 -0
- nautobot/cloud/views.py +1 -1
- nautobot/core/celery/__init__.py +5 -2
- nautobot/core/templates/generic/object_retrieve.html +1 -1
- nautobot/core/templates/inc/computed_fields/panel_data.html +36 -24
- nautobot/core/templates/inc/object_details_advanced_panel.html +1 -1
- nautobot/core/views/__init__.py +1 -1
- nautobot/dcim/forms.py +19 -0
- nautobot/dcim/models/devices.py +12 -15
- nautobot/dcim/templates/dcim/device.html +1 -1
- nautobot/dcim/templates/dcim/softwareimagefile_retrieve.html +1 -1
- nautobot/dcim/tests/test_forms.py +54 -0
- nautobot/dcim/tests/test_models.py +9 -1
- nautobot/dcim/tests/test_views.py +6 -2
- nautobot/extras/api/serializers.py +0 -1
- nautobot/extras/filters/__init__.py +4 -1
- nautobot/extras/forms/forms.py +1 -0
- nautobot/extras/migrations/0114_computedfield_grouping.py +17 -0
- nautobot/extras/models/customfields.py +54 -0
- nautobot/extras/templates/extras/computedfield.html +4 -0
- nautobot/extras/views.py +1 -1
- nautobot/ipam/querysets.py +26 -0
- nautobot/ipam/tests/test_models.py +86 -0
- nautobot/ipam/views.py +4 -4
- nautobot/project-static/docs/code-reference/nautobot/apps/filters.html +0 -45
- nautobot/project-static/docs/code-reference/nautobot/apps/forms.html +0 -90
- nautobot/project-static/docs/code-reference/nautobot/apps/models.html +127 -0
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-2.3.html +148 -24
- nautobot/project-static/docs/requirements.txt +1 -1
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +271 -271
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- {nautobot-2.3.0.dist-info → nautobot-2.3.1.dist-info}/METADATA +1 -1
- {nautobot-2.3.0.dist-info → nautobot-2.3.1.dist-info}/RECORD +40 -39
- {nautobot-2.3.0.dist-info → nautobot-2.3.1.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.3.0.dist-info → nautobot-2.3.1.dist-info}/NOTICE +0 -0
- {nautobot-2.3.0.dist-info → nautobot-2.3.1.dist-info}/WHEEL +0 -0
- {nautobot-2.3.0.dist-info → nautobot-2.3.1.dist-info}/entry_points.txt +0 -0
nautobot/cloud/forms.py
CHANGED
|
@@ -26,7 +26,7 @@ class CloudAccountForm(NautobotModelForm):
|
|
|
26
26
|
queryset=Manufacturer.objects.all(),
|
|
27
27
|
help_text="The Manufacturer instance which represents the Cloud Provider",
|
|
28
28
|
)
|
|
29
|
-
secrets_group = DynamicModelChoiceField(queryset=SecretsGroup.objects.all())
|
|
29
|
+
secrets_group = DynamicModelChoiceField(queryset=SecretsGroup.objects.all(), required=False)
|
|
30
30
|
|
|
31
31
|
class Meta:
|
|
32
32
|
model = CloudAccount
|
|
@@ -2,6 +2,7 @@ from django.contrib.contenttypes.models import ContentType
|
|
|
2
2
|
|
|
3
3
|
from nautobot.cloud.models import CloudAccount, CloudNetwork, CloudResourceType, CloudService
|
|
4
4
|
from nautobot.core.testing import ViewTestCases
|
|
5
|
+
from nautobot.core.testing.utils import post_data
|
|
5
6
|
from nautobot.dcim.models import Manufacturer
|
|
6
7
|
from nautobot.extras.models import SecretsGroup, Tag
|
|
7
8
|
|
|
@@ -36,6 +37,22 @@ class CloudAccountTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
|
36
37
|
"comments": "New comments",
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
def test_post_without_secrets_group(self):
|
|
41
|
+
"""Assert Secrets Group form field is not required: Fix for https://github.com/nautobot/nautobot/issues/6096"""
|
|
42
|
+
self.add_permissions("cloud.add_cloudaccount", "dcim.view_manufacturer")
|
|
43
|
+
form_data = {
|
|
44
|
+
"name": "New Cloud Account 2",
|
|
45
|
+
"account_number": "8928371982311",
|
|
46
|
+
"provider": Manufacturer.objects.first().pk,
|
|
47
|
+
"description": "A new cloud account",
|
|
48
|
+
}
|
|
49
|
+
request = {
|
|
50
|
+
"path": self._get_url("add"),
|
|
51
|
+
"data": post_data(form_data),
|
|
52
|
+
}
|
|
53
|
+
self.assertHttpStatus(self.client.post(**request), 302)
|
|
54
|
+
self.assertTrue(CloudAccount.objects.filter(name="New Cloud Account 2").exists())
|
|
55
|
+
|
|
39
56
|
|
|
40
57
|
class CloudNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
41
58
|
model = CloudNetwork
|
nautobot/cloud/views.py
CHANGED
|
@@ -59,7 +59,7 @@ class CloudNetworkUIViewSet(NautobotUIViewSet):
|
|
|
59
59
|
context = super().get_extra_context(request, instance)
|
|
60
60
|
if self.action == "retrieve":
|
|
61
61
|
prefixes = instance.prefixes.restrict(request.user, "view")
|
|
62
|
-
prefixes_table = PrefixTable(prefixes.select_related("namespace"))
|
|
62
|
+
prefixes_table = PrefixTable(prefixes.select_related("namespace"), hide_hierarchy_ui=True)
|
|
63
63
|
prefixes_table.columns.hide("location_count")
|
|
64
64
|
prefixes_table.columns.hide("vlan")
|
|
65
65
|
|
nautobot/core/celery/__init__.py
CHANGED
|
@@ -8,7 +8,7 @@ from celery import Celery, shared_task, signals
|
|
|
8
8
|
from celery.app.log import TaskFormatter
|
|
9
9
|
from celery.utils.log import get_logger
|
|
10
10
|
from django.conf import settings
|
|
11
|
-
from django.db.utils import ProgrammingError
|
|
11
|
+
from django.db.utils import OperationalError, ProgrammingError
|
|
12
12
|
from django.utils.functional import SimpleLazyObject
|
|
13
13
|
from django.utils.module_loading import import_string
|
|
14
14
|
from kombu.serialization import register
|
|
@@ -55,7 +55,10 @@ def import_jobs(sender=None, **kwargs):
|
|
|
55
55
|
|
|
56
56
|
try:
|
|
57
57
|
_import_jobs_from_git_repositories()
|
|
58
|
-
except
|
|
58
|
+
except (
|
|
59
|
+
OperationalError, # Database not present, as may be the case when running pylint-nautobot
|
|
60
|
+
ProgrammingError, # Database not ready yet, as may be the case on initial startup and migration
|
|
61
|
+
):
|
|
59
62
|
pass
|
|
60
63
|
|
|
61
64
|
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
<div class="row">
|
|
135
135
|
<div class="col-md-6">
|
|
136
136
|
{% block content_left_page %}{% endblock content_left_page %}
|
|
137
|
-
{% include 'inc/custom_fields/panel.html' with custom_fields=object.get_custom_field_groupings_basic computed_fields_advanced_ui=False %}
|
|
137
|
+
{% include 'inc/custom_fields/panel.html' with custom_fields=object.get_custom_field_groupings_basic computed_fields=object.get_computed_fields_grouping_basic computed_fields_advanced_ui=False %}
|
|
138
138
|
{% include 'inc/relationships_panel.html' %}
|
|
139
139
|
{% include 'extras/inc/tags_panel.html' %}
|
|
140
140
|
{% plugin_left_page object %}
|
|
@@ -1,25 +1,37 @@
|
|
|
1
|
-
{%
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
</
|
|
1
|
+
{% load helpers %}
|
|
2
|
+
{% if computed_fields %}
|
|
3
|
+
<style>
|
|
4
|
+
.accordion-toggle {
|
|
5
|
+
font-size: 14px;
|
|
6
|
+
}
|
|
7
|
+
</style>
|
|
8
|
+
<div class="panel panel-default">
|
|
9
|
+
<div class="panel-heading">
|
|
10
|
+
<strong>Computed Fields</strong>
|
|
11
|
+
<button type="button" class="btn-xs btn-primary pull-right" id="accordion-toggle-all">Collapse All</button>
|
|
11
12
|
</div>
|
|
12
|
-
|
|
13
|
-
{%
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
{% endif %}
|
|
13
|
+
<table id="accordion" class="table table-hover panel-body attr-table">
|
|
14
|
+
{% for grouping, fields in computed_fields.items %}
|
|
15
|
+
{% with forloop.counter0 as count %}
|
|
16
|
+
{% if grouping != "" %}
|
|
17
|
+
<tr>
|
|
18
|
+
<td colspan="2"><strong>
|
|
19
|
+
<button type="button" class="accordion-toggle mdi mdi-chevron-down"
|
|
20
|
+
name="grouping.{{ grouping }}" data-toggle="collapse"
|
|
21
|
+
data-target=".collapseme-computed-{{ count }}">
|
|
22
|
+
{{ grouping }}
|
|
23
|
+
</button></strong>
|
|
24
|
+
</td>
|
|
25
|
+
</tr>
|
|
26
|
+
{% endif %}
|
|
27
|
+
{% for field, value in fields %}
|
|
28
|
+
<tr class="collapseme-computed-{{ count }} collapse in" data-parent="#accordion">
|
|
29
|
+
<td><span title="{{ field.description }}">{{ field }}</span></td>
|
|
30
|
+
<td>{{ value }}</td>
|
|
31
|
+
</tr>
|
|
32
|
+
{% endfor %}
|
|
33
|
+
{% endwith %}
|
|
34
|
+
{% endfor %}
|
|
35
|
+
</table>
|
|
36
|
+
</div>
|
|
37
|
+
{% endif %}
|
|
@@ -88,6 +88,6 @@
|
|
|
88
88
|
</tbody>
|
|
89
89
|
</table>
|
|
90
90
|
</div>
|
|
91
|
-
{% include 'inc/custom_fields/panel.html' with custom_fields=object.get_custom_field_groupings_advanced computed_fields_advanced_ui=True %}
|
|
91
|
+
{% include 'inc/custom_fields/panel.html' with custom_fields=object.get_custom_field_groupings_advanced computed_fields=object.get_computed_fields_grouping_advanced computed_fields_advanced_ui=True %}
|
|
92
92
|
{% include 'inc/relationships/panel_override.html' with relationships_fields_override=object.get_relationships_data_advanced_fields %}
|
|
93
93
|
{% endif %}
|
nautobot/core/views/__init__.py
CHANGED
|
@@ -299,7 +299,7 @@ class SearchView(AccessMixin, View):
|
|
|
299
299
|
|
|
300
300
|
# Construct the results table for this object type
|
|
301
301
|
filtered_queryset = filterset({"q": form.cleaned_data["q"]}, queryset=queryset).qs
|
|
302
|
-
table = table(filtered_queryset, orderable=False)
|
|
302
|
+
table = table(filtered_queryset, hide_hierarchy_ui=True, orderable=False)
|
|
303
303
|
table.paginate(per_page=SEARCH_MAX_RESULTS)
|
|
304
304
|
|
|
305
305
|
if table.page:
|
nautobot/dcim/forms.py
CHANGED
|
@@ -2064,6 +2064,25 @@ class DeviceForm(LocatableModelFormMixin, NautobotModelForm, TenancyForm, LocalC
|
|
|
2064
2064
|
if position:
|
|
2065
2065
|
self.fields["position"].widget.choices = [(position, f"U{position}")]
|
|
2066
2066
|
|
|
2067
|
+
def clean(self):
|
|
2068
|
+
super().clean()
|
|
2069
|
+
|
|
2070
|
+
device_type = self.cleaned_data["device_type"]
|
|
2071
|
+
software_image_files = self.cleaned_data["software_image_files"]
|
|
2072
|
+
|
|
2073
|
+
# If any software image file is specified, validate that
|
|
2074
|
+
# each of the software image files belongs to the device's device type or is a default image
|
|
2075
|
+
for image_file in software_image_files:
|
|
2076
|
+
if not image_file.default_image and device_type not in image_file.device_types.all():
|
|
2077
|
+
raise ValidationError(
|
|
2078
|
+
{
|
|
2079
|
+
"software_image_files": (
|
|
2080
|
+
f"Software image file {image_file} for version '{image_file.software_version}' is not "
|
|
2081
|
+
f"valid for device type {device_type}."
|
|
2082
|
+
)
|
|
2083
|
+
}
|
|
2084
|
+
)
|
|
2085
|
+
|
|
2067
2086
|
def save(self, *args, **kwargs):
|
|
2068
2087
|
instance = super().save(*args, **kwargs)
|
|
2069
2088
|
instance.vrfs.set(self.cleaned_data["vrfs"])
|
nautobot/dcim/models/devices.py
CHANGED
|
@@ -818,21 +818,18 @@ class Device(PrimaryModel, ConfigContextModel):
|
|
|
818
818
|
}
|
|
819
819
|
)
|
|
820
820
|
|
|
821
|
-
#
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
)
|
|
834
|
-
}
|
|
835
|
-
)
|
|
821
|
+
# If any software image file is specified, validate that
|
|
822
|
+
# each of the software image files belongs to the device's device type or is a default image
|
|
823
|
+
for image_file in self.software_image_files.all():
|
|
824
|
+
if not image_file.default_image and self.device_type not in image_file.device_types.all():
|
|
825
|
+
raise ValidationError(
|
|
826
|
+
{
|
|
827
|
+
"software_image_files": (
|
|
828
|
+
f"Software image file {image_file} for version '{image_file.software_version}' is not "
|
|
829
|
+
f"valid for device type {self.device_type}."
|
|
830
|
+
)
|
|
831
|
+
}
|
|
832
|
+
)
|
|
836
833
|
|
|
837
834
|
def save(self, *args, **kwargs):
|
|
838
835
|
is_new = not self.present_in_database
|
|
@@ -233,7 +233,7 @@
|
|
|
233
233
|
{% include 'dcim/inc/detail_softwareversion_softwareimagefile_rows.html' %}
|
|
234
234
|
</table>
|
|
235
235
|
</div>
|
|
236
|
-
{% include 'inc/custom_fields/panel.html' with custom_fields=object.get_custom_field_groupings_basic computed_fields_advanced_ui=False %}
|
|
236
|
+
{% include 'inc/custom_fields/panel.html' with custom_fields=object.get_custom_field_groupings_basic computed_fields=object.get_computed_fields_grouping_basic computed_fields_advanced_ui=False %}
|
|
237
237
|
{% include 'inc/relationships/panel_override.html' with relationships_fields_override=object.get_relationships_data_basic_fields %}
|
|
238
238
|
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:device_list' %}
|
|
239
239
|
<div class="panel panel-default">
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
</div>
|
|
145
145
|
<div class="row">
|
|
146
146
|
<div class="col-md-6">
|
|
147
|
-
{% include 'inc/custom_fields/panel.html' with custom_fields=object.get_custom_field_groupings_basic computed_fields_advanced_ui=False %}
|
|
147
|
+
{% include 'inc/custom_fields/panel.html' with custom_fields=object.get_custom_field_groupings_basic computed_fields=object.get_computed_fields_grouping_basic computed_fields_advanced_ui=False %}
|
|
148
148
|
{% include 'inc/relationships_panel.html' %}
|
|
149
149
|
{% include 'extras/inc/tags_panel.html' %}
|
|
150
150
|
{% plugin_left_page object %}
|
|
@@ -12,6 +12,8 @@ from nautobot.dcim.models import (
|
|
|
12
12
|
Manufacturer,
|
|
13
13
|
Platform,
|
|
14
14
|
Rack,
|
|
15
|
+
SoftwareImageFile,
|
|
16
|
+
SoftwareVersion,
|
|
15
17
|
)
|
|
16
18
|
from nautobot.extras.models import Role, SecretsGroup, Status
|
|
17
19
|
from nautobot.ipam.models import VLAN
|
|
@@ -43,6 +45,15 @@ class DeviceTestCase(FormTestCases.BaseFormTestCase):
|
|
|
43
45
|
cls.manufacturer = cls.device_type.manufacturer
|
|
44
46
|
cls.platform = Platform.objects.filter(manufacturer=cls.device_type.manufacturer).first()
|
|
45
47
|
cls.device_role = Role.objects.get_for_model(Device).first()
|
|
48
|
+
cls.software_version_contains_no_valid_image_for_device_type = SoftwareVersion.objects.create(
|
|
49
|
+
platform=cls.platform,
|
|
50
|
+
version="New version 1.0.0",
|
|
51
|
+
status=Status.objects.get_for_model(SoftwareVersion).first(),
|
|
52
|
+
)
|
|
53
|
+
cls.software_version = SoftwareVersion.objects.first()
|
|
54
|
+
cls.software_image_files = SoftwareImageFile.objects.exclude(software_version=cls.software_version).exclude(
|
|
55
|
+
default_image=True
|
|
56
|
+
)
|
|
46
57
|
|
|
47
58
|
Device.objects.create(
|
|
48
59
|
name="Device 1",
|
|
@@ -134,6 +145,49 @@ class DeviceTestCase(FormTestCases.BaseFormTestCase):
|
|
|
134
145
|
self.assertFalse(form.is_valid())
|
|
135
146
|
self.assertIn("face", form.errors)
|
|
136
147
|
|
|
148
|
+
def test_no_software_image_file_specified_is_valid(self):
|
|
149
|
+
form = DeviceForm(
|
|
150
|
+
data={
|
|
151
|
+
"name": "New Device",
|
|
152
|
+
"role": self.device_role.pk,
|
|
153
|
+
"tenant": None,
|
|
154
|
+
"manufacturer": self.manufacturer.pk,
|
|
155
|
+
"device_type": self.device_type.pk,
|
|
156
|
+
"location": self.location.pk,
|
|
157
|
+
"rack": None,
|
|
158
|
+
"face": None,
|
|
159
|
+
"position": None,
|
|
160
|
+
"platform": self.platform.pk,
|
|
161
|
+
"status": self.device_status.pk,
|
|
162
|
+
"secrets_group": SecretsGroup.objects.first().pk,
|
|
163
|
+
"software_version": self.software_version_contains_no_valid_image_for_device_type.pk,
|
|
164
|
+
"software_image_files": [],
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
self.assertTrue(form.is_valid())
|
|
168
|
+
|
|
169
|
+
def test_invalid_software_image_file_specified(self):
|
|
170
|
+
form = DeviceForm(
|
|
171
|
+
data={
|
|
172
|
+
"name": "New Device",
|
|
173
|
+
"role": self.device_role.pk,
|
|
174
|
+
"tenant": None,
|
|
175
|
+
"manufacturer": self.manufacturer.pk,
|
|
176
|
+
"device_type": self.device_type.pk,
|
|
177
|
+
"location": self.location.pk,
|
|
178
|
+
"rack": None,
|
|
179
|
+
"face": None,
|
|
180
|
+
"position": None,
|
|
181
|
+
"platform": self.platform.pk,
|
|
182
|
+
"status": self.device_status.pk,
|
|
183
|
+
"secrets_group": SecretsGroup.objects.first().pk,
|
|
184
|
+
"software_version": self.software_version.pk,
|
|
185
|
+
"software_image_files": list(self.software_image_files.values_list("pk", flat=True)),
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
self.assertFalse(form.is_valid())
|
|
189
|
+
self.assertIn("software_image_files", form.errors)
|
|
190
|
+
|
|
137
191
|
def test_non_racked_device_with_position(self):
|
|
138
192
|
form = DeviceForm(
|
|
139
193
|
data={
|
|
@@ -1723,11 +1723,19 @@ class DeviceTestCase(ModelTestCases.BaseModelTestCase):
|
|
|
1723
1723
|
software_version.software_image_files.all().update(default_image=False)
|
|
1724
1724
|
self.device_type.software_image_files.set([])
|
|
1725
1725
|
self.device.software_version = software_version
|
|
1726
|
+
invalid_software_image_file = SoftwareImageFile.objects.filter(default_image=False).first()
|
|
1727
|
+
invalid_software_image_file.device_types.set([])
|
|
1728
|
+
self.device.software_image_files.set([invalid_software_image_file])
|
|
1726
1729
|
|
|
1727
|
-
#
|
|
1730
|
+
# There is an invalid non-default software_image_file specified for the software version
|
|
1731
|
+
# It is not a default image and it does not match any device type
|
|
1728
1732
|
with self.assertRaises(ValidationError):
|
|
1729
1733
|
self.device.validated_save()
|
|
1730
1734
|
|
|
1735
|
+
# user should be able to specify any software version without specifying software_image_files
|
|
1736
|
+
self.device.software_image_files.set([])
|
|
1737
|
+
self.device.validated_save()
|
|
1738
|
+
|
|
1731
1739
|
# Default image matches
|
|
1732
1740
|
software_image_file = software_version.software_image_files.first()
|
|
1733
1741
|
software_image_file.default_image = True
|
|
@@ -2084,7 +2084,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
|
2084
2084
|
status_active = statuses[0]
|
|
2085
2085
|
|
|
2086
2086
|
# We want unique sets of software image files for each device type
|
|
2087
|
-
software_image_files = list(SoftwareImageFile.objects.
|
|
2087
|
+
software_image_files = list(SoftwareImageFile.objects.filter(default_image=False)[:4])
|
|
2088
2088
|
software_versions = list(SoftwareVersion.objects.filter(software_image_files__isnull=False)[:2])
|
|
2089
2089
|
software_image_files[0].software_version = software_versions[0]
|
|
2090
2090
|
software_image_files[1].software_version = software_versions[0]
|
|
@@ -2094,6 +2094,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
|
2094
2094
|
software_image_file.save()
|
|
2095
2095
|
devicetypes[0].software_image_files.set(software_image_files[:2])
|
|
2096
2096
|
devicetypes[1].software_image_files.set(software_image_files[2:])
|
|
2097
|
+
# Only valid software image files are those that belong to the device type or default images
|
|
2098
|
+
valid_software_image_files = software_image_files[2:] + [
|
|
2099
|
+
SoftwareImageFile.objects.filter(default_image=True).first()
|
|
2100
|
+
]
|
|
2097
2101
|
|
|
2098
2102
|
cls.custom_fields = (
|
|
2099
2103
|
CustomField.objects.create(
|
|
@@ -2205,7 +2209,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
|
2205
2209
|
"cf_crash_counter": -1,
|
|
2206
2210
|
"cr_router-id": None,
|
|
2207
2211
|
"software_version": software_versions[1].pk,
|
|
2208
|
-
"software_image_files": [f.pk for f in
|
|
2212
|
+
"software_image_files": [f.pk for f in valid_software_image_files],
|
|
2209
2213
|
}
|
|
2210
2214
|
|
|
2211
2215
|
cls.bulk_edit_data = {
|
|
@@ -181,6 +181,7 @@ class ComputedFieldFilterSet(BaseFilterSet):
|
|
|
181
181
|
"description": "icontains",
|
|
182
182
|
"content_type__app_label": "icontains",
|
|
183
183
|
"content_type__model": "icontains",
|
|
184
|
+
"grouping": "icontains",
|
|
184
185
|
"template": "icontains",
|
|
185
186
|
"fallback_value": "icontains",
|
|
186
187
|
},
|
|
@@ -192,6 +193,7 @@ class ComputedFieldFilterSet(BaseFilterSet):
|
|
|
192
193
|
fields = (
|
|
193
194
|
"content_type",
|
|
194
195
|
"key",
|
|
196
|
+
"grouping",
|
|
195
197
|
"template",
|
|
196
198
|
"fallback_value",
|
|
197
199
|
"weight",
|
|
@@ -422,6 +424,7 @@ class CustomFieldFilterSet(BaseFilterSet):
|
|
|
422
424
|
filter_predicates={
|
|
423
425
|
"label": "icontains",
|
|
424
426
|
"description": "icontains",
|
|
427
|
+
"grouping": "icontains",
|
|
425
428
|
},
|
|
426
429
|
)
|
|
427
430
|
content_types = ContentTypeMultipleChoiceFilter(
|
|
@@ -430,7 +433,7 @@ class CustomFieldFilterSet(BaseFilterSet):
|
|
|
430
433
|
|
|
431
434
|
class Meta:
|
|
432
435
|
model = CustomField
|
|
433
|
-
fields = ["id", "content_types", "label", "required", "filter_logic", "weight"]
|
|
436
|
+
fields = ["id", "content_types", "label", "grouping", "required", "filter_logic", "weight"]
|
|
434
437
|
|
|
435
438
|
|
|
436
439
|
class CustomFieldChoiceFilterSet(BaseFilterSet):
|
nautobot/extras/forms/forms.py
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Generated by Django 4.2.15 on 2024-08-09 14:28
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
dependencies = [
|
|
8
|
+
("extras", "0113_saved_views"),
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
operations = [
|
|
12
|
+
migrations.AddField(
|
|
13
|
+
model_name="computedfield",
|
|
14
|
+
name="grouping",
|
|
15
|
+
field=models.CharField(blank=True, max_length=255),
|
|
16
|
+
),
|
|
17
|
+
]
|
|
@@ -88,6 +88,11 @@ class ComputedField(
|
|
|
88
88
|
help_text="Internal field name. Please use underscores rather than dashes in this key.",
|
|
89
89
|
slugify_function=slugify_dashes_to_underscores,
|
|
90
90
|
)
|
|
91
|
+
grouping = models.CharField(
|
|
92
|
+
max_length=CHARFIELD_MAX_LENGTH,
|
|
93
|
+
blank=True,
|
|
94
|
+
help_text="Human-readable grouping that this computed field belongs to.",
|
|
95
|
+
)
|
|
91
96
|
label = models.CharField(max_length=CHARFIELD_MAX_LENGTH, help_text="Name of the field as displayed to users")
|
|
92
97
|
description = models.CharField(max_length=CHARFIELD_MAX_LENGTH, blank=True)
|
|
93
98
|
template = models.TextField(max_length=500, help_text="Jinja2 template code for field value")
|
|
@@ -295,6 +300,55 @@ class CustomFieldModel(models.Model):
|
|
|
295
300
|
return computed_field.render(context={"obj": self})
|
|
296
301
|
return computed_field.template
|
|
297
302
|
|
|
303
|
+
def get_computed_fields_grouping_basic(self):
|
|
304
|
+
"""
|
|
305
|
+
This method exists to help call get_computed_field_groupings() in templates where a function argument (advanced_ui) cannot be specified.
|
|
306
|
+
Return a dictonary of computed fields grouped by the same grouping in the form
|
|
307
|
+
{
|
|
308
|
+
<grouping_1>: [(cf1, <value for cf1>), (cf2, <value for cf2>), ...],
|
|
309
|
+
...
|
|
310
|
+
<grouping_5>: [(cf8, <value for cf8>), (cf9, <value for cf9>), ...],
|
|
311
|
+
...
|
|
312
|
+
}
|
|
313
|
+
which have advanced_ui set to False
|
|
314
|
+
"""
|
|
315
|
+
return self.get_computed_fields_grouping(advanced_ui=False)
|
|
316
|
+
|
|
317
|
+
def get_computed_fields_grouping_advanced(self):
|
|
318
|
+
"""
|
|
319
|
+
This method exists to help call get_computed_field_groupings() in templates where a function argument (advanced_ui) cannot be specified.
|
|
320
|
+
Return a dictonary of computed fields grouped by the same grouping in the form
|
|
321
|
+
{
|
|
322
|
+
<grouping_1>: [(cf1, <value for cf1>), (cf2, <value for cf2>), ...],
|
|
323
|
+
...
|
|
324
|
+
<grouping_5>: [(cf8, <value for cf8>), (cf9, <value for cf9>), ...],
|
|
325
|
+
...
|
|
326
|
+
}
|
|
327
|
+
which have advanced_ui set to True
|
|
328
|
+
"""
|
|
329
|
+
return self.get_computed_fields_grouping(advanced_ui=True)
|
|
330
|
+
|
|
331
|
+
def get_computed_fields_grouping(self, advanced_ui=None):
|
|
332
|
+
"""
|
|
333
|
+
Return a dictonary of computed fields grouped by the same grouping in the form
|
|
334
|
+
{
|
|
335
|
+
<grouping_1>: [(cf1, <value for cf1>), (cf2, <value for cf2>), ...],
|
|
336
|
+
...
|
|
337
|
+
<grouping_5>: [(cf8, <value for cf8>), (cf9, <value for cf9>), ...],
|
|
338
|
+
...
|
|
339
|
+
}
|
|
340
|
+
"""
|
|
341
|
+
record = {}
|
|
342
|
+
computed_fields = ComputedField.objects.get_for_model(self)
|
|
343
|
+
if advanced_ui is not None:
|
|
344
|
+
computed_fields = computed_fields.filter(advanced_ui=advanced_ui)
|
|
345
|
+
|
|
346
|
+
for field in computed_fields:
|
|
347
|
+
data = (field, field.render(context={"obj": self}))
|
|
348
|
+
record.setdefault(field.grouping, []).append(data)
|
|
349
|
+
record = dict(sorted(record.items()))
|
|
350
|
+
return record
|
|
351
|
+
|
|
298
352
|
def get_computed_fields(self, label_as_key=False, advanced_ui=None):
|
|
299
353
|
"""
|
|
300
354
|
Return a dictionary of all computed fields and their rendered values for this model.
|
nautobot/extras/views.py
CHANGED
|
@@ -2421,7 +2421,7 @@ class RoleUIViewSet(viewsets.NautobotUIViewSet):
|
|
|
2421
2421
|
|
|
2422
2422
|
if ContentType.objects.get_for_model(Prefix) in context["content_types"]:
|
|
2423
2423
|
prefixes = instance.prefixes.restrict(request.user, "view")
|
|
2424
|
-
prefix_table = PrefixTable(prefixes)
|
|
2424
|
+
prefix_table = PrefixTable(prefixes, hide_hierarchy_ui=True)
|
|
2425
2425
|
prefix_table.columns.hide("role")
|
|
2426
2426
|
RequestConfig(request, paginate).configure(prefix_table)
|
|
2427
2427
|
context["prefix_table"] = prefix_table
|
nautobot/ipam/querysets.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import re
|
|
2
2
|
|
|
3
3
|
from django.core.exceptions import ValidationError
|
|
4
|
+
from django.core.validators import validate_ipv46_address
|
|
4
5
|
from django.db.models import ProtectedError, Q
|
|
5
6
|
import netaddr
|
|
6
7
|
|
|
@@ -397,6 +398,31 @@ class IPAddressQuerySet(BaseNetworkQuerySet):
|
|
|
397
398
|
"""
|
|
398
399
|
return super().order_by("host")
|
|
399
400
|
|
|
401
|
+
def get_or_create(self, **kwargs):
|
|
402
|
+
from nautobot.ipam.models import get_default_namespace, Prefix
|
|
403
|
+
|
|
404
|
+
parent = kwargs.get("parent")
|
|
405
|
+
namespace = kwargs.pop("namespace", None)
|
|
406
|
+
host = kwargs.get("host")
|
|
407
|
+
mask_length = kwargs.get("mask_length")
|
|
408
|
+
# If `host` or `mask_length` is None skip; then there is no way of getting the closest parent;
|
|
409
|
+
if parent is None and host is not None and mask_length is not None:
|
|
410
|
+
if namespace is None:
|
|
411
|
+
namespace = get_default_namespace()
|
|
412
|
+
cidr = f"{host}/{mask_length}"
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
validate_ipv46_address(host)
|
|
416
|
+
except ValidationError as err:
|
|
417
|
+
raise ValidationError({"host": err.error_list}) from err
|
|
418
|
+
try:
|
|
419
|
+
netaddr.IPNetwork(cidr)
|
|
420
|
+
except netaddr.AddrFormatError as err:
|
|
421
|
+
raise ValidationError(f"{cidr} does not appear to be an IPv4 or IPv6 network.") from err
|
|
422
|
+
parent = Prefix.objects.filter(namespace=namespace).get_closest_parent(cidr=cidr, include_self=True)
|
|
423
|
+
kwargs["parent"] = parent
|
|
424
|
+
return super().get_or_create(**kwargs)
|
|
425
|
+
|
|
400
426
|
def string_search(self, search):
|
|
401
427
|
"""
|
|
402
428
|
Interpret a search string and return useful results.
|