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
@@ -4,6 +4,7 @@
4
4
  {% load helpers %}
5
5
  {% load perms %}
6
6
  {% load plugins %}
7
+ {% load tz %}
7
8
 
8
9
  {% block buttons %}
9
10
  {% plugin_buttons object %}
@@ -94,13 +95,23 @@
94
95
  <tr>
95
96
  <td>Start Time</td>
96
97
  <td>
97
- {{ object.start_time }}
98
+ {{ object.start_time|timezone:object.time_zone|date:'Y-m-d H:i:s T' }}
99
+ {% if default_time_zone != object.time_zone %}
100
+ <br>{{ object.start_time|timezone:default_time_zone|date:'Y-m-d H:i:s T' }}
101
+ {% endif %}
98
102
  </td>
99
103
  </tr>
100
104
  <tr>
101
105
  <td>Last Run At</td>
102
106
  <td>
103
- {{ object.last_run_at|placeholder }}
107
+ {% if object.last_run_at %}
108
+ {{ object.last_run_at|timezone:object.time_zone|date:'Y-m-d H:i:s T' }}
109
+ {% if default_time_zone != object.time_zone %}
110
+ <br>{{ object.last_run_at|timezone:default_time_zone|date:'Y-m-d H:i:s T' }}
111
+ {% endif %}
112
+ {% else %}
113
+ {{ object.last_run_at|placeholder }}
114
+ {% endif %}
104
115
  </td>
105
116
  </tr>
106
117
  <tr>
@@ -12,6 +12,11 @@ from django.urls import reverse
12
12
  from django.utils.timezone import make_aware, now
13
13
  from rest_framework import status
14
14
 
15
+ try:
16
+ from zoneinfo import ZoneInfo
17
+ except ImportError: # Python 3.8
18
+ from backports.zoneinfo import ZoneInfo
19
+
15
20
  from nautobot.core.choices import ColorChoices
16
21
  from nautobot.core.models.fields import slugify_dashes_to_underscores
17
22
  from nautobot.core.testing import APITestCase, APIViewTestCases
@@ -1583,7 +1588,7 @@ class JobTest(
1583
1588
  "schedule": {
1584
1589
  "name": "test",
1585
1590
  "interval": "future",
1586
- "start_time": str(datetime.now() + timedelta(minutes=1)),
1591
+ "start_time": str(now() + timedelta(minutes=1)),
1587
1592
  },
1588
1593
  }
1589
1594
 
@@ -1780,7 +1785,7 @@ class JobTest(
1780
1785
  "var2": "Ground control to Major Tom",
1781
1786
  "var23": "Commencing countdown, engines on",
1782
1787
  "var1": test_file,
1783
- "_schedule_start_time": str(datetime.now() + timedelta(minutes=1)),
1788
+ "_schedule_start_time": str(now() + timedelta(minutes=1)),
1784
1789
  "_schedule_interval": "future",
1785
1790
  "_schedule_name": "test",
1786
1791
  }
