nautobot 3.0.0rc2__py3-none-any.whl → 3.0.2__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 (51) hide show
  1. nautobot/apps/views.py +2 -0
  2. nautobot/core/celery/__init__.py +46 -1
  3. nautobot/core/cli/bootstrap_v3_to_v5.py +125 -44
  4. nautobot/core/jobs/bulk_actions.py +12 -6
  5. nautobot/core/settings.py +13 -0
  6. nautobot/core/settings.yaml +22 -0
  7. nautobot/core/templates/components/panel/body_wrapper_generic_table.html +1 -1
  8. nautobot/core/templates/nautobot_config.py.j2 +14 -1
  9. nautobot/core/templates/redoc_ui.html +3 -0
  10. nautobot/core/testing/filters.py +2 -2
  11. nautobot/core/testing/views.py +1 -1
  12. nautobot/core/tests/test_jobs.py +118 -0
  13. nautobot/core/tests/test_views.py +24 -0
  14. nautobot/core/ui/bulk_buttons.py +2 -3
  15. nautobot/core/ui/object_detail.py +2 -2
  16. nautobot/core/views/generic.py +1 -0
  17. nautobot/core/views/mixins.py +6 -7
  18. nautobot/core/views/renderers.py +1 -0
  19. nautobot/core/views/utils.py +3 -3
  20. nautobot/dcim/tables/devices.py +1 -1
  21. nautobot/dcim/views.py +1 -1
  22. nautobot/extras/jobs.py +48 -2
  23. nautobot/extras/models/jobs.py +1 -0
  24. nautobot/extras/models/models.py +19 -0
  25. nautobot/extras/tables.py +9 -6
  26. nautobot/extras/templates/extras/approval_workflow/approve.html +9 -2
  27. nautobot/extras/templates/extras/approval_workflow/deny.html +9 -3
  28. nautobot/extras/tests/test_customfields_filters.py +84 -4
  29. nautobot/extras/tests/test_views.py +40 -4
  30. nautobot/extras/views.py +63 -38
  31. nautobot/project-static/dist/css/graphql-libraries.css.map +1 -1
  32. nautobot/project-static/dist/css/materialdesignicons.css.map +1 -1
  33. nautobot/project-static/dist/css/nautobot.css +1 -1
  34. nautobot/project-static/dist/css/nautobot.css.map +1 -1
  35. nautobot/project-static/dist/js/graphql-libraries.js +1 -1
  36. nautobot/project-static/dist/js/graphql-libraries.js.map +1 -1
  37. nautobot/project-static/dist/js/libraries.js.map +1 -1
  38. nautobot/project-static/dist/js/nautobot-graphiql.js.map +1 -1
  39. nautobot/project-static/dist/js/nautobot.js.map +1 -1
  40. nautobot/project-static/img/dark-theme.png +0 -0
  41. nautobot/project-static/img/light-theme.png +0 -0
  42. nautobot/project-static/img/system-theme.png +0 -0
  43. nautobot/ui/package-lock.json +25 -25
  44. nautobot/ui/package.json +6 -6
  45. nautobot/ui/src/scss/nautobot.scss +2 -1
  46. {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/METADATA +6 -6
  47. {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/RECORD +51 -51
  48. {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/LICENSE.txt +0 -0
  49. {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/NOTICE +0 -0
  50. {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/WHEEL +0 -0
  51. {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/entry_points.txt +0 -0
nautobot/apps/views.py CHANGED
@@ -24,6 +24,7 @@ from nautobot.core.views.mixins import (
24
24
  ObjectBulkDestroyViewMixin,
25
25
  ObjectBulkUpdateViewMixin,
26
26
  ObjectChangeLogViewMixin,
27
+ ObjectDataComplianceViewMixin,
27
28
  ObjectDestroyViewMixin,
28
29
  ObjectDetailViewMixin,
29
30
  ObjectEditViewMixin,
@@ -72,6 +73,7 @@ __all__ = (
72
73
  "ObjectBulkDestroyViewMixin",
73
74
  "ObjectBulkUpdateViewMixin",
74
75
  "ObjectChangeLogViewMixin",
76
+ "ObjectDataComplianceViewMixin",
75
77
  "ObjectDeleteView",
76
78
  "ObjectDestroyViewMixin",
77
79
  "ObjectDetailViewMixin",
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
  import shutil
6
6
  import sys
7
7
 
8
- from celery import Celery, shared_task, signals
8
+ from celery import bootsteps, Celery, shared_task, signals
9
9
  from celery.app.log import TaskFormatter
10
10
  from celery.utils.log import get_logger
11
11
  from django.apps import apps
@@ -268,3 +268,48 @@ def register_jobs(*jobs):
268
268
  for job in jobs:
269
269
  if job.class_path not in registry["jobs"]:
270
270
  registry["jobs"][job.class_path] = job
271
+
272
+
273
+ @signals.worker_ready.connect
274
+ def worker_ready(**_):
275
+ if not settings.CELERY_HEALTH_PROBES_AS_FILES:
276
+ return
277
+ WORKER_READINESS_FILE = Path(settings.CELERY_WORKER_READINESS_FILE)
278
+ WORKER_READINESS_FILE.touch(exist_ok=True)
279
+
280
+
281
+ @signals.worker_shutdown.connect
282
+ def worker_shutdown(**_):
283
+ if not settings.CELERY_HEALTH_PROBES_AS_FILES:
284
+ return
285
+ WORKER_READINESS_FILE = Path(settings.CELERY_WORKER_READINESS_FILE)
286
+ WORKER_READINESS_FILE.unlink(missing_ok=True)
287
+
288
+
289
+ class LivenessProbe(bootsteps.StartStopStep):
290
+ requires = {"celery.worker.components:Timer"}
291
+
292
+ def __init__(self, parent, **kwargs):
293
+ self.requests = []
294
+ self.tref = None
295
+ self.WORKER_HEARTBEAT_FILE = Path(settings.CELERY_WORKER_HEARTBEAT_FILE)
296
+
297
+ def start(self, parent):
298
+ if not settings.CELERY_HEALTH_PROBES_AS_FILES:
299
+ return
300
+ # This is a 1-second interval.
301
+ self.tref = parent.timer.call_repeatedly(
302
+ 1.0,
303
+ self.update_worker_heartbeat_file,
304
+ (parent,),
305
+ priority=10,
306
+ )
307
+
308
+ def stop(self, parent):
309
+ self.WORKER_HEARTBEAT_FILE.unlink(missing_ok=True)
310
+
311
+ def update_worker_heartbeat_file(self, parent):
312
+ self.WORKER_HEARTBEAT_FILE.touch(exist_ok=True)
313
+
314
+
315
+ app.steps["worker"].add(LivenessProbe)
@@ -549,6 +549,97 @@ def _fix_breadcrumbs_block(html_string: str, stats: dict) -> str:
549
549
  return block_pattern.sub(process_match, html_string)
550
550
 
551
551
 
552
+ # --- Grid Breakpoints Resize Function ---
553
+
554
+
555
+ def _resize_grid_breakpoints(html_string: str, class_combinations: list[str], stats: dict, file_path: str) -> str:
556
+ """
557
+ Resizes grid breakpoints in `col-*` and `offset-*` classes one step up. Uses given `class_combinations` for known
558
+ class pattern replacements and otherwise does generic xs → sm and md → lg breakpoint resize. In case class list
559
+ contains grid breakpoints other than xs and md, flags it for manual review.
560
+ """
561
+ # Define the breakpoint mapping
562
+ breakpoint_map = {"xs": "sm", "sm": "md", "md": "lg", "lg": "xl", "xl": "xxl"}
563
+ breakpoint_map_keys = list(breakpoint_map.keys())
564
+
565
+ if "manual_grid_template_lines" not in stats:
566
+ stats["manual_grid_template_lines"] = []
567
+
568
+ def create_grid_class_regex(breakpoints=breakpoint_map_keys): # pylint: disable=dangerous-default-value
569
+ # Create regex matching Bootstrap grid classes, i.e. `col-*` and `offset-*`, within given breakpoints.
570
+ return re.compile(rf"\b(col|offset)-({'|'.join(breakpoints)})([a-zA-Z0-9-]*)")
571
+
572
+ # Resize all given grid `breakpoints` in `string` according to defined `breakpoint_map`
573
+ def resize_breakpoints(string, breakpoints=breakpoint_map_keys, count_stats=False): # pylint: disable=dangerous-default-value
574
+ def regex_repl(match):
575
+ new_breakpoint = breakpoint_map[match.group(2)]
576
+ if count_stats:
577
+ stats["grid_breakpoints"] += 1
578
+ return f"{match.group(1)}-{new_breakpoint}{match.group(3)}"
579
+
580
+ # Replace with regex, e.g., col-xs-12 → col-sm-12
581
+ regex = create_grid_class_regex(breakpoints)
582
+ return regex.sub(regex_repl, string)
583
+
584
+ # Resize given `class_combinations` and create an additional joint array from the two. This is required to determine
585
+ # whether a known class combination is present in certain element class list and handle one of the following cases:
586
+ # 1. No, but identified grid breakpoints other than xs and md: flag for manual review.
587
+ # 2. No, and only xs and md grid breakpoints found: generic xs → sm and md → lg replacement.
588
+ # 3. Yes, but has not been resized yet: resize with proper combination.
589
+ # 4. Yes, and has already been resized: do nothing.
590
+ resized_class_combinations = [resize_breakpoints(class_combination) for class_combination in class_combinations]
591
+ known_class_combinations = [*class_combinations, *resized_class_combinations]
592
+
593
+ def grid_breakpoints_replacer(match):
594
+ classes = match.group(1)
595
+ # Remove Django template tag blocks, variables and comments and split individual classes into separate strings.
596
+ raw_classes = re.compile(r"{{?((?!{|}).)*}}?").sub(" ", classes).split()
597
+ # Filter out all non-grid classes, keep only `col-*` and `offset-*`.
598
+ grid_class_regex = create_grid_class_regex()
599
+ grid_classes = [cls for cls in raw_classes if grid_class_regex.search(cls)]
600
+
601
+ # Check whether given class list consists of any of the known class combinations.
602
+ known_class_combination = None
603
+ for class_combination in known_class_combinations:
604
+ # Look for an exact match, when all classes from given combination are included in element classes and vice versa.
605
+ if all(cls in classes for cls in class_combination.split()) and all(
606
+ grid_class in class_combination for grid_class in grid_classes
607
+ ):
608
+ known_class_combination = class_combination
609
+ break
610
+
611
+ if known_class_combination is None:
612
+ # Class combination has not been found.
613
+ if any("xs" not in grid_class and "md" not in grid_class for grid_class in grid_classes):
614
+ # Class list contains grid breakpoints other than xs and md, require manual review.
615
+ linenum = match.string.count("\n", 0, match.start()) + 1
616
+ stats["manual_grid_template_lines"].append(
617
+ f"{file_path}:{linenum} - Please review manually '{match.group(0)}'"
618
+ )
619
+ else:
620
+ # Class list contains only xs and md grid breakpoints, do generic xs → sm and md → lg replacement
621
+ return f'class="{resize_breakpoints(classes, breakpoints=["xs", "md"], count_stats=True)}"'
622
+
623
+ elif known_class_combination not in resized_class_combinations:
624
+ # Class combination has been found, but has not been resized yet: resize with proper combination.
625
+ resized_classes = resized_class_combinations[class_combinations.index(known_class_combination)].split()
626
+
627
+ def class_replacer(m):
628
+ current_class = m.group(0)
629
+ stats["grid_breakpoints"] += 1
630
+ return resized_classes[known_class_combination.split().index(current_class)]
631
+
632
+ # Replace all classes from given combination by mapping them individually to their resized equivalents.
633
+ return f'class="{re.compile("|".join(known_class_combination.split())).sub(class_replacer, classes)}"'
634
+
635
+ # Return unchanged string if conditions above are not satisfied, i.e. do nothing.
636
+ return match.group(0)
637
+
638
+ # Find all `class="..."` matches and execute grid breakpoint replacement on them.
639
+ pattern = re.compile(r'class="([^"]*)"')
640
+ return pattern.sub(grid_breakpoints_replacer, html_string)
641
+
642
+
552
643
  # --- Main Conversion Function ---
553
644
 
554
645
 
@@ -566,7 +657,9 @@ def convert_bootstrap_classes(html_input: str, file_path: str) -> tuple[str, dic
566
657
  "nav_items": 0,
567
658
  "dropdown_items": 0,
568
659
  "panel_classes": 0,
660
+ "grid_breakpoints": 0,
569
661
  "manual_nav_template_lines": [],
662
+ "manual_grid_template_lines": [],
570
663
  }
571
664
 
572
665
  # --- Stage 1: Apply rules that work directly on the HTML string (simple string/regex replacements) ---
@@ -630,6 +723,26 @@ def convert_bootstrap_classes(html_input: str, file_path: str) -> tuple[str, dic
630
723
  "data-backdrop": "data-bs-backdrop", # Bootstrap 5 uses data-bs-* attributes
631
724
  }
632
725
 
726
+ standard_grid_breakpoint_combinations = [
727
+ "col-md-6 col-sm-6 col-xs-12", # Rack elevation container: nautobot/dcim/templates/dcim/rack_elevation.html:2
728
+ "col-sm-3 col-md-2 col-md-offset-1", # Legacy user page nav pills container: nautobot/users/templates/users/base.html:10
729
+ "col-sm-3 col-md-2 offset-md-1",
730
+ "col-sm-4 col-sm-offset-4", # Selected centered panel containers, e.g. on 404 and 500 error pages and legacy login page: nautobot/core/templates/login.html:54
731
+ "col-sm-4 offset-sm-4",
732
+ "col-sm-4 col-md-3", # Legacy header search form container: nautobot/core/templates/generic/object_list.html:32
733
+ "col-sm-8 col-md-9 col-sm-12 col-md-12", # Legacy breadcrumbs container variation on change list page: nautobot/core/templates/admin/change_list.html:33
734
+ "col-sm-8 col-md-9 col-md-12", # Legacy breadcrumbs container variation on generic object list view page: nautobot/core/templates/generic/object_list.html:13
735
+ "col-sm-8 col-md-9", # Legacy breadcrumbs container: nautobot/core/templates/generic/object_retrieve.html:16
736
+ "col-sm-9 col-md-8", # Legacy user page content container: nautobot/users/templates/users/base.html:31
737
+ "col-md-5 col-sm-12", # Cable trace form left-hand side container: nautobot/dcim/templates/dcim/cable_trace.html:10
738
+ "col-md-7 col-sm-12", # Cable trace form right-hand side container: nautobot/dcim/templates/dcim/cable_trace.html:86
739
+ "col-lg-6 col-md-6", # Jinja template/rendered template panel containers: nautobot/core/templates/utilities/render_jinja2.html:29
740
+ "col-md-4 col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1", # Standard centered form container variation on generic object bulk update page: nautobot/core/templates/generic/object_bulk_update.html:39
741
+ "col-md-4 col-lg-8 col-lg-offset-2 col-md-10 offset-md-1",
742
+ "col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1", # Standard centered form container, e.g. on generic object create page: nautobot/core/templates/generic/object_create.html:12
743
+ "col-lg-8 offset-lg-2 col-md-10 offset-md-1",
744
+ ]
745
+
633
746
  current_html = _replace_attributes(current_html, attribute_replacements, stats)
634
747
  current_html = _replace_classes(current_html, class_replacements, stats, file_path=file_path)
635
748
  current_html = _fix_extra_breadcrumbs_block(current_html, stats)
@@ -644,6 +757,7 @@ def convert_bootstrap_classes(html_input: str, file_path: str) -> tuple[str, dic
644
757
  current_html = _convert_hover_copy_buttons(current_html, stats)
645
758
  current_html = _fix_nav_tabs_items(current_html, stats, file_path=file_path)
646
759
  current_html = _fix_dropdown_items(current_html, stats, file_path=file_path)
760
+ current_html = _resize_grid_breakpoints(current_html, standard_grid_breakpoint_combinations, stats, file_path)
647
761
 
648
762
  return current_html, stats
649
763
 
@@ -651,11 +765,10 @@ def convert_bootstrap_classes(html_input: str, file_path: str) -> tuple[str, dic
651
765
  # --- File Processing ---
652
766
 
653
767
 
654
- def fix_html_files_in_directory(directory: str, resize=False, dry_run=False, skip_templates=False) -> None:
768
+ def fix_html_files_in_directory(directory: str, dry_run=False, skip_templates=False) -> None:
655
769
  """
656
770
  Recursively finds all .html files in the given directory, applies convert_bootstrap_classes,
657
- and overwrites each file with the fixed content. If resize is True, it will only change the
658
- breakpoints (This should only be done once.).
771
+ and overwrites each file with the fixed content.
659
772
  """
660
773
 
661
774
  totals = {
@@ -667,11 +780,9 @@ def fix_html_files_in_directory(directory: str, resize=False, dry_run=False, ski
667
780
  "nav_items",
668
781
  "dropdown_items",
669
782
  "panel_classes",
670
- "resizing_xs",
783
+ "grid_breakpoints",
671
784
  ]
672
785
  }
673
- # Breakpoints that are not xs do not count as failures in djlint, so we keep a separate counter
674
- resizing_other = 0
675
786
 
676
787
  if not os.path.exists(directory):
677
788
  raise FileNotFoundError(directory)
@@ -682,9 +793,6 @@ def fix_html_files_in_directory(directory: str, resize=False, dry_run=False, ski
682
793
  else:
683
794
  only_filename = None
684
795
 
685
- # Define the breakpoint mapping
686
- breakpoint_map = {"xs": "sm", "sm": "md", "md": "lg", "lg": "xl", "xl": "xxl"}
687
-
688
796
  for root, _, files in os.walk(directory):
689
797
  for filename in files:
690
798
  if only_filename and only_filename != filename:
@@ -697,29 +805,6 @@ def fix_html_files_in_directory(directory: str, resize=False, dry_run=False, ski
697
805
 
698
806
  content = original_content
699
807
 
700
- if resize:
701
- # If resize is True, we only change the breakpoints
702
- # This is a one-time operation to adjust the breakpoints.
703
- logger.info("Resizing Breakpoints: %s", file_path)
704
-
705
- resizing_other = 0
706
-
707
- # Iterate from the highest breakpoint to the lowest
708
- for bkpt in ["xl", "lg", "md", "sm", "xs"]:
709
- # Replace with regex, e.g., col-xs-12 → col-sm-12
710
- regex = re.compile(rf"(\bcol-{bkpt})([a-zA-Z0-9-]*)")
711
-
712
- def regex_repl(m, captured_bkpt=bkpt):
713
- nonlocal resizing_other
714
- if captured_bkpt == "xs":
715
- totals["resizing_xs"] += 1
716
- else:
717
- resizing_other += 1
718
- new_bkpt = breakpoint_map[captured_bkpt]
719
- return f"col-{new_bkpt}{m.group(2)}"
720
-
721
- content = regex.sub(regex_repl, content)
722
-
723
808
  fixed_content, stats = convert_bootstrap_classes(content, file_path=file_path)
724
809
 
725
810
  if dry_run:
@@ -743,12 +828,18 @@ def fix_html_files_in_directory(directory: str, resize=False, dry_run=False, ski
743
828
  print(f"{stats['dropdown_items']} dropdown-items, ", end="")
744
829
  if stats["panel_classes"]:
745
830
  print(f"{stats['panel_classes']} panel replacements, ", end="")
831
+ if stats["grid_breakpoints"]:
832
+ print(f"{stats['grid_breakpoints']} grid breakpoint replacements, ", end="")
746
833
  print()
747
834
 
748
835
  if stats.get("manual_nav_template_lines"):
749
836
  print(" !!! Manual review needed for nav-item fixes at:")
750
837
  for line in stats["manual_nav_template_lines"]:
751
838
  print(f" - {line}")
839
+ if stats.get("manual_grid_template_lines"):
840
+ print(" !!! Manual review needed for non-standard grid breakpoints at:")
841
+ for line in stats["manual_grid_template_lines"]:
842
+ print(f" - {line}")
752
843
  for k, v in stats.items():
753
844
  if k in totals:
754
845
  totals[k] += v
@@ -765,9 +856,7 @@ def fix_html_files_in_directory(directory: str, resize=False, dry_run=False, ski
765
856
  print(f"- <li> in <ul.nav-tabs>: {totals['nav_items']}")
766
857
  print(f"- <a> in <ul.dropdown-menu>: {totals['dropdown_items']}")
767
858
  print(f"- Panel class replacements: {totals['panel_classes']}")
768
- print(f"- Resizing breakpoint xs: {totals['resizing_xs']}")
769
- print("-------------------------------------")
770
- print(f"- Resizing other breakpoints: {resizing_other}")
859
+ print(f"- Grid breakpoint resizes: {totals['grid_breakpoints']}")
771
860
  print("-------------------------------------")
772
861
  print(f"- Deprecated templates replaced: {templates_replaced}")
773
862
 
@@ -824,12 +913,6 @@ def check_python_files_for_legacy_html(directory: str):
824
913
 
825
914
  def main():
826
915
  parser = argparse.ArgumentParser(description="Bootstrap 3 to 5 HTML fixer.")
827
- parser.add_argument(
828
- "-r",
829
- "--resize",
830
- action="store_true",
831
- help="Change column breakpoints to be one level higher, such as 'col-xs-*' to 'col-sm-*'",
832
- )
833
916
  parser.add_argument(
834
917
  "-d",
835
918
  "--dry-run",
@@ -848,9 +931,7 @@ def main():
848
931
  if args.check_python_files:
849
932
  exit_code = check_python_files_for_legacy_html(args.path)
850
933
  if not args.no_fix_html_templates:
851
- fix_html_files_in_directory(
852
- args.path, resize=args.resize, dry_run=args.dry_run, skip_templates=args.skip_template_replacement
853
- )
934
+ fix_html_files_in_directory(args.path, dry_run=args.dry_run, skip_templates=args.skip_template_replacement)
854
935
 
855
936
  return exit_code
856
937
 
@@ -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}"):
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."
@@ -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>
@@ -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
 
@@ -65,8 +65,8 @@ class FilterTestCases:
65
65
 
66
66
  if len(test_values) < 2:
67
67
  raise ValueError(
68
- f"Cannot find enough valid test data for {queryset.model._meta.object_name} field {field_name} "
69
- f"(found {len(test_values)} option(s): {test_values}) but need at least 2 of them"
68
+ f"Cannot find enough valid test data for {queryset.model._meta.object_name} field {field_name}. "
69
+ "At least 3 unique values are required to test multivalue filters."
70
70
  )
71
71
  return test_values
72
72
 
@@ -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)
@@ -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"