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.

Files changed (51) hide show
  1. nautobot/core/celery/schedulers.py +18 -0
  2. nautobot/core/settings.yaml +3 -3
  3. nautobot/core/tables.py +1 -1
  4. nautobot/core/templates/home.html +4 -3
  5. nautobot/core/templatetags/buttons.py +1 -1
  6. nautobot/core/views/utils.py +3 -3
  7. nautobot/dcim/factory.py +3 -3
  8. nautobot/dcim/tables/devices.py +7 -7
  9. nautobot/dcim/templates/dcim/device.html +12 -0
  10. nautobot/dcim/templates/dcim/softwareimagefile_retrieve.html +12 -0
  11. nautobot/dcim/utils.py +9 -6
  12. nautobot/extras/api/serializers.py +2 -0
  13. nautobot/extras/filters/__init__.py +14 -2
  14. nautobot/extras/forms/forms.py +6 -0
  15. nautobot/extras/forms/mixins.py +2 -2
  16. nautobot/extras/management/__init__.py +3 -0
  17. nautobot/extras/migrations/0115_scheduledjob_time_zone.py +23 -0
  18. nautobot/extras/models/jobs.py +24 -11
  19. nautobot/extras/tables.py +34 -4
  20. nautobot/extras/templates/extras/scheduledjob.html +13 -2
  21. nautobot/extras/tests/test_api.py +17 -18
  22. nautobot/extras/tests/test_filters.py +57 -1
  23. nautobot/extras/tests/test_models.py +299 -1
  24. nautobot/extras/tests/test_views.py +3 -2
  25. nautobot/extras/views.py +7 -0
  26. nautobot/ipam/api/views.py +9 -2
  27. nautobot/ipam/choices.py +17 -0
  28. nautobot/ipam/factory.py +6 -0
  29. nautobot/ipam/filters.py +1 -1
  30. nautobot/ipam/forms.py +5 -3
  31. nautobot/ipam/migrations/0048_vrf_status.py +23 -0
  32. nautobot/ipam/migrations/0049_vrf_data_migration.py +25 -0
  33. nautobot/ipam/models.py +2 -0
  34. nautobot/ipam/tables.py +3 -2
  35. nautobot/ipam/templates/ipam/vrf.html +4 -0
  36. nautobot/ipam/templates/ipam/vrf_edit.html +1 -0
  37. nautobot/ipam/tests/test_api.py +33 -3
  38. nautobot/ipam/tests/test_views.py +3 -0
  39. nautobot/project-static/css/base.css +6 -0
  40. nautobot/project-static/docs/release-notes/version-2.3.html +163 -33
  41. nautobot/project-static/docs/search/search_index.json +1 -1
  42. nautobot/project-static/docs/sitemap.xml +271 -271
  43. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  44. nautobot/project-static/docs/user-guide/administration/configuration/optional-settings.html +3 -3
  45. nautobot/project-static/js/homepage_layout.js +3 -0
  46. {nautobot-2.3.1.dist-info → nautobot-2.3.2.dist-info}/METADATA +1 -1
  47. {nautobot-2.3.1.dist-info → nautobot-2.3.2.dist-info}/RECORD +51 -48
  48. {nautobot-2.3.1.dist-info → nautobot-2.3.2.dist-info}/LICENSE.txt +0 -0
  49. {nautobot-2.3.1.dist-info → nautobot-2.3.2.dist-info}/NOTICE +0 -0
  50. {nautobot-2.3.1.dist-info → nautobot-2.3.2.dist-info}/WHEEL +0 -0
  51. {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
  """
@@ -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 run in the time zone configured in this setting. If you change this setting from the
1834
- default UTC, you must change it on the Celery Beat server and all Nautobot web servers or your scheduled
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.is_dynamic_group_associable_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"].is_dynamic_group_associable_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"])
@@ -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.is_contact_associable_model:
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.is_dynamic_group_associable_model:
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.is_metadata_associable_model:
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 = {timezone for timezone, _ in TimeZoneFormField().choices}
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(list(unused_models))
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(list(unused_models))
787
+ return factory.random.randgen.choice(sorted(unused_models))
788
788
 
789
789
 
790
790
  class ModuleFactory(PrimaryModelFactory):
@@ -396,7 +396,7 @@ class DeviceModuleConsolePortTable(ConsolePortTable):
396
396
  "actions",
397
397
  )
398
398
  row_attrs = {
399
- "style": cable_status_color_css,
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
- "style": cable_status_color_css,
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 = {"style": cable_status_color_css}
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 = {"style": cable_status_color_css}
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
- "style": cable_status_color_css,
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 = {"style": cable_status_color_css}
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 = {"style": cable_status_color_css}
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.utils.color import hex_to_rgb, lighten_color, rgb_to_hex
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
- # The status colors are for use with labels and such, and tend to be quite bright.
59
- # For this function we want a much milder, mellower color suitable as a row background.
60
- base_color = record.cable.get_status_color().strip("#")
61
- lighter_color = rgb_to_hex(*lighten_color(*hex_to_rgb(base_color), 0.75))
62
- return f"background-color: #{lighter_color}"
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
  #
@@ -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):
@@ -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.is_dynamic_group_associable_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.is_dynamic_group_associable_model:
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
+ ]
@@ -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
- return schedules.crontab(
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.now()
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.now() + timedelta(seconds=20)
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
- t = self.start_time
1174
+ tz = self.time_zone
1175
+ t = self.start_time.astimezone(tz)
1163
1176
  if self.interval == JobExecutionType.TYPE_HOURLY:
1164
- return schedules.crontab(minute=t.minute)
1177
+ return TzAwareCrontab(minute=t.minute, tz=tz)
1165
1178
  elif self.interval == JobExecutionType.TYPE_DAILY:
1166
- return schedules.crontab(minute=t.minute, hour=t.hour)
1179
+ return TzAwareCrontab(minute=t.minute, hour=t.hour, tz=tz)
1167
1180
  elif self.interval == JobExecutionType.TYPE_WEEKLY:
1168
- return schedules.crontab(minute=t.minute, hour=t.hour, day_of_week=t.strftime("%w"))
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.LinkColumn()
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.Column(verbose_name="First Run")
1045
- last_run_at = tables.Column(verbose_name="Most Recent Run")
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 = ("pk", "name", "job_model", "interval", "start_time", "last_run_at")
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):