nautobot 3.0.0rc1__py3-none-any.whl → 3.0.1__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 (103) hide show
  1. nautobot/apps/forms.py +8 -0
  2. nautobot/apps/templatetags.py +231 -0
  3. nautobot/apps/testing.py +11 -1
  4. nautobot/apps/ui.py +21 -1
  5. nautobot/apps/utils.py +26 -1
  6. nautobot/core/celery/__init__.py +46 -1
  7. nautobot/core/cli/bootstrap_v3_to_v5.py +185 -44
  8. nautobot/core/cli/bootstrap_v3_to_v5_changes.yaml +314 -0
  9. nautobot/core/graphql/generators.py +2 -2
  10. nautobot/core/jobs/bulk_actions.py +12 -6
  11. nautobot/core/jobs/cleanup.py +13 -1
  12. nautobot/core/settings.py +13 -0
  13. nautobot/core/settings.yaml +22 -0
  14. nautobot/core/settings_funcs.py +11 -1
  15. nautobot/core/tables.py +19 -1
  16. nautobot/core/templates/components/panel/body_wrapper_generic_table.html +1 -1
  17. nautobot/core/templates/components/panel/header_extra_content_table.html +9 -3
  18. nautobot/core/templates/generic/object_create.html +1 -1
  19. nautobot/core/templates/inc/header.html +9 -10
  20. nautobot/core/templates/login.html +16 -1
  21. nautobot/core/templates/nautobot_config.py.j2 +14 -1
  22. nautobot/core/templates/redoc_ui.html +3 -0
  23. nautobot/core/templatetags/helpers.py +3 -3
  24. nautobot/core/testing/views.py +3 -1
  25. nautobot/core/tests/test_graphql.py +13 -0
  26. nautobot/core/tests/test_jobs.py +118 -0
  27. nautobot/core/tests/test_views.py +24 -0
  28. nautobot/core/ui/bulk_buttons.py +2 -3
  29. nautobot/core/utils/lookup.py +2 -3
  30. nautobot/core/utils/permissions.py +1 -1
  31. nautobot/core/views/generic.py +1 -0
  32. nautobot/core/views/mixins.py +37 -10
  33. nautobot/core/views/renderers.py +1 -0
  34. nautobot/core/views/utils.py +3 -3
  35. nautobot/data_validation/views.py +1 -9
  36. nautobot/dcim/forms.py +9 -9
  37. nautobot/dcim/models/devices.py +3 -3
  38. nautobot/dcim/tables/power.py +3 -0
  39. nautobot/dcim/templates/dcim/controllermanageddevicegroup_create.html +1 -1
  40. nautobot/dcim/views.py +30 -44
  41. nautobot/extras/api/views.py +14 -3
  42. nautobot/extras/choices.py +3 -0
  43. nautobot/extras/jobs.py +48 -2
  44. nautobot/extras/migrations/0132_approval_workflow_seed_data.py +127 -0
  45. nautobot/extras/models/approvals.py +11 -1
  46. nautobot/extras/models/models.py +19 -0
  47. nautobot/extras/models/relationships.py +3 -1
  48. nautobot/extras/tables.py +35 -18
  49. nautobot/extras/templates/extras/approval_workflow/approve.html +9 -2
  50. nautobot/extras/templates/extras/approval_workflow/deny.html +9 -3
  51. nautobot/extras/templates/extras/approvalworkflowdefinition_update.html +1 -1
  52. nautobot/extras/templates/extras/customfield_update.html +1 -1
  53. nautobot/extras/templates/extras/dynamicgroup_update.html +2 -2
  54. nautobot/extras/templates/extras/inc/approval_buttons_column.html +10 -2
  55. nautobot/extras/templates/extras/inc/job_tiles.html +2 -2
  56. nautobot/extras/templates/extras/inc/jobresult.html +1 -1
  57. nautobot/extras/templates/extras/metadatatype_create.html +1 -1
  58. nautobot/extras/templates/extras/object_approvalworkflow.html +2 -3
  59. nautobot/extras/templates/extras/secretsgroup_update.html +1 -1
  60. nautobot/extras/tests/test_api.py +57 -3
  61. nautobot/extras/tests/test_customfields_filters.py +84 -4
  62. nautobot/extras/tests/test_views.py +323 -6
  63. nautobot/extras/views.py +114 -39
  64. nautobot/ipam/constants.py +2 -2
  65. nautobot/ipam/tables.py +7 -6
  66. nautobot/load_balancers/constants.py +6 -0
  67. nautobot/load_balancers/migrations/0001_initial.py +14 -3
  68. nautobot/load_balancers/models.py +5 -4
  69. nautobot/load_balancers/tables.py +5 -0
  70. nautobot/project-static/dist/css/nautobot.css +1 -1
  71. nautobot/project-static/dist/css/nautobot.css.map +1 -1
  72. nautobot/project-static/dist/js/graphql-libraries.js +1 -1
  73. nautobot/project-static/dist/js/graphql-libraries.js.map +1 -1
  74. nautobot/project-static/dist/js/libraries.js +1 -1
  75. nautobot/project-static/dist/js/libraries.js.LICENSE.txt +38 -2
  76. nautobot/project-static/dist/js/libraries.js.map +1 -1
  77. nautobot/project-static/dist/js/nautobot-graphiql.js +1 -1
  78. nautobot/project-static/dist/js/nautobot-graphiql.js.map +1 -1
  79. nautobot/project-static/dist/js/nautobot.js +1 -1
  80. nautobot/project-static/dist/js/nautobot.js.map +1 -1
  81. nautobot/project-static/img/dark-theme.png +0 -0
  82. nautobot/project-static/img/light-theme.png +0 -0
  83. nautobot/project-static/img/system-theme.png +0 -0
  84. nautobot/project-static/js/forms.js +1 -85
  85. nautobot/tenancy/tables.py +3 -2
  86. nautobot/tenancy/views.py +3 -2
  87. nautobot/ui/package-lock.json +553 -569
  88. nautobot/ui/package.json +10 -10
  89. nautobot/ui/src/js/checkbox.js +132 -0
  90. nautobot/ui/src/js/nautobot.js +6 -0
  91. nautobot/ui/src/js/select2.js +69 -73
  92. nautobot/ui/src/js/theme.js +129 -39
  93. nautobot/ui/src/scss/nautobot.scss +11 -1
  94. nautobot/vpn/templates/vpn/vpnprofile_create.html +2 -2
  95. nautobot/wireless/filters.py +15 -1
  96. nautobot/wireless/tables.py +18 -14
  97. nautobot/wireless/templates/wireless/wirelessnetwork_create.html +1 -1
  98. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/METADATA +2 -2
  99. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/RECORD +103 -98
  100. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/LICENSE.txt +0 -0
  101. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/NOTICE +0 -0
  102. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/WHEEL +0 -0
  103. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/entry_points.txt +0 -0
