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
@@ -31,11 +31,14 @@ class BulkEditObjects(Job):
31
31
  model=ContentType,
32
32
  description="Type of objects to update",
33
33
  )
34
+ # The names of the job inputs must match the parameters of `key_params` and get_bulk_queryset_from_view
35
+ # This may be confusing for the saved_view_id since the job input is an ObjectVar but the key_param is a PK
36
+ # But it is the lesser of two evils.
34
37
  form_data = JSONVar(description="BulkEditForm data")
35
38
  pk_list = JSONVar(description="List of objects pks to edit", required=False)
36
39
  edit_all = BooleanVar(description="Bulk Edit all object / all filtered objects", required=False)
37
40
  filter_query_params = JSONVar(label="Filter Query Params", required=False)
38
- saved_view = ObjectVar(model=SavedView, required=False)
41
+ saved_view_id = ObjectVar(model=SavedView, required=False)
39
42
 
40
43
  class Meta:
41
44
  name = "Bulk Edit Objects"
@@ -127,9 +130,9 @@ class BulkEditObjects(Job):
127
130
  raise RunJobTaskFailed("Bulk Edit not fully successful, see logs")
128
131
 
129
132
  def run( # pylint: disable=arguments-differ
130
- self, *, content_type, form_data, pk_list=None, edit_all=False, filter_query_params=None, saved_view=None
133
+ self, *, content_type, form_data, pk_list=None, edit_all=False, filter_query_params=None, saved_view_id=None
131
134
  ):
132
- saved_view_id = saved_view.pk if saved_view is not None else None
135
+ saved_view_id = saved_view_id.pk if saved_view_id is not None else None
133
136
  if not filter_query_params:
134
137
  filter_query_params = {}
135
138
 
@@ -186,10 +189,13 @@ class BulkDeleteObjects(Job):
186
189
  model=ContentType,
187
190
  description="Type of objects to delete",
188
191
  )
192
+ # The names of the job inputs must match the parameters of `key_params` and get_bulk_queryset_from_view
193
+ # This may be confusing for the saved_view_id since the job input is an ObjectVar but the key_param is a PK
194
+ # But it is the lesser of two evils.
189
195
  pk_list = JSONVar(description="List of objects pks to delete", required=False)
190
196
  delete_all = BooleanVar(description="Delete all (filtered) objects instead of a list of PKs", required=False)
191
197
  filter_query_params = JSONVar(label="Filter Query Params", required=False)
192
- saved_view = ObjectVar(model=SavedView, required=False)
198
+ saved_view_id = ObjectVar(model=SavedView, required=False)
193
199
 
194
200
  class Meta:
195
201
  name = "Bulk Delete Objects"
@@ -200,9 +206,9 @@ class BulkDeleteObjects(Job):
200
206
  hidden = True
201
207
 
202
208
  def run( # pylint: disable=arguments-differ
203
- self, *, content_type, pk_list=None, delete_all=False, filter_query_params=None, saved_view=None
209
+ self, *, content_type, pk_list=None, delete_all=False, filter_query_params=None, saved_view_id=None
204
210
  ):
205
- saved_view_id = saved_view.pk if saved_view is not None else None
211
+ saved_view_id = saved_view_id.pk if saved_view_id is not None else None
206
212
  if not filter_query_params:
207
213
  filter_query_params = {}
208
214
  if not self.user.has_perm(f"{content_type.app_label}.delete_{content_type.model}"):
@@ -1,7 +1,7 @@
1
1
  from datetime import timedelta
2
2
 
3
3
  from django.core.exceptions import PermissionDenied
4
- from django.db.models import CASCADE
4
+ from django.db.models import CASCADE, PROTECT
5
5
  from django.db.models.signals import pre_delete
6
6
  from django.utils import timezone
7
7
 
@@ -67,6 +67,18 @@ class LogsCleanup(Job):
67
67
  cascade_queryset = related_model.objects.filter(**{f"{related_field_name}__id__in": queryset})
68
68
  if cascade_queryset.exists():
69
69
  self.recursive_delete_with_cascade(cascade_queryset, deletion_summary)
