nautobot 2.4.17__py3-none-any.whl → 2.4.18__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 (92) hide show
  1. nautobot/apps/views.py +2 -0
  2. nautobot/circuits/templates/circuits/circuittermination_retrieve.html +1 -8
  3. nautobot/circuits/templates/circuits/inc/circuit_termination_speed_fragment.html +9 -0
  4. nautobot/circuits/tests/integration/test_circuit.py +2 -2
  5. nautobot/circuits/views.py +32 -15
  6. nautobot/core/filters.py +2 -2
  7. nautobot/core/settings.py +1 -0
  8. nautobot/core/settings.yaml +9 -0
  9. nautobot/core/tables.py +21 -23
  10. nautobot/core/templates/components/breadcrumbs.html +19 -0
  11. nautobot/core/templates/generic/object_changelog.html +0 -2
  12. nautobot/core/templates/generic/object_list.html +15 -12
  13. nautobot/core/templates/generic/object_notes.html +0 -2
  14. nautobot/core/templates/generic/object_retrieve.html +16 -9
  15. nautobot/core/templatetags/helpers.py +24 -0
  16. nautobot/core/templatetags/ui_framework.py +40 -5
  17. nautobot/core/testing/filters.py +37 -21
  18. nautobot/core/testing/views.py +25 -0
  19. nautobot/core/tests/test_tables.py +43 -6
  20. nautobot/core/tests/test_templatetags_ui_framework.py +146 -0
  21. nautobot/core/tests/test_titles.py +2 -2
  22. nautobot/core/tests/test_ui.py +14 -1
  23. nautobot/core/tests/test_views.py +45 -0
  24. nautobot/core/ui/breadcrumbs.py +13 -8
  25. nautobot/core/ui/object_detail.py +43 -5
  26. nautobot/core/ui/titles.py +9 -5
  27. nautobot/core/views/__init__.py +24 -3
  28. nautobot/core/views/generic.py +42 -17
  29. nautobot/core/views/mixins.py +146 -12
  30. nautobot/core/views/utils.py +117 -0
  31. nautobot/dcim/models/devices.py +4 -0
  32. nautobot/dcim/tables/__init__.py +2 -0
  33. nautobot/dcim/tables/devices.py +24 -0
  34. nautobot/dcim/tables/power.py +2 -2
  35. nautobot/dcim/templates/dcim/device/base.html +1 -11
  36. nautobot/dcim/templates/dcim/device_component.html +0 -19
  37. nautobot/dcim/templates/dcim/modulebay_retrieve.html +0 -16
  38. nautobot/dcim/templates/dcim/virtualchassis_retrieve.html +1 -50
  39. nautobot/dcim/tests/test_views.py +41 -0
  40. nautobot/dcim/views.py +160 -39
  41. nautobot/extras/filters/mixins.py +1 -1
  42. nautobot/extras/forms/forms.py +15 -0
  43. nautobot/extras/models/groups.py +10 -1
  44. nautobot/extras/models/jobs.py +2 -2
  45. nautobot/extras/plugins/views.py +18 -5
  46. nautobot/extras/tables.py +4 -2
  47. nautobot/extras/templates/extras/customfield_retrieve.html +1 -128
  48. nautobot/extras/templates/extras/dynamicgroup.html +2 -99
  49. nautobot/extras/templates/extras/dynamicgroup_edit.html +2 -199
  50. nautobot/extras/templates/extras/dynamicgroup_retrieve.html +99 -0
  51. nautobot/extras/templates/extras/dynamicgroup_update.html +199 -0
  52. nautobot/extras/templates/extras/gitrepository.html +2 -82
  53. nautobot/extras/templates/extras/gitrepository_object_edit.html +2 -13
  54. nautobot/extras/templates/extras/gitrepository_retrieve.html +82 -0
  55. nautobot/extras/templates/extras/gitrepository_update.html +13 -0
  56. nautobot/extras/templates/extras/note_retrieve.html +0 -52
  57. nautobot/extras/templates/extras/plugin_detail.html +3 -7
  58. nautobot/extras/templates/extras/plugins_list.html +0 -2
  59. nautobot/extras/tests/test_dynamicgroups.py +73 -18
  60. nautobot/extras/tests/test_views.py +5 -0
  61. nautobot/extras/urls.py +2 -94
  62. nautobot/extras/views.py +424 -430
  63. nautobot/ipam/querysets.py +3 -3
  64. nautobot/ipam/signals.py +6 -1
  65. nautobot/ipam/templates/ipam/prefix.html +0 -8
  66. nautobot/ipam/tests/test_api.py +5 -0
  67. nautobot/ipam/tests/test_models.py +387 -0
  68. nautobot/ipam/tests/test_querysets.py +46 -0
  69. nautobot/ipam/utils/migrations.py +1 -1
  70. nautobot/ipam/views.py +17 -8
  71. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +72 -0
  72. nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +45 -9
  73. nautobot/project-static/docs/code-reference/nautobot/apps/views.html +393 -15
  74. nautobot/project-static/docs/development/apps/api/nautobot-app-config.html +1 -1
  75. nautobot/project-static/docs/development/core/getting-started.html +0 -15
  76. nautobot/project-static/docs/development/core/ui-component-framework.html +6 -11
  77. nautobot/project-static/docs/objects.inv +0 -0
  78. nautobot/project-static/docs/release-notes/version-2.4.html +222 -0
  79. nautobot/project-static/docs/search/search_index.json +1 -1
  80. nautobot/project-static/docs/sitemap.xml +300 -300
  81. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  82. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +27 -0
  83. nautobot/project-static/img/nautobot_icon.svg +32 -34
  84. nautobot/project-static/js/table_sorting_indicator.js +0 -2
  85. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/METADATA +4 -4
  86. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/RECORD +90 -85
  87. nautobot/core/templates/inc/breadcrumbs.html +0 -14
  88. nautobot/project-static/docs/requirements.txt +0 -14
  89. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/LICENSE.txt +0 -0
  90. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/NOTICE +0 -0
  91. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/WHEEL +0 -0
  92. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/entry_points.txt +0 -0
