nautobot 2.4.20__py3-none-any.whl → 2.4.21__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.
Files changed (91) hide show
  1. nautobot/circuits/templates/circuits/circuit.html +1 -1
  2. nautobot/circuits/templates/circuits/circuittermination.html +1 -1
  3. nautobot/circuits/templates/circuits/circuittype.html +1 -1
  4. nautobot/circuits/templates/circuits/providernetwork.html +1 -1
  5. nautobot/core/cli/migrate_deprecated_templates.py +200 -0
  6. nautobot/core/jobs/__init__.py +2 -1
  7. nautobot/core/jobs/groups.py +31 -1
  8. nautobot/core/models/tree_queries.py +10 -5
  9. nautobot/core/signals.py +12 -1
  10. nautobot/core/templates/components/panel/panel.html +1 -1
  11. nautobot/core/templates/inc/image_attachments.html +2 -1
  12. nautobot/core/templatetags/helpers.py +22 -0
  13. nautobot/core/tests/runner.py +3 -0
  14. nautobot/core/tests/test_cli.py +40 -0
  15. nautobot/core/tests/test_forms.py +41 -0
  16. nautobot/core/tests/test_jobs.py +75 -1
  17. nautobot/core/tests/test_tree_queries.py +14 -1
  18. nautobot/core/ui/object_detail.py +41 -5
  19. nautobot/core/utils/filtering.py +11 -9
  20. nautobot/core/views/generic.py +3 -3
  21. nautobot/dcim/models/device_components.py +81 -68
  22. nautobot/dcim/templates/dcim/device/config.html +1 -1
  23. nautobot/dcim/templates/dcim/device/consoleports.html +1 -1
  24. nautobot/dcim/templates/dcim/device/consoleserverports.html +1 -1
  25. nautobot/dcim/templates/dcim/device/devicebays.html +1 -1
  26. nautobot/dcim/templates/dcim/device/frontports.html +1 -1
  27. nautobot/dcim/templates/dcim/device/interfaces.html +1 -1
  28. nautobot/dcim/templates/dcim/device/inventory.html +1 -1
  29. nautobot/dcim/templates/dcim/device/lldp_neighbors.html +1 -1
  30. nautobot/dcim/templates/dcim/device/modulebays.html +1 -1
  31. nautobot/dcim/templates/dcim/device/poweroutlets.html +1 -1
  32. nautobot/dcim/templates/dcim/device/powerports.html +1 -1
  33. nautobot/dcim/templates/dcim/device/rearports.html +1 -1
  34. nautobot/dcim/templates/dcim/device/status.html +1 -1
  35. nautobot/dcim/templates/dcim/device/wireless.html +1 -1
  36. nautobot/dcim/templates/dcim/device.html +1 -1
  37. nautobot/dcim/templates/dcim/device_interface_delete.html +1 -1
  38. nautobot/dcim/templates/dcim/devicetype.html +1 -1
  39. nautobot/dcim/templates/dcim/footer_convert_to_contact_or_team_record.html +14 -0
  40. nautobot/dcim/templates/dcim/interface_bulk_delete.html +1 -1
  41. nautobot/dcim/templates/dcim/inventoryitem_bulk_delete.html +1 -1
  42. nautobot/dcim/templates/dcim/location_retrieve.html +1 -242
  43. nautobot/dcim/templates/dcim/modulefamily_retrieve.html +1 -1
  44. nautobot/dcim/templates/dcim/powerfeed.html +1 -1
  45. nautobot/dcim/templates/dcim/powerpanel.html +1 -1
  46. nautobot/dcim/templates/dcim/virtualchassis.html +1 -1
  47. nautobot/dcim/tests/test_models.py +43 -3
  48. nautobot/dcim/tests/test_views.py +52 -21
  49. nautobot/dcim/views.py +203 -87
  50. nautobot/extras/api/views.py +9 -1
  51. nautobot/extras/filters/customfields.py +9 -3
  52. nautobot/extras/models/groups.py +42 -5
  53. nautobot/extras/signals.py +20 -19
  54. nautobot/extras/tables.py +31 -2
  55. nautobot/extras/templates/extras/computedfield.html +1 -1
  56. nautobot/extras/templates/extras/configcontext.html +1 -1
  57. nautobot/extras/templates/extras/configcontextschema_validation.html +1 -1
  58. nautobot/extras/templates/extras/customfield.html +1 -1
  59. nautobot/extras/templates/extras/dynamicgroup_retrieve.html +11 -5
  60. nautobot/extras/templates/extras/gitrepository_result.html +0 -2
  61. nautobot/extras/templates/extras/graphqlquery_retrieve.html +1 -96
  62. nautobot/extras/templates/extras/inc/graphqlquery_execute.html +71 -0
  63. nautobot/extras/templates/extras/object_dynamicgroups.html +2 -2
  64. nautobot/extras/templates/extras/secretsgroup.html +1 -1
  65. nautobot/extras/templates/extras/tag.html +1 -1
  66. nautobot/extras/tests/integration/test_dynamicgroups.py +5 -1
  67. nautobot/extras/tests/test_api.py +1 -0
  68. nautobot/extras/tests/test_changelog.py +28 -0
  69. nautobot/extras/tests/test_customfields.py +10 -2
  70. nautobot/extras/tests/test_dynamicgroups.py +37 -1
  71. nautobot/extras/views.py +49 -19
  72. nautobot/ipam/signals.py +71 -0
  73. nautobot/ipam/templates/ipam/prefix_delete.html +1 -1
  74. nautobot/ipam/templates/ipam/service.html +1 -1
  75. nautobot/ipam/templates/ipam/vlan.html +1 -1
  76. nautobot/ipam/templates/ipam/vlan_interfaces.html +1 -1
  77. nautobot/ipam/templates/ipam/vlan_vminterfaces.html +1 -1
  78. nautobot/ipam/tests/test_models.py +42 -0
  79. nautobot/users/templates/users/sessionkey_delete.html +1 -1
  80. nautobot/users/views.py +2 -2
  81. nautobot/virtualization/models.py +1 -68
  82. nautobot/virtualization/templates/virtualization/virtual_machine_vminterface_delete.html +1 -1
  83. nautobot/virtualization/templates/virtualization/virtualmachine.html +1 -1
  84. nautobot/virtualization/tests/test_models.py +42 -3
  85. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/METADATA +9 -9
  86. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/RECORD +90 -86
  87. nautobot-2.4.21.dist-info/entry_points.txt +4 -0
  88. nautobot-2.4.20.dist-info/entry_points.txt +0 -3
  89. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/LICENSE.txt +0 -0
  90. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/NOTICE +0 -0
  91. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/WHEEL +0 -0