70
+ elif related_object.on_delete is PROTECT:
71
+ self.logger.warning(
72
+ "Skipping %s records with a protected relationship to %s."
73
+ " You must delete the related object(s) first.",
74
+ queryset.model._meta.label,
75
+ related_object.related_model._meta.label,
76
+ )
77
+ items_to_exclude = related_object.related_model.objects.values_list(
78
+ related_object.field.name, flat=True
79
+ )
80
+ queryset = queryset.exclude(id__in=items_to_exclude)
81
+ deletion_summary.update({related_object.related_model._meta.label: 0})
70
82
 
71
83
  genericrelation_related_fields = [
72
84
  field for field in queryset.model._meta.private_fields if hasattr(field, "bulk_related_objects")
nautobot/core/settings.py CHANGED
@@ -959,6 +959,19 @@ CELERY_BEAT_HEARTBEAT_FILE = os.getenv(
959
959
  os.path.join(tempfile.gettempdir(), "nautobot_celery_beat_heartbeat"),
960
960
  )
961
961
 
962
+ # Celery Worker heartbeat file path - will be touched by each worker process as a proof-of-health.
963
+ CELERY_WORKER_HEARTBEAT_FILE = os.getenv(
964
+ "NAUTOBOT_CELERY_WORKER_HEARTBEAT_FILE", os.path.join(tempfile.gettempdir(), "nautobot_celery_worker_heartbeat")
965
+ )
966
+
967
+ # Celery Worker readiness file path - will be created by each worker process once it's ready to accept tasks.
968
+ CELERY_WORKER_READINESS_FILE = os.getenv(
969
+ "NAUTOBOT_CELERY_WORKER_READINESS_FILE", os.path.join(tempfile.gettempdir(), "nautobot_celery_worker_ready")
970
+ )
971
+
972
+ # Celery health probes as files - if enabled, Celery worker health probes will be implemented as files
973
+ CELERY_HEALTH_PROBES_AS_FILES = is_truthy(os.getenv("NAUTOBOT_CELERY_HEALTH_PROBES_AS_FILES", "False"))
974
+
962
975
  # Celery broker URL used to tell workers where queues are located
963
976
  CELERY_BROKER_URL = os.getenv("NAUTOBOT_CELERY_BROKER_URL", parse_redis_connection(redis_database=0))
964
977
 
@@ -383,6 +383,14 @@ properties:
383
383
  see_also:
384
384
  "Celery documentation": "https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-broker_use_ssl"
385
385
  type: "object"
386
+ CELERY_HEALTH_PROBES_AS_FILES:
387
+ default: false
388
+ description: "Optional configuration for Celery workers to use file-based health probes."
389
+ environment_variable: "NAUTOBOT_CELERY_HEALTH_PROBES_AS_FILES"
390
+ see_also:
391
+ "`CELERY_WORKER_HEARTBEAT_FILE`": "#celery_worker_heartbeat_file"
392
+ "`CELERY_WORKER_READINESS_FILE`": "#celery_worker_readiness_file"
393
+ type: "boolean"
386
394
  CELERY_REDIS_BACKEND_USE_SSL:
387
395
  default: false
388
396
  description: "Optional configuration for Celery to use custom SSL certificates to connect to Redis."
@@ -425,6 +433,13 @@ properties:
425
433
  see_also:
426
434
  "`CELERY_TASK_SOFT_TIME_LIMIT`": "#celery_task_soft_time_limit"
427
435
  type: "integer"
436
+ CELERY_WORKER_HEARTBEAT_FILE:
437
+ default: "/tmp/nautobot_celery_worker_heartbeat"
438
+ description: "A file touched by the Celery worker every second while it is alive, suitable for health checks."
439
+ environment_variable: "NAUTOBOT_CELERY_WORKER_HEARTBEAT_FILE"
440
+ see_also:
441
+ "`CELERY_HEALTH_PROBES_AS_FILES`": "#celery_health_probes_as_files"
442
+ type: "string"
428
443
  CELERY_WORKER_PREFETCH_MULTIPLIER:
429
444
  default: 4
430
445
  description: "How many tasks a worker is allowed to reserve for its own consumption and execution."
@@ -453,6 +468,13 @@ properties:
453
468
  type: "integer"
454
469
  type: "array"
455
470
  version_added: "1.5.10"
471
+ CELERY_WORKER_READINESS_FILE:
472
+ default: "/tmp/nautobot_celery_worker_ready"
473
+ description: "A file touched by the Celery worker when it starts and is ready to accept tasks."
474
+ environment_variable: "NAUTOBOT_CELERY_WORKER_READINESS_FILE"
475
+ see_also:
476
+ "`CELERY_HEALTH_PROBES_AS_FILES`": "#celery_health_probes_as_files"
477
+ type: "string"
456
478
  CELERY_WORKER_REDIRECT_STDOUTS:
457
479
  default: true
458
480
  description: "If enabled stdout and stderr of running jobs will be redirected to the task logger."
@@ -123,7 +123,17 @@ def setup_structlog_logging(
123
123
  return
124
124
 
125
125
  django_apps.append("django_structlog")
126
- django_middleware.append("django_structlog.middlewares.RequestMiddleware")
126
+
127
+ # Insert the middleware ahead of django_prometheus.middleware.PrometheusAfterMiddleware, which consumes the request.
128
+ # If that middleware is not present, append it at the end.
129
+ django_structlog_middleware = "django_structlog.middlewares.RequestMiddleware"
130
+ try:
131
+ index_of_prometheus_after_middleware = django_middleware.index(
132
+ "django_prometheus.middleware.PrometheusAfterMiddleware"
133
+ )
134
+ django_middleware.insert(index_of_prometheus_after_middleware, django_structlog_middleware)
135
+ except ValueError:
136
+ django_middleware.append(django_structlog_middleware)
127
137
 
128
138
  processors = (
129
139
  # Add the log level to the event dict under the level key.
nautobot/core/tables.py CHANGED
@@ -496,7 +496,7 @@ class ApprovalButtonsColumn(django_tables2.TemplateColumn):
496
496
  return_url_extra (Optional[str]): String to append to the return URL (e.g. for specifying a tab)
497
497
  """
498
498
 
499
- buttons = ("detail", "changelog", "approve", "deny")
499
+ buttons = ("detail", "changelog", "comment", "approve", "deny")
500
500
  attrs = {"td": {"class": "d-print-none text-end text-nowrap"}}
501
501
  template_name = "extras/inc/approval_buttons_column.html"
502
502
 
@@ -511,6 +511,7 @@ class ApprovalButtonsColumn(django_tables2.TemplateColumn):
511
511
  app_label = model._meta.app_label
512
512
  changelog_route = get_route_for_model(model, "changelog")
513
513
  approval_route = "extras:approvalworkflowstage_approve"
514
+ comment_route = "extras:approvalworkflowstage_comment"
514
515
  deny_route = "extras:approvalworkflowstage_deny"
515
516
 
516
517
  super().__init__(template_name=self.template_name, *args, **kwargs)
@@ -521,11 +522,28 @@ class ApprovalButtonsColumn(django_tables2.TemplateColumn):
521
522
  "return_url_extra": return_url_extra,
522
523
  "changelog_route": changelog_route,
523
524
  "approval_route": approval_route,
525
+ "comment_route": comment_route,
524
526
  "deny_route": deny_route,
525
527
  "have_permission": f"perms.{app_label}.change_{model._meta.model_name}",
526
528
  }
527
529
  )
528
530
 
531
+ def render(self, record, table, value, bound_column, **kwargs):
532
+ active_buttons = self.extra_context.get("buttons", self.buttons)
533
+ needs_approval_check = "approve" in active_buttons or "deny" in active_buttons
534
+
535
+ can_approve = False
536
+
537
+ request = table.context.get("request")
538
+ if needs_approval_check and request and request.user:
539
+ can_approve = (
540
+ request.user.is_superuser
541
+ or record.approval_workflow_stage_definition.approver_group.user_set.filter(id=request.user.id).exists()
542
+ )
543
+
544
+ self.extra_context["can_approve"] = can_approve
545
+ return super().render(record, table, value, bound_column, **kwargs)
546
+
529
547
  def header(self): # pylint: disable=invalid-overridden-method
530
548
  return ""
531
549
 
@@ -1,3 +1,3 @@
1
- <table class="collapse show table table-hover card-body"{% if body_id %} id="{{ body_id }}"{% endif %}>
1
+ <table class="collapse show table table-hover"{% if body_id %} id="{{ body_id }}"{% endif %}>
2
2
  {{ body_content }}
3
3
  </table>
@@ -2,10 +2,16 @@
2
2
 
3
3
  <strong>{{ body_content_table_verbose_name_plural|bettertitle }}</strong>
4
4
  {% if body_content_table_list_url %}
5
- <a href="{{ body_content_table_list_url }}" class="badge bg-primary">{% if badge_count_override %}{{ badge_count_override }}{% else %}{{ body_content_table.rows|length }}{% endif %}</a>
6
- {% elif body_content_table or badge_count_override %}
5
+ <a href="{{ body_content_table_list_url }}" class="badge bg-primary">
6
+ {% if badge_count_override is not none %}
7
+ {{ badge_count_override }}
8
+ {% else %}
9
+ {{ body_content_table.rows|length }}
10
+ {% endif %}
11
+ </a>
12
+ {% elif body_content_table or badge_count_override is not none %}
7
13
  <span class="badge bg-secondary">
8
- {% if badge_count_override %}
14
+ {% if badge_count_override is not none %}
9
15
  {{ badge_count_override }}
10
16
  {% else %}
11
17
  {{ body_content_table.rows|length }}
@@ -5,7 +5,7 @@
5
5
  {% block title %}{% if editing %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}{% endblock %}
6
6
 
7
7
  {% block content %}
8
- <form action="" method="post" enctype="multipart/form-data" class="h-100 vstack">
8
+ <form id="nb-create-form" action="" method="post" enctype="multipart/form-data" class="h-100 vstack">
9
9
  {% csrf_token %}
10
10
  {% for field in form.hidden_fields %}
11
11
  {{ field }}
@@ -36,11 +36,18 @@
36
36
  class="align-items-center d-inline-flex gap-4 text-nowrap{% if not request.GET.q %} text-secondary{% endif %}"
37
37
  >
38
38
  {% if not request.GET.q %}
39
- Press <kbd>{% if is_apple_os %}⌘{% else %}Ctrl+{% endif %}K</kbd> to search
39
+ Press <kbd class="mt-n1">{% if is_apple_os %}⌘{% else %}Ctrl+{% endif %}K</kbd> to search
40
40
  {% else %}
41
41
  {{ request.GET.q }}
42
42
  {% endif %}
43
43
  </span>
44
+ <a class="align-items-center d-inline-flex ms-auto pe-auto"
45
+ href="{% static 'docs/user-guide/platform-functionality/user-interface/search.html' %}"
46
+ target="_blank"
47
+ rel="noopener"
48
+ >
49
+ <span aria-hidden="true" class="mdi mdi-help-circle-outline text-secondary"></span>
50
+ </a>
44
51
  </span>
45
52
  </label>
46
53
  </form>
@@ -49,15 +56,7 @@
49
56
  <div class="col-4 text-end">
50
57
  <div class="dropdown">
51
58
  <button class="btn dropdown-toggle align-items-center d-inline-flex gap-6" type="button" data-bs-toggle="dropdown" aria-expanded="false">
52
- <span class="align-items-center bg-primary d-inline-flex justify-content-center rounded nb-text-body-bg" style="height: 1.5rem; width: 1.5rem;">
53
- {% if request.user.get_full_name %}
54
- {% for name in request.user.get_full_name|split:" " %}<!--
55
- -->{{ name|first|upper }}<!--
56
- -->{% endfor %}
57
- {% else %}
58
- {{ request.user.username|first|upper }}
59
- {% endif %}
60
- </span>
59
+ <span aria-hidden="true" class="mdi mdi-account text-secondary"></span>
61
60
  {% firstof request.user.get_full_name request.user.username %}
62
61
  </button>
63
62
  <ul class="dropdown-menu dropdown-menu-end">
@@ -2,7 +2,22 @@
2
2
  {% load helpers %}
3
3
  {% load form_helpers %}
4
4
 
5
- {% block title %}Home{% endblock title %}
5
+ {% block title %}Log In{% endblock title %}
6
+
7
+ {% block extra_styles %}
8
+ {{ block.super }}
9
+
10
+ <style>
11
+ /* This is a wrapper element containing page title, breadcrumbs, search bar, and user profile dropdown. */
12
+ #header :nth-child(2) {
13
+ display: none;
14
+ }
15
+
16
+ #header .alert:last-child {
17
+ margin-bottom: 0;
18
+ }
19
+ </style>
20
+ {% endblock %}
6
21
 
7
22
  {% block content %}
8
23
  <div class="row justify-content-center" style="margin-block-start: {% if 'BANNER_LOGIN'|settings_or_config %}6.25{% else %}9.375{% endif %}rem;">
@@ -43,6 +43,19 @@ from nautobot.core.settings_funcs import is_truthy, parse_redis_connection
43
43
  # os.path.join(tempfile.gettempdir(), "nautobot_celery_beat_heartbeat"),
44
44
  # )
45
45
 
46
+ # Celery Worker heartbeat file path - will be touched by each worker process as a proof-of-health.
47
+ # CELERY_WORKER_HEARTBEAT_FILE = os.getenv(
48
+ # "NAUTOBOT_CELERY_BEAT_READINESS_FILE", os.path.join(tempfile.gettempdir(), "nautobot_celery_worker_heartbeat")
49
+ # )
50
+
51
+ # Celery Worker readiness file path - will be created by each worker process once it's ready to accept tasks.
52
+ # CELERY_WORKER_READINESS_FILE = os.getenv(
53
+ # "NAUTOBOT_CELERY_WORKER_READINESS_FILE", os.path.join(tempfile.gettempdir(), "nautobot_celery_worker_ready")
54
+ # )
55
+
56
+ # Celery health probes as files - if enabled, Celery worker health probes will be implemented as files
57
+ # CELERY_HEALTH_PROBES_AS_FILES = is_truthy(os.getenv("NAUTOBOT_CELERY_HEALTH_PROBES_AS_FILES", "False"))
58
+
46
59
  # Celery broker URL used to tell workers where queues are located
47
60
  #
48
61
  # CELERY_BROKER_URL = os.getenv("NAUTOBOT_CELERY_BROKER_URL", parse_redis_connection(redis_database=0))
@@ -94,7 +107,7 @@ SECRET_KEY = os.getenv("NAUTOBOT_SECRET_KEY", "{{ secret_key }}")
94
107
  # FQDNs that are considered trusted origins for secure, cross-domain, requests such as HTTPS POST.
95
108
  # If running Nautobot under a single domain, you may not need to set this variable;
96
109
  # if running on multiple domains, you *may* need to set this variable to more or less the same as ALLOWED_HOSTS above.
97
- # You also want to set this variable if you are facing CSRF validation issues such as
110
+ # You also want to set this variable if you are facing CSRF validation issues such as
98
111
  # 'CSRF failure has occured' or 'Origin checking failed - https://subdomain.example.com does not match any trusted origins.'
99
112
  # https://docs.djangoproject.com/en/stable/ref/settings/#csrf-trusted-origins
100
113
  #
@@ -58,6 +58,9 @@
58
58
  [data-bs-theme="dark"] .redoc-wrap .menu-content .operation-type {
59
59
  filter: hue-rotate(180deg) invert(1);
60
60
  }
61
+
62
+ {# Revert default Nautobot `<code>` element styles in `<div class="redoc-json">` blocks. #}
63
+ div.redoc-json code { background: none; color: inherit; border-radius: 0; padding: 0; }
61
64
  </style>
62
65
  {% endblock extra_styles %}
63
66
 
@@ -824,7 +824,7 @@ def render_button_class(value):
824
824
  value (str): A string representing the button class (e.g., 'primary').
825
825
 
826
826
  Returns:
827
- str: HTML string for a button with the given class.
827
+ (str): HTML string for a button with the given class.
828
828
 
829
829
  Example:
830
830
  >>> render_button_class("primary")
@@ -848,7 +848,7 @@ def render_job_run_link(value):
848
848
  value (Job): The job object.
849
849
 
850
850
  Returns:
851
- str: HTML anchor tag linking to the job's run view.
851
+ (str): HTML anchor tag linking to the job's run view.
852
852
  """
853
853
  if hasattr(value, "class_path"):
854
854
  url = reverse("extras:job_run_by_class_path", kwargs={"class_path": value.class_path})
@@ -1313,7 +1313,7 @@ def tree_hierarchy_ui_representation(tree_depth, hide_hierarchy_ui, base_depth=0
1313
1313
  base_depth (int, optional): Starting depth (number of dots to skip rendering).
1314
1314
 
1315
1315
  Returns:
1316
- str: A string containing dots (representing hierarchy levels) if `hide_hierarchy_ui` is False,
1316
+ (str): A string containing dots (representing hierarchy levels) if `hide_hierarchy_ui` is False,
1317
1317
  otherwise an empty string.
1318
1318
  """
1319
1319
  if hide_hierarchy_ui or tree_depth == 0:
@@ -402,7 +402,7 @@ class ViewTestCases:
402
402
  self.assertHttpStatus(self.client.get(url), [403, 404])
403
403
 
404
404
  self.add_permissions(required_permissions[-1])
405
- self.assertHttpStatus(self.client.get(url), 200)
405
+ self.assertHttpStatus(self.client.get(url, follow=True), 200)
406
406
  finally:
407
407
  # delete the permissions here so that we start from a clean slate on the next loop
408
408
  self.remove_permissions(*required_permissions)
@@ -1558,6 +1558,7 @@ class ViewTestCases:
1558
1558
 
1559
1559
  response = self.client.post(self._get_url("bulk_delete"), data)
1560
1560
  job_result = JobResult.objects.filter(name="Bulk Delete Objects").first()
1561
+ self.assertIsNotNone(job_result)
1561
1562
  self.assertRedirects(
1562
1563
  response,
1563
1564
  reverse("extras:jobresult", args=[job_result.pk]),
@@ -1647,6 +1648,7 @@ class ViewTestCases:
1647
1648
  self.add_permissions("extras.view_jobresult")
1648
1649
  response = self.client.post(self._get_url("bulk_delete"), data)
1649
1650
  job_result = JobResult.objects.filter(name="Bulk Delete Objects").first()
1651
+ self.assertIsNotNone(job_result)
1650
1652
  self.assertRedirects(
1651
1653
  response,
1652
1654
  reverse("extras:jobresult", args=[job_result.pk]),
@@ -2263,6 +2263,19 @@ query {
2263
2263
  result_2 = self.execute_query(query_all)
2264
2264
  self.assertEqual(len(result_2.data.get("interfaces", [])), Interface.objects.count())
2265
2265
 
2266
+ def test_query_pagination_with_restricted_permissions(self):
2267
+ # Test for https://github.com/nautobot/nautobot/issues/8155
2268
+ self.user.is_superuser = False
2269
+ self.user.save()
2270
+ try:
2271
+ self.add_permissions("dcim.view_manufacturer")
2272
+ result = self.execute_query("query { manufacturers (limit: 1) { name } }")
2273
+ self.assertIsNone(result.errors)
2274
+ self.assertEqual(len(result.data.get("manufacturers", [])), 1)
2275
+ finally:
2276
+ self.user.is_superuser = True
2277
+ self.user.save()
2278
+
2266
2279
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
2267
2280
  def test_query_power_feeds_cable_peer(self):
2268
2281
  """Test querying power feeds for their cable peers"""
@@ -1060,6 +1060,70 @@ class BulkEditTestCase(TransactionTestCase):
1060
1060
  )
1061
1061
  self.assertEqual(IPAddress.objects.all().count(), IPAddress.objects.filter(status=active_status).count())
1062
1062
 
1063
+ def test_bulk_edit_objects_with_saved_view(self):
1064
+ """
1065
+ Bulk edit Status objects using a SavedView filter.
1066
+ """
1067
+ self.add_permissions("extras.change_status", "extras.view_status")
1068
+ saved_view = SavedView.objects.create(
1069
+ name="Save View for Statuses",
1070
+ owner=self.user,
1071
+ view="extras:status_list",
1072
+ config={"filter_params": {"name__isw": ["A"]}},
1073
+ )
1074
+
1075
+ # Confirm the SavedView filter matches some but not all Statuses
1076
+ self.assertTrue(
1077
+ 0 < saved_view.get_filtered_queryset(self.user).count() < Status.objects.exclude(color="aa1409").count()
1078
+ )
1079
+ delta_count = (
1080
+ Status.objects.exclude(color="aa1409").count() - saved_view.get_filtered_queryset(self.user).count()
1081
+ )
1082
+
1083
+ create_job_result_and_run_job(
1084
+ "nautobot.core.jobs.bulk_actions",
1085
+ "BulkEditObjects",
1086
+ username=self.user.username,
1087
+ content_type=self.status_ct.id,
1088
+ edit_all=True,
1089
+ filter_query_params={},
1090
+ pk_list=[],
1091
+ saved_view_id=saved_view.id,
1092
+ form_data={"color": "aa1409", "_all": "True"},
1093
+ )
1094
+
1095
+ self.assertEqual(delta_count, Status.objects.exclude(color="aa1409").count())
1096
+
1097
+ def test_bulk_edit_objects_with_saved_view_with_all_filters_removed(self):
1098
+ """
1099
+ Bulk edit Status objects using a SavedView filter but overwriting the saved field.
1100
+ """
1101
+ self.add_permissions("extras.change_status", "extras.view_status")
1102
+ saved_view = SavedView.objects.create(
1103
+ name="Save View for Statuses",
1104
+ owner=self.user,
1105
+ view="extras:status_list",
1106
+ config={"filter_params": {"name__isw": ["A"]}},
1107
+ )
1108
+
1109
+ self.assertTrue(
1110
+ 0 < saved_view.get_filtered_queryset(self.user).count() < Status.objects.exclude(color="aa1409").count()
1111
+ )
1112
+
1113
+ create_job_result_and_run_job(
1114
+ "nautobot.core.jobs.bulk_actions",
1115
+ "BulkEditObjects",
1116
+ username=self.user.username,
1117
+ content_type=self.status_ct.id,
1118
+ edit_all=True,
1119
+ filter_query_params={"all_filters_removed": [True]},
1120
+ pk_list=[],
1121
+ saved_view_id=saved_view.id,
1122
+ form_data={"color": "aa1409", "_all": "True"},
1123
+ )
1124
+
1125
+ self.assertEqual(0, Status.objects.exclude(color="aa1409").count())
1126
+
1063
1127
 
1064
1128
  class BulkDeleteTestCase(TransactionTestCase):
1065
1129
  """
@@ -1099,6 +1163,19 @@ class BulkDeleteTestCase(TransactionTestCase):
1099
1163
  circuit_type=circuit_type,
1100
1164
  status=statuses[0],
1101
1165
  )
1166
+ Circuit.objects.create(
1167
+ cid="Not Circuit",
1168
+ provider=provider,
1169
+ circuit_type=circuit_type,
1170
+ status=statuses[0],
1171
+ )
1172
+
1173
+ self.saved_view = SavedView.objects.create(
1174
+ name="Save View for Circuits",
1175
+ owner=self.user,
1176
+ view="circuits:circuit_list",
1177
+ config={"filter_params": {"cid__isw": "Circuit "}},
1178
+ )
1102
1179
 
1103
1180
  def _common_no_error_test_assertion(self, model, job_result, **filter_params):
1104
1181
  self.assertJobResultStatus(job_result)
@@ -1250,6 +1327,47 @@ class BulkDeleteTestCase(TransactionTestCase):
1250
1327
  )
1251
1328
  self._common_no_error_test_assertion(Role, job_result, name__istartswith="Example Status")
1252
1329
 
1330
+ def test_bulk_delete_objects_with_saved_view(self):
1331
+ """
1332
+ Delete objects using a SavedView filter.
1333
+ """
1334
+ self.add_permissions("circuits.delete_circuit", "circuits.view_circuit")
1335
+
1336
+ # we assert that the saved view filter actually filters some circuits and there are others not filtered out
1337
+ self.assertTrue(0 < self.saved_view.get_filtered_queryset(self.user).count() < Circuit.objects.all().count())
1338
+ create_job_result_and_run_job(
1339
+ "nautobot.core.jobs.bulk_actions",
1340
+ "BulkDeleteObjects",
1341
+ username=self.user.username,
1342
+ content_type=ContentType.objects.get_for_model(Circuit).id,
1343
+ delete_all=True,
1344
+ filter_query_params={},
1345
+ pk_list=[],
1346
+ saved_view_id=self.saved_view.id,
1347
+ )
1348
+ self.assertTrue(Circuit.objects.exists())
1349
+ self.assertFalse(self.saved_view.get_filtered_queryset(self.user).exists())
1350
+
1351
+ def test_bulk_delete_objects_with_saved_view_with_all_filters_removed(self):
1352
+ """
1353
+ Delete Objects using a SavedView filter, but ignore the filter if all_filters_removed is set.
1354
+ """
1355
+ self.add_permissions("circuits.delete_circuit", "circuits.view_circuit")
1356
+
1357
+ # we assert that the saved view filter actually filters some circuits and there are others not filtered out
1358
+ self.assertTrue(0 < self.saved_view.get_filtered_queryset(self.user).count() < Circuit.objects.all().count())
1359
+ create_job_result_and_run_job(
1360
+ "nautobot.core.jobs.bulk_actions",
1361
+ "BulkDeleteObjects",
1362
+ username=self.user.username,
1363
+ content_type=ContentType.objects.get_for_model(Circuit).id,
1364
+ delete_all=True,
1365
+ filter_query_params={"all_filters_removed": [True]},
1366
+ pk_list=[],
1367
+ saved_view_id=self.saved_view.id,
1368
+ )
1369
+ self.assertFalse(Circuit.objects.all().exists())
1370
+
1253
1371
 
1254
1372
  class RefreshDynamicGroupCacheJobButtonReceiverTestCase(TransactionTestCase):
1255
1373
  job_module = "nautobot.core.jobs.groups"
@@ -997,3 +997,27 @@ class SearchRobotsTestCase(TestCase):
997
997
  url = reverse("home")
998
998
  response = self.client.get(url)
999
999
  self.assertNotContains(response, '<meta name="robots" content="noindex, nofollow">', html=True)
1000
+
1001
+
1002
+ @tag("example_app")
1003
+ class ViewSetCustomActionsTestCase(TestCase):
1004
+ """Tests for NautobotUIViewSet custom actions."""
1005
+
1006
+ def test_custom_view_base_action(self):
1007
+ """Assert that all of the permissions in a custom action's custom_view_base_action are enforced."""
1008
+ from example_app.models import AnotherExampleModel
1009
+
1010
+ self.add_permissions("example_app.view_anotherexamplemodel", "dcim.view_location")
1011
+ self.remove_permissions("dcim.add_location", "dcim.change_location")
1012
+
1013
+ example_model = AnotherExampleModel.objects.create(name="test")
1014
+ url = reverse(
1015
+ "plugins:example_app:anotherexamplemodel_custom-action-permissions-test",
1016
+ kwargs={"pk": example_model.pk},
1017
+ )
1018
+ response = self.client.get(url)
1019
+ self.assertEqual(response.status_code, 403)
1020
+
1021
+ self.add_permissions("dcim.add_location", "dcim.change_location")
1022
+ response = self.client.get(url)
1023
+ self.assertEqual(response.status_code, 200)
@@ -1,5 +1,6 @@
1
1
  from nautobot.core.choices import ButtonActionColorChoices
2
2
  from nautobot.core.ui import object_detail
3
+ from nautobot.core.utils.lookup import get_route_for_model
3
4
 
4
5
 
5
6
  class BaseBulkButton(object_detail.FormButton):
@@ -12,9 +13,7 @@ class BaseBulkButton(object_detail.FormButton):
12
13
  weight = None
13
14
 
14
15
  def __init__(self, *, form_id: str, model, **kwargs):
15
- model_name = model.__name__.lower()
16
- app_label = model._meta.app_label
17
- link_name = f"{app_label}:{model_name}_bulk_{self.action}"
16
+ link_name = get_route_for_model(model, f"bulk_{self.action}")
18
17
 
19
18
  super().__init__(
20
19
  link_name=link_name,
@@ -391,11 +391,10 @@ def get_table_class_string_from_view_name(view_name):
391
391
  def get_created_and_last_updated_usernames_for_model(instance):
392
392
  """
393
393
  Args:
394
- instance: A model class instance
394
+ instance (Model): A model class instance
395
395
 
396
396
  Returns:
397
- created_by: Username of the user that created the instance
398
- last_updated_by: Username of the user that last modified the instance
397
+ (str, str): Usernames of the users that created the instance and last modified the instance.
399
398
  """
400
399
  from nautobot.extras.choices import ObjectChangeActionChoices
401
400
  from nautobot.extras.models import ObjectChange
@@ -82,7 +82,7 @@ def qs_filter_from_constraints(constraints, tokens=None):
82
82
  tokens (dict, optional): user tokens. Defaults to a None.
83
83
 
84
84
  Returns:
85
- QuerySet object: A QuerySet of tuples or, an empty QuerySet if constraints are null.
85
+ (Q): QuerySet filter constructed from the given constraints, possibly empty.
86
86
  """
87
87
  if tokens is None:
88
88
  tokens = {}
@@ -227,6 +227,7 @@ class ObjectListView(UIComponentsMixin, ObjectPermissionRequiredMixin, View):
227
227
  hide_hierarchy_ui = False
228
228
  clear_view = request.GET.get("clear_view", False)
229
229
  resolved_path = resolve(request.path)
230
+ # Note that `resolved_path.app_name` does work even for nested paths like `plugins:example_app:...`
230
231
  list_url = f"{resolved_path.app_name}:{resolved_path.url_name}"
231
232
 
232
233
  skip_user_and_global_default_saved_view = False