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.
- nautobot/circuits/templates/circuits/circuit.html +1 -1
- nautobot/circuits/templates/circuits/circuittermination.html +1 -1
- nautobot/circuits/templates/circuits/circuittype.html +1 -1
- nautobot/circuits/templates/circuits/providernetwork.html +1 -1
- nautobot/core/cli/migrate_deprecated_templates.py +200 -0
- nautobot/core/jobs/__init__.py +2 -1
- nautobot/core/jobs/groups.py +31 -1
- nautobot/core/models/tree_queries.py +10 -5
- nautobot/core/signals.py +12 -1
- nautobot/core/templates/components/panel/panel.html +1 -1
- nautobot/core/templates/inc/image_attachments.html +2 -1
- nautobot/core/templatetags/helpers.py +22 -0
- nautobot/core/tests/runner.py +3 -0
- nautobot/core/tests/test_cli.py +40 -0
- nautobot/core/tests/test_forms.py +41 -0
- nautobot/core/tests/test_jobs.py +75 -1
- nautobot/core/tests/test_tree_queries.py +14 -1
- nautobot/core/ui/object_detail.py +41 -5
- nautobot/core/utils/filtering.py +11 -9
- nautobot/core/views/generic.py +3 -3
- nautobot/dcim/models/device_components.py +81 -68
- nautobot/dcim/templates/dcim/device/config.html +1 -1
- nautobot/dcim/templates/dcim/device/consoleports.html +1 -1
- nautobot/dcim/templates/dcim/device/consoleserverports.html +1 -1
- nautobot/dcim/templates/dcim/device/devicebays.html +1 -1
- nautobot/dcim/templates/dcim/device/frontports.html +1 -1
- nautobot/dcim/templates/dcim/device/interfaces.html +1 -1
- nautobot/dcim/templates/dcim/device/inventory.html +1 -1
- nautobot/dcim/templates/dcim/device/lldp_neighbors.html +1 -1
- nautobot/dcim/templates/dcim/device/modulebays.html +1 -1
- nautobot/dcim/templates/dcim/device/poweroutlets.html +1 -1
- nautobot/dcim/templates/dcim/device/powerports.html +1 -1
- nautobot/dcim/templates/dcim/device/rearports.html +1 -1
- nautobot/dcim/templates/dcim/device/status.html +1 -1
- nautobot/dcim/templates/dcim/device/wireless.html +1 -1
- nautobot/dcim/templates/dcim/device.html +1 -1
- nautobot/dcim/templates/dcim/device_interface_delete.html +1 -1
- nautobot/dcim/templates/dcim/devicetype.html +1 -1
- nautobot/dcim/templates/dcim/footer_convert_to_contact_or_team_record.html +14 -0
- nautobot/dcim/templates/dcim/interface_bulk_delete.html +1 -1
- nautobot/dcim/templates/dcim/inventoryitem_bulk_delete.html +1 -1
- nautobot/dcim/templates/dcim/location_retrieve.html +1 -242
- nautobot/dcim/templates/dcim/modulefamily_retrieve.html +1 -1
- nautobot/dcim/templates/dcim/powerfeed.html +1 -1
- nautobot/dcim/templates/dcim/powerpanel.html +1 -1
- nautobot/dcim/templates/dcim/virtualchassis.html +1 -1
- nautobot/dcim/tests/test_models.py +43 -3
- nautobot/dcim/tests/test_views.py +52 -21
- nautobot/dcim/views.py +203 -87
- nautobot/extras/api/views.py +9 -1
- nautobot/extras/filters/customfields.py +9 -3
- nautobot/extras/models/groups.py +42 -5
- nautobot/extras/signals.py +20 -19
- nautobot/extras/tables.py +31 -2
- nautobot/extras/templates/extras/computedfield.html +1 -1
- nautobot/extras/templates/extras/configcontext.html +1 -1
- nautobot/extras/templates/extras/configcontextschema_validation.html +1 -1
- nautobot/extras/templates/extras/customfield.html +1 -1
- nautobot/extras/templates/extras/dynamicgroup_retrieve.html +11 -5
- nautobot/extras/templates/extras/gitrepository_result.html +0 -2
- nautobot/extras/templates/extras/graphqlquery_retrieve.html +1 -96
- nautobot/extras/templates/extras/inc/graphqlquery_execute.html +71 -0
- nautobot/extras/templates/extras/object_dynamicgroups.html +2 -2
- nautobot/extras/templates/extras/secretsgroup.html +1 -1
- nautobot/extras/templates/extras/tag.html +1 -1
- nautobot/extras/tests/integration/test_dynamicgroups.py +5 -1
- nautobot/extras/tests/test_api.py +1 -0
- nautobot/extras/tests/test_changelog.py +28 -0
- nautobot/extras/tests/test_customfields.py +10 -2
- nautobot/extras/tests/test_dynamicgroups.py +37 -1
- nautobot/extras/views.py +49 -19
- nautobot/ipam/signals.py +71 -0
- nautobot/ipam/templates/ipam/prefix_delete.html +1 -1
- nautobot/ipam/templates/ipam/service.html +1 -1
- nautobot/ipam/templates/ipam/vlan.html +1 -1
- nautobot/ipam/templates/ipam/vlan_interfaces.html +1 -1
- nautobot/ipam/templates/ipam/vlan_vminterfaces.html +1 -1
- nautobot/ipam/tests/test_models.py +42 -0
- nautobot/users/templates/users/sessionkey_delete.html +1 -1
- nautobot/users/views.py +2 -2
- nautobot/virtualization/models.py +1 -68
- nautobot/virtualization/templates/virtualization/virtual_machine_vminterface_delete.html +1 -1
- nautobot/virtualization/templates/virtualization/virtualmachine.html +1 -1
- nautobot/virtualization/tests/test_models.py +42 -3
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/METADATA +9 -9
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/RECORD +90 -86
- nautobot-2.4.21.dist-info/entry_points.txt +4 -0
- nautobot-2.4.20.dist-info/entry_points.txt +0 -3
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/NOTICE +0 -0
- {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
|
-
{%
|
|
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
|
|
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 "
|
|
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 '
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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/
|
|
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
|
|
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 = "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 = "
|
|
3096
|
+
base_template = "generic/object_retrieve.html"
|
|
3067
3097
|
|
|
3068
3098
|
|
|
3069
3099
|
class JobObjectNotesView(ObjectNotesView):
|
|
3070
|
-
base_template = "
|
|
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,2 +1,2 @@
|
|
|
1
|
-
{% extends '
|
|
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 '
|
|
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 %}
|
|
@@ -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
|
|