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.

Files changed (40) hide show
  1. nautobot/cloud/forms.py +1 -1
  2. nautobot/cloud/tests/test_views.py +17 -0
  3. nautobot/cloud/views.py +1 -1
  4. nautobot/core/celery/__init__.py +5 -2
  5. nautobot/core/templates/generic/object_retrieve.html +1 -1
  6. nautobot/core/templates/inc/computed_fields/panel_data.html +36 -24
  7. nautobot/core/templates/inc/object_details_advanced_panel.html +1 -1
  8. nautobot/core/views/__init__.py +1 -1
  9. nautobot/dcim/forms.py +19 -0
  10. nautobot/dcim/models/devices.py +12 -15
  11. nautobot/dcim/templates/dcim/device.html +1 -1
  12. nautobot/dcim/templates/dcim/softwareimagefile_retrieve.html +1 -1
  13. nautobot/dcim/tests/test_forms.py +54 -0
  14. nautobot/dcim/tests/test_models.py +9 -1
  15. nautobot/dcim/tests/test_views.py +6 -2
  16. nautobot/extras/api/serializers.py +0 -1
  17. nautobot/extras/filters/__init__.py +4 -1
  18. nautobot/extras/forms/forms.py +1 -0
  19. nautobot/extras/migrations/0114_computedfield_grouping.py +17 -0
  20. nautobot/extras/models/customfields.py +54 -0
  21. nautobot/extras/templates/extras/computedfield.html +4 -0
  22. nautobot/extras/views.py +1 -1
  23. nautobot/ipam/querysets.py +26 -0
  24. nautobot/ipam/tests/test_models.py +86 -0
  25. nautobot/ipam/views.py +4 -4
  26. nautobot/project-static/docs/code-reference/nautobot/apps/filters.html +0 -45
  27. nautobot/project-static/docs/code-reference/nautobot/apps/forms.html +0 -90
  28. nautobot/project-static/docs/code-reference/nautobot/apps/models.html +127 -0
  29. nautobot/project-static/docs/objects.inv +0 -0
  30. nautobot/project-static/docs/release-notes/version-2.3.html +148 -24
  31. nautobot/project-static/docs/requirements.txt +1 -1
  32. nautobot/project-static/docs/search/search_index.json +1 -1
  33. nautobot/project-static/docs/sitemap.xml +271 -271
  34. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  35. {nautobot-2.3.0.dist-info → nautobot-2.3.1.dist-info}/METADATA +1 -1
  36. {nautobot-2.3.0.dist-info → nautobot-2.3.1.dist-info}/RECORD +40 -39
  37. {nautobot-2.3.0.dist-info → nautobot-2.3.1.dist-info}/LICENSE.txt +0 -0
  38. {nautobot-2.3.0.dist-info → nautobot-2.3.1.dist-info}/NOTICE +0 -0
  39. {nautobot-2.3.0.dist-info → nautobot-2.3.1.dist-info}/WHEEL +0 -0
  40. {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
 
@@ -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 ProgrammingError: # Database not ready yet, as may be the case on initial startup and migration
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
- {% if not advanced_ui %}
2
- {% if object.has_computed_fields_basic %}
3
- {% load computed_fields %}
4
- <div class="panel panel-default">
5
- <div class="panel-heading">
6
- <strong>Computed Fields</strong>
7
- </div>
8
- <table class="table table-hover panel-body attr-table">
9
- {% computed_fields object advanced_ui %}
10
- </table>
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
- {% endif %}
13
- {% else %}
14
- {% if object.has_computed_fields_advanced %}
15
- {% load computed_fields %}
16
- <div class="panel panel-default">
17
- <div class="panel-heading">
18
- <strong>Computed Fields</strong>
19
- </div>
20
- <table class="table table-hover panel-body attr-table">
21
- {% computed_fields object advanced_ui %}
22
- </table>
23
- </div>
24
- {% endif %}
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 %}
@@ -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"])
@@ -818,21 +818,18 @@ class Device(PrimaryModel, ConfigContextModel):
818
818
  }
819
819
  )
820
820
 
821
- # Validate device software version has a software image file that matches the device's device type or is a default image
822
- if self.software_version is not None and not any(
823
- (
824
- self.software_version.software_image_files.filter(device_types=self.device_type).exists(),
825
- self.software_version.software_image_files.filter(default_image=True).exists(),
826
- )
827
- ):
828
- raise ValidationError(
829
- {
830
- "software_version": (
831
- f"No software image files for version '{self.software_version}' are "
832
- f"valid for device type {self.device_type}."
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
- # No device type or default image match
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.all()[:4])
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 software_versions[0].software_image_files.all()],
2212
+ "software_image_files": [f.pk for f in valid_software_image_files],
2209
2213
  }
2210
2214
 
2211
2215
  cls.bulk_edit_data = {
@@ -228,7 +228,6 @@ class ContactAssociationSerializer(NautobotModelSerializer):
228
228
  fields = "__all__"
229
229
  validators = []
230
230
  extra_kwargs = {
231
- "role": {"required": False},
232
231
  "contact": {"required": False},
233
232
  "team": {"required": False},
234
233
  }
@@ -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):
@@ -219,6 +219,7 @@ class ComputedFieldForm(BootstrapMixin, forms.ModelForm):
219
219
  fields = (
220
220
  "content_type",
221
221
  "label",
222
+ "grouping",
222
223
  "key",
223
224
  "description",
224
225
  "template",
@@ -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.
@@ -20,6 +20,10 @@
20
20
  <td>Key</td>
21
21
  <td><span>{{ object.key }}</span></td>
22
22
  </tr>
23
+ <tr>
24
+ <td>Grouping</td>
25
+ <td>{{ object.grouping | placeholder }}</td>
26
+ </tr>
23
27
  <tr>
24
28
  <td>Description</td>
25
29
  <td><span>{{ object.description|placeholder }}</span></td>
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
@@ -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.