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.
- nautobot/apps/forms.py +8 -0
- nautobot/apps/templatetags.py +231 -0
- nautobot/apps/testing.py +11 -1
- nautobot/apps/ui.py +21 -1
- nautobot/apps/utils.py +26 -1
- nautobot/core/celery/__init__.py +46 -1
- nautobot/core/cli/bootstrap_v3_to_v5.py +185 -44
- nautobot/core/cli/bootstrap_v3_to_v5_changes.yaml +314 -0
- nautobot/core/graphql/generators.py +2 -2
- nautobot/core/jobs/bulk_actions.py +12 -6
- nautobot/core/jobs/cleanup.py +13 -1
- nautobot/core/settings.py +13 -0
- nautobot/core/settings.yaml +22 -0
- nautobot/core/settings_funcs.py +11 -1
- nautobot/core/tables.py +19 -1
- nautobot/core/templates/components/panel/body_wrapper_generic_table.html +1 -1
- nautobot/core/templates/components/panel/header_extra_content_table.html +9 -3
- nautobot/core/templates/generic/object_create.html +1 -1
- nautobot/core/templates/inc/header.html +9 -10
- nautobot/core/templates/login.html +16 -1
- nautobot/core/templates/nautobot_config.py.j2 +14 -1
- nautobot/core/templates/redoc_ui.html +3 -0
- nautobot/core/templatetags/helpers.py +3 -3
- nautobot/core/testing/views.py +3 -1
- nautobot/core/tests/test_graphql.py +13 -0
- 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/utils/lookup.py +2 -3
- nautobot/core/utils/permissions.py +1 -1
- nautobot/core/views/generic.py +1 -0
- nautobot/core/views/mixins.py +37 -10
- nautobot/core/views/renderers.py +1 -0
- nautobot/core/views/utils.py +3 -3
- nautobot/data_validation/views.py +1 -9
- nautobot/dcim/forms.py +9 -9
- nautobot/dcim/models/devices.py +3 -3
- nautobot/dcim/tables/power.py +3 -0
- nautobot/dcim/templates/dcim/controllermanageddevicegroup_create.html +1 -1
- nautobot/dcim/views.py +30 -44
- nautobot/extras/api/views.py +14 -3
- nautobot/extras/choices.py +3 -0
- nautobot/extras/jobs.py +48 -2
- nautobot/extras/migrations/0132_approval_workflow_seed_data.py +127 -0
- nautobot/extras/models/approvals.py +11 -1
- nautobot/extras/models/models.py +19 -0
- nautobot/extras/models/relationships.py +3 -1
- nautobot/extras/tables.py +35 -18
- nautobot/extras/templates/extras/approval_workflow/approve.html +9 -2
- nautobot/extras/templates/extras/approval_workflow/deny.html +9 -3
- nautobot/extras/templates/extras/approvalworkflowdefinition_update.html +1 -1
- nautobot/extras/templates/extras/customfield_update.html +1 -1
- nautobot/extras/templates/extras/dynamicgroup_update.html +2 -2
- nautobot/extras/templates/extras/inc/approval_buttons_column.html +10 -2
- nautobot/extras/templates/extras/inc/job_tiles.html +2 -2
- nautobot/extras/templates/extras/inc/jobresult.html +1 -1
- nautobot/extras/templates/extras/metadatatype_create.html +1 -1
- nautobot/extras/templates/extras/object_approvalworkflow.html +2 -3
- nautobot/extras/templates/extras/secretsgroup_update.html +1 -1
- nautobot/extras/tests/test_api.py +57 -3
- nautobot/extras/tests/test_customfields_filters.py +84 -4
- nautobot/extras/tests/test_views.py +323 -6
- nautobot/extras/views.py +114 -39
- nautobot/ipam/constants.py +2 -2
- nautobot/ipam/tables.py +7 -6
- nautobot/load_balancers/constants.py +6 -0
- nautobot/load_balancers/migrations/0001_initial.py +14 -3
- nautobot/load_balancers/models.py +5 -4
- nautobot/load_balancers/tables.py +5 -0
- 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 +1 -1
- nautobot/project-static/dist/js/libraries.js.LICENSE.txt +38 -2
- nautobot/project-static/dist/js/libraries.js.map +1 -1
- nautobot/project-static/dist/js/nautobot-graphiql.js +1 -1
- nautobot/project-static/dist/js/nautobot-graphiql.js.map +1 -1
- nautobot/project-static/dist/js/nautobot.js +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/project-static/js/forms.js +1 -85
- nautobot/tenancy/tables.py +3 -2
- nautobot/tenancy/views.py +3 -2
- nautobot/ui/package-lock.json +553 -569
- nautobot/ui/package.json +10 -10
- nautobot/ui/src/js/checkbox.js +132 -0
- nautobot/ui/src/js/nautobot.js +6 -0
- nautobot/ui/src/js/select2.js +69 -73
- nautobot/ui/src/js/theme.js +129 -39
- nautobot/ui/src/scss/nautobot.scss +11 -1
- nautobot/vpn/templates/vpn/vpnprofile_create.html +2 -2
- nautobot/wireless/filters.py +15 -1
- nautobot/wireless/tables.py +18 -14
- nautobot/wireless/templates/wireless/wirelessnetwork_create.html +1 -1
- {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/METADATA +2 -2
- {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/RECORD +103 -98
- {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/LICENSE.txt +0 -0
- {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/NOTICE +0 -0
- {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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/jobs/cleanup.py
CHANGED
|
@@ -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
|
|
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."
|
nautobot/core/settings_funcs.py
CHANGED
|
@@ -123,7 +123,17 @@ def setup_structlog_logging(
|
|
|
123
123
|
return
|
|
124
124
|
|
|
125
125
|
django_apps.append("django_structlog")
|
|
126
|
-
|
|
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
|
|
|
@@ -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">
|
|
6
|
-
{%
|
|
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="
|
|
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 %}
|
|
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:
|
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)
|
|
@@ -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"""
|
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"
|
|
@@ -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)
|
nautobot/core/ui/bulk_buttons.py
CHANGED
|
@@ -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
|
-
|
|
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,
|
nautobot/core/utils/lookup.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
85
|
+
(Q): QuerySet filter constructed from the given constraints, possibly empty.
|
|
86
86
|
"""
|
|
87
87
|
if tokens is None:
|
|
88
88
|
tokens = {}
|
nautobot/core/views/generic.py
CHANGED
|
@@ -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
|