nautobot 2.3.1__py3-none-any.whl → 2.3.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/core/celery/schedulers.py +18 -0
- nautobot/core/settings.yaml +3 -3
- nautobot/core/tables.py +1 -1
- nautobot/core/templates/home.html +4 -3
- nautobot/core/templatetags/buttons.py +1 -1
- nautobot/core/views/utils.py +3 -3
- nautobot/dcim/factory.py +3 -3
- nautobot/dcim/tables/devices.py +7 -7
- nautobot/dcim/templates/dcim/device.html +12 -0
- nautobot/dcim/templates/dcim/softwareimagefile_retrieve.html +12 -0
- nautobot/dcim/utils.py +9 -6
- nautobot/extras/api/serializers.py +2 -0
- nautobot/extras/filters/__init__.py +14 -2
- nautobot/extras/forms/forms.py +6 -0
- nautobot/extras/forms/mixins.py +2 -2
- nautobot/extras/management/__init__.py +3 -0
- nautobot/extras/migrations/0115_scheduledjob_time_zone.py +23 -0
- nautobot/extras/models/jobs.py +24 -11
- nautobot/extras/tables.py +34 -4
- nautobot/extras/templates/extras/scheduledjob.html +13 -2
- nautobot/extras/tests/test_api.py +17 -18
- nautobot/extras/tests/test_filters.py +57 -1
- nautobot/extras/tests/test_models.py +299 -1
- nautobot/extras/tests/test_views.py +3 -2
- nautobot/extras/views.py +7 -0
- nautobot/ipam/api/views.py +9 -2
- nautobot/ipam/choices.py +17 -0
- nautobot/ipam/factory.py +6 -0
- nautobot/ipam/filters.py +1 -1
- nautobot/ipam/forms.py +5 -3
- nautobot/ipam/migrations/0048_vrf_status.py +23 -0
- nautobot/ipam/migrations/0049_vrf_data_migration.py +25 -0
- nautobot/ipam/models.py +2 -0
- nautobot/ipam/tables.py +3 -2
- nautobot/ipam/templates/ipam/vrf.html +4 -0
- nautobot/ipam/templates/ipam/vrf_edit.html +1 -0
- nautobot/ipam/tests/test_api.py +33 -3
- nautobot/ipam/tests/test_views.py +3 -0
- nautobot/project-static/css/base.css +6 -0
- nautobot/project-static/docs/release-notes/version-2.3.html +163 -33
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +271 -271
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/configuration/optional-settings.html +3 -3
- nautobot/project-static/js/homepage_layout.js +3 -0
- {nautobot-2.3.1.dist-info → nautobot-2.3.2.dist-info}/METADATA +1 -1
- {nautobot-2.3.1.dist-info → nautobot-2.3.2.dist-info}/RECORD +51 -48
- {nautobot-2.3.1.dist-info → nautobot-2.3.2.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.3.1.dist-info → nautobot-2.3.2.dist-info}/NOTICE +0 -0
- {nautobot-2.3.1.dist-info → nautobot-2.3.2.dist-info}/WHEEL +0 -0
- {nautobot-2.3.1.dist-info → nautobot-2.3.2.dist-info}/entry_points.txt +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from collections.abc import Mapping
|
|
2
|
+
from datetime import datetime
|
|
2
3
|
import logging
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
@@ -20,7 +21,10 @@ class NautobotScheduleEntry(ModelEntry):
|
|
|
20
21
|
|
|
21
22
|
def __init__(self, model, app=None):
|
|
22
23
|
"""Initialize the model entry."""
|
|
24
|
+
# copy-paste from django_celery_beat.schedulers
|
|
23
25
|
self.app = app or current_app._get_current_object()
|
|
26
|
+
|
|
27
|
+
# Nautobot-specific logic
|
|
24
28
|
self.name = f"{model.name}_{model.pk}"
|
|
25
29
|
self.task = "nautobot.extras.jobs.run_job"
|
|
26
30
|
try:
|
|
@@ -33,6 +37,8 @@ class NautobotScheduleEntry(ModelEntry):
|
|
|
33
37
|
except (TypeError, ValueError) as exc:
|
|
34
38
|
logger.exception("Removing schedule %s for argument deserialization error: %s", self.name, exc)
|
|
35
39
|
self._disable(model)
|
|
40
|
+
|
|
41
|
+
# copy-paste from django_celery_beat.schedulers
|
|
36
42
|
try:
|
|
37
43
|
self.schedule = model.schedule
|
|
38
44
|
except model.DoesNotExist:
|
|
@@ -42,6 +48,7 @@ class NautobotScheduleEntry(ModelEntry):
|
|
|
42
48
|
)
|
|
43
49
|
self._disable(model)
|
|
44
50
|
|
|
51
|
+
# Nautobot-specific logic
|
|
45
52
|
self.options = {"nautobot_job_scheduled_job_id": model.id, "headers": {}}
|
|
46
53
|
|
|
47
54
|
if model.user:
|
|
@@ -65,14 +72,25 @@ class NautobotScheduleEntry(ModelEntry):
|
|
|
65
72
|
if isinstance(model.celery_kwargs, Mapping):
|
|
66
73
|
self.options.update(model.celery_kwargs)
|
|
67
74
|
|
|
75
|
+
# copy-paste from django_celery_beat.schedulers
|
|
68
76
|
self.total_run_count = model.total_run_count
|
|
69
77
|
self.model = model
|
|
70
78
|
|
|
71
79
|
if not model.last_run_at:
|
|
72
80
|
model.last_run_at = self._default_now()
|
|
81
|
+
# if last_run_at is not set and
|
|
82
|
+
# model.start_time last_run_at should be in way past.
|
|
83
|
+
# This will trigger the job to run at start_time
|
|
84
|
+
# and avoid the heap block.
|
|
85
|
+
if model.start_time:
|
|
86
|
+
model.last_run_at = model.last_run_at - datetime.timedelta(days=365 * 30)
|
|
73
87
|
|
|
74
88
|
self.last_run_at = model.last_run_at
|
|
75
89
|
|
|
90
|
+
def _default_now(self):
|
|
91
|
+
"""Instead of using self.app.timezone, use the timezone specific to this schedule entry."""
|
|
92
|
+
return datetime.now(self.model.time_zone)
|
|
93
|
+
|
|
76
94
|
|
|
77
95
|
class NautobotDatabaseScheduler(DatabaseScheduler):
|
|
78
96
|
"""
|
nautobot/core/settings.yaml
CHANGED
|
@@ -1830,9 +1830,9 @@ properties:
|
|
|
1830
1830
|
The time zone Nautobot will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
|
|
1831
1831
|
details: |-
|
|
1832
1832
|
!!! warning
|
|
1833
|
-
Scheduled jobs will
|
|
1834
|
-
default UTC,
|
|
1835
|
-
jobs may run in the wrong time zone.
|
|
1833
|
+
Scheduled jobs will default to running in the time zone configured in this setting.
|
|
1834
|
+
If you change this setting from the default UTC, it must be set consistently on the Celery Beat server
|
|
1835
|
+
and all Nautobot web servers, or else your scheduled jobs may run in the wrong time zone.
|
|
1836
1836
|
environment_variable: "NAUTOBOT_TIME_ZONE"
|
|
1837
1837
|
see_also:
|
|
1838
1838
|
"Time Zones documentation": "./time-zones.md"
|
nautobot/core/tables.py
CHANGED
|
@@ -49,7 +49,7 @@ class BaseTable(django_tables2.Table):
|
|
|
49
49
|
# Add custom field columns
|
|
50
50
|
model = self._meta.model
|
|
51
51
|
|
|
52
|
-
if model
|
|
52
|
+
if getattr(model, "is_dynamic_group_associable_model", False):
|
|
53
53
|
self.base_columns["dynamic_group_count"] = LinkedCountColumn(
|
|
54
54
|
viewname="extras:dynamicgroup_list",
|
|
55
55
|
url_params={"member_id": "pk"},
|
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
{% for panel_name, panel_details in registry.homepage_layout.panels.items %}
|
|
40
40
|
{% if request.user|has_one_or_more_perms:panel_details.permissions %}
|
|
41
41
|
<div class="panel panel-default" id="{{ panel_name|slugify }}" style="break-inside: avoid" data-panel-weight="{{ panel_details.weight }}">
|
|
42
|
-
<div class="panel-heading">
|
|
43
|
-
<strong>{{ panel_name }}</strong><span id="toggle-homepanel-{{ panel_name|slugify }}" class="glyphicon glyphicon-chevron-down collapse-icon" type="button" data-toggle="collapse" data-target="#homepanel-{{ panel_name|slugify }}" aria-expanded="false" aria-controls="homepanel-{{ panel_name|slugify }}"></span>
|
|
44
|
-
</div>
|
|
45
42
|
{% with cookie_key='homepanel-'|add:panel_name|slugify %}
|
|
43
|
+
<div class="panel-heading">
|
|
44
|
+
<strong>{{ panel_name }}</strong>
|
|
45
|
+
<span id="collapse-icon-{{ panel_name|slugify }}" class="glyphicon glyphicon-chevron-down collapse-icon{% if request.COOKIES|default:''|get_item:cookie_key|default:'False' == 'False' %} rotated180{% endif %}" type="button" data-toggle="collapse" data-target="#homepanel-{{ panel_name|slugify }}" aria-expanded="false" aria-controls="homepanel-{{ panel_name|slugify }}"></span>
|
|
46
|
+
</div>
|
|
46
47
|
<div class="list-group collapse{% if request.COOKIES|default:''|get_item:cookie_key|default:'False' == 'False' %} in{% endif %} collapsible-div" id="homepanel-{{ panel_name|slugify }}" >
|
|
47
48
|
{% endwith %}
|
|
48
49
|
{% if panel_details.rendered_html %}
|
|
@@ -165,7 +165,7 @@ def consolidate_bulk_action_buttons(context):
|
|
|
165
165
|
|
|
166
166
|
render_edit_button = bool(context["bulk_edit_url"] and context["permissions"]["change"])
|
|
167
167
|
render_static_group_assign_button = bool(
|
|
168
|
-
context["model"]
|
|
168
|
+
getattr(context["model"], "is_dynamic_group_associable_model", False)
|
|
169
169
|
and context["user"].has_perms(["extras.add_staticgroupassociation"])
|
|
170
170
|
)
|
|
171
171
|
render_delete_button = bool(context["bulk_delete_url"] and context["permissions"]["delete"])
|
nautobot/core/views/utils.py
CHANGED
|
@@ -337,7 +337,7 @@ def common_detail_view_context(request, instance):
|
|
|
337
337
|
context["created_by"] = created_by
|
|
338
338
|
context["last_updated_by"] = last_updated_by
|
|
339
339
|
|
|
340
|
-
if instance
|
|
340
|
+
if getattr(instance, "is_contact_associable_model", False):
|
|
341
341
|
paginate = {"paginator_class": EnhancedPaginator, "per_page": get_paginate_count(request)}
|
|
342
342
|
associations = instance.associated_contacts.restrict(request.user, "view").order_by("role__name")
|
|
343
343
|
associations_table = AssociatedContactsTable(associations, orderable=False)
|
|
@@ -347,7 +347,7 @@ def common_detail_view_context(request, instance):
|
|
|
347
347
|
else:
|
|
348
348
|
context["associated_contacts_table"] = None
|
|
349
349
|
|
|
350
|
-
if instance
|
|
350
|
+
if getattr(instance, "is_dynamic_group_associable_model", False):
|
|
351
351
|
paginate = {"paginator_class": EnhancedPaginator, "per_page": get_paginate_count(request)}
|
|
352
352
|
dynamic_groups = instance.dynamic_groups.restrict(request.user, "view")
|
|
353
353
|
dynamic_groups_table = DynamicGroupTable(dynamic_groups, orderable=False)
|
|
@@ -358,7 +358,7 @@ def common_detail_view_context(request, instance):
|
|
|
358
358
|
else:
|
|
359
359
|
context["associated_dynamic_groups_table"] = None
|
|
360
360
|
|
|
361
|
-
if instance
|
|
361
|
+
if getattr(instance, "is_metadata_associable_model", False):
|
|
362
362
|
paginate = {"paginator_class": EnhancedPaginator, "per_page": get_paginate_count(request)}
|
|
363
363
|
object_metadata = instance.associated_object_metadata.restrict(request.user, "view").order_by(
|
|
364
364
|
"metadata_type", "scoped_fields"
|
nautobot/dcim/factory.py
CHANGED
|
@@ -110,7 +110,7 @@ NETWORK_DRIVERS = {
|
|
|
110
110
|
"Palo Alto": ["paloalto_panos"],
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
TIME_ZONES =
|
|
113
|
+
TIME_ZONES = sorted(timezone for timezone, _ in TimeZoneFormField().choices)
|
|
114
114
|
|
|
115
115
|
|
|
116
116
|
# Retrieve correct rack reservation units
|
|
@@ -296,7 +296,7 @@ class DeviceTypeFactory(PrimaryModelFactory):
|
|
|
296
296
|
while not unused_models:
|
|
297
297
|
unused_models = {f"{device_type} {count}" for device_type in device_types}.difference(current_models)
|
|
298
298
|
count += 1
|
|
299
|
-
return factory.random.randgen.choice(
|
|
299
|
+
return factory.random.randgen.choice(sorted(unused_models))
|
|
300
300
|
|
|
301
301
|
has_part_number = NautobotBoolIterator()
|
|
302
302
|
part_number = factory.Maybe("has_part_number", factory.Faker("ean", length=8), "")
|
|
@@ -784,7 +784,7 @@ class ModuleTypeFactory(PrimaryModelFactory):
|
|
|
784
784
|
while not unused_models:
|
|
785
785
|
unused_models = {f"{module_type} {count}" for module_type in module_types}.difference(current_models)
|
|
786
786
|
count += 1
|
|
787
|
-
return factory.random.randgen.choice(
|
|
787
|
+
return factory.random.randgen.choice(sorted(unused_models))
|
|
788
788
|
|
|
789
789
|
|
|
790
790
|
class ModuleFactory(PrimaryModelFactory):
|
nautobot/dcim/tables/devices.py
CHANGED
|
@@ -396,7 +396,7 @@ class DeviceModuleConsolePortTable(ConsolePortTable):
|
|
|
396
396
|
"actions",
|
|
397
397
|
)
|
|
398
398
|
row_attrs = {
|
|
399
|
-
"
|
|
399
|
+
"class": cable_status_color_css,
|
|
400
400
|
}
|
|
401
401
|
|
|
402
402
|
|
|
@@ -460,7 +460,7 @@ class DeviceModuleConsoleServerPortTable(ConsoleServerPortTable):
|
|
|
460
460
|
"actions",
|
|
461
461
|
)
|
|
462
462
|
row_attrs = {
|
|
463
|
-
"
|
|
463
|
+
"class": cable_status_color_css,
|
|
464
464
|
}
|
|
465
465
|
|
|
466
466
|
|
|
@@ -535,7 +535,7 @@ class DeviceModulePowerPortTable(PowerPortTable):
|
|
|
535
535
|
"connection",
|
|
536
536
|
"actions",
|
|
537
537
|
)
|
|
538
|
-
row_attrs = {"
|
|
538
|
+
row_attrs = {"class": cable_status_color_css}
|
|
539
539
|
|
|
540
540
|
|
|
541
541
|
class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
|
|
@@ -613,7 +613,7 @@ class DeviceModulePowerOutletTable(PowerOutletTable):
|
|
|
613
613
|
"connection",
|
|
614
614
|
"actions",
|
|
615
615
|
)
|
|
616
|
-
row_attrs = {"
|
|
616
|
+
row_attrs = {"class": cable_status_color_css}
|
|
617
617
|
|
|
618
618
|
|
|
619
619
|
class BaseInterfaceTable(BaseTable):
|
|
@@ -739,7 +739,7 @@ class DeviceModuleInterfaceTable(InterfaceTable):
|
|
|
739
739
|
"actions",
|
|
740
740
|
]
|
|
741
741
|
row_attrs = {
|
|
742
|
-
"
|
|
742
|
+
"class": cable_status_color_css,
|
|
743
743
|
"data-name": lambda record: record.name,
|
|
744
744
|
}
|
|
745
745
|
|
|
@@ -815,7 +815,7 @@ class DeviceModuleFrontPortTable(FrontPortTable):
|
|
|
815
815
|
"cable_peer",
|
|
816
816
|
"actions",
|
|
817
817
|
)
|
|
818
|
-
row_attrs = {"
|
|
818
|
+
row_attrs = {"class": cable_status_color_css}
|
|
819
819
|
|
|
820
820
|
|
|
821
821
|
class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
|
|
@@ -874,7 +874,7 @@ class DeviceModuleRearPortTable(RearPortTable):
|
|
|
874
874
|
"cable_peer",
|
|
875
875
|
"actions",
|
|
876
876
|
)
|
|
877
|
-
row_attrs = {"
|
|
877
|
+
row_attrs = {"class": cable_status_color_css}
|
|
878
878
|
|
|
879
879
|
|
|
880
880
|
class DeviceBayTable(DeviceComponentTable):
|
|
@@ -406,6 +406,18 @@
|
|
|
406
406
|
{% endif %}
|
|
407
407
|
{% if object.is_dynamic_group_associable_model and perms.extras.view_dynamicgroup %}
|
|
408
408
|
<div id="dynamic_groups" role="tabpanel" class="tab-pane {% if request.GET.tab == 'dynamic_groups' %}active{% else %}fade{% endif %}">
|
|
409
|
+
<div class="row">
|
|
410
|
+
<div class="col-md-12">
|
|
411
|
+
<div class="alert alert-warning">
|
|
412
|
+
Dynamic group membership is cached for performance reasons,
|
|
413
|
+
therefore this table may not always be up-to-date.
|
|
414
|
+
<br>You can refresh the membership of any specific group by navigating to it from the list below
|
|
415
|
+
or from the <a href="{% url 'extras:dynamicgroup_list' %}">Dynamic Groups list view</a>.
|
|
416
|
+
<br>You can also refresh the membership of all groups by running the
|
|
417
|
+
<a href="{% url 'extras:job_run_by_class_path' class_path='nautobot.core.jobs.groups.RefreshDynamicGroupCaches' %}">Refresh Dynamic Group Caches job</a>.
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
409
421
|
<div class="row">
|
|
410
422
|
<div class="col-md-12">
|
|
411
423
|
<form method="post">
|
|
@@ -215,6 +215,18 @@
|
|
|
215
215
|
{% endif %}
|
|
216
216
|
{% if object.is_dynamic_group_associable_model and perms.extras.view_dynamicgroup %}
|
|
217
217
|
<div id="dynamic_groups" role="tabpanel" class="tab-pane {% if request.GET.tab == 'dynamic_groups' %}active{% else %}fade{% endif %}">
|
|
218
|
+
<div class="row">
|
|
219
|
+
<div class="col-md-12">
|
|
220
|
+
<div class="alert alert-warning">
|
|
221
|
+
Dynamic group membership is cached for performance reasons,
|
|
222
|
+
therefore this table may not always be up-to-date.
|
|
223
|
+
<br>You can refresh the membership of any specific group by navigating to it from the list below
|
|
224
|
+
or from the <a href="{% url 'extras:dynamicgroup_list' %}">Dynamic Groups list view</a>.
|
|
225
|
+
<br>You can also refresh the membership of all groups by running the
|
|
226
|
+
<a href="{% url 'extras:job_run_by_class_path' class_path='nautobot.core.jobs.groups.RefreshDynamicGroupCaches' %}">Refresh Dynamic Group Caches job</a>.
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
218
230
|
<div class="row">
|
|
219
231
|
<div class="col-md-12">
|
|
220
232
|
<form method="post">
|
nautobot/dcim/utils.py
CHANGED
|
@@ -14,7 +14,7 @@ from netutils.lib_mapper import (
|
|
|
14
14
|
SCRAPLI_LIB_MAPPER_REVERSE,
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
from nautobot.core.
|
|
17
|
+
from nautobot.core.choices import ColorChoices
|
|
18
18
|
from nautobot.core.utils.config import get_settings_or_config
|
|
19
19
|
from nautobot.dcim.choices import InterfaceModeChoices
|
|
20
20
|
from nautobot.dcim.constants import NETUTILS_NETWORK_DRIVER_MAPPING_NAMES
|
|
@@ -55,11 +55,14 @@ def cable_status_color_css(record):
|
|
|
55
55
|
"""
|
|
56
56
|
if not record.cable:
|
|
57
57
|
return ""
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
else:
|
|
59
|
+
CABLE_STATUS_TO_CSS_CLASS = {
|
|
60
|
+
ColorChoices.COLOR_GREEN: "success",
|
|
61
|
+
ColorChoices.COLOR_AMBER: "warning",
|
|
62
|
+
ColorChoices.COLOR_CYAN: "info",
|
|
63
|
+
}
|
|
64
|
+
status_color = record.cable.get_status_color().strip("#")
|
|
65
|
+
return CABLE_STATUS_TO_CSS_CLASS.get(status_color, "")
|
|
63
66
|
|
|
64
67
|
|
|
65
68
|
def get_network_driver_mapping_tool_names():
|
|
@@ -6,6 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
|
|
6
6
|
from drf_spectacular.utils import extend_schema_field
|
|
7
7
|
from rest_framework import serializers
|
|
8
8
|
from rest_framework.validators import UniqueTogetherValidator
|
|
9
|
+
from timezone_field.rest_framework import TimeZoneSerializerField
|
|
9
10
|
|
|
10
11
|
from nautobot.core.api import (
|
|
11
12
|
BaseModelSerializer,
|
|
@@ -581,6 +582,7 @@ class JobVariableSerializer(serializers.Serializer):
|
|
|
581
582
|
|
|
582
583
|
class ScheduledJobSerializer(BaseModelSerializer):
|
|
583
584
|
# start_time = serializers.DateTimeField(format=None, required=False)
|
|
585
|
+
time_zone = TimeZoneSerializerField(required=False)
|
|
584
586
|
|
|
585
587
|
class Meta:
|
|
586
588
|
model = ScheduledJob
|
|
@@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
|
|
|
6
6
|
from django.db.models import Q
|
|
7
7
|
import django_filters
|
|
8
8
|
from drf_spectacular.utils import extend_schema_field
|
|
9
|
+
from timezone_field import TimeZoneField
|
|
9
10
|
|
|
10
11
|
from nautobot.core.api.exceptions import SerializerNotFound
|
|
11
12
|
from nautobot.core.api.utils import get_serializer_for_model
|
|
@@ -911,6 +912,7 @@ class JobResultFilterSet(BaseFilterSet, CustomFieldModelFilterSetMixin):
|
|
|
911
912
|
"job_model__name": "icontains",
|
|
912
913
|
"name": "icontains",
|
|
913
914
|
"user__username": "icontains",
|
|
915
|
+
"scheduled_job__name": "icontains",
|
|
914
916
|
},
|
|
915
917
|
)
|
|
916
918
|
job_model = NaturalKeyOrPKMultipleChoiceFilter(
|
|
@@ -922,11 +924,16 @@ class JobResultFilterSet(BaseFilterSet, CustomFieldModelFilterSetMixin):
|
|
|
922
924
|
queryset=Job.objects.all(),
|
|
923
925
|
label="Job (ID) - Deprecated (use job_model filter)",
|
|
924
926
|
)
|
|
927
|
+
scheduled_job = NaturalKeyOrPKMultipleChoiceFilter(
|
|
928
|
+
to_field_name="name",
|
|
929
|
+
queryset=ScheduledJob.objects.all(),
|
|
930
|
+
label="Scheduled Job (name or ID)",
|
|
931
|
+
)
|
|
925
932
|
status = django_filters.MultipleChoiceFilter(choices=JobResultStatusChoices, null_value=None)
|
|
926
933
|
|
|
927
934
|
class Meta:
|
|
928
935
|
model = JobResult
|
|
929
|
-
fields = ["id", "date_created", "date_done", "name", "status", "user"]
|
|
936
|
+
fields = ["id", "date_created", "date_done", "name", "status", "user", "scheduled_job"]
|
|
930
937
|
|
|
931
938
|
|
|
932
939
|
class JobLogEntryFilterSet(BaseFilterSet):
|
|
@@ -960,10 +967,15 @@ class ScheduledJobFilterSet(BaseFilterSet):
|
|
|
960
967
|
queryset=Job.objects.all(),
|
|
961
968
|
label="Job (ID) - Deprecated (use job_model filter)",
|
|
962
969
|
)
|
|
970
|
+
time_zone = django_filters.MultipleChoiceFilter(
|
|
971
|
+
choices=[(str(obj), name) for obj, name in TimeZoneField().choices],
|
|
972
|
+
label="Time zone",
|
|
973
|
+
null_value="",
|
|
974
|
+
)
|
|
963
975
|
|
|
964
976
|
class Meta:
|
|
965
977
|
model = ScheduledJob
|
|
966
|
-
fields = ["id", "name", "total_run_count", "start_time", "last_run_at"]
|
|
978
|
+
fields = ["id", "name", "total_run_count", "start_time", "last_run_at", "time_zone"]
|
|
967
979
|
|
|
968
980
|
|
|
969
981
|
#
|
nautobot/extras/forms/forms.py
CHANGED
|
@@ -1286,6 +1286,12 @@ class JobResultFilterForm(BootstrapMixin, forms.Form):
|
|
|
1286
1286
|
required=False,
|
|
1287
1287
|
widget=StaticSelect2Multiple(),
|
|
1288
1288
|
)
|
|
1289
|
+
scheduled_job = DynamicModelMultipleChoiceField(
|
|
1290
|
+
label="Scheduled Job",
|
|
1291
|
+
queryset=ScheduledJob.objects.all(),
|
|
1292
|
+
required=False,
|
|
1293
|
+
to_field_name="name",
|
|
1294
|
+
)
|
|
1289
1295
|
|
|
1290
1296
|
|
|
1291
1297
|
class ScheduledJobFilterForm(BootstrapMixin, forms.Form):
|
nautobot/extras/forms/mixins.py
CHANGED
|
@@ -174,7 +174,7 @@ class DynamicGroupModelFormMixin(forms.ModelForm):
|
|
|
174
174
|
|
|
175
175
|
def __init__(self, *args, **kwargs):
|
|
176
176
|
super().__init__(*args, **kwargs)
|
|
177
|
-
if self._meta.model
|
|
177
|
+
if getattr(self._meta.model, "is_dynamic_group_associable_model", False):
|
|
178
178
|
self.fields["dynamic_groups"] = DynamicModelMultipleChoiceField(
|
|
179
179
|
required=False,
|
|
180
180
|
initial=self.instance.dynamic_groups if self.instance else None,
|
|
@@ -193,7 +193,7 @@ class DynamicGroupModelFormMixin(forms.ModelForm):
|
|
|
193
193
|
|
|
194
194
|
def save(self, commit=True):
|
|
195
195
|
obj = super().save(commit=commit)
|
|
196
|
-
if commit and obj
|
|
196
|
+
if commit and getattr(obj, "is_dynamic_group_associable_model", False):
|
|
197
197
|
current_groups = set(obj.dynamic_groups.filter(group_type=DynamicGroupTypeChoices.TYPE_STATIC))
|
|
198
198
|
for dynamic_group in set(self.cleaned_data.get("dynamic_groups")).difference(current_groups):
|
|
199
199
|
dynamic_group.add_members([obj])
|
|
@@ -32,6 +32,7 @@ STATUS_CHOICESET_MAP = {
|
|
|
32
32
|
"ipam.IPAddress": ipam_choices.IPAddressStatusChoices,
|
|
33
33
|
"ipam.Prefix": ipam_choices.PrefixStatusChoices,
|
|
34
34
|
"ipam.VLAN": ipam_choices.VLANStatusChoices,
|
|
35
|
+
"ipam.VRF": ipam_choices.VRFStatusChoices,
|
|
35
36
|
"virtualization.VirtualMachine": vm_choices.VirtualMachineStatusChoices,
|
|
36
37
|
"virtualization.VMInterface": vm_choices.VMInterfaceStatusChoices,
|
|
37
38
|
}
|
|
@@ -48,6 +49,7 @@ STATUS_COLOR_MAP = {
|
|
|
48
49
|
"Decommissioning": ColorChoices.COLOR_AMBER,
|
|
49
50
|
"Deprecated": ColorChoices.COLOR_RED,
|
|
50
51
|
"Deprovisioning": ColorChoices.COLOR_AMBER,
|
|
52
|
+
"Down": ColorChoices.COLOR_AMBER,
|
|
51
53
|
"End-of-Life": ColorChoices.COLOR_RED,
|
|
52
54
|
"Extended Support": ColorChoices.COLOR_CYAN,
|
|
53
55
|
"Failed": ColorChoices.COLOR_RED,
|
|
@@ -76,6 +78,7 @@ STATUS_DESCRIPTION_MAP = {
|
|
|
76
78
|
"Decommissioning": "Unit is being decommissioned",
|
|
77
79
|
"Deprecated": "Unit has been deprecated",
|
|
78
80
|
"Deprovisioning": "Circuit is being deprovisioned",
|
|
81
|
+
"Down": "VRF is down",
|
|
79
82
|
"End-of-Life": "Unit has reached end-of-life",
|
|
80
83
|
"Extended Support": "Software is in extended support",
|
|
81
84
|
"Failed": "Unit has failed",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Generated by Django 4.2.15 on 2024-08-19 13:44
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
from django.utils import timezone
|
|
5
|
+
import timezone_field.fields
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
dependencies = [
|
|
10
|
+
("extras", "0114_computedfield_grouping"),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AddField(
|
|
15
|
+
model_name="scheduledjob",
|
|
16
|
+
name="time_zone",
|
|
17
|
+
field=timezone_field.fields.TimeZoneField(default=timezone.get_default_timezone_name),
|
|
18
|
+
),
|
|
19
|
+
migrations.AlterModelOptions(
|
|
20
|
+
name="scheduledjob",
|
|
21
|
+
options={"ordering": ["name"]},
|
|
22
|
+
),
|
|
23
|
+
]
|
nautobot/extras/models/jobs.py
CHANGED
|
@@ -4,7 +4,6 @@ import contextlib
|
|
|
4
4
|
from datetime import timedelta
|
|
5
5
|
import logging
|
|
6
6
|
|
|
7
|
-
from celery import schedules
|
|
8
7
|
from celery.exceptions import NotRegistered
|
|
9
8
|
from celery.utils.log import get_logger, LoggingProxy
|
|
10
9
|
from django.conf import settings
|
|
@@ -16,7 +15,9 @@ from django.db.models import signals
|
|
|
16
15
|
from django.utils import timezone
|
|
17
16
|
from django.utils.functional import cached_property
|
|
18
17
|
from django_celery_beat.clockedschedule import clocked
|
|
18
|
+
from django_celery_beat.tzcrontab import TzAwareCrontab
|
|
19
19
|
from prometheus_client import Histogram
|
|
20
|
+
from timezone_field import TimeZoneField
|
|
20
21
|
|
|
21
22
|
from nautobot.core.celery import (
|
|
22
23
|
app,
|
|
@@ -935,6 +936,9 @@ class ScheduledJob(BaseModel):
|
|
|
935
936
|
verbose_name="Start Datetime",
|
|
936
937
|
help_text="Datetime when the schedule should begin triggering the task to run",
|
|
937
938
|
)
|
|
939
|
+
# Django always stores DateTimeField as UTC internally, but we want scheduled jobs to respect DST and similar,
|
|
940
|
+
# so we need to store the time zone the job was scheduled under as well.
|
|
941
|
+
time_zone = TimeZoneField(default=timezone.get_default_timezone_name)
|
|
938
942
|
# todoindex:
|
|
939
943
|
enabled = models.BooleanField(
|
|
940
944
|
default=True,
|
|
@@ -1005,12 +1009,15 @@ class ScheduledJob(BaseModel):
|
|
|
1005
1009
|
def __str__(self):
|
|
1006
1010
|
return f"{self.name}: {self.interval}"
|
|
1007
1011
|
|
|
1012
|
+
class Meta:
|
|
1013
|
+
ordering = ["name"]
|
|
1014
|
+
|
|
1008
1015
|
def save(self, *args, **kwargs):
|
|
1009
1016
|
self.queue = self.queue or ""
|
|
1010
1017
|
# make sure non-valid crontab doesn't get saved
|
|
1011
1018
|
if self.interval == JobExecutionType.TYPE_CUSTOM:
|
|
1012
1019
|
try:
|
|
1013
|
-
self.get_crontab(self.crontab)
|
|
1020
|
+
self.get_crontab(self.crontab, tz=self.time_zone)
|
|
1014
1021
|
except Exception as e:
|
|
1015
1022
|
raise ValidationError({"crontab": e})
|
|
1016
1023
|
if not self.enabled:
|
|
@@ -1055,7 +1062,7 @@ class ScheduledJob(BaseModel):
|
|
|
1055
1062
|
return timezone.now() + timedelta(seconds=15)
|
|
1056
1063
|
|
|
1057
1064
|
@classmethod
|
|
1058
|
-
def get_crontab(cls, crontab):
|
|
1065
|
+
def get_crontab(cls, crontab, tz=None):
|
|
1059
1066
|
"""
|
|
1060
1067
|
Wrapper method translates crontab syntax to Celery crontab.
|
|
1061
1068
|
|
|
@@ -1068,13 +1075,17 @@ class ScheduledJob(BaseModel):
|
|
|
1068
1075
|
|
|
1069
1076
|
No support for Last (L), Weekday (W), Number symbol (#), Question mark (?), and special @ strings.
|
|
1070
1077
|
"""
|
|
1078
|
+
if not tz:
|
|
1079
|
+
tz = timezone.get_default_timezone()
|
|
1071
1080
|
minute, hour, day_of_month, month_of_year, day_of_week = crontab.split(" ")
|
|
1072
|
-
|
|
1081
|
+
|
|
1082
|
+
return TzAwareCrontab(
|
|
1073
1083
|
minute=minute,
|
|
1074
1084
|
hour=hour,
|
|
1075
1085
|
day_of_month=day_of_month,
|
|
1076
1086
|
month_of_year=month_of_year,
|
|
1077
1087
|
day_of_week=day_of_week,
|
|
1088
|
+
tz=tz,
|
|
1078
1089
|
)
|
|
1079
1090
|
|
|
1080
1091
|
@classmethod
|
|
@@ -1116,13 +1127,13 @@ class ScheduledJob(BaseModel):
|
|
|
1116
1127
|
"""
|
|
1117
1128
|
|
|
1118
1129
|
if interval == JobExecutionType.TYPE_IMMEDIATELY:
|
|
1119
|
-
start_time = timezone.
|
|
1130
|
+
start_time = timezone.localtime()
|
|
1120
1131
|
name = name or f"{job_model.name} - {start_time}"
|
|
1121
1132
|
elif interval == JobExecutionType.TYPE_CUSTOM:
|
|
1122
1133
|
if start_time is None:
|
|
1123
1134
|
# "start_time" is checked against models.ScheduledJob.earliest_possible_time()
|
|
1124
1135
|
# which returns timezone.now() + timedelta(seconds=15)
|
|
1125
|
-
start_time = timezone.
|
|
1136
|
+
start_time = timezone.localtime() + timedelta(seconds=20)
|
|
1126
1137
|
|
|
1127
1138
|
celery_kwargs = {
|
|
1128
1139
|
"nautobot_job_profile": profile,
|
|
@@ -1145,6 +1156,7 @@ class ScheduledJob(BaseModel):
|
|
|
1145
1156
|
task=job_model.class_path,
|
|
1146
1157
|
job_model=job_model,
|
|
1147
1158
|
start_time=start_time,
|
|
1159
|
+
time_zone=start_time.tzinfo,
|
|
1148
1160
|
description=f"Nautobot job {name} scheduled by {user} for {start_time}",
|
|
1149
1161
|
kwargs=job_kwargs,
|
|
1150
1162
|
celery_kwargs=celery_kwargs,
|
|
@@ -1159,15 +1171,16 @@ class ScheduledJob(BaseModel):
|
|
|
1159
1171
|
return scheduled_job
|
|
1160
1172
|
|
|
1161
1173
|
def to_cron(self):
|
|
1162
|
-
|
|
1174
|
+
tz = self.time_zone
|
|
1175
|
+
t = self.start_time.astimezone(tz)
|
|
1163
1176
|
if self.interval == JobExecutionType.TYPE_HOURLY:
|
|
1164
|
-
return
|
|
1177
|
+
return TzAwareCrontab(minute=t.minute, tz=tz)
|
|
1165
1178
|
elif self.interval == JobExecutionType.TYPE_DAILY:
|
|
1166
|
-
return
|
|
1179
|
+
return TzAwareCrontab(minute=t.minute, hour=t.hour, tz=tz)
|
|
1167
1180
|
elif self.interval == JobExecutionType.TYPE_WEEKLY:
|
|
1168
|
-
return
|
|
1181
|
+
return TzAwareCrontab(minute=t.minute, hour=t.hour, day_of_week=t.strftime("%w"), tz=tz)
|
|
1169
1182
|
elif self.interval == JobExecutionType.TYPE_CUSTOM:
|
|
1170
|
-
return self.get_crontab(self.crontab)
|
|
1183
|
+
return self.get_crontab(self.crontab, tz=tz)
|
|
1171
1184
|
raise ValueError(f"I do not know to convert {self.interval} to a Cronjob!")
|
|
1172
1185
|
|
|
1173
1186
|
|
nautobot/extras/tables.py
CHANGED
|
@@ -110,6 +110,10 @@ JOB_BUTTONS = """
|
|
|
110
110
|
<a href="{% url 'extras:jobresult_list' %}?job_model={{ record.name | urlencode }}" class="btn btn-default btn-xs" title="Job Results"><i class="mdi mdi-format-list-bulleted" aria-hidden="true"></i></a>
|
|
111
111
|
"""
|
|
112
112
|
|
|
113
|
+
SCHEDULED_JOB_BUTTONS = """
|
|
114
|
+
<a href="{% url 'extras:jobresult_list' %}?scheduled_job={{ record.name | urlencode }}" class="btn btn-default btn-xs" title="Job Results"><i class="mdi mdi-format-list-bulleted" aria-hidden="true"></i></a>
|
|
115
|
+
"""
|
|
116
|
+
|
|
113
117
|
OBJECTCHANGE_OBJECT = """
|
|
114
118
|
{% if record.changed_object and record.changed_object.get_absolute_url %}
|
|
115
119
|
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
|
|
@@ -835,6 +839,10 @@ class JobResultTable(BaseTable):
|
|
|
835
839
|
orderable=False,
|
|
836
840
|
attrs={"td": {"class": "text-nowrap report-stats"}},
|
|
837
841
|
)
|
|
842
|
+
scheduled_job = tables.Column(
|
|
843
|
+
linkify=True,
|
|
844
|
+
verbose_name="Scheduled Job",
|
|
845
|
+
)
|
|
838
846
|
actions = tables.TemplateColumn(
|
|
839
847
|
template_code="""
|
|
840
848
|
{% load helpers %}
|
|
@@ -884,6 +892,7 @@ class JobResultTable(BaseTable):
|
|
|
884
892
|
"date_created",
|
|
885
893
|
"name",
|
|
886
894
|
"job_model",
|
|
895
|
+
"scheduled_job",
|
|
887
896
|
"duration",
|
|
888
897
|
"date_done",
|
|
889
898
|
"user",
|
|
@@ -1038,16 +1047,37 @@ class NoteTable(BaseTable):
|
|
|
1038
1047
|
|
|
1039
1048
|
class ScheduledJobTable(BaseTable):
|
|
1040
1049
|
pk = ToggleColumn()
|
|
1041
|
-
name = tables.
|
|
1050
|
+
name = tables.Column(linkify=True)
|
|
1042
1051
|
job_model = tables.Column(verbose_name="Job", linkify=True)
|
|
1043
1052
|
interval = tables.Column(verbose_name="Execution Type")
|
|
1044
|
-
start_time = tables.
|
|
1045
|
-
last_run_at = tables.
|
|
1053
|
+
start_time = tables.DateTimeColumn(verbose_name="First Run", format=settings.SHORT_DATETIME_FORMAT)
|
|
1054
|
+
last_run_at = tables.DateTimeColumn(verbose_name="Most Recent Run", format=settings.SHORT_DATETIME_FORMAT)
|
|
1055
|
+
crontab = tables.Column()
|
|
1046
1056
|
total_run_count = tables.Column(verbose_name="Total Run Count")
|
|
1057
|
+
actions = ButtonsColumn(ScheduledJob, buttons=("delete"), prepend_template=SCHEDULED_JOB_BUTTONS)
|
|
1047
1058
|
|
|
1048
1059
|
class Meta(BaseTable.Meta):
|
|
1049
1060
|
model = ScheduledJob
|
|
1050
|
-
fields = (
|
|
1061
|
+
fields = (
|
|
1062
|
+
"pk",
|
|
1063
|
+
"name",
|
|
1064
|
+
"total_run_count",
|
|
1065
|
+
"job_model",
|
|
1066
|
+
"interval",
|
|
1067
|
+
"start_time",
|
|
1068
|
+
"last_run_at",
|
|
1069
|
+
"crontab",
|
|
1070
|
+
"time_zone",
|
|
1071
|
+
"actions",
|
|
1072
|
+
)
|
|
1073
|
+
default_columns = (
|
|
1074
|
+
"pk",
|
|
1075
|
+
"name",
|
|
1076
|
+
"job_model",
|
|
1077
|
+
"interval",
|
|
1078
|
+
"last_run_at",
|
|
1079
|
+
"actions",
|
|
1080
|
+
)
|
|
1051
1081
|
|
|
1052
1082
|
|
|
1053
1083
|
class ScheduledJobApprovalQueueTable(BaseTable):
|