nautobot 1.6.21__py3-none-any.whl → 1.6.23__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nautobot might be problematic. Click here for more details.
- nautobot/core/settings.py +13 -3
- nautobot/core/templates/admin/base.html +8 -2
- nautobot/core/templates/base.html +2 -2
- nautobot/core/templates/graphene/graphiql.html +3 -0
- nautobot/core/templates/inc/javascript.html +3 -0
- nautobot/core/templates/inc/media.html +3 -0
- nautobot/core/templates/login.html +2 -2
- nautobot/core/templates/nautobot_config.py.j2 +2 -0
- nautobot/core/tests/test_views.py +33 -0
- nautobot/extras/api/views.py +2 -2
- nautobot/extras/tests/test_api.py +23 -1
- nautobot/extras/tests/test_datasources.py +72 -2
- nautobot/extras/tests/test_dynamicgroups.py +1 -1
- nautobot/extras/tests/test_views.py +48 -4
- nautobot/extras/utils.py +32 -15
- nautobot/extras/views.py +4 -2
- nautobot/project-static/docs/code-reference/nautobot/apps/models.html +20 -20
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +2824 -2804
- nautobot/project-static/docs/configuration/optional-settings.html +19 -5
- nautobot/project-static/docs/release-notes/version-1.6.html +305 -159
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +187 -187
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/utilities/testing/views.py +6 -1
- {nautobot-1.6.21.dist-info → nautobot-1.6.23.dist-info}/METADATA +1 -1
- {nautobot-1.6.21.dist-info → nautobot-1.6.23.dist-info}/RECORD +30 -30
- {nautobot-1.6.21.dist-info → nautobot-1.6.23.dist-info}/LICENSE.txt +0 -0
- {nautobot-1.6.21.dist-info → nautobot-1.6.23.dist-info}/NOTICE +0 -0
- {nautobot-1.6.21.dist-info → nautobot-1.6.23.dist-info}/WHEEL +0 -0
- {nautobot-1.6.21.dist-info → nautobot-1.6.23.dist-info}/entry_points.txt +0 -0
nautobot/core/settings.py
CHANGED
|
@@ -59,6 +59,14 @@ ALLOWED_URL_SCHEMES = (
|
|
|
59
59
|
"xmpp",
|
|
60
60
|
)
|
|
61
61
|
|
|
62
|
+
# Banners to display to users. Markdown and limited HTML are allowed.
|
|
63
|
+
if "NAUTOBOT_BANNER_BOTTOM" in os.environ and os.environ["NAUTOBOT_BANNER_BOTTOM"] != "":
|
|
64
|
+
BANNER_BOTTOM = os.environ["NAUTOBOT_BANNER_BOTTOM"]
|
|
65
|
+
if "NAUTOBOT_BANNER_LOGIN" in os.environ and os.environ["NAUTOBOT_BANNER_LOGIN"] != "":
|
|
66
|
+
BANNER_LOGIN = os.environ["NAUTOBOT_BANNER_LOGIN"]
|
|
67
|
+
if "NAUTOBOT_BANNER_TOP" in os.environ and os.environ["NAUTOBOT_BANNER_TOP"] != "":
|
|
68
|
+
BANNER_TOP = os.environ["NAUTOBOT_BANNER_TOP"]
|
|
69
|
+
|
|
62
70
|
# Base directory wherein all created files (jobs, git repositories, file uploads, static files) will be stored)
|
|
63
71
|
NAUTOBOT_ROOT = os.getenv("NAUTOBOT_ROOT", os.path.expanduser("~/.nautobot"))
|
|
64
72
|
|
|
@@ -586,15 +594,15 @@ CONSTANCE_CONFIG = {
|
|
|
586
594
|
],
|
|
587
595
|
"BANNER_BOTTOM": [
|
|
588
596
|
"",
|
|
589
|
-
"Custom HTML to display in a banner at the bottom of all pages.",
|
|
597
|
+
"Custom Markdown or limited HTML to display in a banner at the bottom of all pages.",
|
|
590
598
|
],
|
|
591
599
|
"BANNER_LOGIN": [
|
|
592
600
|
"",
|
|
593
|
-
"Custom HTML to display in a banner at the top of the login page.",
|
|
601
|
+
"Custom Markdown or limited HTML to display in a banner at the top of the login page.",
|
|
594
602
|
],
|
|
595
603
|
"BANNER_TOP": [
|
|
596
604
|
"",
|
|
597
|
-
"Custom HTML to display in a banner at the top of all pages.",
|
|
605
|
+
"Custom Markdown or limited HTML to display in a banner at the top of all pages.",
|
|
598
606
|
],
|
|
599
607
|
"CHANGELOG_RETENTION": [
|
|
600
608
|
90,
|
|
@@ -869,6 +877,8 @@ BRANDING_FILEPATHS = {
|
|
|
869
877
|
"icon_mask": os.getenv(
|
|
870
878
|
"NAUTOBOT_BRANDING_FILEPATHS_ICON_MASK", None
|
|
871
879
|
), # mono-chrome icon used for the mask-icon header
|
|
880
|
+
"css": os.getenv("NAUTOBOT_BRANDING_FILEPATHS_CSS", None), # Custom global CSS
|
|
881
|
+
"javascript": os.getenv("NAUTOBOT_BRANDING_FILEPATHS_JAVASCRIPT", None), # Custom global JavaScript
|
|
872
882
|
}
|
|
873
883
|
|
|
874
884
|
# Title to use in place of "Nautobot"
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
<link rel="stylesheet" id="base-theme"
|
|
33
33
|
href="{% static 'css/base.css' %}?v{{ settings.VERSION }}"
|
|
34
34
|
onerror="window.location='{% url 'media_failure' %}?filename=css/base.css'">
|
|
35
|
+
{% if settings.BRANDING_FILEPATHS.css %}
|
|
36
|
+
<link rel="stylesheet" id="custom-css" href="{% custom_branding_or_static 'css' None %}">
|
|
37
|
+
{% endif %}
|
|
35
38
|
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'img/nautobot_icon_180x180.png' %}">
|
|
36
39
|
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/nautobot_icon_32x32.png' %}">
|
|
37
40
|
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/nautobot_icon_16x16.png' %}">
|
|
@@ -101,7 +104,7 @@
|
|
|
101
104
|
<div class="container-fluid wrapper" {% if is_popup %}style="padding-bottom: 0px;"{% endif %}>
|
|
102
105
|
{% if "BANNER_TOP"|settings_or_config %}
|
|
103
106
|
<div class="alert alert-info text-center" role="alert">
|
|
104
|
-
{{ "BANNER_TOP"|settings_or_config|
|
|
107
|
+
{{ "BANNER_TOP"|settings_or_config|render_markdown }}
|
|
105
108
|
</div>
|
|
106
109
|
{% endif %}
|
|
107
110
|
{% if settings.MAINTENANCE_MODE %}
|
|
@@ -153,7 +156,7 @@
|
|
|
153
156
|
<div class="push"></div>
|
|
154
157
|
{% if "BANNER_BOTTOM"|settings_or_config %}
|
|
155
158
|
<div class="alert alert-info text-center banner-bottom" role="alert">
|
|
156
|
-
{{ "BANNER_BOTTOM"|settings_or_config|
|
|
159
|
+
{{ "BANNER_BOTTOM"|settings_or_config|render_markdown }}
|
|
157
160
|
</div>
|
|
158
161
|
{% endif %}
|
|
159
162
|
</div>
|
|
@@ -189,6 +192,9 @@
|
|
|
189
192
|
{% endif %}
|
|
190
193
|
{% include 'modals/modal_theme.html' with name='theme'%}
|
|
191
194
|
<script src="{% static 'js/theme.js' %}"></script>
|
|
195
|
+
{% if settings.BRANDING_FILEPATHS.javascript %}
|
|
196
|
+
<script src="{% custom_branding_or_static 'javascript' None %}"></script>
|
|
197
|
+
{% endif %}
|
|
192
198
|
{% block javascript %}{% endblock %}
|
|
193
199
|
</body>
|
|
194
200
|
</html>
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
{% if request.user.is_authenticated or not "HIDE_RESTRICTED_UI"|settings_or_config %}
|
|
16
16
|
{% if "BANNER_TOP"|settings_or_config %}
|
|
17
17
|
<div class="alert alert-info text-center" role="alert">
|
|
18
|
-
{{ "BANNER_TOP"|settings_or_config|
|
|
18
|
+
{{ "BANNER_TOP"|settings_or_config|render_markdown }}
|
|
19
19
|
</div>
|
|
20
20
|
{% endif %}
|
|
21
21
|
{% endif %}
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
{% if request.user.is_authenticated or not "HIDE_RESTRICTED_UI"|settings_or_config %}
|
|
41
41
|
{% if "BANNER_BOTTOM"|settings_or_config %}
|
|
42
42
|
<div class="alert alert-info text-center banner-bottom" role="alert">
|
|
43
|
-
{{ "BANNER_BOTTOM"|settings_or_config|
|
|
43
|
+
{{ "BANNER_BOTTOM"|settings_or_config|render_markdown }}
|
|
44
44
|
</div>
|
|
45
45
|
{% endif %}
|
|
46
46
|
{% endif %}
|
|
@@ -36,6 +36,9 @@ add "&raw" to the end of the URL within a browser.
|
|
|
36
36
|
<link rel="stylesheet"
|
|
37
37
|
href="{% static 'css/base.css' %}?v{{ settings.VERSION }}"
|
|
38
38
|
onerror="window.location='{% url 'media_failure' %}?filename=css/base.css'">
|
|
39
|
+
{% if settings.BRANDING_FILEPATHS.css %}
|
|
40
|
+
<link rel="stylesheet" id="custom-css" href="{% custom_branding_or_static 'css' None %}">
|
|
41
|
+
{% endif %}
|
|
39
42
|
<link rel="apple-touch-icon" sizes="180x180" href="{% custom_branding_or_static 'icon_180' 'img/nautobot_icon_180x180.png' %}">
|
|
40
43
|
<link rel="icon" type="image/png" sizes="32x32" href="{% custom_branding_or_static 'icon_32' 'img/nautobot_icon_32x32.png' %}">
|
|
41
44
|
<link rel="icon" type="image/png" sizes="16x16" href="{% custom_branding_or_static 'icon_16' 'img/nautobot_icon_16x16.png' %}">
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
<link rel="stylesheet" id="base-theme"
|
|
27
27
|
href="{% static 'css/base.css' %}?v{{ settings.VERSION }}"
|
|
28
28
|
onerror="window.location='{% url 'media_failure' %}?filename=css/base.css'">
|
|
29
|
+
{% if settings.BRANDING_FILEPATHS.css %}
|
|
30
|
+
<link rel="stylesheet" id="custom-css" href="{% custom_branding_or_static 'css' None %}">
|
|
31
|
+
{% endif %}
|
|
29
32
|
<link rel="apple-touch-icon" sizes="180x180" href="{% custom_branding_or_static 'icon_180' 'img/nautobot_icon_180x180.png' %}">
|
|
30
33
|
<link rel="icon" type="image/png" sizes="32x32" href="{% custom_branding_or_static 'icon_32' 'img/nautobot_icon_32x32.png' %}">
|
|
31
34
|
<link rel="icon" type="image/png" sizes="16x16" href="{% custom_branding_or_static 'icon_16' 'img/nautobot_icon_16x16.png' %}">
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
<div class="row" style="margin-top: {% if 'BANNER_LOGIN'|settings_or_config %}100{% else %}150{% endif %}px;">
|
|
54
54
|
<div class="col-sm-4 col-sm-offset-4">
|
|
55
55
|
{% if "BANNER_LOGIN"|settings_or_config %}
|
|
56
|
-
<div
|
|
57
|
-
{{ "BANNER_LOGIN"|settings_or_config|
|
|
56
|
+
<div class="alert alert-info text-center" role="alert">
|
|
57
|
+
{{ "BANNER_LOGIN"|settings_or_config|render_markdown }}
|
|
58
58
|
</div>
|
|
59
59
|
{% endif %}
|
|
60
60
|
{% if form.non_field_errors %}
|
|
@@ -263,6 +263,8 @@ SECRET_KEY = os.getenv("NAUTOBOT_SECRET_KEY", "{{ secret_key }}")
|
|
|
263
263
|
# "icon_mask": os.getenv(
|
|
264
264
|
# "NAUTOBOT_BRANDING_FILEPATHS_ICON_MASK", None
|
|
265
265
|
# ), # mono-chrome icon used for the mask-icon header
|
|
266
|
+
# "css": os.getenv("NAUTOBOT_BRANDING_FILEPATHS_CSS", None), # Custom global CSS
|
|
267
|
+
# "javascript": os.getenv("NAUTOBOT_BRANDING_FILEPATHS_JAVASCRIPT", None), # Custom global JavaScript
|
|
266
268
|
# }
|
|
267
269
|
|
|
268
270
|
# Prepended to CSV, YAML and export template filenames (i.e. `nautobot_device.yml`)
|
|
@@ -99,6 +99,39 @@ class HomeViewTestCase(TestCase):
|
|
|
99
99
|
response_content = response.content.decode(response.charset).replace("\n", "")
|
|
100
100
|
self.assertNotRegex(response_content, footer_hostname_version_pattern)
|
|
101
101
|
|
|
102
|
+
def test_banners_markdown(self):
|
|
103
|
+
url = reverse("home")
|
|
104
|
+
with override_settings(
|
|
105
|
+
BANNER_TOP="# Hello world",
|
|
106
|
+
BANNER_BOTTOM="[info](https://nautobot.com)",
|
|
107
|
+
):
|
|
108
|
+
response = self.client.get(url)
|
|
109
|
+
self.assertInHTML("<h1>Hello world</h1>", response.content.decode(response.charset))
|
|
110
|
+
self.assertInHTML(
|
|
111
|
+
'<a href="https://nautobot.com" rel="noopener noreferrer">info</a>',
|
|
112
|
+
response.content.decode(response.charset),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
with override_settings(BANNER_LOGIN="_Welcome to Nautobot!_"):
|
|
116
|
+
self.client.logout()
|
|
117
|
+
response = self.client.get(reverse("login"))
|
|
118
|
+
self.assertInHTML("<em>Welcome to Nautobot!</em>", response.content.decode(response.charset))
|
|
119
|
+
|
|
120
|
+
def test_banners_no_xss(self):
|
|
121
|
+
url = reverse("home")
|
|
122
|
+
with override_settings(
|
|
123
|
+
BANNER_TOP='<script>alert("Hello from above!");</script>',
|
|
124
|
+
BANNER_BOTTOM='<script>alert("Hello from below!");</script>',
|
|
125
|
+
):
|
|
126
|
+
response = self.client.get(url)
|
|
127
|
+
self.assertNotIn("Hello from above", response.content.decode(response.charset))
|
|
128
|
+
self.assertNotIn("Hello from below", response.content.decode(response.charset))
|
|
129
|
+
|
|
130
|
+
with override_settings(BANNER_LOGIN='<script>alert("Welcome to Nautobot!");</script>'):
|
|
131
|
+
self.client.logout()
|
|
132
|
+
response = self.client.get(reverse("login"))
|
|
133
|
+
self.assertNotIn("Welcome to Nautobot!", response.content.decode(response.charset))
|
|
134
|
+
|
|
102
135
|
|
|
103
136
|
@override_settings(BRANDING_TITLE="Nautobot")
|
|
104
137
|
class SearchFieldsTestCase(TestCase):
|
nautobot/extras/api/views.py
CHANGED
|
@@ -330,13 +330,13 @@ class DynamicGroupViewSet(ModelViewSet, NotesViewSetMixin):
|
|
|
330
330
|
# @extend_schema(methods=["get"], responses={200: member_response})
|
|
331
331
|
@action(detail=True, methods=["get"])
|
|
332
332
|
def members(self, request, pk, *args, **kwargs):
|
|
333
|
-
"""List member objects of
|
|
333
|
+
"""List the member objects of this dynamic group."""
|
|
334
334
|
instance = get_object_or_404(self.queryset, pk=pk)
|
|
335
335
|
|
|
336
336
|
# Retrieve the serializer for the content_type and paginate the results
|
|
337
337
|
member_model_class = instance.content_type.model_class()
|
|
338
338
|
member_serializer_class = get_serializer_for_model(member_model_class)
|
|
339
|
-
members = self.paginate_queryset(instance.members)
|
|
339
|
+
members = self.paginate_queryset(instance.members.restrict(request.user, "view"))
|
|
340
340
|
member_serializer = member_serializer_class(members, many=True, context={"request": request})
|
|
341
341
|
return self.get_paginated_response(member_serializer.data)
|
|
342
342
|
|
|
@@ -69,6 +69,7 @@ from nautobot.ipam.factory import VLANFactory
|
|
|
69
69
|
from nautobot.ipam.models import VLAN, VLANGroup
|
|
70
70
|
from nautobot.users.models import ObjectPermission
|
|
71
71
|
from nautobot.utilities.choices import ColorChoices
|
|
72
|
+
from nautobot.utilities.permissions import get_permission_for_model
|
|
72
73
|
from nautobot.utilities.testing import APITestCase, APIViewTestCases
|
|
73
74
|
from nautobot.utilities.testing.utils import disable_warnings
|
|
74
75
|
from nautobot.utilities.utils import get_route_for_model, slugify_dashes_to_underscores
|
|
@@ -752,13 +753,34 @@ class DynamicGroupTest(DynamicGroupTestMixin, APIViewTestCases.APIViewTestCase):
|
|
|
752
753
|
def test_get_members(self):
|
|
753
754
|
"""Test that the `/members/` API endpoint returns what is expected."""
|
|
754
755
|
self.add_permissions("extras.view_dynamicgroup")
|
|
755
|
-
instance =
|
|
756
|
+
instance = self.groups[0]
|
|
757
|
+
self.add_permissions(get_permission_for_model(instance.content_type.model_class(), "view"))
|
|
756
758
|
member_count = instance.members.count()
|
|
757
759
|
url = reverse("extras-api:dynamicgroup-members", kwargs={"pk": instance.pk})
|
|
758
760
|
response = self.client.get(url, **self.header)
|
|
759
761
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
|
760
762
|
self.assertEqual(member_count, len(response.json()["results"]))
|
|
761
763
|
|
|
764
|
+
def test_get_members_with_constrained_permission(self):
|
|
765
|
+
"""Test that the `/members/` API endpoint enforces permissions on the member model."""
|
|
766
|
+
self.add_permissions("extras.view_dynamicgroup")
|
|
767
|
+
instance = self.groups[0]
|
|
768
|
+
obj1 = instance.members.first()
|
|
769
|
+
obj_perm = ObjectPermission(
|
|
770
|
+
name="Test permission",
|
|
771
|
+
constraints={"pk__in": [obj1.pk]},
|
|
772
|
+
actions=["view"],
|
|
773
|
+
)
|
|
774
|
+
obj_perm.save()
|
|
775
|
+
obj_perm.users.add(self.user)
|
|
776
|
+
obj_perm.object_types.add(instance.content_type)
|
|
777
|
+
|
|
778
|
+
url = reverse("extras-api:dynamicgroup-members", kwargs={"pk": instance.pk})
|
|
779
|
+
response = self.client.get(url, **self.header)
|
|
780
|
+
self.assertHttpStatus(response, status.HTTP_200_OK)
|
|
781
|
+
self.assertEqual(len(response.json()["results"]), 1)
|
|
782
|
+
self.assertEqual(response.json()["results"][0]["id"], str(obj1.pk))
|
|
783
|
+
|
|
762
784
|
|
|
763
785
|
class DynamicGroupMembershipTest(DynamicGroupTestMixin, APIViewTestCases.APIViewTestCase):
|
|
764
786
|
model = DynamicGroupMembership
|
|
@@ -27,6 +27,7 @@ from nautobot.extras.models import (
|
|
|
27
27
|
ConfigContextSchema,
|
|
28
28
|
ExportTemplate,
|
|
29
29
|
GitRepository,
|
|
30
|
+
Job,
|
|
30
31
|
JobLogEntry,
|
|
31
32
|
JobResult,
|
|
32
33
|
Secret,
|
|
@@ -119,13 +120,13 @@ class GitTest(TransactionTestCase):
|
|
|
119
120
|
|
|
120
121
|
def populate_repo(self, path, url, *args, **kwargs):
|
|
121
122
|
os.makedirs(path)
|
|
122
|
-
# TODO(Glenn): populate Jobs as well?
|
|
123
123
|
os.makedirs(os.path.join(path, "config_contexts"))
|
|
124
124
|
os.makedirs(os.path.join(path, "config_contexts", "devices"))
|
|
125
125
|
os.makedirs(os.path.join(path, "config_contexts", "locations"))
|
|
126
126
|
os.makedirs(os.path.join(path, "config_context_schemas"))
|
|
127
127
|
os.makedirs(os.path.join(path, "export_templates", "dcim", "device"))
|
|
128
128
|
os.makedirs(os.path.join(path, "export_templates", "ipam", "vlan"))
|
|
129
|
+
os.makedirs(os.path.join(path, "jobs"))
|
|
129
130
|
|
|
130
131
|
with open(os.path.join(path, "config_contexts", "context.yaml"), "w") as fd:
|
|
131
132
|
yaml.dump(
|
|
@@ -167,6 +168,19 @@ class GitTest(TransactionTestCase):
|
|
|
167
168
|
with open(os.path.join(path, "export_templates", "ipam", "vlan", "template.j2"), "w") as fd:
|
|
168
169
|
fd.write("{% for vlan in queryset %}\n{{ vlan.name }}\n{% endfor %}")
|
|
169
170
|
|
|
171
|
+
with open(os.path.join(path, "jobs", "__init__.py"), "w") as fd:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
with open(os.path.join(path, "jobs", "job.py"), "w") as fd:
|
|
175
|
+
fd.write(
|
|
176
|
+
"""\
|
|
177
|
+
from nautobot.extras.jobs import Job
|
|
178
|
+
|
|
179
|
+
class MyJob(Job):
|
|
180
|
+
def run(self, data, commit):
|
|
181
|
+
pass"""
|
|
182
|
+
)
|
|
183
|
+
|
|
170
184
|
return mock.DEFAULT
|
|
171
185
|
|
|
172
186
|
def empty_repo(self, path, url, *args, **kwargs):
|
|
@@ -177,6 +191,8 @@ class GitTest(TransactionTestCase):
|
|
|
177
191
|
os.remove(os.path.join(path, "export_templates", "dcim", "device", "template.j2"))
|
|
178
192
|
os.remove(os.path.join(path, "export_templates", "dcim", "device", "template2.html"))
|
|
179
193
|
os.remove(os.path.join(path, "export_templates", "ipam", "vlan", "template.j2"))
|
|
194
|
+
os.remove(os.path.join(path, "jobs", "__init__.py"))
|
|
195
|
+
os.remove(os.path.join(path, "jobs", "job.py"))
|
|
180
196
|
return mock.DEFAULT
|
|
181
197
|
|
|
182
198
|
def assert_config_context_schema_record_exists(self, name):
|
|
@@ -263,6 +279,11 @@ class GitTest(TransactionTestCase):
|
|
|
263
279
|
)
|
|
264
280
|
self.assertIsNotNone(export_template_vlan)
|
|
265
281
|
|
|
282
|
+
def assert_job_exists(self, installed=True):
|
|
283
|
+
"""Helper function to assert Job exists."""
|
|
284
|
+
job = Job.objects.get(git_repository=self.repo, job_class_name="MyJob")
|
|
285
|
+
self.assertEqual(job.installed, installed)
|
|
286
|
+
|
|
266
287
|
def test_pull_git_repository_and_refresh_data_with_no_data(self, MockGitRepo):
|
|
267
288
|
"""
|
|
268
289
|
The pull_git_repository_and_refresh_data job should succeed if the given repo is empty.
|
|
@@ -482,6 +503,8 @@ class GitTest(TransactionTestCase):
|
|
|
482
503
|
# Case when ContentType.model != ContentType.name, template was added and deleted during sync (#570)
|
|
483
504
|
self.assert_export_template_vlan_exists("template.j2")
|
|
484
505
|
|
|
506
|
+
self.assert_job_exists()
|
|
507
|
+
|
|
485
508
|
# Now "resync" the repository, but now those files no longer exist in the repository
|
|
486
509
|
MockGitRepo.side_effect = self.empty_repo
|
|
487
510
|
# For verisimilitude, don't re-use the old request and job_result
|
|
@@ -525,6 +548,9 @@ class GitTest(TransactionTestCase):
|
|
|
525
548
|
self.assertIsNone(device.local_context_data)
|
|
526
549
|
self.assertIsNone(device.local_context_data_owner)
|
|
527
550
|
|
|
551
|
+
# Job should still be present in the database but no longer installed
|
|
552
|
+
self.assert_job_exists(installed=False)
|
|
553
|
+
|
|
528
554
|
def test_pull_git_repository_and_refresh_data_with_bad_data(self, MockGitRepo):
|
|
529
555
|
"""
|
|
530
556
|
The test_pull_git_repository_and_refresh_data job should gracefully handle bad data in the Git repository
|
|
@@ -654,11 +680,11 @@ class GitTest(TransactionTestCase):
|
|
|
654
680
|
|
|
655
681
|
def populate_repo(path, url):
|
|
656
682
|
os.makedirs(path)
|
|
657
|
-
# Just make config_contexts and export_templates directories as we don't load jobs
|
|
658
683
|
os.makedirs(os.path.join(path, "config_contexts"))
|
|
659
684
|
os.makedirs(os.path.join(path, "config_contexts", "devices"))
|
|
660
685
|
os.makedirs(os.path.join(path, "config_context_schemas"))
|
|
661
686
|
os.makedirs(os.path.join(path, "export_templates", "dcim", "device"))
|
|
687
|
+
os.makedirs(os.path.join(path, "jobs"))
|
|
662
688
|
with open(os.path.join(path, "config_contexts", "context.yaml"), "w") as fd:
|
|
663
689
|
yaml.dump(
|
|
664
690
|
{
|
|
@@ -685,6 +711,18 @@ class GitTest(TransactionTestCase):
|
|
|
685
711
|
"w",
|
|
686
712
|
) as fd:
|
|
687
713
|
fd.write("{% for device in queryset %}\n{{ device.name }}\n{% endfor %}")
|
|
714
|
+
with open(os.path.join(path, "jobs", "__init__.py"), "w") as fd:
|
|
715
|
+
pass
|
|
716
|
+
with open(os.path.join(path, "jobs", "job.py"), "w") as fd:
|
|
717
|
+
fd.write(
|
|
718
|
+
"""\
|
|
719
|
+
from nautobot.extras.jobs import Job
|
|
720
|
+
|
|
721
|
+
class MyJob(Job):
|
|
722
|
+
def run(self, data, commit):
|
|
723
|
+
pass"""
|
|
724
|
+
)
|
|
725
|
+
|
|
688
726
|
return mock.DEFAULT
|
|
689
727
|
|
|
690
728
|
MockGitRepo.side_effect = populate_repo
|
|
@@ -744,6 +782,8 @@ class GitTest(TransactionTestCase):
|
|
|
744
782
|
)
|
|
745
783
|
self.assertIsNotNone(export_template)
|
|
746
784
|
|
|
785
|
+
self.assert_job_exists()
|
|
786
|
+
|
|
747
787
|
# Now delete the GitRepository
|
|
748
788
|
self.repo.delete()
|
|
749
789
|
|
|
@@ -768,6 +808,36 @@ class GitTest(TransactionTestCase):
|
|
|
768
808
|
device = Device.objects.get(name=self.device.name)
|
|
769
809
|
self.assertIsNone(device.local_context_data)
|
|
770
810
|
self.assertIsNone(device.local_context_data_owner)
|
|
811
|
+
self.assert_job_exists(installed=False)
|
|
812
|
+
|
|
813
|
+
# Now recreate the repository (https://github.com/nautobot/nautobot/issues/2974)
|
|
814
|
+
self.repo = GitRepository(
|
|
815
|
+
name="Test Git Repository",
|
|
816
|
+
slug="test_git_repo",
|
|
817
|
+
remote_url="http://localhost/git.git",
|
|
818
|
+
# Provide everything we know we can provide
|
|
819
|
+
provided_contents=[
|
|
820
|
+
entry.content_identifier for entry in get_datasource_contents("extras.gitrepository")
|
|
821
|
+
],
|
|
822
|
+
)
|
|
823
|
+
self.repo.save(trigger_resync=False)
|
|
824
|
+
|
|
825
|
+
self.job_result = JobResult.objects.create(
|
|
826
|
+
name=self.repo.name,
|
|
827
|
+
obj_type=ContentType.objects.get_for_model(GitRepository),
|
|
828
|
+
job_id=uuid.uuid4(),
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
pull_git_repository_and_refresh_data(self.repo.pk, self.mock_request, self.job_result.pk)
|
|
832
|
+
self.job_result.refresh_from_db()
|
|
833
|
+
|
|
834
|
+
self.assertEqual(
|
|
835
|
+
self.job_result.status,
|
|
836
|
+
JobResultStatusChoices.STATUS_COMPLETED,
|
|
837
|
+
self.job_result.data,
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
self.assert_job_exists(installed=True)
|
|
771
841
|
|
|
772
842
|
def test_git_dry_run(self, MockGitRepo):
|
|
773
843
|
with tempfile.TemporaryDirectory() as tempdir:
|
|
@@ -1002,7 +1002,7 @@ class DynamicGroupModelTest(DynamicGroupTestBase):
|
|
|
1002
1002
|
group.members_cached
|
|
1003
1003
|
self.assertEqual(mock_get_queryset.call_count, 1)
|
|
1004
1004
|
|
|
1005
|
-
time.sleep(
|
|
1005
|
+
time.sleep(3) # Let the cache expire
|
|
1006
1006
|
|
|
1007
1007
|
group.members_cached
|
|
1008
1008
|
self.assertEqual(mock_get_queryset.call_count, 2)
|
|
@@ -66,6 +66,7 @@ from nautobot.extras.utils import get_job_content_type, TaggableClassesQuery
|
|
|
66
66
|
from nautobot.ipam.factory import VLANFactory
|
|
67
67
|
from nautobot.ipam.models import VLAN, VLANGroup
|
|
68
68
|
from nautobot.users.models import ObjectPermission
|
|
69
|
+
from nautobot.utilities.permissions import get_permission_for_model
|
|
69
70
|
from nautobot.utilities.testing import ViewTestCases, TestCase, extract_page_body, extract_form_failures
|
|
70
71
|
from nautobot.utilities.testing.utils import disable_warnings, post_data
|
|
71
72
|
from nautobot.utilities.utils import slugify_dashes_to_underscores
|
|
@@ -614,9 +615,11 @@ class DynamicGroupTestCase(
|
|
|
614
615
|
content_type = ContentType.objects.get_for_model(Device)
|
|
615
616
|
|
|
616
617
|
# DynamicGroup objects to test.
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
618
|
+
cls.dynamic_groups = [
|
|
619
|
+
DynamicGroup.objects.create(name="DG 1", slug="dg-1", content_type=content_type),
|
|
620
|
+
DynamicGroup.objects.create(name="DG 2", slug="dg-2", content_type=content_type),
|
|
621
|
+
DynamicGroup.objects.create(name="DG 3", slug="dg-3", content_type=content_type),
|
|
622
|
+
]
|
|
620
623
|
|
|
621
624
|
manufacturer = Manufacturer.objects.create(name="Manufacturer 1", slug="manufacturer-1")
|
|
622
625
|
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model="Device Type 1", slug="device-type-1")
|
|
@@ -637,6 +640,38 @@ class DynamicGroupTestCase(
|
|
|
637
640
|
"dynamic_group_memberships-MAX_NUM_FORMS": "1000",
|
|
638
641
|
}
|
|
639
642
|
|
|
643
|
+
def test_get_object_with_permission(self):
|
|
644
|
+
instance = self._get_queryset().first()
|
|
645
|
+
# Add view permissions for the group's members:
|
|
646
|
+
self.add_permissions(get_permission_for_model(instance.content_type.model_class(), "view"))
|
|
647
|
+
|
|
648
|
+
response = super().test_get_object_with_permission()
|
|
649
|
+
|
|
650
|
+
response_body = extract_page_body(response.content.decode(response.charset))
|
|
651
|
+
# Check that the "members" table in the detail view includes all appropriate member objects
|
|
652
|
+
for member in instance.members:
|
|
653
|
+
self.assertIn(str(member.pk), response_body)
|
|
654
|
+
|
|
655
|
+
def test_get_object_with_constrained_permission(self):
|
|
656
|
+
instance = self._get_queryset().first()
|
|
657
|
+
# Add view permission for one of the group's members but not the others:
|
|
658
|
+
member1, member2 = instance.members[:2]
|
|
659
|
+
obj_perm = ObjectPermission(
|
|
660
|
+
name="Members permission",
|
|
661
|
+
constraints={"pk": member1.pk},
|
|
662
|
+
actions=["view"],
|
|
663
|
+
)
|
|
664
|
+
obj_perm.save()
|
|
665
|
+
obj_perm.users.add(self.user)
|
|
666
|
+
obj_perm.object_types.add(instance.content_type)
|
|
667
|
+
|
|
668
|
+
response = super().test_get_object_with_constrained_permission()
|
|
669
|
+
|
|
670
|
+
response_body = extract_page_body(response.content.decode(response.charset))
|
|
671
|
+
# Check that the "members" table in the detail view includes all permitted member objects
|
|
672
|
+
self.assertIn(str(member1.pk), response_body)
|
|
673
|
+
self.assertNotIn(str(member2.pk), response_body)
|
|
674
|
+
|
|
640
675
|
def test_get_object_dynamic_groups_anonymous(self):
|
|
641
676
|
url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk})
|
|
642
677
|
self.client.logout()
|
|
@@ -660,7 +695,6 @@ class DynamicGroupTestCase(
|
|
|
660
695
|
self.assertIn("DG 3", response_body, msg=response_body)
|
|
661
696
|
|
|
662
697
|
def test_get_object_dynamic_groups_with_constrained_permission(self):
|
|
663
|
-
self.add_permissions("extras.view_dynamicgroup")
|
|
664
698
|
obj_perm = ObjectPermission(
|
|
665
699
|
name="View a device",
|
|
666
700
|
constraints={"pk": Device.objects.first().pk},
|
|
@@ -669,12 +703,22 @@ class DynamicGroupTestCase(
|
|
|
669
703
|
obj_perm.save()
|
|
670
704
|
obj_perm.users.add(self.user)
|
|
671
705
|
obj_perm.object_types.add(ContentType.objects.get_for_model(Device))
|
|
706
|
+
obj_perm_2 = ObjectPermission(
|
|
707
|
+
name="View a Dynamic Group",
|
|
708
|
+
constraints={"pk": self.dynamic_groups[0].pk},
|
|
709
|
+
actions=["view"],
|
|
710
|
+
)
|
|
711
|
+
obj_perm_2.save()
|
|
712
|
+
obj_perm_2.users.add(self.user)
|
|
713
|
+
obj_perm_2.object_types.add(ContentType.objects.get_for_model(DynamicGroup))
|
|
672
714
|
|
|
673
715
|
url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk})
|
|
674
716
|
response = self.client.get(url)
|
|
675
717
|
self.assertHttpStatus(response, 200)
|
|
676
718
|
response_body = response.content.decode(response.charset)
|
|
677
719
|
self.assertIn("DG 1", response_body, msg=response_body)
|
|
720
|
+
self.assertNotIn("DG 2", response_body, msg=response_body)
|
|
721
|
+
self.assertNotIn("DG 3", response_body, msg=response_body)
|
|
678
722
|
|
|
679
723
|
url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.last().pk})
|
|
680
724
|
response = self.client.get(url)
|
nautobot/extras/utils.py
CHANGED
|
@@ -9,6 +9,7 @@ import sys
|
|
|
9
9
|
from django.apps import apps
|
|
10
10
|
from django.conf import settings
|
|
11
11
|
from django.contrib.contenttypes.models import ContentType
|
|
12
|
+
from django.db import IntegrityError
|
|
12
13
|
from django.db.models import Q
|
|
13
14
|
from django.template.loader import get_template, TemplateDoesNotExist
|
|
14
15
|
from django.utils.deconstruct import deconstructible
|
|
@@ -365,21 +366,37 @@ def refresh_job_model_from_job_class(job_model_class, job_source, job_class, *,
|
|
|
365
366
|
JOB_MAX_NAME_LENGTH,
|
|
366
367
|
)
|
|
367
368
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
369
|
+
try:
|
|
370
|
+
job_model, created = job_model_class.objects.get_or_create(
|
|
371
|
+
source=job_source[:JOB_MAX_SOURCE_LENGTH],
|
|
372
|
+
git_repository=git_repository,
|
|
373
|
+
module_name=job_class.__module__[:JOB_MAX_NAME_LENGTH],
|
|
374
|
+
job_class_name=job_class.__name__[:JOB_MAX_NAME_LENGTH],
|
|
375
|
+
defaults={
|
|
376
|
+
"slug": default_slug[:JOB_MAX_SLUG_LENGTH],
|
|
377
|
+
"grouping": job_class.grouping[:JOB_MAX_GROUPING_LENGTH],
|
|
378
|
+
"name": job_class.name[:JOB_MAX_NAME_LENGTH],
|
|
379
|
+
"is_job_hook_receiver": issubclass(job_class, JobHookReceiver),
|
|
380
|
+
"is_job_button_receiver": issubclass(job_class, JobButtonReceiver),
|
|
381
|
+
"installed": True,
|
|
382
|
+
"enabled": False,
|
|
383
|
+
},
|
|
384
|
+
)
|
|
385
|
+
except IntegrityError:
|
|
386
|
+
# can occur in the case where we've deleted a GitRepository, resulting in a Job record with
|
|
387
|
+
# source="git" but git_repository=None, and are now creating a new GitRepository to "re-claim" the Job.
|
|
388
|
+
if git_repository is not None:
|
|
389
|
+
created = False
|
|
390
|
+
job_model = job_model_class.objects.get(
|
|
391
|
+
source=job_source[:JOB_MAX_SOURCE_LENGTH],
|
|
392
|
+
git_repository=None,
|
|
393
|
+
module_name=job_class.__module__[:JOB_MAX_NAME_LENGTH],
|
|
394
|
+
job_class_name=job_class.__name__[:JOB_MAX_NAME_LENGTH],
|
|
395
|
+
)
|
|
396
|
+
job_model.git_repository = git_repository
|
|
397
|
+
job_model.save()
|
|
398
|
+
else:
|
|
399
|
+
raise
|
|
383
400
|
|
|
384
401
|
for field_name in JOB_OVERRIDABLE_FIELDS:
|
|
385
402
|
# Was this field directly inherited from the job before, or was it overridden in the database?
|
nautobot/extras/views.py
CHANGED
|
@@ -550,7 +550,7 @@ class DynamicGroupView(generic.ObjectView):
|
|
|
550
550
|
|
|
551
551
|
if table_class is not None:
|
|
552
552
|
# Members table (for display on Members nav tab)
|
|
553
|
-
members_table = table_class(instance.members, orderable=False)
|
|
553
|
+
members_table = table_class(instance.members.restrict(request.user, "view"), orderable=False)
|
|
554
554
|
paginate = {
|
|
555
555
|
"paginator_class": EnhancedPaginator,
|
|
556
556
|
"per_page": get_paginate_count(request),
|
|
@@ -722,7 +722,9 @@ class ObjectDynamicGroupsView(generic.GenericView):
|
|
|
722
722
|
obj = get_object_or_404(model, **kwargs)
|
|
723
723
|
|
|
724
724
|
# Gather all dynamic groups for this object (and its related objects)
|
|
725
|
-
dynamicsgroups_table = tables.DynamicGroupTable(
|
|
725
|
+
dynamicsgroups_table = tables.DynamicGroupTable(
|
|
726
|
+
data=obj.dynamic_groups_cached.restrict(request.user, "view"), orderable=False
|
|
727
|
+
)
|
|
726
728
|
|
|
727
729
|
# Apply the request context
|
|
728
730
|
paginate = {
|