@@ -1,51 +1,2 @@
1
1
  {% extends 'generic/object_retrieve.html' %}
2
- {% load helpers %}
3
-
4
- {% block content_left_page %}
5
- <div class="panel panel-default">
6
- <div class="panel-heading">
7
- <strong>Virtual Chassis</strong>
8
- </div>
9
- <table class="table table-hover panel-body attr-table">
10
- <tr>
11
- <td>Domain</td>
12
- <td>{{ object.domain|placeholder }}</td>
13
- </tr>
14
- <tr>
15
- <td>Master</td>
16
- <td>{{ object.master|hyperlinked_object }}</td>
17
- </tr>
18
- </table>
19
- </div>
20
- {% endblock content_left_page %}
21
-
22
- {% block content_right_page %}
23
- <div class="panel panel-default">
24
- <div class="panel-heading">
25
- <strong>Members</strong>
26
- </div>
27
- <table class="table table-hover panel-body attr-table">
28
- <tr>
29
- <th>Device</th>
30
- <th>Position</th>
31
- <th>Master</th>
32
- <th>Priority</th>
33
- </tr>
34
- {% for vc_member in members %}
35
- <tr{% if vc_member == device %} class="info"{% endif %}>
36
- <td>{{ vc_member|hyperlinked_object }}</td>
37
- <td><span class="badge badge-default">{{ vc_member.vc_position }}</span></td>
38
- <td>{% if object.master == vc_member %}{{ True | render_boolean }}{% endif %}</td>
39
- <td>{{ vc_member.vc_priority|placeholder }}</td>
40
- </tr>
41
- {% endfor %}
42
- </table>
43
- {% if perms.dcim.change_virtualchassis %}
44
- <div class="panel-footer text-right noprint">
45
- <a href="{% url 'dcim:virtualchassis_add_member' pk=object.pk %}?location={{ object.master.location.pk }}&rack={{ object.master.rack.pk }}" class="btn btn-primary btn-xs">
46
- <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Member
47
- </a>
48
- </div>
49
- {% endif %}
50
- </div>
51
- {% endblock content_right_page %}
2
+ {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -4202,6 +4202,47 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
4202
4202
  # Sanity check:
4203
4203
  self.assertBodyContains(response, "<th>Name</th>", html=True)
4204
4204
 
4205
+ def test_set_master_after_adding_member(self):
4206
+ """Ensure master can be set for a member that was added via the Add Member flow."""
4207
+ self.add_permissions(
4208
+ "dcim.view_device",
4209
+ "dcim.view_virtualchassis",
4210
+ "dcim.change_virtualchassis",
4211
+ "dcim.change_device",
4212
+ )
4213
+
4214
+ # Create VC
4215
+ vc = VirtualChassis.objects.create(name="VC-test", domain="domain-test")
4216
+
4217
+ # Simulate adding a member via the separate "add-member" flow by creating the device with virtual_chassis
4218
+ member = Device.objects.create(
4219
+ device_type=self.devices[0].device_type,
4220
+ role=self.devices[0].role,
4221
+ status=self.devices[0].status,
4222
+ name="separately-added-device",
4223
+ location=self.devices[0].location,
4224
+ virtual_chassis=vc,
4225
+ vc_position=1,
4226
+ )
4227
+
4228
+ # Now edit the VC and set the master to the existing member
4229
+ payload_data = {
4230
+ "name": vc.name,
4231
+ "domain": vc.domain,
4232
+ "master": str(member.pk),
4233
+ # no members formset rows are required because the member already exists
4234
+ "form-TOTAL_FORMS": "0",
4235
+ "form-INITIAL_FORMS": "0",
4236
+ "form-MIN_NUM_FORMS": "0",
4237
+ "form-MAX_NUM_FORMS": "1000",
4238
+ }
4239
+
4240
+ url = reverse("dcim:virtualchassis_edit", kwargs={"pk": vc.pk})
4241
+ self.client.post(url, data=payload_data, follow=True)
4242
+
4243
+ vc.refresh_from_db()
4244
+ self.assertEqual(vc.master, member)
4245
+
4205
4246
 
4206
4247
  class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
4207
4248
  model = PowerPanel
nautobot/dcim/views.py CHANGED
@@ -21,7 +21,7 @@ from django.urls import reverse
21
21
  from django.utils.encoding import iri_to_uri
22
22
  from django.utils.functional import cached_property
23
23
  from django.utils.html import format_html
24
- from django.utils.http import url_has_allowed_host_and_scheme
24
+ from django.utils.http import url_has_allowed_host_and_scheme, urlencode
25
25
  from django.views.generic import View
26
26
  from django_tables2 import RequestConfig
27
27
  from rest_framework.decorators import action
@@ -42,6 +42,7 @@ from nautobot.core.ui.breadcrumbs import (
42
42
  Breadcrumbs,
43
43
  InstanceBreadcrumbItem,
44
44
  ModelBreadcrumbItem,
45
+ ViewNameBreadcrumbItem,
45
46
  )
46
47
  from nautobot.core.ui.bulk_buttons import (
47
48
  BulkDeleteButton,
@@ -49,6 +50,7 @@ from nautobot.core.ui.bulk_buttons import (
49
50
  BulkRenameButton,
50
51
  )
51
52
  from nautobot.core.ui.choices import SectionChoices
53
+ from nautobot.core.ui.titles import Titles
52
54
  from nautobot.core.utils.lookup import get_form_for_model
53
55
  from nautobot.core.utils.permissions import get_permission_for_model
54
56
  from nautobot.core.utils.requests import normalize_querydict
@@ -515,6 +517,7 @@ class RackGroupUIViewSet(NautobotUIViewSet):
515
517
  if self.action == "retrieve" and instance:
516
518
  racks = (
517
519
  Rack.objects.restrict(request.user, "view")
520
+ # Note this filter - we want the table to include racks assigned to child rack groups as well
518
521
  .filter(rack_group__in=instance.descendants(include_self=True))
519
522
  .select_related("role", "location", "tenant")
520
523
  )
@@ -590,6 +593,10 @@ class RackElevationListView(generic.ObjectListView):
590
593
  filterset_form = forms.RackFilterForm
591
594
  action_buttons = []
592
595
  template_name = "dcim/rack_elevation_list.html"
596
+ view_titles = Titles(titles={"list": "Rack Elevation"})
597
+ breadcrumbs = Breadcrumbs(
598
+ items={"list": [ViewNameBreadcrumbItem(view_name="dcim:rack_elevation_list", label="Rack Elevation")]}
599
+ )
593
600
 
594
601
  def extra_context(self):
595
602
  racks = self.queryset
@@ -644,11 +651,15 @@ class RackReservationUIViewSet(NautobotUIViewSet):
644
651
 
645
652
  object_detail_content = object_detail.ObjectDetailContent(
646
653
  panels=(
647
- object_detail.KeyValueTablePanel(
654
+ object_detail.ObjectFieldsPanel(
648
655
  section=SectionChoices.LEFT_HALF,
649
656
  weight=100,
650
657
  label="Rack",
651
- context_data_key="rack_data",
658
+ fields=["rack__location", "rack__rack_group", "rack"],
659
+ key_transforms={
660
+ "rack__location": "Location",
661
+ "rack__rack_group": "Rack Group",
662
+ },
652
663
  ),
653
664
  object_detail.ObjectFieldsPanel(
654
665
  section=SectionChoices.LEFT_HALF,
@@ -664,23 +675,6 @@ class RackReservationUIViewSet(NautobotUIViewSet):
664
675
  ),
665
676
  )
666
677
 
667
- def get_extra_context(self, request, instance):
668
- context = super().get_extra_context(request, instance)
669
- if self.action == "retrieve":
670
- context["rack_data"] = self.get_rack_context(instance)
671
- return context
672
-
673
- def get_rack_context(self, instance):
674
- rack = getattr(instance, "rack", None)
675
- if not rack:
676
- return {}
677
-
678
- return {
679
- "location": rack.location,
680
- "rack_group": rack.rack_group,
681
- "rack": rack,
682
- }
683
-
684
678
  def get_object(self):
685
679
  obj = super().get_object()
686
680
 
@@ -1890,13 +1884,14 @@ class PlatformUIViewSet(NautobotUIViewSet):
1890
1884
  #
1891
1885
 
1892
1886
 
1893
- class DeviceBreadcrumbsMixin:
1887
+ class DevicePageMixin:
1894
1888
  breadcrumbs = Breadcrumbs(
1895
1889
  items={
1896
1890
  "detail": [
1897
1891
  ModelBreadcrumbItem(model=Device),
1898
1892
  ModelBreadcrumbItem(
1899
1893
  model=Device,
1894
+ label=lambda c: c["object"].location,
1900
1895
  reverse_query_params=lambda c: {"location": c["object"].location.pk},
1901
1896
  ),
1902
1897
  InstanceBreadcrumbItem(
@@ -1911,6 +1906,49 @@ class DeviceBreadcrumbsMixin:
1911
1906
  )
1912
1907
 
1913
1908
 
1909
+ class DeviceComponentPageMixin:
1910
+ """
1911
+ This class hold the breadcrumbs paths for Device Components Pages like console ports.
1912
+ Depending on whether the component is associated with a device or a module, the appropriate breadcrumb path will be rendered.
1913
+
1914
+ For example:
1915
+ - Console Port assigned to the module: Modules / <Module name and link to details> / Console Ports (dcim/modules/<id>/console-ports/) / <Console Port name>
1916
+ - Console Port assigned to the device: Devices / <Device name and link to details> / Console Ports (dcim/devices/<id>/console-ports/) / <Console Port name>
1917
+ """
1918
+
1919
+ breadcrumbs = Breadcrumbs(
1920
+ items={
1921
+ "detail": [
1922
+ ModelBreadcrumbItem(model=Device, should_render=lambda c: c["object"].device is not None),
1923
+ InstanceBreadcrumbItem(
1924
+ instance=lambda c: c["object"].device, should_render=lambda c: c["object"].device is not None
1925
+ ),
1926
+ ViewNameBreadcrumbItem(
1927
+ view_name_key="device_breadcrumb_url",
1928
+ should_render=lambda c: c["object"].device is not None and c.get("device_breadcrumb_url"),
1929
+ reverse_kwargs=lambda c: {"pk": c["object"].device.pk},
1930
+ label=lambda c: c["object"]._meta.verbose_name_plural,
1931
+ ),
1932
+ ModelBreadcrumbItem(model=Module, should_render=lambda c: c["object"].device is None),
1933
+ InstanceBreadcrumbItem(
1934
+ instance=lambda c: c["object"].module, should_render=lambda c: c["object"].device is None
1935
+ ),
1936
+ ViewNameBreadcrumbItem(
1937
+ view_name_key="module_breadcrumb_url",
1938
+ should_render=lambda c: c["object"].device is None and c.get("module_breadcrumb_url"),
1939
+ reverse_kwargs=lambda c: {"pk": c["object"].module.pk},
1940
+ label=lambda c: c["object"]._meta.verbose_name_plural,
1941
+ ),
1942
+ ]
1943
+ }
1944
+ )
1945
+ view_titles = Titles(
1946
+ titles={
1947
+ "detail": "{% if object.device %}{{ object.device }}{% else %}{{ object.module.display }}{% endif %} / {{ object }}"
1948
+ }
1949
+ )
1950
+
1951
+
1914
1952
  class DeviceListView(generic.ObjectListView):
1915
1953
  queryset = Device.objects.select_related(
1916
1954
  "device_type__manufacturer", # Needed for __str__() on device_type
@@ -1921,7 +1959,7 @@ class DeviceListView(generic.ObjectListView):
1921
1959
  template_name = "dcim/device_list.html"
1922
1960
 
1923
1961
 
1924
- class DeviceView(generic.ObjectView):
1962
+ class DeviceView(DevicePageMixin, generic.ObjectView):
1925
1963
  queryset = Device.objects.select_related(
1926
1964
  "cluster__cluster_group",
1927
1965
  "controller_managed_device_group__controller",
@@ -2177,7 +2215,7 @@ class DeviceView(generic.ObjectView):
2177
2215
  }
2178
2216
 
2179
2217
 
2180
- class DeviceComponentTabView(generic.ObjectView):
2218
+ class DeviceComponentTabView(DevicePageMixin, generic.ObjectView):
2181
2219
  queryset = Device.objects.all()
2182
2220
 
2183
2221
  def get_extra_context(self, request, instance):
@@ -2185,6 +2223,7 @@ class DeviceComponentTabView(generic.ObjectView):
2185
2223
  module_count = instance.module_bays.filter(installed_module__isnull=False).count()
2186
2224
 
2187
2225
  return {
2226
+ **super().get_extra_context(request, instance),
2188
2227
  "modulebay_count": modulebay_count,
2189
2228
  "module_count": f"{module_count}/{modulebay_count}",
2190
2229
  }
@@ -2444,7 +2483,7 @@ class DeviceConfigView(generic.ObjectView):
2444
2483
  }
2445
2484
 
2446
2485
 
2447
- class DeviceConfigContextView(ObjectConfigContextView):
2486
+ class DeviceConfigContextView(DevicePageMixin, ObjectConfigContextView):
2448
2487
  base_template = "dcim/device/base.html"
2449
2488
 
2450
2489
  @cached_property
@@ -2455,7 +2494,7 @@ class DeviceConfigContextView(ObjectConfigContextView):
2455
2494
  return Device.objects.annotate_config_context_data()
2456
2495
 
2457
2496
 
2458
- class DeviceChangeLogView(ObjectChangeLogView):
2497
+ class DeviceChangeLogView(DevicePageMixin, ObjectChangeLogView):
2459
2498
  base_template = "dcim/device/base.html"
2460
2499
 
2461
2500
 
@@ -3007,7 +3046,7 @@ class ConsolePortListView(generic.ObjectListView):
3007
3046
  action_buttons = ("import", "export")
3008
3047
 
3009
3048
 
3010
- class ConsolePortView(generic.ObjectView):
3049
+ class ConsolePortView(DeviceComponentPageMixin, generic.ObjectView):
3011
3050
  queryset = ConsolePort.objects.all()
3012
3051
 
3013
3052
  def get_extra_context(self, request, instance):
@@ -3073,7 +3112,7 @@ class ConsoleServerPortListView(generic.ObjectListView):
3073
3112
  action_buttons = ("import", "export")
3074
3113
 
3075
3114
 
3076
- class ConsoleServerPortView(generic.ObjectView):
3115
+ class ConsoleServerPortView(DeviceComponentPageMixin, generic.ObjectView):
3077
3116
  queryset = ConsoleServerPort.objects.all()
3078
3117
 
3079
3118
  def get_extra_context(self, request, instance):
@@ -3139,7 +3178,7 @@ class PowerPortListView(generic.ObjectListView):
3139
3178
  action_buttons = ("import", "export")
3140
3179
 
3141
3180
 
3142
- class PowerPortView(generic.ObjectView):
3181
+ class PowerPortView(DeviceComponentPageMixin, generic.ObjectView):
3143
3182
  queryset = PowerPort.objects.all()
3144
3183
 
3145
3184
  def get_extra_context(self, request, instance):
@@ -3205,7 +3244,7 @@ class PowerOutletListView(generic.ObjectListView):
3205
3244
  action_buttons = ("import", "export")
3206
3245
 
3207
3246
 
3208
- class PowerOutletView(generic.ObjectView):
3247
+ class PowerOutletView(DeviceComponentPageMixin, generic.ObjectView):
3209
3248
  queryset = PowerOutlet.objects.all()
3210
3249
 
3211
3250
  def get_extra_context(self, request, instance):
@@ -3271,7 +3310,10 @@ class InterfaceListView(generic.ObjectListView):
3271
3310
  action_buttons = ("import", "export")
3272
3311
 
3273
3312
 
3274
- class InterfaceView(generic.ObjectView):
3313
+ class InterfaceView(
3314
+ DeviceComponentPageMixin,
3315
+ generic.ObjectView,
3316
+ ):
3275
3317
  queryset = Interface.objects.all()
3276
3318
 
3277
3319
  def get_extra_context(self, request, instance):
@@ -3401,7 +3443,7 @@ class FrontPortListView(generic.ObjectListView):
3401
3443
  action_buttons = ("import", "export")
3402
3444
 
3403
3445
 
3404
- class FrontPortView(generic.ObjectView):
3446
+ class FrontPortView(DeviceComponentPageMixin, generic.ObjectView):
3405
3447
  queryset = FrontPort.objects.all()
3406
3448
 
3407
3449
  def get_extra_context(self, request, instance):
@@ -3467,7 +3509,7 @@ class RearPortListView(generic.ObjectListView):
3467
3509
  action_buttons = ("import", "export")
3468
3510
 
3469
3511
 
3470
- class RearPortView(generic.ObjectView):
3512
+ class RearPortView(DeviceComponentPageMixin, generic.ObjectView):
3471
3513
  queryset = RearPort.objects.all()
3472
3514
 
3473
3515
  def get_extra_context(self, request, instance):
@@ -3533,7 +3575,7 @@ class DeviceBayListView(generic.ObjectListView):
3533
3575
  action_buttons = ("import", "export")
3534
3576
 
3535
3577
 
3536
- class DeviceBayView(generic.ObjectView):
3578
+ class DeviceBayView(DeviceComponentPageMixin, generic.ObjectView):
3537
3579
  queryset = DeviceBay.objects.all()
3538
3580
 
3539
3581
  def get_extra_context(self, request, instance):
@@ -3695,6 +3737,35 @@ class ModuleBayUIViewSet(ModuleBayCommonViewSetMixin, NautobotUIViewSet):
3695
3737
  ),
3696
3738
  )
3697
3739
  )
3740
+ breadcrumbs = Breadcrumbs(
3741
+ items={
3742
+ "detail": [
3743
+ # Breadcrumb path if ModuleBay is linked with device
3744
+ ModelBreadcrumbItem(model=Device, should_render=lambda c: c["object"].parent_device),
3745
+ InstanceBreadcrumbItem(
3746
+ instance=lambda c: c["object"].parent_device, should_render=lambda c: c["object"].parent_device
3747
+ ),
3748
+ ViewNameBreadcrumbItem(
3749
+ view_name_key="device_breadcrumb_url",
3750
+ should_render=lambda c: c["object"].parent_device and c.get("device_breadcrumb_url"),
3751
+ reverse_kwargs=lambda c: {"pk": c["object"].parent_device.pk},
3752
+ label=lambda c: c["object"]._meta.verbose_name_plural,
3753
+ ),
3754
+ # Breadcrumb path if ModuleBay is linked with module
3755
+ ModelBreadcrumbItem(model=Module, should_render=lambda c: c["object"].parent_device is None),
3756
+ InstanceBreadcrumbItem(
3757
+ instance=lambda c: c["object"].parent_module,
3758
+ should_render=lambda c: c["object"].parent_device is None,
3759
+ ),
3760
+ ViewNameBreadcrumbItem(
3761
+ view_name_key="module_breadcrumb_url",
3762
+ should_render=lambda c: c["object"].parent_device is None and c.get("module_breadcrumb_url"),
3763
+ reverse_kwargs=lambda c: {"pk": c["object"].parent_module.pk},
3764
+ label=lambda c: c["object"]._meta.verbose_name_plural,
3765
+ ),
3766
+ ]
3767
+ }
3768
+ )
3698
3769
 
3699
3770
  def get_extra_context(self, request, instance):
3700
3771
  context = super().get_extra_context(request, instance)
@@ -3748,7 +3819,7 @@ class InventoryItemListView(generic.ObjectListView):
3748
3819
  action_buttons = ("import", "export")
3749
3820
 
3750
3821
 
3751
- class InventoryItemView(generic.ObjectView):
3822
+ class InventoryItemView(DeviceComponentPageMixin, generic.ObjectView):
3752
3823
  queryset = InventoryItem.objects.all().select_related("device", "manufacturer", "software_version")
3753
3824
 
3754
3825
  def get_extra_context(self, request, instance):
@@ -4108,6 +4179,10 @@ class ConsoleConnectionsListView(ConnectionsListView):
4108
4179
  table = tables.ConsoleConnectionTable
4109
4180
  template_name = "dcim/console_port_connection_list.html"
4110
4181
  action_buttons = ("export",)
4182
+ view_titles = Titles(titles={"list": "Console Connections"})
4183
+ breadcrumbs = Breadcrumbs(
4184
+ items={"list": [ViewNameBreadcrumbItem(view_name="dcim:console_connections_list", label="Console Connections")]}
4185
+ )
4111
4186
 
4112
4187
  def extra_context(self):
4113
4188
  return {
@@ -4124,6 +4199,10 @@ class PowerConnectionsListView(ConnectionsListView):
4124
4199
  table = tables.PowerConnectionTable
4125
4200
  template_name = "dcim/power_port_connection_list.html"
4126
4201
  action_buttons = ("export",)
4202
+ view_titles = Titles(titles={"list": "Power Connections"})
4203
+ breadcrumbs = Breadcrumbs(
4204
+ items={"list": [ViewNameBreadcrumbItem(view_name="dcim:power_connections_list", label="Power Connections")]}
4205
+ )
4127
4206
 
4128
4207
  def extra_context(self):
4129
4208
  return {
@@ -4140,6 +4219,12 @@ class InterfaceConnectionsListView(ConnectionsListView):
4140
4219
  table = tables.InterfaceConnectionTable
4141
4220
  template_name = "dcim/interface_connection_list.html"
4142
4221
  action_buttons = ("export",)
4222
+ view_titles = Titles(titles={"list": "Interface Connections"})
4223
+ breadcrumbs = Breadcrumbs(
4224
+ items={
4225
+ "list": [ViewNameBreadcrumbItem(view_name="dcim:interface_connections_list", label="Interface Connections")]
4226
+ }
4227
+ )
4143
4228
 
4144
4229
  def __init__(self, *args, **kwargs):
4145
4230
  super().__init__(*args, **kwargs)
@@ -4182,10 +4267,50 @@ class VirtualChassisUIViewSet(NautobotUIViewSet):
4182
4267
  bulk_update_form_class = forms.VirtualChassisBulkEditForm
4183
4268
  filterset_class = filters.VirtualChassisFilterSet
4184
4269
  filterset_form_class = forms.VirtualChassisFilterForm
4185
- form_class = forms.VirtualChassisCreateForm
4186
4270
  serializer_class = serializers.VirtualChassisSerializer
4187
4271
  table_class = tables.VirtualChassisTable
4188
4272
  queryset = VirtualChassis.objects.all()
4273
+ create_form_class = forms.VirtualChassisCreateForm
4274
+ update_form_class = forms.VirtualChassisForm
4275
+
4276
+ class MembersObjectsTablePanel(object_detail.ObjectsTablePanel):
4277
+ def _get_table_add_url(self, context):
4278
+ obj = get_obj_from_context(context)
4279
+ request = context["request"]
4280
+ return_url = context.get("return_url", obj.get_absolute_url())
4281
+
4282
+ if not request.user.has_perm("dcim.change_virtualchassis"):
4283
+ return None
4284
+
4285
+ params = []
4286
+ master = obj.master
4287
+
4288
+ if master is not None:
4289
+ if master.location is not None:
4290
+ params.append(("location", master.location.pk))
4291
+
4292
+ if master.rack is not None:
4293
+ params.append(("rack", master.rack.pk))
4294
+
4295
+ params.append(("return_url", return_url))
4296
+ return reverse("dcim:virtualchassis_add_member", kwargs={"pk": obj.pk}) + "?" + urlencode(params)
4297
+
4298
+ object_detail_content = object_detail.ObjectDetailContent(
4299
+ panels=[
4300
+ object_detail.ObjectFieldsPanel(
4301
+ section=SectionChoices.LEFT_HALF,
4302
+ weight=100,
4303
+ fields="__all__",
4304
+ ),
4305
+ MembersObjectsTablePanel(
4306
+ section=SectionChoices.RIGHT_HALF,
4307
+ weight=100,
4308
+ table_class=tables.VirtualChassisMembersTable,
4309
+ table_filter="virtual_chassis",
4310
+ table_title="Members",
4311
+ ),
4312
+ ]
4313
+ )
4189
4314
 
4190
4315
  def get_extra_context(self, request, instance):
4191
4316
  context = super().get_extra_context(request, instance)
@@ -4215,10 +4340,6 @@ class VirtualChassisUIViewSet(NautobotUIViewSet):
4215
4340
  }
4216
4341
  )
4217
4342
 
4218
- elif self.action == "retrieve":
4219
- members = Device.objects.restrict(request.user).filter(virtual_chassis=instance)
4220
- context.update({"members": members})
4221
-
4222
4343
  return context
4223
4344
 
4224
4345
  def form_save(self, form, **kwargs):
@@ -209,7 +209,7 @@ class RelationshipFilter(django_filters.ModelMultipleChoiceFilter):
209
209
  return query
210
210
 
211
211
  def filter(self, qs, value):
212
- if not value or any(v in EMPTY_VALUES for v in value):
212
+ if not value or any(v in EMPTY_VALUES for v in value) or (hasattr(value, "exists") and not value.exists()):
213
213
  return qs
214
214
 
215
215
  query = self.generate_query(value)
@@ -144,6 +144,7 @@ __all__ = (
144
144
  "CustomLinkFilterForm",
145
145
  "CustomLinkForm",
146
146
  "DynamicGroupBulkAssignForm",
147
+ "DynamicGroupBulkEditForm",
147
148
  "DynamicGroupFilterForm",
148
149
  "DynamicGroupForm",
149
150
  "DynamicGroupMembershipFormSet",
@@ -704,6 +705,20 @@ class CustomLinkFilterForm(BootstrapMixin, forms.Form):
704
705
  #
705
706
  # Dynamic Groups
706
707
  #
708
+ class DynamicGroupBulkEditForm(NautobotBulkEditForm):
709
+ pk = forms.ModelMultipleChoiceField(queryset=DynamicGroup.objects.all(), widget=forms.MultipleHiddenInput())
710
+ description = forms.CharField(max_length=CHARFIELD_MAX_LENGTH, required=False)
711
+ tenant = DynamicModelChoiceField(
712
+ queryset=Tenant.objects.all(),
713
+ required=False,
714
+ )
715
+
716
+ class Meta:
717
+ model = DynamicGroup
718
+ fields = [
719
+ "description",
720
+ "tenant",
721
+ ]
707
722
 
708
723
 
709
724
  class DynamicGroupForm(TenancyForm, NautobotModelForm):
@@ -806,7 +806,16 @@ class DynamicGroup(PrimaryModel):
806
806
  def _get_group_queryset(self):
807
807
  """Construct the queryset representing dynamic membership of this group."""
808
808
  query = self.generate_query()
809
- return self.model.objects.filter(query)
809
+ # https://github.com/nautobot/nautobot/issues/7631
810
+ # Some queries may result in duplicate records, hence the need for `.distinct()`.
811
+ # Use of `.distinct()` in general is a code smell and a performance hit, but given the wide variety of
812
+ # filters that can be applied, support for both MySQL and PostgreSQL, and limitations of the Django ORM,
813
+ # I don't see a clear alternative at this time.
814
+ #
815
+ # Additionally, due to the use of `.distinct()`, in combination with our use of `.only("id")`
816
+ # on this queryset, e.g. in `_set_members()`, we also need to override any default model ordering on the
817
+ # queryset in order to avoid SQL errors like "each EXCEPT query must have the same number of columns"
818
+ return self.model.objects.filter(query).order_by("id").distinct()
810
819
 
811
820
  # TODO: unused in core
812
821
  def add_child(self, child, operator, weight):
@@ -48,7 +48,7 @@ from nautobot.extras.constants import (
48
48
  )
49
49
  from nautobot.extras.managers import JobResultManager, ScheduledJobsManager
50
50
  from nautobot.extras.models import ChangeLoggedModel, GitRepository
51
- from nautobot.extras.models.mixins import ContactMixin, DynamicGroupsModelMixin, NotesMixin
51
+ from nautobot.extras.models.mixins import ContactMixin, DynamicGroupsModelMixin, NotesMixin, SavedViewMixin
52
52
  from nautobot.extras.querysets import JobQuerySet, ScheduledJobExtendedQuerySet
53
53
  from nautobot.extras.utils import (
54
54
  ChangeLoggedModelsQuery,
@@ -631,7 +631,7 @@ class JobQueueAssignment(BaseModel):
631
631
  "custom_links",
632
632
  "graphql",
633
633
  )
634
- class JobResult(BaseModel, CustomFieldModel):
634
+ class JobResult(SavedViewMixin, BaseModel, CustomFieldModel):
635
635
  """
636
636
  This model stores the results from running a Job.
637
637
  """
@@ -86,10 +86,8 @@ class InstalledAppsView(GenericView):
86
86
  """
87
87
 
88
88
  table = InstalledAppsTable
89
- breadcrumbs = Breadcrumbs(
90
- items={"generic": [ViewNameBreadcrumbItem(view_name="apps:apps_list", label="Installed Apps")]}
91
- )
92
- view_titles = Titles(titles={"generic": "Installed Apps"})
89
+ breadcrumbs = Breadcrumbs(items={"*": [ViewNameBreadcrumbItem(view_name="apps:apps_list", label="Installed Apps")]})
90
+ view_titles = Titles(titles={"*": "Installed Apps"})
93
91
 
94
92
  def get(self, request):
95
93
  marketplace_data = load_marketplace_data()
@@ -129,7 +127,6 @@ class InstalledAppsView(GenericView):
129
127
  "filter_form": None,
130
128
  "app_icons": app_icons,
131
129
  "display": display,
132
- "view_action": "generic",
133
130
  "breadcrumbs": self.breadcrumbs,
134
131
  "view_titles": self.view_titles,
135
132
  },
@@ -141,6 +138,20 @@ class InstalledAppDetailView(GenericView):
141
138
  View for showing details of an installed App.
142
139
  """
143
140
 
141
+ breadcrumbs = Breadcrumbs(
142
+ items={
143
+ "*": [
144
+ ViewNameBreadcrumbItem(view_name="apps:apps_list", label="Installed Apps"),
145
+ ViewNameBreadcrumbItem(
146
+ view_name="apps:app_detail",
147
+ reverse_kwargs=lambda context: {"app": context["app_data"]["package"]},
148
+ label=lambda context: context["app_data"]["name"],
149
+ ),
150
+ ]
151
+ }
152
+ )
153
+ view_titles = Titles(titles={"*": "{{ app_data.name | bettertitle }}"})
154
+
144
155
  def get(self, request, app=None, plugin=None):
145
156
  if plugin and not app:
146
157
  app = plugin
@@ -154,6 +165,8 @@ class InstalledAppDetailView(GenericView):
154
165
  {
155
166
  "app_data": extract_app_data(app_config, load_marketplace_data()),
156
167
  "object": app_config,
168
+ "breadcrumbs": self.breadcrumbs,
169
+ "view_titles": self.view_titles,
157
170
  },
158
171
  )
159
172
 
nautobot/extras/tables.py CHANGED
@@ -736,11 +736,12 @@ class JobTable(BaseTable):
736
736
  accessor="latest_result",
737
737
  template_code="""
738
738
  {% if value %}
739
- {{ value.date_created|date:settings.SHORT_DATETIME_FORMAT }} by {{ value.user }}
739
+ {{ value.date_created|date:SHORT_DATETIME_FORMAT }} by {{ value.user }}
740
740
  {% else %}
741
741
  <span class="text-muted">Never</span>
742
742
  {% endif %}
743
743
  """,
744
+ extra_context={"SHORT_DATETIME_FORMAT": settings.SHORT_DATETIME_FORMAT},
744
745
  linkify=lambda value: value.get_absolute_url() if value else None,
745
746
  )
746
747
  last_status = tables.TemplateColumn(
@@ -906,6 +907,7 @@ class JobResultTable(BaseTable):
906
907
  linkify=True,
907
908
  verbose_name="Scheduled Job",
908
909
  )
910
+ duration = tables.Column(orderable=False)
909
911
  actions = ButtonsColumn(JobResult, buttons=("delete",), prepend_template=JOB_RESULT_BUTTONS)
910
912
 
911
913
  def __init__(self, *args, **kwargs):
@@ -1069,7 +1071,7 @@ class ObjectMetadataTable(BaseTable):
1069
1071
  )
1070
1072
  # This is needed so that render_value method below does not skip itself
1071
1073
  # when metadata_type.data_type is TYPE_CONTACT_TEAM and we need it to display either contact or team
1072
- value = tables.Column(empty_values=[])
1074
+ value = tables.Column(empty_values=[], order_by=("_value",))
1073
1075
 
1074
1076
  class Meta(BaseTable.Meta):
1075
1077
  model = ObjectMetadata