@@ -1799,7 +1804,7 @@ class JobTest(
1799
1804
  data = {
1800
1805
  "data": {"var1": "x", "var2": 1, "var3": False, "var4": d.pk},
1801
1806
  "schedule": {
1802
- "start_time": str(datetime.now() + timedelta(minutes=1)),
1807
+ "start_time": str(now() + timedelta(minutes=1)),
1803
1808
  "interval": "future",
1804
1809
  "name": "test",
1805
1810
  },
@@ -1838,7 +1843,7 @@ class JobTest(
1838
1843
  data = {
1839
1844
  "data": {},
1840
1845
  "schedule": {
1841
- "start_time": str(datetime.now() + timedelta(minutes=1)),
1846
+ "start_time": str(now() + timedelta(minutes=1)),
1842
1847
  "interval": "future",
1843
1848
  "name": "test",
1844
1849
  },
@@ -1914,7 +1919,7 @@ class JobTest(
1914
1919
  data = {
1915
1920
  "data": {"var1": "x", "var2": 1, "var3": False, "var4": d.pk},
1916
1921
  "schedule": {
1917
- "start_time": str(datetime.now() - timedelta(minutes=1)),
1922
+ "start_time": str(now() - timedelta(minutes=1)),
1918
1923
  "interval": "future",
1919
1924
  "name": "test",
1920
1925
  },
@@ -1933,7 +1938,7 @@ class JobTest(
1933
1938
  data = {
1934
1939
  "data": {"var1": "x", "var2": 1, "var3": False, "var4": d.pk},
1935
1940
  "schedule": {
1936
- "start_time": str(datetime.now() + timedelta(minutes=1)),
1941
+ "start_time": str(now() + timedelta(minutes=1)),
1937
1942
  "interval": "hourly",
1938
1943
  "name": "test",
1939
1944
  },
@@ -2438,30 +2443,24 @@ class ScheduledJobTest(
2438
2443
  name="test2",
2439
2444
  task="pass.TestPass",
2440
2445
  job_model=job_model,
2441
- interval=JobExecutionType.TYPE_IMMEDIATELY,
2446
+ interval=JobExecutionType.TYPE_DAILY,
2442
2447
  user=user,
2443
2448
  approval_required=True,
2444
- start_time=now(),
2449
+ start_time=datetime(2020, 1, 23, 12, 34, 56, tzinfo=ZoneInfo("America/New_York")),
2450
+ time_zone=ZoneInfo("America/New_York"),
2445
2451
  )
2446
2452
  ScheduledJob.objects.create(
2447
2453
  name="test3",
2448
2454
  task="pass.TestPass",
2449
2455
  job_model=job_model,
2450
- interval=JobExecutionType.TYPE_IMMEDIATELY,
2456
+ interval=JobExecutionType.TYPE_CUSTOM,
2457
+ crontab="34 12 * * *",
2458
+ enabled=False,
2451
2459
  user=user,
2452
2460
  approval_required=True,
2453
2461
  start_time=now(),
2454
2462
  )
2455
2463
 
2456
- # TODO: Unskip after resolving #2908, #2909
2457
- @skip("DRF's built-in OrderingFilter triggering natural key attribute error in our base")
2458
- def test_list_objects_ascending_ordered(self):
2459
- pass
2460
-
2461
- @skip("DRF's built-in OrderingFilter triggering natural key attribute error in our base")
2462
- def test_list_objects_descending_ordered(self):
2463
- pass
2464
-
2465
2464
 
2466
2465
  class JobApprovalTest(APITestCase):
2467
2466
  @classmethod
@@ -1,3 +1,4 @@
1
+ from datetime import datetime
1
2
  import uuid
2
3
 
3
4
  from django.contrib.auth import get_user_model
@@ -5,6 +6,12 @@ from django.contrib.contenttypes.models import ContentType
5
6
  from django.core.files.uploadedfile import SimpleUploadedFile
6
7
  from django.db.models import Q
7
8
  from django.test import override_settings, RequestFactory
9
+ from django.utils.timezone import now
10
+
11
+ try:
12
+ from zoneinfo import ZoneInfo
13
+ except ImportError: # Python 3.8
14
+ from backports.zoneinfo import ZoneInfo
8
15
 
9
16
  from nautobot.core.choices import ColorChoices
10
17
  from nautobot.core.testing import FilterTestCases
@@ -22,6 +29,7 @@ from nautobot.dcim.models import (
22
29
  from nautobot.extras.choices import (
23
30
  CustomFieldTypeChoices,
24
31
  DynamicGroupTypeChoices,
32
+ JobExecutionType,
25
33
  JobResultStatusChoices,
26
34
  MetadataTypeDataTypeChoices,
27
35
  ObjectChangeActionChoices,
@@ -93,6 +101,7 @@ from nautobot.extras.models import (
93
101
  RelationshipAssociation,
94
102
  Role,
95
103
  SavedView,
104
+ ScheduledJob,
96
105
  Secret,
97
106
  SecretsGroup,
98
107
  SecretsGroupAssociation,
@@ -1124,13 +1133,49 @@ class JobResultFilterSetTestCase(FilterTestCases.FilterTestCase):
1124
1133
  def setUpTestData(cls):
1125
1134
  jobs = Job.objects.all()[:3]
1126
1135
  cls.jobs = jobs
1136
+ user = User.objects.create(username="user1", is_active=True)
1137
+ job_model = Job.objects.get_for_class_path("pass.TestPass")
1138
+ scheduled_jobs = [
1139
+ ScheduledJob.objects.create(
1140
+ name="test1",
1141
+ task="pass.TestPass",
1142
+ job_model=job_model,
1143
+ interval=JobExecutionType.TYPE_IMMEDIATELY,
1144
+ user=user,
1145
+ approval_required=True,
1146
+ start_time=now(),
1147
+ ),
1148
+ ScheduledJob.objects.create(
1149
+ name="test2",
1150
+ task="pass.TestPass",
1151
+ job_model=job_model,
1152
+ interval=JobExecutionType.TYPE_DAILY,
1153
+ user=user,
1154
+ approval_required=True,
1155
+ start_time=datetime(2020, 1, 23, 12, 34, 56, tzinfo=ZoneInfo("America/New_York")),
1156
+ time_zone=ZoneInfo("America/New_York"),
1157
+ ),
1158
+ ScheduledJob.objects.create(
1159
+ name="test3",
1160
+ task="pass.TestPass",
1161
+ job_model=job_model,
1162
+ interval=JobExecutionType.TYPE_CUSTOM,
1163
+ crontab="34 12 * * *",
1164
+ enabled=False,
1165
+ user=user,
1166
+ approval_required=True,
1167
+ start_time=now(),
1168
+ ),
1169
+ ]
1170
+ cls.scheduled_jobs = scheduled_jobs
1127
1171
  user = UserFactory.create()
1128
- for job in jobs:
1172
+ for idx, job in enumerate(jobs):
1129
1173
  JobResult.objects.create(
1130
1174
  job_model=job,
1131
1175
  name=job.class_path,
1132
1176
  user=user,
1133
1177
  status=JobResultStatusChoices.STATUS_STARTED,
1178
+ scheduled_job=scheduled_jobs[idx],
1134
1179
  )
1135
1180
 
1136
1181
  def test_job_model(self):
@@ -1144,6 +1189,17 @@ class JobResultFilterSetTestCase(FilterTestCases.FilterTestCase):
1144
1189
  self.filterset(params, self.queryset).qs, self.queryset.filter(job_model__in=jobs).distinct()
1145
1190
  )
1146
1191
 
1192
+ def test_scheduled_job(self):
1193
+ scheduled_jobs = list(self.scheduled_jobs[:2])
1194
+ filter_params = [
1195
+ {"scheduled_job": [scheduled_jobs[0].pk, scheduled_jobs[1].name]},
1196
+ ]
1197
+ for params in filter_params:
1198
+ self.assertQuerysetEqualAndNotEmpty(
1199
+ self.filterset(params, self.queryset).qs,
1200
+ self.queryset.filter(scheduled_job__in=scheduled_jobs).distinct(),
1201
+ )
1202
+
1147
1203
 
1148
1204
  class JobHookFilterSetTestCase(FilterTestCases.NameOnlyFilterTestCase):
1149
1205
  queryset = JobHook.objects.all()
@@ -14,8 +14,15 @@ from django.db.models import ProtectedError
14
14
  from django.db.utils import IntegrityError
15
15
  from django.test import override_settings
16
16
  from django.test.utils import isolate_apps
17
- from django.utils.timezone import now
17
+ from django.utils.timezone import get_default_timezone, now
18
+ from django_celery_beat.tzcrontab import TzAwareCrontab
18
19
  from jinja2.exceptions import TemplateAssertionError, TemplateSyntaxError
20
+ import time_machine
21
+
22
+ try:
23
+ from zoneinfo import ZoneInfo
24
+ except ImportError: # python 3.8
25
+ from backports.zoneinfo import ZoneInfo
19
26
 
20
27
  from nautobot.circuits.models import CircuitType
21
28
  from nautobot.core.choices import ColorChoices
@@ -30,6 +37,7 @@ from nautobot.dcim.models import (
30
37
  Platform,
31
38
  )
32
39
  from nautobot.extras.choices import (
40
+ JobExecutionType,
33
41
  JobResultStatusChoices,
34
42
  LogLevelChoices,
35
43
  MetadataTypeDataTypeChoices,
@@ -65,6 +73,7 @@ from nautobot.extras.models import (
65
73
  ObjectMetadata,
66
74
  Role,
67
75
  SavedView,
76
+ ScheduledJob,
68
77
  Secret,
69
78
  SecretsGroup,
70
79
  SecretsGroupAssociation,
@@ -1790,6 +1799,295 @@ class SavedViewTest(ModelTestCases.BaseModelTestCase):
1790
1799
  self.assertEqual(self.ipaddress_global_sv.is_shared, True)
1791
1800
 
1792
1801
 
1802
+ @override_settings(TIME_ZONE="UTC")
1803
+ class ScheduledJobTest(ModelTestCases.BaseModelTestCase):
1804
+ """Tests for the `ScheduledJob` model class."""
1805
+
1806
+ model = ScheduledJob
1807
+
1808
+ def setUp(self):
1809
+ self.user = User.objects.create_user(username="scheduledjobuser")
1810
+ self.job_model = JobModel.objects.get(name="TestPass")
1811
+
1812
+ self.daily_utc_job = ScheduledJob.objects.create(
1813
+ name="Daily UTC Job",
1814
+ task="pass.TestPass",
1815
+ job_model=self.job_model,
1816
+ interval=JobExecutionType.TYPE_DAILY,
1817
+ start_time=datetime(year=2050, month=1, day=22, hour=17, minute=0, tzinfo=get_default_timezone()),
1818
+ time_zone=get_default_timezone(),
1819
+ )
1820
+ self.daily_est_job = ScheduledJob.objects.create(
1821
+ name="Daily EST Job",
1822
+ task="pass.TestPass",
1823
+ job_model=self.job_model,
1824
+ interval=JobExecutionType.TYPE_DAILY,
1825
+ start_time=datetime(year=2050, month=1, day=22, hour=17, minute=0, tzinfo=ZoneInfo("America/New_York")),
1826
+ time_zone=ZoneInfo("America/New_York"),
1827
+ )
1828
+ self.crontab_utc_job = ScheduledJob.create_schedule(
1829
+ job_model=self.job_model,
1830
+ user=self.user,
1831
+ name="Crontab UTC Job",
1832
+ interval=JobExecutionType.TYPE_CUSTOM,
1833
+ crontab="0 17 * * *",
1834
+ )
1835
+ self.crontab_est_job = ScheduledJob.objects.create(
1836
+ name="Crontab EST Job",
1837
+ task="pass.TestPass",
1838
+ job_model=self.job_model,
1839
+ interval=JobExecutionType.TYPE_CUSTOM,
1840
+ start_time=datetime(year=2050, month=1, day=22, hour=17, minute=0, tzinfo=ZoneInfo("America/New_York")),
1841
+ time_zone=ZoneInfo("America/New_York"),
1842
+ crontab="0 17 * * *",
1843
+ )
1844
+ self.one_off_utc_job = ScheduledJob.objects.create(
1845
+ name="One-off UTC Job",
1846
+ task="pass.TestPass",
1847
+ job_model=self.job_model,
1848
+ interval=JobExecutionType.TYPE_FUTURE,
1849
+ start_time=datetime(year=2050, month=1, day=22, hour=0, minute=0, tzinfo=ZoneInfo("UTC")),
1850
+ time_zone=ZoneInfo("UTC"),
1851
+ )
1852
+ self.one_off_est_job = ScheduledJob.create_schedule(
1853
+ job_model=self.job_model,
1854
+ user=self.user,
1855
+ name="One-off EST Job",
1856
+ interval=JobExecutionType.TYPE_FUTURE,
1857
+ start_time=datetime(year=2050, month=1, day=22, hour=0, minute=0, tzinfo=ZoneInfo("America/New_York")),
1858
+ )
1859
+
1860
+ def test_schedule(self):
1861
+ """Test the schedule property."""
1862
+ with self.subTest("Test TYPE_DAILY schedules"):
1863
+ daily_utc_schedule = self.daily_utc_job.schedule
1864
+ daily_est_schedule = self.daily_est_job.schedule
1865
+ self.assertIsInstance(daily_utc_schedule, TzAwareCrontab)
1866
+ self.assertIsInstance(daily_est_schedule, TzAwareCrontab)
1867
+ self.assertNotEqual(daily_utc_schedule, daily_est_schedule)
1868
+ # Crontabs are validated in test_to_cron()
1869
+
1870
+ with self.subTest("Test TYPE_CUSTOM schedules"):
1871
+ crontab_utc_schedule = self.crontab_utc_job.schedule
1872
+ crontab_est_schedule = self.crontab_est_job.schedule
1873
+ self.assertIsInstance(crontab_utc_schedule, TzAwareCrontab)
1874
+ self.assertIsInstance(crontab_est_schedule, TzAwareCrontab)
1875
+ self.assertNotEqual(crontab_utc_schedule, crontab_est_schedule)
1876
+ # Crontabs are validated in test_to_cron()
1877
+
1878
+ with self.subTest("Test TYPE_FUTURE schedules"):
1879
+ # TYPE_FUTURE schedules are one off, not cron tabs:
1880
+ self.assertEqual(self.one_off_utc_job.schedule.clocked_time, self.one_off_utc_job.start_time)
1881
+ self.assertEqual(self.one_off_est_job.schedule.clocked_time, self.one_off_est_job.start_time)
1882
+ self.assertEqual(
1883
+ self.one_off_est_job.schedule.clocked_time - self.one_off_utc_job.schedule.clocked_time,
1884
+ timedelta(hours=5),
1885
+ )
1886
+
1887
+ def test_to_cron(self):
1888
+ """Test the to_cron() method and its interaction with time zone variants."""
1889
+
1890
+ with self.subTest("Test TYPE_DAILY schedule with UTC time zone and UTC schedule time zone"):
1891
+ self.daily_utc_job.refresh_from_db()
1892
+ daily_utc_schedule = self.daily_utc_job.to_cron()
1893
+ self.assertEqual(daily_utc_schedule.tz, ZoneInfo("UTC"))
1894
+ self.assertEqual(daily_utc_schedule.hour, {17})
1895
+ self.assertEqual(daily_utc_schedule.minute, {0})
1896
+ last_run = datetime(2050, 1, 21, 17, 0, tzinfo=ZoneInfo("UTC"))
1897
+ with time_machine.travel("2050-01-22 16:59 +0000"):
1898
+ is_due, _ = daily_utc_schedule.is_due(last_run_at=last_run)
1899
+ self.assertFalse(is_due)
1900
+ with time_machine.travel("2050-01-22 17:00 +0000"):
1901
+ is_due, _ = daily_utc_schedule.is_due(last_run_at=last_run)
1902
+ self.assertTrue(is_due)
1903
+
1904
+ with self.subTest("Test TYPE_DAILY schedule with UTC time zone and EST schedule time zone"):
1905
+ self.daily_est_job.refresh_from_db()
1906
+ daily_est_schedule = self.daily_est_job.to_cron()
1907
+ self.assertEqual(daily_est_schedule.tz, ZoneInfo("America/New_York"))
1908
+ self.assertEqual(daily_est_schedule.hour, {17})
1909
+ self.assertEqual(daily_est_schedule.minute, {0})
1910
+ last_run = datetime(2050, 1, 21, 22, 0, tzinfo=ZoneInfo("UTC"))
1911
+ with time_machine.travel("2050-01-22 21:59 +0000"):
1912
+ is_due, _ = daily_est_schedule.is_due(last_run_at=last_run)
1913
+ self.assertFalse(is_due)
1914
+ with time_machine.travel("2050-01-22 22:00 +0000"):
1915
+ is_due, _ = daily_est_schedule.is_due(last_run_at=last_run)
1916
+ self.assertTrue(is_due)
1917
+
1918
+ with self.subTest("Test TYPE_CUSTOM schedule with UTC time zone and UTC schedule time zone"):
1919
+ self.crontab_utc_job.refresh_from_db()
1920
+ crontab_utc_schedule = self.crontab_utc_job.to_cron()
1921
+ self.assertEqual(crontab_utc_schedule.tz, ZoneInfo("UTC"))
1922
+ self.assertEqual(crontab_utc_schedule.hour, {17})
1923
+ self.assertEqual(crontab_utc_schedule.minute, {0})
1924
+
1925
+ with self.subTest("Test TYPE_CUSTOM schedule with UTC time zone and EST schedule time zone"):
1926
+ self.crontab_est_job.refresh_from_db()
1927
+ crontab_est_schedule = self.crontab_est_job.to_cron()
1928
+ self.assertEqual(crontab_est_schedule.tz, ZoneInfo("America/New_York"))
1929
+ self.assertEqual(crontab_est_schedule.hour, {17})
1930
+ self.assertEqual(crontab_est_schedule.minute, {0})
1931
+
1932
+ with self.subTest("Test TYPE_FUTURE schedules do not map to cron"):
1933
+ with self.assertRaises(ValueError):
1934
+ self.one_off_utc_job.to_cron()
1935
+ with self.assertRaises(ValueError):
1936
+ self.one_off_est_job.to_cron()
1937
+
1938
+ with override_settings(TIME_ZONE="America/New_York"):
1939
+ with self.subTest("Test TYPE_DAILY schedule with EST time zone and UTC schedule time zone"):
1940
+ self.daily_utc_job.refresh_from_db()
1941
+ daily_utc_schedule = self.daily_utc_job.to_cron()
1942
+ self.assertEqual(daily_utc_schedule.tz, ZoneInfo("UTC"))
1943
+ self.assertEqual(daily_utc_schedule.hour, {17})
1944
+ self.assertEqual(daily_utc_schedule.minute, {0})
1945
+ last_run = datetime(2050, 1, 21, 12, 0, tzinfo=ZoneInfo("America/New_York"))
1946
+ with time_machine.travel("2050-01-22 11:59 -0500"):
1947
+ is_due, _ = daily_utc_schedule.is_due(last_run_at=last_run)
1948
+ self.assertFalse(is_due)
1949
+ with time_machine.travel("2050-01-22 12:00 -0500"):
1950
+ is_due, _ = daily_utc_schedule.is_due(last_run_at=last_run)
1951
+ self.assertTrue(is_due)
1952
+
1953
+ with self.subTest("Test TYPE_DAILY schedule with EST time zone and EST schedule time zone"):
1954
+ self.daily_est_job.refresh_from_db()
1955
+ daily_est_schedule = self.daily_est_job.to_cron()
1956
+ self.assertEqual(daily_est_schedule.tz, ZoneInfo("America/New_York"))
1957
+ self.assertEqual(daily_est_schedule.hour, {17})
1958
+ self.assertEqual(daily_est_schedule.minute, {0})
1959
+ last_run = datetime(2050, 1, 21, 22, 0, tzinfo=ZoneInfo("America/New_York"))
1960
+ with time_machine.travel("2050-01-22 16:59 -0500"):
1961
+ is_due, _ = daily_est_schedule.is_due(last_run_at=last_run)
1962
+ self.assertFalse(is_due)
1963
+ with time_machine.travel("2050-01-22 17:00 -0500"):
1964
+ is_due, _ = daily_est_schedule.is_due(last_run_at=last_run)
1965
+ self.assertTrue(is_due)
1966
+
1967
+ with self.subTest("Test TYPE_CUSTOM schedule with EST time zone and UTC schedule time zone"):
1968
+ self.crontab_utc_job.refresh_from_db()
1969
+ crontab_utc_schedule = self.crontab_utc_job.to_cron()
1970
+ self.assertEqual(crontab_utc_schedule.tz, ZoneInfo("UTC"))
1971
+ self.assertEqual(crontab_utc_schedule.hour, {17})
1972
+ self.assertEqual(crontab_utc_schedule.minute, {0})
1973
+
1974
+ with self.subTest("Test TYPE_CUSTOM schedule with EST time zone and EST schedule time zone"):
1975
+ self.crontab_est_job.refresh_from_db()
1976
+ crontab_est_schedule = self.crontab_est_job.to_cron()
1977
+ self.assertEqual(crontab_est_schedule.tz, ZoneInfo("America/New_York"))
1978
+ self.assertEqual(crontab_est_schedule.hour, {17})
1979
+ self.assertEqual(crontab_est_schedule.minute, {0})
1980
+
1981
+ def test_crontab_dst(self):
1982
+ """Test that TYPE_CUSTOM behavior around DST is as expected."""
1983
+ cronjob = ScheduledJob.objects.create(
1984
+ name="DST Aware Cronjob",
1985
+ task="pass.TestPass",
1986
+ job_model=self.job_model,
1987
+ enabled=False,
1988
+ interval=JobExecutionType.TYPE_CUSTOM,
1989
+ start_time=datetime(year=2024, month=1, day=1, hour=17, minute=0, tzinfo=ZoneInfo("America/New_York")),
1990
+ crontab="0 17 * * *", # 5 PM local time
1991
+ time_zone=ZoneInfo("America/New_York"),
1992
+ )
1993
+
1994
+ # Before DST takes effect
1995
+ with self.subTest("Test UTC time zone with EST job"):
1996
+ cronjob.refresh_from_db()
1997
+ crontab = cronjob.to_cron()
1998
+ with time_machine.travel("2024-03-09 21:59 +0000"):
1999
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 8, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2000
+ self.assertFalse(is_due)
2001
+ with time_machine.travel("2024-03-09 22:00 +0000"):
2002
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 8, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2003
+ self.assertTrue(is_due)
2004
+
2005
+ with self.subTest("Test EST time zone with EST job"), override_settings(TIME_ZONE="America/New_York"):
2006
+ cronjob.refresh_from_db()
2007
+ crontab = cronjob.to_cron()
2008
+ with time_machine.travel("2024-03-09 16:59 -0500"):
2009
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 8, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2010
+ self.assertFalse(is_due)
2011
+ with time_machine.travel("2024-03-09 17:00 -0500"):
2012
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 8, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2013
+ self.assertTrue(is_due)
2014
+
2015
+ # Day that DST takes effect
2016
+ with self.subTest("Test UTC time zone with EDT job"):
2017
+ cronjob.refresh_from_db()
2018
+ crontab = cronjob.to_cron()
2019
+ with time_machine.travel("2024-03-10 20:59 +0000"):
2020
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 9, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2021
+ self.assertFalse(is_due)
2022
+ with time_machine.travel("2024-03-10 21:00 +0000"):
2023
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 9, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2024
+ self.assertTrue(is_due)
2025
+
2026
+ with self.subTest("Test EDT time zone with EDT job"), override_settings(TIME_ZONE="America/New_York"):
2027
+ cronjob.refresh_from_db()
2028
+ crontab = cronjob.to_cron()
2029
+ with time_machine.travel("2024-03-10 16:59 -0400"):
2030
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 9, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2031
+ self.assertFalse(is_due)
2032
+ with time_machine.travel("2024-03-10 17:00 -0400"):
2033
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 9, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2034
+ self.assertTrue(is_due)
2035
+
2036
+ def test_daily_dst(self):
2037
+ """Test the interaction of TYPE_DAILY around DST."""
2038
+ daily = ScheduledJob.objects.create(
2039
+ name="Daily Job",
2040
+ task="pass.TestPass",
2041
+ job_model=self.job_model,
2042
+ enabled=False,
2043
+ interval=JobExecutionType.TYPE_DAILY,
2044
+ start_time=datetime(year=2024, month=1, day=1, hour=17, minute=0, tzinfo=ZoneInfo("America/New_York")),
2045
+ time_zone=ZoneInfo("America/New_York"),
2046
+ )
2047
+
2048
+ # Before DST takes effect
2049
+ with self.subTest("Test UTC time zone with EST job"):
2050
+ daily.refresh_from_db()
2051
+ crontab = daily.to_cron()
2052
+ with time_machine.travel("2024-03-09 21:59 +0000"):
2053
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 8, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2054
+ self.assertFalse(is_due)
2055
+ with time_machine.travel("2024-03-09 22:00 +0000"):
2056
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 8, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2057
+ self.assertTrue(is_due)
2058
+
2059
+ with self.subTest("Test EST time zone with EST job"), override_settings(TIME_ZONE="America/New_York"):
2060
+ daily.refresh_from_db()
2061
+ crontab = daily.to_cron()
2062
+ with time_machine.travel("2024-03-09 16:59 -0500"):
2063
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 8, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2064
+ self.assertFalse(is_due)
2065
+ with time_machine.travel("2024-03-09 17:00 -0500"):
2066
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 8, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2067
+ self.assertTrue(is_due)
2068
+
2069
+ # Day that DST takes effect
2070
+ with self.subTest("Test UTC time zone with EDT job"):
2071
+ daily.refresh_from_db()
2072
+ crontab = daily.to_cron()
2073
+ with time_machine.travel("2024-03-10 20:59 +0000"):
2074
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 9, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2075
+ self.assertFalse(is_due)
2076
+ with time_machine.travel("2024-03-10 21:00 +0000"):
2077
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 9, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2078
+ self.assertTrue(is_due)
2079
+
2080
+ with self.subTest("Test EDT time zone with EDT job"), override_settings(TIME_ZONE="America/New_York"):
2081
+ daily.refresh_from_db()
2082
+ crontab = daily.to_cron()
2083
+ with time_machine.travel("2024-03-10 16:59 -0400"):
2084
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 9, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2085
+ self.assertFalse(is_due)
2086
+ with time_machine.travel("2024-03-10 17:00 -0400"):
2087
+ is_due, _ = crontab.is_due(last_run_at=datetime(2024, 3, 9, 17, 0, tzinfo=ZoneInfo("America/New_York")))
2088
+ self.assertTrue(is_due)
2089
+
2090
+
1793
2091
  class SecretTest(ModelTestCases.BaseModelTestCase):
1794
2092
  """
1795
2093
  Tests for the `Secret` model class.
@@ -1744,16 +1744,17 @@ class ScheduledJobTestCase(
1744
1744
  ScheduledJob.objects.create(
1745
1745
  name="test2",
1746
1746
  task="pass.TestPass",
1747
- interval=JobExecutionType.TYPE_IMMEDIATELY,
1747
+ interval=JobExecutionType.TYPE_DAILY,
1748
1748
  user=user,
1749
1749
  start_time=timezone.now(),
1750
1750
  )
1751
1751
  ScheduledJob.objects.create(
1752
1752
  name="test3",
1753
1753
  task="pass.TestPass",
1754
- interval=JobExecutionType.TYPE_IMMEDIATELY,
1754
+ interval=JobExecutionType.TYPE_CUSTOM,
1755
1755
  user=user,
1756
1756
  start_time=timezone.now(),
1757
+ crontab="15 10 * * *",
1757
1758
  )
1758
1759
 
1759
1760
  def test_only_enabled_is_listed(self):
nautobot/extras/views.py CHANGED
@@ -2,6 +2,7 @@ import logging
2
2
  from urllib.parse import parse_qs
3
3
 
4
4
  from celery import chain
5
+ from django.conf import settings
5
6
  from django.contrib import messages
6
7
  from django.contrib.auth.models import AnonymousUser
7
8
  from django.contrib.contenttypes.models import ContentType
@@ -23,6 +24,11 @@ from django_tables2 import RequestConfig
23
24
  from jsonschema.validators import Draft7Validator
24
25
  from rest_framework.decorators import action
25
26
 
27
+ try:
28
+ from zoneinfo import ZoneInfo
29
+ except ImportError: # python 3.8
30
+ from backports.zoneinfo import ZoneInfo
31
+
26
32
  from nautobot.core.forms import restrict_form_fields
27
33
  from nautobot.core.models.querysets import count_related
28
34
  from nautobot.core.models.utils import pretty_print_query
@@ -1916,6 +1922,7 @@ class ScheduledJobView(generic.ObjectView):
1916
1922
  return {
1917
1923
  "labels": labels,
1918
1924
  "job_class_found": (job_class is not None),
1925
+ "default_time_zone": ZoneInfo(settings.TIME_ZONE),
1919
1926
  **super().get_extra_context(request, instance),
1920
1927
  }
1921
1928
 
@@ -202,7 +202,7 @@ class PrefixViewSet(NautobotModelViewSet):
202
202
  if requested_prefix["prefix_length"] >= available_prefix.prefixlen:
203
203
  allocated_prefix = f"{available_prefix.network}/{requested_prefix['prefix_length']}"
204
204
  requested_prefix["prefix"] = allocated_prefix
205
- requested_prefix["namespace"] = prefix.namespace.pk
205
+ requested_prefix["namespace"] = prefix.namespace
206
206
  break
207
207
  else:
208
208
  return Response(
@@ -210,6 +210,10 @@ class PrefixViewSet(NautobotModelViewSet):
210
210
  status=status.HTTP_204_NO_CONTENT,
211
211
  )
212
212
 
213
+ # The serializer usage above has mapped "custom_fields" dict to "_custom_field_data".
214
+ # We need to convert it back to "custom_fields" as we're going to deserialize it a second time below
215
+ requested_prefix["custom_fields"] = requested_prefix.pop("_custom_field_data", {})
216
+
213
217
  # Remove the allocated prefix from the list of available prefixes
214
218
  available_prefixes.remove(allocated_prefix)
215
219
 
@@ -299,7 +303,10 @@ class PrefixViewSet(NautobotModelViewSet):
299
303
  prefix_length = prefix.prefix.prefixlen
300
304
  for requested_ip in requested_ips:
301
305
  requested_ip["address"] = f"{next(available_ips)}/{prefix_length}"
302
- requested_ip["namespace"] = prefix.namespace.pk
306
+ requested_ip["namespace"] = prefix.namespace
307
+ # The serializer usage above has mapped "custom_fields" dict to "_custom_field_data".
308
+ # We need to convert it back to "custom_fields" as we're going to deserialize it a second time below
309
+ requested_ip["custom_fields"] = requested_ip.pop("_custom_field_data", {})
303
310
 
304
311
  # Initialize the serializer with a list or a single object depending on what was requested
305
312
  context = {"request": request, "depth": 0}
nautobot/ipam/choices.py CHANGED
@@ -102,6 +102,23 @@ class IPAddressTypeChoices(ChoiceSet):
102
102
  )
103
103
 
104
104
 
105
+ #
106
+ # VRFs
107
+ #
108
+
109
+
110
+ class VRFStatusChoices(ChoiceSet):
111
+ STATUS_ACTIVE = "active"
112
+ STATUS_DOWN = "down"
113
+ STATUS_DEPRECATED = "deprecated"
114
+
115
+ CHOICES = (
116
+ (STATUS_ACTIVE, "Active"),
117
+ (STATUS_DOWN, "Down"),
118
+ (STATUS_DEPRECATED, "Deprecated"),
119
+ )
120
+
121
+
105
122
  #
106
123
  # VLANs
107
124
  #