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.
- nautobot/apps/views.py +2 -0
- nautobot/core/celery/__init__.py +46 -1
- nautobot/core/cli/bootstrap_v3_to_v5.py +125 -44
- nautobot/core/jobs/bulk_actions.py +12 -6
- nautobot/core/settings.py +13 -0
- nautobot/core/settings.yaml +22 -0
- nautobot/core/templates/components/panel/body_wrapper_generic_table.html +1 -1
- nautobot/core/templates/nautobot_config.py.j2 +14 -1
- nautobot/core/templates/redoc_ui.html +3 -0
- nautobot/core/testing/filters.py +2 -2
- nautobot/core/testing/views.py +1 -1
- nautobot/core/tests/test_jobs.py +118 -0
- nautobot/core/tests/test_views.py +24 -0
- nautobot/core/ui/bulk_buttons.py +2 -3
- nautobot/core/ui/object_detail.py +2 -2
- nautobot/core/views/generic.py +1 -0
- nautobot/core/views/mixins.py +6 -7
- nautobot/core/views/renderers.py +1 -0
- nautobot/core/views/utils.py +3 -3
- nautobot/dcim/tables/devices.py +1 -1
- nautobot/dcim/views.py +1 -1
- nautobot/extras/jobs.py +48 -2
- nautobot/extras/models/jobs.py +1 -0
- nautobot/extras/models/models.py +19 -0
- nautobot/extras/tables.py +9 -6
- nautobot/extras/templates/extras/approval_workflow/approve.html +9 -2
- nautobot/extras/templates/extras/approval_workflow/deny.html +9 -3
- nautobot/extras/tests/test_customfields_filters.py +84 -4
- nautobot/extras/tests/test_views.py +40 -4
- nautobot/extras/views.py +63 -38
- nautobot/project-static/dist/css/graphql-libraries.css.map +1 -1
- nautobot/project-static/dist/css/materialdesignicons.css.map +1 -1
- nautobot/project-static/dist/css/nautobot.css +1 -1
- nautobot/project-static/dist/css/nautobot.css.map +1 -1
- nautobot/project-static/dist/js/graphql-libraries.js +1 -1
- nautobot/project-static/dist/js/graphql-libraries.js.map +1 -1
- nautobot/project-static/dist/js/libraries.js.map +1 -1
- nautobot/project-static/dist/js/nautobot-graphiql.js.map +1 -1
- nautobot/project-static/dist/js/nautobot.js.map +1 -1
- nautobot/project-static/img/dark-theme.png +0 -0
- nautobot/project-static/img/light-theme.png +0 -0
- nautobot/project-static/img/system-theme.png +0 -0
- nautobot/ui/package-lock.json +25 -25
- nautobot/ui/package.json +6 -6
- nautobot/ui/src/scss/nautobot.scss +2 -1
- {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/METADATA +6 -6
- {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/RECORD +51 -51
- {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/LICENSE.txt +0 -0
- {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/NOTICE +0 -0
- {nautobot-3.0.0rc2.dist-info → nautobot-3.0.2.dist-info}/WHEEL +0 -0
- {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",
|
nautobot/core/celery/__init__.py
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
"
|
|
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"-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
209
|
+
self, *, content_type, pk_list=None, delete_all=False, filter_query_params=None, saved_view_id=None
|
|
204
210
|
):
|
|
205
|
-
saved_view_id =
|
|
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
|
|
nautobot/core/settings.yaml
CHANGED
|
@@ -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."
|
|
@@ -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
|
|
nautobot/core/testing/filters.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
nautobot/core/testing/views.py
CHANGED
|
@@ -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)
|
nautobot/core/tests/test_jobs.py
CHANGED
|
@@ -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"
|