@@ -17,7 +17,7 @@ from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
17
17
  from django.http import HttpResponse
18
18
  from django.shortcuts import get_object_or_404, redirect
19
19
  from django.template.loader import select_template, TemplateDoesNotExist
20
- from django.urls import reverse
20
+ from django.urls import resolve, reverse
21
21
  from django.urls.exceptions import NoReverseMatch
22
22
  from django.utils.encoding import iri_to_uri
23
23
  from django.utils.html import format_html
@@ -57,7 +57,7 @@ from nautobot.core.views.utils import (
57
57
  )
58
58
  from nautobot.extras.context_managers import deferred_change_logging_for_bulk_operation
59
59
  from nautobot.extras.forms import NoteForm
60
- from nautobot.extras.models import ExportTemplate, Job, JobResult, SavedView, UserSavedViewAssociation
60
+ from nautobot.extras.models import ExportTemplate, Job, JobResult, SavedView, ScheduledJob, UserSavedViewAssociation
61
61
  from nautobot.extras.tables import NoteTable, ObjectChangeTable
62
62
  from nautobot.extras.utils import bulk_delete_with_bulk_change_logging, get_base_template, remove_prefix_from_cf_key
63
63
 
@@ -74,8 +74,6 @@ PERMISSIONS_ACTION_MAP = {
74
74
  "changelog": "view",
75
75
  "notes": "view",
76
76
  "data_compliance": "view",
77
- "approve": "change",
78
- "deny": "change",
79
77
  }
80
78
 
81
79
 