@@ -1,97 +1,2 @@
1
1
  {% extends 'generic/object_retrieve.html' %}
2
- {% load helpers %}
3
-
4
- {% block content_left_page %}
5
- <div class="panel panel-default">
6
- <div class="panel-heading">
7
- <strong>Query</strong>
8
- </div>
9
- <table class="table table-hover panel-body attr-table">
10
- <tr>
11
- <td>Name</td>
12
- <td><span>{{ object.name }}</span></td>
13
- </tr>
14
- <tr>
15
- <td>Query</td>
16
- <td><pre id="query"><code class="language-graphql">{{ object.query }}</code></pre></td>
17
- </tr>
18
- <tr>
19
- <td>Query Variables</td>
20
- <td><pre>{{ object.variables|render_json }}</pre></td>
21
- </tr>
22
- </table>
23
- </div>
24
- {% endblock content_left_page %}
25
-
26
- {% block content_right_page %}
27
- <div class="panel panel-default">
28
- <div class="panel-heading">
29
- <strong>Response</strong>
30
- <button class="btn btn-primary btn-xs pull-right" onclick="test_query()">Execute</button>
31
- </div>
32
- {% if object.variables %}
33
- <table class="table table-hover panel-body attr-table">
34
- <tr>
35
- <td>Variables:</td>
36
- <td><textarea id="query_variables" class="form-control">{{ object.variables|render_json:False }}</textarea></td>
37
- </tr>
38
- </table>
39
- {% endif %}
40
- <div class="panel-footer">
41
- <pre id="query_output">
42
-
43
- </pre>
44
- </div>
45
- </div>
46
- {% endblock content_right_page %}
47
-
48
- {% block javascript %}
49
- {{ block.super }}
50
- <script>
51
- function fillQueryOutput(data) {
52
- const queryOutput = document.querySelector('#query_output');
53
-
54
- [...queryOutput.childNodes].forEach(childNode => childNode.remove());
55
-
56
- const code = document.createElement('code');
57
- code.classList.add('language-json');
58
- code.textContent = JSON.stringify(data, undefined, 2);
59
-
60
- queryOutput.appendChild(code);
61
-
62
- hljs.highlightElement(code);
63
- }
64
-
65
- function test_query() {
66
- const variables = document.querySelector('#query_variables')?.value;
67
-
68
- $.ajax({
69
- url: "{% url 'graphql' %}",
70
- method: "POST",
71
- headers: {"X-CSRFTOKEN": "{{ csrf_token }}"},
72
- dataType: "json",
73
- data: {
74
- "query": `{{ object.query | escapejs }}`,
75
- "variables": variables,
76
- },
77
- success: function(data) {
78
- fillQueryOutput(data);
79
- },
80
- error: function(error) {
81
- console.log(error);
82
- fillQueryOutput(error.responseJSON);
83
- }
84
- });
85
- };
86
-
87
- textarea = document.querySelector("#query_variables");
88
- textarea.addEventListener('input', autoResize, false);
89
- textarea.style.height = 'auto';
90
- textarea.style.height = textarea.scrollHeight + 'px';
91
-
92
- function autoResize() {
93
- this.style.height = 'auto';
94
- this.style.height = this.scrollHeight + 'px';
95
- }
96
- </script>
97
- {% endblock javascript %}
2
+ {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -0,0 +1,71 @@
1
+ {% load helpers %}
2
+
3
+ <div class="panel-default">
4
+ <div class="panel-heading">
5
+ <strong>Response</strong>
6
+ <button class="btn btn-primary btn-xs pull-right" onclick="test_query()">Execute</button>
7
+ </div>
8
+ {% if object.variables %}
9
+ <table class="table table-hover panel-body attr-table">
10
+ <tr>
11
+ <td>Variables:</td>
12
+ <td><textarea id="query_variables" class="form-control">{{ object.variables|render_json:False }}</textarea></td>
13
+ </tr>
14
+ </table>
15
+ {% endif %}
16
+ <div class="panel-footer">
17
+ <pre id="query_output">
18
+
19
+ </pre>
20
+ </div>
21
+ </div>
22
+
23
+ <script>
24
+ function fillQueryOutput(data) {
25
+ const queryOutput = document.querySelector('#query_output');
26
+
27
+ [...queryOutput.childNodes].forEach(childNode => childNode.remove());
28
+
29
+ const code = document.createElement('code');
30
+ code.classList.add('language-json');
31
+ code.textContent = JSON.stringify(data, undefined, 2);
32
+
33
+ queryOutput.appendChild(code);
34
+
35
+ hljs.highlightElement(code);
36
+ }
37
+
38
+ function test_query() {
39
+ const variables = document.querySelector('#query_variables')?.value;
40
+
41
+ $.ajax({
42
+ url: "{% url 'graphql' %}",
43
+ method: "POST",
44
+ headers: {"X-CSRFTOKEN": "{{ csrf_token }}"},
45
+ dataType: "json",
46
+ data: {
47
+ "query": `{{ object.query | escapejs }}`,
48
+ "variables": variables,
49
+ },
50
+ success: function(data) {
51
+ fillQueryOutput(data);
52
+ },
53
+ error: function(error) {
54
+ console.log(error);
55
+ fillQueryOutput(error.responseJSON);
56
+ }
57
+ });
58
+ };
59
+
60
+ window.addEventListener("load", () => {
61
+ textarea = document.querySelector("#query_variables");
62
+ textarea.addEventListener('input', autoResize, false);
63
+ textarea.style.height = 'auto';
64
+ textarea.style.height = textarea.scrollHeight + 'px';
65
+ });
66
+
67
+ function autoResize() {
68
+ this.style.height = 'auto';
69
+ this.style.height = this.scrollHeight + 'px';
70
+ }
71
+ </script>
@@ -7,8 +7,8 @@
7
7
  {% block content %}
8
8
  <div class="alert alert-warning">
9
9
  Dynamic group membership is cached for performance reasons, therefore this page may not always be up-to-date.
10
- <br>You can refresh the membership of any specific group by viewing it from the list below or from the
11
- <a href="{% url 'extras:dynamicgroup_list' %}">Dynamic Groups list view</a>.
10
+ <br>You can refresh the membership of any specific group by accessing it from the list below or from the
11
+ <a href="{% url 'extras:dynamicgroup_list' %}">Dynamic Groups list view</a> and clicking the "Refresh Members" button.
12
12
  <br>You can also refresh the membership of all groups by running the
13
13
  <a href="{% url 'extras:job_run_by_class_path' class_path='nautobot.core.jobs.groups.RefreshDynamicGroupCaches' %}">Refresh Dynamic Group Caches job</a>.
14
14
  </div>
@@ -1,2 +1,2 @@
1
- {% extends "extras/secretsgroup_retrieve.html" %}
1
+ {% extends "generic/object_retrieve.html" %}
2
2
  {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -1,2 +1,2 @@
1
- {% extends 'extras/tag_retrieve.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
  {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -74,8 +74,12 @@ class DynamicGroupTestCase(SeleniumTestCase):
74
74
 
75
75
  # And just a cursory check to make sure that the filter worked.
76
76
  group = DynamicGroup.objects.get(name=name)
77
- self.assertEqual(group.count, Device.objects.filter(status__name="Active").count())
78
77
  self.assertEqual(group.filter, {"status": ["Active"]})
78
+ # Because we don't auto-refresh the members on UI create/update any more:
79
+ # TODO: a more complete integration test could click the "Refresh Members" JobButton, wait until the job completes,
80
+ # and so forth, rather than doing so programmatically here:
81
+ group.update_cached_members()
82
+ self.assertEqual(group.count, Device.objects.filter(status__name="Active").count())
79
83
 
80
84
  # Verify dynamic group shows up on device detail tab
81
85
  self.browser.visit(
@@ -2564,6 +2564,7 @@ class UserSavedViewAssociationTest(APIViewTestCases.APIViewTestCase):
2564
2564
  class ScheduledJobTest(
2565
2565
  APIViewTestCases.GetObjectViewTestCase,
2566
2566
  APIViewTestCases.ListObjectsViewTestCase,
2567
+ APIViewTestCases.DeleteObjectViewTestCase,
2567
2568
  ):
2568
2569
  model = ScheduledJob
2569
2570
  choices_fields = []
@@ -1,3 +1,5 @@
1
+ import uuid
2
+
1
3
  from django.contrib.contenttypes.models import ContentType
2
4
  from django.test import override_settings
3
5
  from django.urls import reverse
@@ -234,6 +236,32 @@ class ChangeLogViewTest(ModelViewTestCase):
234
236
  self.assertContains(resp, escape('"description": "changed description2"'))
235
237
  self.assertContains(resp, escape('"description": "changed description3"'))
236
238
 
239
+ def test_objectchange_skips_add_conditional_prefetch(self):
240
+ """
241
+ Test that ObjectChange.objects.all() skips prefetch_related on ContentTypes without a model class.
242
+ """
243
+ self.add_permissions("extras.view_objectchange")
244
+
245
+ ct = ContentType.objects.create(app_label="nonexistent_app", model="nonexistentmodel")
246
+ oc = ObjectChange.objects.create(
247
+ changed_object_type=ct,
248
+ changed_object_id=1,
249
+ object_repr="nonexistentobject",
250
+ action=ObjectChangeActionChoices.ACTION_CREATE,
251
+ user=self.user,
252
+ object_data={},
253
+ request_id=uuid.uuid4(),
254
+ )
255
+ url = reverse("extras:objectchange_list")
256
+ with self.assertLogs(level="WARNING") as cm:
257
+ response = self.client.get(url)
258
+ self.assertHttpStatus(response, 200)
259
+ self.assertContains(response, oc.object_repr)
260
+ self.assertIn(
261
+ ("One or more ContentType entries in the database are invalid."),
262
+ cm.output[0],
263
+ )
264
+
237
265
 
238
266
  class ChangeLogAPITest(APITestCase):
239
267
  def setUp(self):
@@ -2009,14 +2009,22 @@ class CustomFieldFilterTest(TestCase):
2009
2009
  )
2010
2010
 
2011
2011
  def test_filter_multi_select(self):
2012
- self.assertQuerysetEqual(
2012
+ self.assertQuerysetEqualAndNotEmpty(
2013
2013
  self.filterset({"cf_cf9": "Foo"}, self.queryset).qs,
2014
2014
  self.queryset.filter(_custom_field_data__cf9__contains="Foo"),
2015
2015
  )
2016
- self.assertQuerysetEqual(
2016
+ self.assertQuerysetEqualAndNotEmpty(
2017
2017
  self.filterset({"cf_cf9": "Bar"}, self.queryset).qs,
2018
2018
  self.queryset.filter(_custom_field_data__cf9__contains="Bar"),
2019
2019
  )
2020
+ self.assertQuerysetEqualAndNotEmpty(
2021
+ self.filterset({"cf_cf9": ["Foo"]}, self.queryset).qs,
2022
+ self.queryset.filter(_custom_field_data__cf9__contains="Foo"),
2023
+ )
2024
+ self.assertQuerysetEqualAndNotEmpty(
2025
+ self.filterset({"cf_cf9": ["Bar"]}, self.queryset).qs,
2026
+ self.queryset.filter(_custom_field_data__cf9__contains="Bar"),
2027
+ )
2020
2028
  self.assertQuerysetEqualAndNotEmpty( # https://github.com/nautobot/nautobot/issues/5009
2021
2029
  self.filterset({"cf_cf9": str(self.multiselect_choices[0].pk)}, self.queryset).qs,
2022
2030
  self.queryset.filter(_custom_field_data__cf9__contains=self.multiselect_choices[0].value),
@@ -34,6 +34,7 @@ from nautobot.extras.choices import (
34
34
  from nautobot.extras.filters import DynamicGroupFilterSet, DynamicGroupMembershipFilterSet
35
35
  from nautobot.extras.models import (
36
36
  CustomField,
37
+ CustomFieldChoice,
37
38
  DynamicGroup,
38
39
  DynamicGroupMembership,
39
40
  Relationship,
@@ -1022,7 +1023,7 @@ class DynamicGroupModelTest(DynamicGroupTestBase): # TODO: BaseModelTestCase mi
1022
1023
  self.assertEqual(sorted(dg.members.values_list("name", flat=True)), expected)
1023
1024
 
1024
1025
  def test_filter_custom_fields(self):
1025
- """Test that relationships can be used in filters."""
1026
+ """Test that custom fields can be used in filters."""
1026
1027
 
1027
1028
  device = self.devices[0]
1028
1029
 
@@ -1053,6 +1054,41 @@ class DynamicGroupModelTest(DynamicGroupTestBase): # TODO: BaseModelTestCase mi
1053
1054
  expected = [str(device)]
1054
1055
  self.assertEqual(sorted(dg.members.values_list("name", flat=True)), expected)
1055
1056
 
1057
+ def test_filter_custom_field_select(self):
1058
+ """Test that select and multiselect type custom fields can be used in filters."""
1059
+ device = self.devices[0]
1060
+
1061
+ cf1 = CustomField.objects.create(
1062
+ label="Onboarding Status",
1063
+ type=CustomFieldTypeChoices.TYPE_SELECT,
1064
+ )
1065
+ cf1.content_types.add(self.device_ct)
1066
+ CustomFieldChoice.objects.create(custom_field=cf1, value="Onboarded", weight=100)
1067
+ CustomFieldChoice.objects.create(custom_field=cf1, value="Yet To Be", weight=200)
1068
+
1069
+ cf2 = CustomField.objects.create(
1070
+ label="Features",
1071
+ type=CustomFieldTypeChoices.TYPE_MULTISELECT,
1072
+ )
1073
+ cf2.content_types.add(self.device_ct)
1074
+ CustomFieldChoice.objects.create(custom_field=cf2, value="Telnet", weight=100)
1075
+ CustomFieldChoice.objects.create(custom_field=cf2, value="SSH", weight=200)
1076
+ CustomFieldChoice.objects.create(custom_field=cf2, value="NETCONF", weight=300)
1077
+
1078
+ device._custom_field_data = {"onboarding_status": "Onboarded", "features": ["Telnet", "SSH"]}
1079
+ device.validated_save()
1080
+ device.refresh_from_db()
1081
+
1082
+ dg = DynamicGroup(
1083
+ name="select_fields",
1084
+ filter={"cf_onboarding_status": ["Onboarded"], "cf_features": ["Telnet"]},
1085
+ content_type=self.device_ct,
1086
+ )
1087
+ dg.validated_save()
1088
+
1089
+ self.assertEqual(dg.count, 1)
1090
+ self.assertIn(device, dg.members)
1091
+
1056
1092
  def test_filter_search(self):
1057
1093
  """Test that search (`q` filter) can be used in filters."""
1058
1094
 
nautobot/extras/views.py CHANGED
@@ -25,6 +25,7 @@ from django_tables2 import RequestConfig
25
25
  from jsonschema.validators import Draft7Validator
26
26
  from rest_framework.decorators import action
27
27
  from rest_framework.permissions import IsAuthenticated
28
+ from rest_framework.response import Response
28
29
 
29
30
  from nautobot.core.choices import ButtonActionColorChoices
30
31
  from nautobot.core.constants import PAGINATE_COUNT_DEFAULT
@@ -748,12 +749,7 @@ class DynamicGroupUIViewSet(NautobotUIViewSet):
748
749
  elif self.action == "retrieve":
749
750
  model = instance.model
750
751
  table_class = get_table_for_model(model)
751
- if instance.group_type != DynamicGroupTypeChoices.TYPE_STATIC:
752
- # Ensure that members cache is up-to-date for this specific group
753
- members = instance.update_cached_members()
754
- messages.success(request, f"Refreshed cached members list for {instance}")
755
- else:
756
- members = instance.members
752
+ members = instance.members
757
753
  if table_class is not None:
758
754
  if hasattr(members, "without_tree_fields"):
759
755
  members = members.without_tree_fields()
@@ -808,7 +804,7 @@ class DynamicGroupUIViewSet(NautobotUIViewSet):
808
804
 
809
805
  return context
810
806
 
811
- def form_save(self, form, **kwargs):
807
+ def form_save(self, form, commit=True, **kwargs):
812
808
  obj = form.save(commit=False)
813
809
  context = self.get_extra_context(self.request, obj)
814
810
 
@@ -827,10 +823,18 @@ class DynamicGroupUIViewSet(NautobotUIViewSet):
827
823
  form.add_error(None, msg)
828
824
  raise
829
825
 
830
- # After filters have been set, now we save the object to the database.
831
- obj.save()
832
- # Save m2m fields, such as Tags https://docs.djangoproject.com/en/3.2/topics/forms/modelforms/#the-save-method
833
- form.save_m2m()
826
+ if commit:
827
+ # After filters have been set, now we save the object to the database.
828
+ obj.save(update_cached_members=False)
829
+ # Save m2m fields, such as Tags https://docs.djangoproject.com/en/3.2/topics/forms/modelforms/#the-save-method
830
+ form.save_m2m()
831
+
832
+ if obj.group_type != DynamicGroupTypeChoices.TYPE_STATIC:
833
+ messages.warning(
834
+ self.request,
835
+ "Dynamic Group membership is not automatically recalculated after creating/editing the group, "
836
+ 'as it may take some time to complete. You can use the "Refresh Members" button when ready.',
837
+ )
834
838
 
835
839
  # Process the formsets for children
836
840
  children = context.get("children")
@@ -845,7 +849,7 @@ class DynamicGroupUIViewSet(NautobotUIViewSet):
845
849
  added_errors.add(msg)
846
850
  raise ValidationError("invalid DynamicGroupMembershipFormSet")
847
851
 
848
- if children:
852
+ if commit and children:
849
853
  children.save()
850
854
 
851
855
  return obj
@@ -1144,6 +1148,7 @@ class GitRepositoryUIViewSet(NautobotUIViewSet):
1144
1148
  filterset_class = filters.GitRepositoryFilterSet
1145
1149
  serializer_class = serializers.GitRepositorySerializer
1146
1150
  table_class = tables.GitRepositoryTable
1151
+ view_titles = Titles(titles={"result": "{{ object.display|default:object }} - Synchronization Status"})
1147
1152
 
1148
1153
  def get_extra_context(self, request, instance=None):
1149
1154
  context = super().get_extra_context(request, instance)
@@ -1187,14 +1192,18 @@ class GitRepositoryUIViewSet(NautobotUIViewSet):
1187
1192
  job_result = instance.get_latest_sync()
1188
1193
 
1189
1194
  context = {
1195
+ **super().get_extra_context(request, instance),
1190
1196
  "result": job_result or {},
1191
- "base_template": "extras/gitrepository.html",
1197
+ "base_template": "extras/configcontextschema_retrieve.html",
1192
1198
  "object": instance,
1193
1199
  "active_tab": "result",
1194
1200
  "verbose_name": instance._meta.verbose_name,
1195
1201
  }
1196
1202
 
1197
- return render(request, "extras/gitrepository_result.html", context)
1203
+ return Response(
1204
+ context,
1205
+ template_name="extras/gitrepository_result.html",
1206
+ )
1198
1207
 
1199
1208
  @action(
1200
1209
  detail=True,
@@ -1241,6 +1250,27 @@ class GraphQLQueryUIViewSet(
1241
1250
  table_class = tables.GraphQLQueryTable
1242
1251
  action_buttons = ("add",)
1243
1252
 
1253
+ object_detail_content = object_detail.ObjectDetailContent(
1254
+ panels=(
1255
+ object_detail.ObjectFieldsPanel(
1256
+ label="Query",
1257
+ weight=100,
1258
+ section=SectionChoices.LEFT_HALF,
1259
+ fields=["name", "query", "variables"],
1260
+ value_transforms={
1261
+ "query": [lambda val: format_html('<pre><code class="language-graphql">{}</code></pre>', val)],
1262
+ "variables": [lambda val: helpers.render_json(val, syntax_highlight=True, pretty_print=True)],
1263
+ },
1264
+ ),
1265
+ object_detail.Panel(
1266
+ weight=100,
1267
+ section=object_detail.SectionChoices.RIGHT_HALF,
1268
+ body_content_template_path="extras/inc/graphqlquery_execute.html",
1269
+ body_wrapper_template_path="components/panel/body_wrapper_table.html",
1270
+ ),
1271
+ )
1272
+ )
1273
+
1244
1274
 
1245
1275
  #
1246
1276
  # Image attachments
@@ -1539,7 +1569,7 @@ class JobRunView(ObjectPermissionRequiredMixin, View):
1539
1569
 
1540
1570
  class JobView(generic.ObjectView):
1541
1571
  queryset = JobModel.objects.all()
1542
- template_name = "extras/job_detail.html"
1572
+ template_name = "generic/object_retrieve.html"
1543
1573
  object_detail_content = object_detail.ObjectDetailContent(
1544
1574
  panels=[
1545
1575
  object_detail.ObjectFieldsPanel(
@@ -2362,7 +2392,7 @@ class ObjectChangeLogView(generic.GenericView):
2362
2392
 
2363
2393
  return render(
2364
2394
  request,
2365
- "extras/object_changelog.html",
2395
+ "generic/object_changelog.html",
2366
2396
  {
2367
2397
  "object": obj,
2368
2398
  "verbose_name": obj._meta.verbose_name,
@@ -2569,7 +2599,7 @@ class ObjectNotesView(generic.GenericView):
2569
2599
 
2570
2600
  return render(
2571
2601
  request,
2572
- "extras/object_notes.html",
2602
+ "generic/object_notes.html",
2573
2603
  {
2574
2604
  "object": obj,
2575
2605
  "verbose_name": obj._meta.verbose_name,
@@ -3063,8 +3093,8 @@ class WebhookUIViewSet(NautobotUIViewSet):
3063
3093
 
3064
3094
 
3065
3095
  class JobObjectChangeLogView(ObjectChangeLogView):
3066
- base_template = "extras/job_detail.html"
3096
+ base_template = "generic/object_retrieve.html"
3067
3097
 
3068
3098
 
3069
3099
  class JobObjectNotesView(ObjectNotesView):
3070
- base_template = "extras/job_detail.html"
3100
+ base_template = "generic/object_retrieve.html"
nautobot/ipam/signals.py CHANGED
@@ -5,6 +5,7 @@ from django.db.models.signals import m2m_changed, pre_delete, pre_save
5
5
  from django.dispatch import receiver
6
6
 
7
7
  from nautobot.ipam.models import (
8
+ IPAddress,
8
9
  IPAddressToInterface,
9
10
  Prefix,
10
11
  PrefixLocationAssignment,
@@ -136,3 +137,73 @@ def assert_locations_content_types(sender, instance, action, reverse, model, pk_
136
137
  raise ValidationError(
137
138
  {key: f"{instance} is a {instance.location_type} and may not have {label} associated to it."}
138
139
  )
140
+
141
+
142
+ def _validate_interface_ipaddress_assignments(sender, instance, pk_set, interface_field):
143
+ """
144
+ Helper function to validate IPAddressToInterface instances on Interface and VMInterface objects after M2M operations.
145
+
146
+ For example:
147
+ * interface.ip_addresses.add(ip_address)
148
+ * vm_interface.ip_addresses.add(ip_address)
149
+
150
+ Args:
151
+ sender: The through model class (IPAddressToInterface)
152
+ instance: The interface instance (Interface or VMInterface)
153
+ pk_set: Set of IP address PKs being modified
154
+ interface_field: Field name to filter on ('interface' or 'vm_interface')
155
+ """
156
+ # Get the through model instances that were just created
157
+ filter_kwargs = {interface_field: instance, "ip_address_id__in": pk_set}
158
+ through_instances = sender.objects.filter(**filter_kwargs)
159
+
160
+ # Validate each through model instance
161
+ for through_instance in through_instances:
162
+ through_instance.full_clean()
163
+
164
+
165
+ def _validate_ipaddress_interface_assignments(sender, instance, pk_set, interface_model):
166
+ """
167
+ Helper function to validate IPAddressToInterface instances on IPAddress objects after M2M operations.
168
+
169
+ For example:
170
+ * ip_address.interfaces.add(interface)
171
+ * ip_address.vm_interfaces.add(vm_interface)
172
+
173
+ Args:
174
+ sender: The through model class (IPAddressToInterface)
175
+ instance: The interface instance (Interface or VMInterface)
176
+ pk_set: Set of IP address PKs being modified
177
+ interface_model: Field name to filter on ('interface' or 'vm_interface')
178
+ """
179
+ filter_kwargs = {"ip_address": instance, interface_model + "_id__in": pk_set}
180
+ through_instances = sender.objects.filter(**filter_kwargs)
181
+ for through_instance in through_instances:
182
+ through_instance.full_clean()
183
+
184
+
185
+ @receiver(m2m_changed, sender=IPAddressToInterface)
186
+ def validate_interface_ip_assignments(sender, instance, action, pk_set, **kwargs):
187
+ """
188
+ Validate IPAddressToInterface instances after M2M operations.
189
+
190
+ Handles both physical Interface and VMInterface IP assignments.
191
+ Since Django's M2M add() with through_defaults bypasses save() methods,
192
+ we validate the through model instances via signal handler.
193
+ """
194
+ from nautobot.dcim.models import Interface
195
+ from nautobot.virtualization.models import VMInterface
196
+
197
+ if action == "post_add" and pk_set:
198
+ # Route to appropriate validation based on instance type
199
+ if isinstance(instance, Interface):
200
+ _validate_interface_ipaddress_assignments(sender, instance, pk_set, "interface")
201
+ elif isinstance(instance, VMInterface):
202
+ _validate_interface_ipaddress_assignments(sender, instance, pk_set, "vm_interface")
203
+ elif isinstance(instance, IPAddress):
204
+ interface_model = kwargs["model"]
205
+
206
+ if interface_model == Interface:
207
+ _validate_ipaddress_interface_assignments(sender, instance, pk_set, "interface")
208
+ elif interface_model == VMInterface:
209
+ _validate_ipaddress_interface_assignments(sender, instance, pk_set, "vm_interface")
@@ -1,4 +1,4 @@
1
- {% extends 'generic/object_delete.html' %}
1
+ {% extends 'generic/object_destroy.html' %}
2
2
 
3
3
  {% block message_extra %}
4
4
  <p>Note: This will <strong>not</strong> delete any child prefixes or IP addresses.</p>
@@ -1,2 +1,2 @@
1
- {% extends 'ipam/service_retrieve.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
  {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -1,2 +1,2 @@
1
- {% extends 'ipam/vlan_retrieve.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
  {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -1,4 +1,4 @@
1
- {% extends 'ipam/vlan.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
 
3
3
  {% block content %}
4
4
  <div class="row">
@@ -1,4 +1,4 @@
1
- {% extends 'ipam/vlan.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
 
3
3
  {% block content %}
4
4
  <div class="row">
@@ -172,6 +172,48 @@ class IPAddressToInterfaceTest(TestCase):
172
172
  IPAddressToInterface.objects.create(vm_interface=None, interface=None, ip_address=ip_addr)
173
173
  self.assertIn("Must associate to either an Interface or a VMInterface.", str(cm.exception))
174
174
 
175
+ def test_m2m_save_signal_prevents_iface_ipaddress_and_vminterface_through_defaults(self):
176
+ """Test that the m2m save signal prevents a VMInterface from using the same IPAddressToInterface instance as an Interface."""
177
+ ip_addr = IPAddress.objects.create(address="192.0.2.1/24", status=self.status, namespace=self.namespace)
178
+ with self.assertRaises(ValidationError) as cm:
179
+ self.test_int1.ip_addresses.add(ip_addr, through_defaults={"vm_interface": self.test_vmint1})
180
+
181
+ self.assertIn(
182
+ "Cannot use a single instance to associate to both an Interface and a VMInterface.", str(cm.exception)
183
+ )
184
+
185
+ def test_m2m_save_signal_prevents_vminterface_ipaddress_and_interface_through_defaults(self):
186
+ """Test that the m2m save signal prevents an Interface from using the same IPAddressToInterface instance as a VMInterface."""
187
+ ip_addr = IPAddress.objects.create(address="192.0.2.1/24", status=self.status, namespace=self.namespace)
188
+ with self.assertRaises(ValidationError) as cm:
189
+ self.test_vmint1.ip_addresses.add(ip_addr, through_defaults={"interface": self.test_int1})
190
+
191
+ self.assertIn(
192
+ "Cannot use a single instance to associate to both an Interface and a VMInterface.", str(cm.exception)
193
+ )
194
+
195
+ def test_m2m_save_signal_prevents_ipaddress_interface_and_vminterface_through_defaults(self):
196
+ """Test that the m2m save signal prevents an Interface being added to an IPAddress with a VMInterface through_defaults."""
197
+ ip_addr = IPAddress.objects.create(address="192.0.2.1/24", status=self.status, namespace=self.namespace)
198
+
199
+ with self.assertRaises(ValidationError) as cm:
200
+ ip_addr.interfaces.add(self.test_int1, through_defaults={"vm_interface": self.test_vmint1})
201
+
202
+ self.assertIn(
203
+ "Cannot use a single instance to associate to both an Interface and a VMInterface.", str(cm.exception)
204
+ )
205
+
206
+ def test_m2m_save_signal_prevents_ipaddress_vminterface_and_interface_through_defaults(self):
207
+ """Test that the m2m save signal prevents a VMInterface being added to an IPAddress with an Interface through_defaults."""
208
+ ip_addr = IPAddress.objects.create(address="192.0.2.1/24", status=self.status, namespace=self.namespace)
209
+
210
+ with self.assertRaises(ValidationError) as cm:
211
+ ip_addr.vm_interfaces.add(self.test_vmint1, through_defaults={"interface": self.test_int1})
212
+
213
+ self.assertIn(
214
+ "Cannot use a single instance to associate to both an Interface and a VMInterface.", str(cm.exception)
215
+ )
216
+
175
217
  def test_primary_ip_retained_when_deleted_from_device_or_module_interface(self):
176
218
  """Test primary_ip4 remains set when the same IP is assigned to multiple interfaces and deleted from one."""
177
219