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.

Files changed (30) hide show
  1. nautobot/core/settings.py +13 -3
  2. nautobot/core/templates/admin/base.html +8 -2
  3. nautobot/core/templates/base.html +2 -2
  4. nautobot/core/templates/graphene/graphiql.html +3 -0
  5. nautobot/core/templates/inc/javascript.html +3 -0
  6. nautobot/core/templates/inc/media.html +3 -0
  7. nautobot/core/templates/login.html +2 -2
  8. nautobot/core/templates/nautobot_config.py.j2 +2 -0
  9. nautobot/core/tests/test_views.py +33 -0
  10. nautobot/extras/api/views.py +2 -2
  11. nautobot/extras/tests/test_api.py +23 -1
  12. nautobot/extras/tests/test_datasources.py +72 -2
  13. nautobot/extras/tests/test_dynamicgroups.py +1 -1
  14. nautobot/extras/tests/test_views.py +48 -4
  15. nautobot/extras/utils.py +32 -15
  16. nautobot/extras/views.py +4 -2
  17. nautobot/project-static/docs/code-reference/nautobot/apps/models.html +20 -20
  18. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +2824 -2804
  19. nautobot/project-static/docs/configuration/optional-settings.html +19 -5
  20. nautobot/project-static/docs/release-notes/version-1.6.html +305 -159
  21. nautobot/project-static/docs/search/search_index.json +1 -1
  22. nautobot/project-static/docs/sitemap.xml +187 -187
  23. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  24. nautobot/utilities/testing/views.py +6 -1
  25. {nautobot-1.6.21.dist-info → nautobot-1.6.23.dist-info}/METADATA +1 -1
  26. {nautobot-1.6.21.dist-info → nautobot-1.6.23.dist-info}/RECORD +30 -30
  27. {nautobot-1.6.21.dist-info → nautobot-1.6.23.dist-info}/LICENSE.txt +0 -0
  28. {nautobot-1.6.21.dist-info → nautobot-1.6.23.dist-info}/NOTICE +0 -0
  29. {nautobot-1.6.21.dist-info → nautobot-1.6.23.dist-info}/WHEEL +0 -0
  30. {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|safe }}
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|safe }}
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|safe }}
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|safe }}
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' %}">
@@ -52,3 +52,6 @@
52
52
  dropdown.removeClass('edge');
53
53
  })
54
54
  </script>
55
+ {% if settings.BRANDING_FILEPATHS.javascript %}
56
+ <script src="{% custom_branding_or_static 'javascript' None %}"></script>
57
+ {% endif %}
@@ -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 style="margin-bottom: 25px">
57
- {{ "BANNER_LOGIN"|settings_or_config|safe }}
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):
@@ -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 the same type as the `content_type` for this dynamic group."""
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 = DynamicGroup.objects.first()
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(2) # Let the cache expire
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
- DynamicGroup.objects.create(name="DG 1", slug="dg-1", content_type=content_type)
618
- DynamicGroup.objects.create(name="DG 2", slug="dg-2", content_type=content_type)
619
- DynamicGroup.objects.create(name="DG 3", slug="dg-3", content_type=content_type)
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
- job_model, created = job_model_class.objects.get_or_create(
369
- source=job_source[:JOB_MAX_SOURCE_LENGTH],
370
- git_repository=git_repository,
371
- module_name=job_class.__module__[:JOB_MAX_NAME_LENGTH],
372
- job_class_name=job_class.__name__[:JOB_MAX_NAME_LENGTH],
373
- defaults={
374
- "slug": default_slug[:JOB_MAX_SLUG_LENGTH],
375
- "grouping": job_class.grouping[:JOB_MAX_GROUPING_LENGTH],
376
- "name": job_class.name[:JOB_MAX_NAME_LENGTH],
377
- "is_job_hook_receiver": issubclass(job_class, JobHookReceiver),
378
- "is_job_button_receiver": issubclass(job_class, JobButtonReceiver),
379
- "installed": True,
380
- "enabled": False,
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(data=obj.dynamic_groups_cached, orderable=False)
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 = {