@@ -416,11 +414,11 @@ class NautobotViewSetMixin(GenericViewSet, UIComponentsMixin, AccessMixin, GetRe
416
414
  """
417
415
  model_permissions = []
418
416
  for action in actions:
419
- # Append additional object permissions if specified.
420
- if self.custom_view_additional_permissions:
421
- model_permissions.append(*self.custom_view_additional_permissions)
422
417
  # Append the model-level permissions for the action.
423
418
  model_permissions.append(f"{model._meta.app_label}.{action}_{model._meta.model_name}")
419
+ # Append additional object permissions if specified.
420
+ if self.custom_view_additional_permissions:
421
+ model_permissions.extend(self.custom_view_additional_permissions)
424
422
  return model_permissions
425
423
 
426
424
  def get_required_permission(self):
@@ -931,10 +929,12 @@ class ObjectListViewMixin(NautobotViewSetMixin, mixins.ListModelMixin):
931
929
  self.filterset_class(),
932
930
  )
933
931
 
932
+ resolved_path = resolve(request.path)
933
+ # Note that `resolved_path.app_name` does work even for nested paths like `plugins:example_app:...`
934
+ view_name = f"{resolved_path.app_name}:{resolved_path.url_name}"
935
+
934
936
  # Check if there is a default for this view for this specific user
935
937
  user_default_saved_view = None
936
- app_label, model_name = queryset.model._meta.label.split(".")
937
- view_name = f"{app_label}:{model_name.lower()}_list"
938
938
  user = request.user
939
939
  if not isinstance(user, AnonymousUser):
940
940
  try:
@@ -1065,7 +1065,8 @@ class ObjectEditViewMixin(NautobotViewSetMixin, mixins.CreateModelMixin, mixins.
1065
1065
  if hasattr(obj, "clone_fields"):
1066
1066
  url = f"{request.path}?{prepare_cloned_fields(obj)}"
1067
1067
  self.success_url = url
1068
- self.success_url = request.get_full_path()
1068
+ else:
1069
+ self.success_url = request.get_full_path()
1069
1070
  else:
1070
1071
  return_url = form.cleaned_data.get("return_url")
1071
1072
  if url_has_allowed_host_and_scheme(url=return_url, allowed_hosts=request.get_host()):
@@ -1152,6 +1153,19 @@ class BulkEditAndBulkDeleteModelMixin:
1152
1153
  # BulkDeleteObjects job form cannot be invalid; Hence no handling of invalid case.
1153
1154
  job_form.is_valid()
1154
1155
  job_kwargs = BulkDeleteObjects.prepare_job_kwargs(job_form.cleaned_data)
1156
+ # adapted from nautobot/extras/views JobRunView.post() - TODO: deduplicate this code and unify code paths
1157
+ with transaction.atomic():
1158
+ scheduled_job = ScheduledJob.create_schedule(
1159
+ job_model,
1160
+ request.user,
1161
+ **BulkDeleteObjects.serialize_data(job_kwargs),
1162
+ )
1163
+ if scheduled_job.has_approval_workflow_definition():
1164
+ messages.success(request, "Job '{scheduled_job.name}' successfully submitted for approval")
1165
+ return redirect("extras:scheduledjob_approvalworkflow", pk=scheduled_job.pk)
1166
+ else:
1167
+ scheduled_job.delete()
1168
+
1155
1169
  job_result = JobResult.enqueue_job(
1156
1170
  job_model,
1157
1171
  request.user,
@@ -1174,6 +1188,19 @@ class BulkEditAndBulkDeleteModelMixin:
1174
1188
  # NOTE: BulkEditObjects cant be invalid, so there is no need for handling invalid error
1175
1189
  job_form.is_valid()
1176
1190
  job_kwargs = BulkEditObjects.prepare_job_kwargs(job_form.cleaned_data)
1191
+ # adapted from nautobot/extras/views JobRunView.post() - TODO: deduplicate this code and unify code paths
1192
+ with transaction.atomic():
1193
+ scheduled_job = ScheduledJob.create_schedule(
1194
+ job_model,
1195
+ request.user,
1196
+ **BulkEditObjects.serialize_data(job_kwargs),
1197
+ )
1198
+ if scheduled_job.has_approval_workflow_definition():
1199
+ messages.success(request, "Job '{scheduled_job.name}' successfully submitted for approval")
1200
+ return redirect("extras:scheduledjob_approvalworkflow", pk=scheduled_job.pk)
1201
+ else:
1202
+ scheduled_job.delete()
1203
+
1177
1204
  job_result = JobResult.enqueue_job(
1178
1205
  job_model,
1179
1206
  request.user,
@@ -311,6 +311,7 @@ class NautobotHTMLRenderer(renderers.BrowsableAPIRenderer):
311
311
  valid_actions = self.validate_action_buttons(view, request)
312
312
  # Query SavedViews for dropdown button
313
313
  resolved_path = resolve(request.path)
314
+ # Note that `resolved_path.app_name` does work even for nested paths like `plugins:example_app:...`
314
315
  list_url = f"{resolved_path.app_name}:{resolved_path.url_name}"
315
316
  saved_views = None
316
317
  if model.is_saved_view_model:
@@ -591,9 +591,9 @@ def get_bulk_queryset_from_view(
591
591
 
592
592
  queryset = view_class.queryset.restrict(user, action)
593
593
 
594
- # The filterset_class is determined from model on purpose, as filterset_class the view as a param, will not work
595
- # with a job. It is better to be consistent with each with sending the same params that will
596
- # always be available from to the confirmation page and to the job.
594
+ # The filterset_class is determined from model on purpose versus getting it from the view itself. This is
595
+ # because the filterset_class on the view as a param, will not work with a job. It is better to be consistent
596
+ # with each with sending the same params that will always be available from to the confirmation page and to the job.
597
597
  filterset_class = get_filterset_for_model(model)
598
598
 
599
599
  if not filterset_class:
@@ -5,12 +5,12 @@ from django.contrib import messages
5
5
  from django.contrib.contenttypes.models import ContentType
6
6
  from django.shortcuts import redirect, render
7
7
 
8
- from nautobot.apps.ui import Breadcrumbs, Titles, ViewNameBreadcrumbItem
9
8
  from nautobot.core.ui.choices import SectionChoices
10
9
  from nautobot.core.ui.object_detail import (
11
10
  ObjectDetailContent,
12
11
  ObjectFieldsPanel,
13
12
  )
13
+ from nautobot.core.ui.titles import Titles
14
14
  from nautobot.core.views.generic import GenericView
15
15
  from nautobot.core.views.mixins import (
16
16
  ObjectBulkDestroyViewMixin,
@@ -174,13 +174,6 @@ class DataComplianceUIViewSet( # pylint: disable=W0223
174
174
  class DeviceConstraintsView(GenericView):
175
175
  template_name = "data_validation/device_constraints.html"
176
176
  view_titles = Titles(titles={"*": "Device Constraints"})
177
- breadcrumbs = Breadcrumbs(
178
- items={
179
- "*": [
180
- ViewNameBreadcrumbItem(view_name="data_validation:device-constraints", label="Device Constraints"),
181
- ],
182
- },
183
- )
184
177
 
185
178
  def get(self, request):
186
179
  form = forms.DeviceConstraintsForm(user=request.user)
@@ -190,7 +183,6 @@ class DeviceConstraintsView(GenericView):
190
183
  {
191
184
  "form": form,
192
185
  "view_titles": self.get_view_titles(),
193
- "breadcrumbs": self.get_breadcrumbs(),
194
186
  },
195
187
  )
196
188
 
nautobot/dcim/forms.py CHANGED
@@ -1491,7 +1491,7 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
1491
1491
 
1492
1492
 
1493
1493
  class InterfaceTemplateCreateForm(ModularComponentTemplateCreateForm):
1494
- type = forms.ChoiceField(choices=InterfaceTypeChoices, widget=StaticSelect2())
1494
+ type = forms.ChoiceField(choices=add_blank_choice(InterfaceTypeChoices), widget=StaticSelect2())
1495
1495
  mgmt_only = forms.BooleanField(required=False, label="Management only")
1496
1496
  speed = forms.IntegerField(
1497
1497
  required=False, min_value=0, label="Speed (Kbps)", widget=NumberWithSelect(choices=InterfaceSpeedChoices)
@@ -1567,7 +1567,7 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
1567
1567
 
1568
1568
 
1569
1569
  class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
1570
- type = forms.ChoiceField(choices=PortTypeChoices, widget=StaticSelect2())
1570
+ type = forms.ChoiceField(choices=add_blank_choice(PortTypeChoices), widget=StaticSelect2())
1571
1571
  rear_port_template_set = forms.MultipleChoiceField(
1572
1572
  choices=[],
1573
1573
  label="Rear ports",
@@ -1677,7 +1677,7 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
1677
1677
 
1678
1678
  class RearPortTemplateCreateForm(ModularComponentTemplateCreateForm):
1679
1679
  type = forms.ChoiceField(
1680
- choices=PortTypeChoices,
1680
+ choices=add_blank_choice(PortTypeChoices),
1681
1681
  widget=StaticSelect2(),
1682
1682
  )
1683
1683
  positions = forms.IntegerField(
@@ -3306,7 +3306,7 @@ class InterfaceForm(InterfaceCommonForm, ModularComponentEditForm):
3306
3306
  class InterfaceCreateForm(ModularComponentCreateForm, InterfaceCommonForm, RoleNotRequiredModelFormMixin):
3307
3307
  model = Interface
3308
3308
  type = forms.ChoiceField(
3309
- choices=InterfaceTypeChoices,
3309
+ choices=add_blank_choice(InterfaceTypeChoices),
3310
3310
  widget=StaticSelect2(),
3311
3311
  )
3312
3312
  status = DynamicModelChoiceField(
@@ -3435,7 +3435,7 @@ class InterfaceBulkCreateForm(
3435
3435
  ):
3436
3436
  model = Interface
3437
3437
  type = forms.ChoiceField(
3438
- choices=InterfaceTypeChoices,
3438
+ choices=add_blank_choice(InterfaceTypeChoices),
3439
3439
  widget=StaticSelect2(),
3440
3440
  )
3441
3441
  status = DynamicModelChoiceField(
@@ -3473,7 +3473,7 @@ class ModuleInterfaceBulkCreateForm(
3473
3473
  ):
3474
3474
  model = Interface
3475
3475
  type = forms.ChoiceField(
3476
- choices=InterfaceTypeChoices,
3476
+ choices=add_blank_choice(InterfaceTypeChoices),
3477
3477
  widget=StaticSelect2(),
3478
3478
  )
3479
3479
  status = DynamicModelChoiceField(
@@ -3678,7 +3678,7 @@ class FrontPortForm(ModularComponentEditForm):
3678
3678
  # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
3679
3679
  class FrontPortCreateForm(ModularComponentCreateForm):
3680
3680
  type = forms.ChoiceField(
3681
- choices=PortTypeChoices,
3681
+ choices=add_blank_choice(PortTypeChoices),
3682
3682
  widget=StaticSelect2(),
3683
3683
  )
3684
3684
  rear_port_set = forms.MultipleChoiceField(
@@ -3806,7 +3806,7 @@ class RearPortForm(ModularComponentEditForm):
3806
3806
 
3807
3807
  class RearPortCreateForm(ModularComponentCreateForm):
3808
3808
  type = forms.ChoiceField(
3809
- choices=PortTypeChoices,
3809
+ choices=add_blank_choice(PortTypeChoices),
3810
3810
  widget=StaticSelect2(),
3811
3811
  )
3812
3812
  positions = forms.IntegerField(
@@ -5134,7 +5134,7 @@ class InterfaceRedundancyGroupBulkEditForm(
5134
5134
  queryset=InterfaceRedundancyGroup.objects.all(),
5135
5135
  widget=forms.MultipleHiddenInput,
5136
5136
  )
5137
- protocol = forms.ChoiceField(choices=InterfaceRedundancyGroupProtocolChoices, required=False)
5137
+ protocol = forms.ChoiceField(choices=add_blank_choice(InterfaceRedundancyGroupProtocolChoices), required=False)
5138
5138
  description = forms.CharField(required=False)
5139
5139
  virtual_ip = DynamicModelChoiceField(queryset=IPAddress.objects.all(), required=False)
5140
5140
  secrets_group = DynamicModelChoiceField(queryset=SecretsGroup.objects.all(), required=False)
@@ -1962,11 +1962,11 @@ class Module(PrimaryModel):
1962
1962
  @property
1963
1963
  def page_title(self):
1964
1964
  if self.location:
1965
- return f"{self.module_type!s} at location {self.location}"
1965
+ return f"{self.location} {self.module_type!s}"
1966
1966
  if self.parent_module_bay.parent_device is not None:
1967
- return f"{self.module_type!s} installed in {self.parent_module_bay.parent_device.display}"
1967
+ return f"{self.parent_module_bay.parent_device.display} {self.module_type!s}"
1968
1968
 
1969
- return f"{self.module_type!s} installed in {self.parent_module_bay.parent_module.module_type!s}"
1969
+ return f"{self.parent_module_bay.parent_module.module_type!s} {self.module_type!s}"
1970
1970
 
1971
1971
  @property
1972
1972
  def device(self):
@@ -2,6 +2,7 @@ import django_tables2 as tables
2
2
 
3
3
  from nautobot.core.tables import (
4
4
  BaseTable,
5
+ ButtonsColumn,
5
6
  ChoiceFieldColumn,
6
7
  LinkedCountColumn,
7
8
  TagColumn,
@@ -36,6 +37,7 @@ class PowerPanelTable(BaseTable):
36
37
  verbose_name="Feeds",
37
38
  )
38
39
  tags = TagColumn(url_name="dcim:powerpanel_list")
40
+ actions = ButtonsColumn(PowerPanel)
39
41
 
40
42
  class Meta(BaseTable.Meta):
41
43
  model = PowerPanel
@@ -81,6 +83,7 @@ class PowerFeedTable(StatusTableMixin, CableTerminationTable):
81
83
  max_utilization = tables.TemplateColumn(template_code="{{ value }}%")
82
84
  available_power = tables.Column(verbose_name="Available power (VA)")
83
85
  tags = TagColumn(url_name="dcim:powerfeed_list")
86
+ actions = ButtonsColumn(PowerFeed)
84
87
 
85
88
  class Meta(BaseTable.Meta):
86
89
  model = PowerFeed
@@ -20,7 +20,7 @@
20
20
  </div>
21
21
  <div class="card">
22
22
  <div class="card-header"><strong>Wireless Network Assignment</strong></div>
23
- <div class="card-body">
23
+ <div class="card-body overflow-auto">
24
24
  {% if wireless_networks.errors %}
25
25
  <div class="text-danger">
26
26
  Please correct the error(s) below:
nautobot/dcim/views.py CHANGED
@@ -3093,7 +3093,8 @@ class DeviceUIViewSet(NautobotUIViewSet):
3093
3093
  table_title="Wireless Networks",
3094
3094
  table_class=BaseControllerManagedDeviceGroupWirelessNetworkAssignmentTable,
3095
3095
  table_attribute="wireless_network_assignments",
3096
- related_field_name="controller_device_redundancy_group",
3096
+ related_field_name="controller_managed_device_groups__devices",
3097
+ related_list_url_name="wireless:wirelessnetwork_list",
3097
3098
  tab_id="wireless",
3098
3099
  include_paginator=True,
3099
3100
  exclude_columns=["controller_managed_device_group", "controller"],
@@ -3104,7 +3105,8 @@ class DeviceUIViewSet(NautobotUIViewSet):
3104
3105
  table_title="Radio Profiles",
3105
3106
  table_class=ControllerManagedDeviceGroupRadioProfileAssignmentTable,
3106
3107
  table_attribute="radio_profile_assignments",
3107
- related_field_name="controller_device_redundancy_group",
3108
+ related_field_name="controller_managed_device_groups__devices",
3109
+ related_list_url_name="wireless:radioprofile_list",
3108
3110
  tab_id="wireless",
3109
3111
  include_paginator=True,
3110
3112
  exclude_columns=["controller_managed_device_group"],
@@ -3508,6 +3510,30 @@ class ModuleUIViewSet(BulkComponentCreateUIViewSetMixin, NautobotUIViewSet):
3508
3510
  serializer_class = serializers.ModuleSerializer
3509
3511
  table_class = tables.ModuleTable
3510
3512
  component_model = None
3513
+ breadcrumbs = Breadcrumbs(
3514
+ items={
3515
+ "detail": [
3516
+ ModelBreadcrumbItem(),
3517
+ InstanceBreadcrumbItem(
3518
+ instance=context_object_attr("parent_module_bay.parent_device"),
3519
+ should_render=lambda c: c["object"].parent_module_bay is not None
3520
+ and c["object"].parent_module_bay.parent_device is not None,
3521
+ ),
3522
+ InstanceBreadcrumbItem(
3523
+ instance=context_object_attr("parent_module_bay.parent_module"),
3524
+ should_render=lambda c: c["object"].parent_module_bay is not None
3525
+ and c["object"].parent_module_bay.parent_module is not None,
3526
+ ),
3527
+ InstanceBreadcrumbItem(instance=context_object_attr("parent_module_bay")),
3528
+ AncestorsInstanceBreadcrumbItem(
3529
+ instance=context_object_attr("location"),
3530
+ ancestor_item=lambda ancestor: InstanceParentBreadcrumbItem(parent_key="location", parent=ancestor),
3531
+ include_self=True,
3532
+ should_render=lambda c: c["object"].location is not None,
3533
+ ),
3534
+ ]
3535
+ }
3536
+ )
3511
3537
 
3512
3538
  def get_extra_context(self, request, instance):
3513
3539
  context = super().get_extra_context(request, instance)
@@ -4592,24 +4618,12 @@ class ModuleBayUIViewSet(ModuleBayCommonViewSetMixin, NautobotUIViewSet):
4592
4618
  instance=context_object_attr("parent_device"),
4593
4619
  should_render=context_object_attr("parent_device"),
4594
4620
  ),
4595
- ViewNameBreadcrumbItem(
4596
- view_name_key="device_breadcrumb_url",
4597
- should_render=lambda c: c["object"].parent_device and c.get("device_breadcrumb_url"),
4598
- reverse_kwargs=lambda c: {"pk": c["object"].parent_device.pk},
4599
- label=lambda c: c["object"]._meta.verbose_name_plural,
4600
- ),
4601
4621
  # Breadcrumb path if ModuleBay is linked with module
4602
4622
  ModelBreadcrumbItem(model=Module, should_render=lambda c: c["object"].parent_device is None),
4603
4623
  InstanceBreadcrumbItem(
4604
4624
  instance=context_object_attr("parent_module"),
4605
4625
  should_render=lambda c: c["object"].parent_device is None,
4606
4626
  ),
4607
- ViewNameBreadcrumbItem(
4608
- view_name_key="module_breadcrumb_url",
4609
- should_render=lambda c: c["object"].parent_device is None and c.get("module_breadcrumb_url"),
4610
- reverse_kwargs=lambda c: {"pk": c["object"].parent_module.pk},
4611
- label=lambda c: c["object"]._meta.verbose_name_plural,
4612
- ),
4613
4627
  ]
4614
4628
  }
4615
4629
  )
@@ -4999,15 +5013,6 @@ class ConsoleConnectionsListView(ConnectionsListView):
4999
5013
  template_name = "dcim/console_port_connection_list.html"
5000
5014
  action_buttons = ("export",)
5001
5015
  view_titles = Titles(titles={"list": "Console Connections"})
5002
- breadcrumbs = Breadcrumbs(
5003
- items={"list": [ViewNameBreadcrumbItem(view_name="dcim:console_connections_list", label="Console Connections")]}
5004
- )
5005
-
5006
- def extra_context(self):
5007
- return {
5008
- "title": "Console Connections",
5009
- "list_url": "dcim:console_connections_list",
5010
- }
5011
5016
 
5012
5017
 
5013
5018
  class PowerConnectionsListView(ConnectionsListView):
@@ -5018,15 +5023,6 @@ class PowerConnectionsListView(ConnectionsListView):
5018
5023
  template_name = "dcim/power_port_connection_list.html"
5019
5024
  action_buttons = ("export",)
5020
5025
  view_titles = Titles(titles={"list": "Power Connections"})
5021
- breadcrumbs = Breadcrumbs(
5022
- items={"list": [ViewNameBreadcrumbItem(view_name="dcim:power_connections_list", label="Power Connections")]}
5023
- )
5024
-
5025
- def extra_context(self):
5026
- return {
5027
- "title": "Power Connections",
5028
- "list_url": "dcim:power_connections_list",
5029
- }
5030
5026
 
5031
5027
 
5032
5028
  class InterfaceConnectionsListView(ConnectionsListView):
@@ -5037,11 +5033,6 @@ class InterfaceConnectionsListView(ConnectionsListView):
5037
5033
  template_name = "dcim/interface_connection_list.html"
5038
5034
  action_buttons = ("export",)
5039
5035
  view_titles = Titles(titles={"list": "Interface Connections"})
5040
- breadcrumbs = Breadcrumbs(
5041
- items={
5042
- "list": [ViewNameBreadcrumbItem(view_name="dcim:interface_connections_list", label="Interface Connections")]
5043
- }
5044
- )
5045
5036
 
5046
5037
  def __init__(self, *args, **kwargs):
5047
5038
  super().__init__(*args, **kwargs)
@@ -5067,12 +5058,6 @@ class InterfaceConnectionsListView(ConnectionsListView):
5067
5058
 
5068
5059
  return self.queryset
5069
5060
 
5070
- def extra_context(self):
5071
- return {
5072
- "title": "Interface Connections",
5073
- "list_url": "dcim:interface_connections_list",
5074
- }
5075
-
5076
5061
 
5077
5062
  #
5078
5063
  # Virtual chassis
@@ -5967,12 +5952,13 @@ class ControllerUIViewSet(NautobotUIViewSet):
5967
5952
  table_title="Wireless Networks",
5968
5953
  table_class=BaseControllerManagedDeviceGroupWirelessNetworkAssignmentTable,
5969
5954
  table_filter="controller_managed_device_group__controller",
5955
+ related_field_name="controller_managed_device_groups__controller",
5956
+ related_list_url_name="wireless:wirelessnetwork_list",
5970
5957
  tab_id="wireless_networks",
5971
5958
  add_button_route=None,
5972
5959
  select_related_fields=["wireless_network"],
5973
5960
  exclude_columns=["controller"],
5974
5961
  include_paginator=True,
5975
- enable_related_link=False,
5976
5962
  ),
5977
5963
  ),
5978
5964
  ),
@@ -311,7 +311,7 @@ class ApprovalWorkflowStageViewSet(NautobotModelViewSet):
311
311
  def _is_user_approver(self, user, stage):
312
312
  """Checks if the user belongs to the group allowed to approve the current stage."""
313
313
  approver_group = stage.approval_workflow_stage_definition.approver_group
314
- return user in approver_group.user_set.all()
314
+ return approver_group.user_set.filter(id=user.id).exists()
315
315
 
316
316
  def _user_already_approved_or_denied(self, user, stage, action_type):
317
317
  """Checks if the user has already approved/denied to the current stage."""
@@ -425,6 +425,12 @@ class ApprovalWorkflowStageViewSet(NautobotModelViewSet):
425
425
  """Add a comment to the specific stage (without approving or denying)."""
426
426
  stage = self.get_object()
427
427
 
428
+ if not stage.is_not_done_stage:
429
+ return Response(
430
+ {"detail": f"This stage is in {stage.state} state. Can't comment approved or denied stage."},
431
+ status=status.HTTP_400_BAD_REQUEST,
432
+ )
433
+
428
434
  comment = request.data.get("comments", "")
429
435
  if not comment:
430
436
  return Response(
@@ -432,9 +438,14 @@ class ApprovalWorkflowStageViewSet(NautobotModelViewSet):
432
438
  status=status.HTTP_400_BAD_REQUEST,
433
439
  )
434
440
 
435
- ApprovalWorkflowStageResponse.objects.create(
436
- approval_workflow_stage=stage, user=request.user, state=stage.state, comments=comment
441
+ approval_workflow_stage_response, _ = ApprovalWorkflowStageResponse.objects.get_or_create(
442
+ approval_workflow_stage=stage, user=request.user
437
443
  )
444
+ # we don't want to change a state if is approved, denied or canceled
445
+ if approval_workflow_stage_response.state == ApprovalWorkflowStateChoices.PENDING:
446
+ approval_workflow_stage_response.state = ApprovalWorkflowStateChoices.COMMENT
447
+ approval_workflow_stage_response.comments = comment
448
+ approval_workflow_stage_response.save()
438
449
 
439
450
  serializer = serializers.ApprovalWorkflowStageSerializer(stage, context={"request": request})
440
451
  return Response(serializer.data, status=status.HTTP_200_OK)
@@ -19,18 +19,21 @@ class ApprovalWorkflowStateChoices(ChoiceSet):
19
19
  APPROVED = "Approved"
20
20
  DENIED = "Denied"
21
21
  CANCELED = "Canceled"
22
+ COMMENT = "Comment"
22
23
 
23
24
  CHOICES = (
24
25
  (PENDING, "Pending"),
25
26
  (APPROVED, "Approved"),
26
27
  (DENIED, "Denied"),
27
28
  (CANCELED, "Canceled"),
29
+ (COMMENT, "Comment"),
28
30
  )
29
31
  CSS_CLASSES = {
30
32
  PENDING: "info",
31
33
  APPROVED: "success",
32
34
  DENIED: "danger",
33
35
  CANCELED: "warning",
36
+ COMMENT: "info",
34
37
  }
35
38
 
36
39
 
nautobot/extras/jobs.py CHANGED
@@ -29,6 +29,7 @@ from django.db.models.query import QuerySet
29
29
  from django.forms import ValidationError
30
30
  from django.utils.functional import classproperty
31
31
  import netaddr
32
+ from prometheus_client import Counter
32
33
  import yaml
33
34
 
34
35
  from nautobot.core.celery import import_jobs, nautobot_task
@@ -89,6 +90,27 @@ __all__ = [
89
90
 
90
91
  logger = logging.getLogger(__name__)
91
92
 
93
+ started_jobs_counter = Counter(
94
+ name="nautobot_worker_started_jobs",
95
+ documentation="Job executions that started running",
96
+ labelnames=("job_class_name", "module_name"),
97
+ )
98
+ finished_jobs_counter = Counter(
99
+ name="nautobot_worker_finished_jobs",
100
+ documentation="Job executions that finished running",
101
+ labelnames=("job_class_name", "module_name", "status"),
102
+ )
103
+ exception_jobs_counter = Counter(
104
+ name="nautobot_worker_exception_jobs",
105
+ documentation="Job executions that raised an exception",
106
+ labelnames=("job_class_name", "module_name", "exception_type"),
107
+ )
108
+ singleton_conflict_counter = Counter(
109
+ name="nautobot_worker_singleton_conflict",
110
+ documentation="Job executions that ran into a singleton lock",
111
+ labelnames=("job_class_name", "module_name"),
112
+ )
113
+
92
114
 
93
115
  class RunJobTaskFailed(Exception):
94
116
  """Celery task failed for some reason."""
@@ -1194,6 +1216,9 @@ def _prepare_job(job_class_path, request, kwargs) -> tuple[Job, dict]:
1194
1216
  extra={"object": job.job_model, "grouping": "initialization"},
1195
1217
  )
1196
1218
  else:
1219
+ singleton_conflict_counter.labels(
1220
+ job_class_name=job.job_model.job_class_name, module_name=job.job_model.module_name
1221
+ ).inc()
1197
1222
  # TODO 3.0: maybe change to logger.failure() and return cleanly, as this is an "acceptable" failure?
1198
1223
  job.logger.error(
1199
1224
  "Job %s is a singleton and already running.",
@@ -1277,6 +1302,9 @@ def run_job(self, job_class_path, *args, **kwargs):
1277
1302
 
1278
1303
  result = None
1279
1304
  status = None
1305
+ started_jobs_counter.labels(
1306
+ job_class_name=job.job_model.job_class_name, module_name=job.job_model.module_name
1307
+ ).inc()
1280
1308
  try:
1281
1309
  before_start_result = job.before_start(self.request.id, args, kwargs)
1282
1310
  if not job._failed:
@@ -1293,6 +1321,9 @@ def run_job(self, job_class_path, *args, **kwargs):
1293
1321
  job.on_success(result, self.request.id, args, kwargs)
1294
1322
  else:
1295
1323
  job.on_failure(result, self.request.id, args, kwargs, None)
1324
+ finished_jobs_counter.labels(
1325
+ job_class_name=job.job_model.job_class_name, module_name=job.job_model.module_name, status=status
1326
+ ).inc()
1296
1327
 
1297
1328
  job.after_return(status, result, self.request.id, args, kwargs, None)
1298
1329
 
@@ -1309,11 +1340,21 @@ def run_job(self, job_class_path, *args, **kwargs):
1309
1340
  # We don't want to overwrite the manual state update that we did above, so:
1310
1341
  raise Ignore()
1311
1342
 
1312
- except Reject:
1343
+ except Reject as exc:
1344
+ exception_jobs_counter.labels(
1345
+ job_class_name=job.job_model.job_class_name,
1346
+ module_name=job.job_model.module_name,
1347
+ exception_type=type(exc).__name__,
1348
+ ).inc()
1313
1349
  status = status or JobResultStatusChoices.STATUS_REJECTED
1314
1350
  raise
1315
1351
 
1316
- except Ignore:
1352
+ except Ignore as exc:
1353
+ exception_jobs_counter.labels(
1354
+ job_class_name=job.job_model.job_class_name,
1355
+ module_name=job.job_model.module_name,
1356
+ exception_type=type(exc).__name__,
1357
+ ).inc()
1317
1358
  status = status or JobResultStatusChoices.STATUS_IGNORED
1318
1359
  raise
1319
1360
 
@@ -1326,6 +1367,11 @@ def run_job(self, job_class_path, *args, **kwargs):
1326
1367
  "exc_type": type(exc).__name__,
1327
1368
  "exc_message": sanitize(str(exc)),
1328
1369
  }
1370
+ exception_jobs_counter.labels(
1371
+ job_class_name=job.job_model.job_class_name,
1372
+ module_name=job.job_model.module_name,
1373
+ exception_type=type(exc).__name__,
1374
+ ).inc()
1329
1375
  raise
1330
1376
 
1331
1377
  finally: