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
nautobot/ipam/factory.py CHANGED
@@ -84,6 +84,7 @@ class VRFFactory(PrimaryModelFactory):
84
84
  model = VRF
85
85
  exclude = (
86
86
  "has_description",
87
+ "has_status",
87
88
  "has_tenant",
88
89
  )
89
90
 
@@ -103,6 +104,11 @@ class VRFFactory(PrimaryModelFactory):
103
104
  has_description = NautobotBoolIterator()
104
105
  description = factory.Maybe("has_description", factory.Faker("text", max_nb_chars=CHARFIELD_MAX_LENGTH), "")
105
106
 
107
+ has_status = NautobotBoolIterator()
108
+ status = factory.Maybe(
109
+ "has_status", random_instance(lambda: Status.objects.get_for_model(VRF), allow_null=False), None
110
+ )
111
+
106
112
  namespace = random_instance(Namespace, allow_null=False)
107
113
 
108
114
  @factory.post_generation
nautobot/ipam/filters.py CHANGED
@@ -66,7 +66,7 @@ class NamespaceFilterSet(NautobotFilterSet):
66
66
  fields = "__all__"
67
67
 
68
68
 
69
- class VRFFilterSet(NautobotFilterSet, TenancyModelFilterSetMixin):
69
+ class VRFFilterSet(NautobotFilterSet, StatusModelFilterSetMixin, TenancyModelFilterSetMixin):
70
70
  q = SearchFilter(
71
71
  filter_predicates={
72
72
  "name": "icontains",
nautobot/ipam/forms.py CHANGED
@@ -125,6 +125,7 @@ class VRFForm(NautobotModelForm, TenancyForm):
125
125
  "name",
126
126
  "rd",
127
127
  "namespace",
128
+ "status",
128
129
  "description",
129
130
  "import_targets",
130
131
  "export_targets",
@@ -140,10 +141,11 @@ class VRFForm(NautobotModelForm, TenancyForm):
140
141
  }
141
142
  help_texts = {
142
143
  "rd": "Route distinguisher unique to this Namespace (as defined in RFC 4364)",
144
+ "status": "Operational status of this VRF",
143
145
  }
144
146
 
145
147
 
146
- class VRFBulkEditForm(TagsBulkEditFormMixin, NautobotBulkEditForm):
148
+ class VRFBulkEditForm(TagsBulkEditFormMixin, StatusModelBulkEditFormMixin, NautobotBulkEditForm):
147
149
  pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput())
148
150
  namespace = DynamicModelChoiceField(queryset=Namespace.objects.all(), required=False)
149
151
  tenant = DynamicModelChoiceField(queryset=Tenant.objects.all(), required=False)
@@ -162,9 +164,9 @@ class VRFBulkEditForm(TagsBulkEditFormMixin, NautobotBulkEditForm):
162
164
  ]
163
165
 
164
166
 
165
- class VRFFilterForm(NautobotFilterForm, TenancyFilterForm):
167
+ class VRFFilterForm(NautobotFilterForm, StatusModelFilterFormMixin, TenancyFilterForm):
166
168
  model = VRF
167
- field_order = ["q", "import_targets", "export_targets", "tenant_group", "tenant"]
169
+ field_order = ["q", "import_targets", "export_targets", "status", "tenant_group", "tenant"]
168
170
  q = forms.CharField(required=False, label="Search")
169
171
  import_targets = DynamicModelMultipleChoiceField(
170
172
  queryset=RouteTarget.objects.all(), to_field_name="name", required=False
@@ -0,0 +1,23 @@
1
+ # Generated by Django 4.2.15 on 2024-08-26 17:20
2
+
3
+ from django.db import migrations
4
+ import django.db.models.deletion
5
+
6
+ import nautobot.extras.models.statuses
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+ dependencies = [
11
+ ("extras", "0114_computedfield_grouping"),
12
+ ("ipam", "0047_alter_ipaddress_role_alter_ipaddress_status_and_more"),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.AddField(
17
+ model_name="vrf",
18
+ name="status",
19
+ field=nautobot.extras.models.statuses.StatusField(
20
+ blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="extras.status"
21
+ ),
22
+ ),
23
+ ]
@@ -0,0 +1,25 @@
1
+ # Generated by Django 4.2.15 on 2024-08-26 18:05
2
+
3
+ from django.db import migrations
4
+
5
+ from nautobot.extras.management import clear_status_choices, populate_status_choices
6
+
7
+
8
+ def populate_vrf_status_choices(apps, schema_editor):
9
+ """Create default Status records for the VRF model."""
10
+ populate_status_choices(apps, schema_editor, models=["ipam.VRF"])
11
+
12
+
13
+ def clear_vrf_status_choices(apps, schema_editor):
14
+ """Remove default Status records for the VRF model."""
15
+ clear_status_choices(apps, schema_editor, models=["ipam.VRF"])
16
+
17
+
18
+ class Migration(migrations.Migration):
19
+ dependencies = [
20
+ ("ipam", "0048_vrf_status"),
21
+ ]
22
+
23
+ operations = [
24
+ migrations.RunPython(populate_vrf_status_choices, clear_vrf_status_choices),
25
+ ]
nautobot/ipam/models.py CHANGED
@@ -97,6 +97,7 @@ def get_default_namespace_pk():
97
97
  "custom_validators",
98
98
  "export_templates",
99
99
  "graphql",
100
+ "statuses",
100
101
  "webhooks",
101
102
  )
102
103
  class VRF(PrimaryModel):
@@ -114,6 +115,7 @@ class VRF(PrimaryModel):
114
115
  verbose_name="Route distinguisher",
115
116
  help_text="Unique route distinguisher (as defined in RFC 4364)",
116
117
  )
118
+ status = StatusField(blank=True, null=True)
117
119
  namespace = models.ForeignKey(
118
120
  "ipam.Namespace",
119
121
  on_delete=models.PROTECT,
nautobot/ipam/tables.py CHANGED
@@ -217,7 +217,7 @@ class NamespaceTable(BaseTable):
217
217
  #
218
218
 
219
219
 
220
- class VRFTable(BaseTable):
220
+ class VRFTable(StatusTableMixin, BaseTable):
221
221
  pk = ToggleColumn()
222
222
  name = tables.LinkColumn()
223
223
  # rd = tables.Column(verbose_name="RD")
@@ -232,6 +232,7 @@ class VRFTable(BaseTable):
232
232
  "pk",
233
233
  "name",
234
234
  # "rd",
235
+ "status",
235
236
  "namespace",
236
237
  "tenant",
237
238
  "description",
@@ -240,7 +241,7 @@ class VRFTable(BaseTable):
240
241
  "tags",
241
242
  )
242
243
  # default_columns = ("pk", "name", "rd", "namespace", "tenant", "description")
243
- default_columns = ("pk", "name", "namespace", "tenant", "description")
244
+ default_columns = ("pk", "name", "status", "namespace", "tenant", "description")
244
245
 
245
246
 
246
247
  class VRFDeviceAssignmentTable(BaseTable):
@@ -19,6 +19,10 @@
19
19
  <td>Tenant</td>
20
20
  <td>{{ object.tenant|hyperlinked_object }}</td>
21
21
  </tr>
22
+ <tr>
23
+ <td>Status</td>
24
+ <td>{{ object.status|hyperlinked_object_with_color }}</td>
25
+ </tr>
22
26
  <tr>
23
27
  <td>Description</td>
24
28
  <td>{{ object.description|placeholder }}</td>
@@ -8,6 +8,7 @@
8
8
  {% render_field form.name %}
9
9
  {% render_field form.namespace %}
10
10
  {% render_field form.rd %}
11
+ {% render_field form.status %}
11
12
  {% render_field form.description %}
12
13
  </div>
13
14
  </div>
@@ -13,7 +13,7 @@ from nautobot.core.testing import APITestCase, APIViewTestCases, disable_warning
13
13
  from nautobot.core.testing.api import APITransactionTestCase
14
14
  from nautobot.dcim.choices import InterfaceTypeChoices
15
15
  from nautobot.dcim.models import Device, DeviceType, Interface, Location, LocationType, Manufacturer
16
- from nautobot.extras.models import Role, Status
16
+ from nautobot.extras.models import CustomField, Role, Status
17
17
  from nautobot.ipam import choices
18
18
  from nautobot.ipam.models import (
19
19
  IPAddress,
@@ -84,11 +84,14 @@ class VRFTest(APIViewTestCases.APIViewTestCase):
84
84
  @classmethod
85
85
  def setUpTestData(cls):
86
86
  namespace = Namespace.objects.first()
87
+ vrf_statuses = Status.objects.get_for_model(VRF)
88
+
87
89
  cls.create_data = [
88
90
  {
89
91
  "namespace": namespace.pk,
90
92
  "name": "VRF 4",
91
93
  "rd": "65000:4",
94
+ "status": vrf_statuses.first().pk,
92
95
  },
93
96
  {
94
97
  "namespace": namespace.pk,
@@ -98,10 +101,12 @@ class VRFTest(APIViewTestCases.APIViewTestCase):
98
101
  {
99
102
  "name": "VRF 6",
100
103
  "rd": "65000:6",
104
+ "status": vrf_statuses.last().pk,
101
105
  },
102
106
  ]
103
107
  cls.bulk_update_data = {
104
108
  "description": "New description",
109
+ "status": vrf_statuses.last().pk,
105
110
  }
106
111
 
107
112
 
@@ -332,6 +337,8 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
332
337
  cls.statuses = Status.objects.get_for_model(Prefix)
333
338
  cls.status = cls.statuses[0]
334
339
  cls.locations = Location.objects.get_for_model(Prefix)
340
+ cls.custom_field = CustomField.objects.create(key="prefixcf", label="Prefix Custom Field", type="text")
341
+ cls.custom_field.content_types.add(ContentType.objects.get_for_model(Prefix))
335
342
  cls.create_data = [
336
343
  {
337
344
  "prefix": "192.168.4.0/24",
@@ -346,6 +353,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
346
353
  "rir": rir.pk,
347
354
  "type": choices.PrefixTypeChoices.TYPE_NETWORK,
348
355
  "namespace": cls.namespace.pk,
356
+ "custom_fields": {"prefixcf": "hello world"},
349
357
  },
350
358
  {
351
359
  "prefix": "192.168.6.0/24",
@@ -457,12 +465,16 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
457
465
  "prefix_length": child_prefix_length,
458
466
  "status": self.status.pk,
459
467
  "description": f"Test Prefix {i + 1}",
468
+ "custom_fields": {"prefixcf": f"value {i+1}"},
460
469
  }
461
470
  response = self.client.post(url, data, format="json", **self.header)
462
471
  self.assertHttpStatus(response, status.HTTP_201_CREATED)
463
472
  self.assertEqual(response.data["prefix"], str(prefixes_to_be_created[i]))
464
473
  self.assertEqual(str(response.data["namespace"]["url"]), self.absolute_api_url(prefix.namespace))
465
474
  self.assertEqual(response.data["description"], data["description"])
475
+ self.assertIn("custom_fields", response.data)
476
+ self.assertIn("prefixcf", response.data["custom_fields"])
477
+ self.assertEqual(response.data["custom_fields"]["prefixcf"], data["custom_fields"]["prefixcf"])
466
478
 
467
479
  # Try to create one more prefix, and expect a HTTP 204 response.
468
480
  # This feels wrong to me (shouldn't it be a 4xx or 5xx?) but it's how the API has historically behaved.
@@ -501,6 +513,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
501
513
  "prefix_length": child_prefix_length,
502
514
  "description": "Test Prefix 1",
503
515
  "status": self.status.pk,
516
+ "custom_fields": {"prefixcf": "value 1"},
504
517
  },
505
518
  {
506
519
  "prefix_length": child_prefix_length,
@@ -537,6 +550,9 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
537
550
  response = self.client.post(url, data[:4], format="json", **self.header)
538
551
  self.assertHttpStatus(response, status.HTTP_201_CREATED)
539
552
  self.assertEqual(len(response.data), 4)
553
+ self.assertIn("custom_fields", response.data[0])
554
+ self.assertIn("prefixcf", response.data[0]["custom_fields"])
555
+ self.assertEqual("value 1", response.data[0]["custom_fields"]["prefixcf"])
540
556
 
541
557
  def test_list_available_ips(self):
542
558
  """
@@ -571,6 +587,8 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
571
587
  type=choices.PrefixTypeChoices.TYPE_NETWORK,
572
588
  status=self.status,
573
589
  )
590
+ cf = CustomField.objects.create(key="ipcf", label="IP Custom Field", type="text")
591
+ cf.content_types.add(ContentType.objects.get_for_model(IPAddress))
574
592
  url = reverse("ipam-api:prefix-available-ips", kwargs={"pk": prefix.pk})
575
593
  self.add_permissions("ipam.view_prefix", "ipam.add_ipaddress", "extras.view_status")
576
594
 
@@ -579,11 +597,15 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
579
597
  data = {
580
598
  "description": f"Test IP {i}",
581
599
  "status": self.status.pk,
600
+ "custom_fields": {"ipcf": f"value {i}"},
582
601
  }
583
602
  response = self.client.post(url, data, format="json", **self.header)
584
603
  self.assertHttpStatus(response, status.HTTP_201_CREATED)
585
604
  self.assertEqual(str(response.data["parent"]["url"]), self.absolute_api_url(prefix))
586
605
  self.assertEqual(response.data["description"], data["description"])
606
+ self.assertIn("custom_fields", response.data)
607
+ self.assertIn("ipcf", response.data["custom_fields"])
608
+ self.assertEqual(f"value {i}", response.data["custom_fields"]["ipcf"])
587
609
 
588
610
  # Try to create one more IP
589
611
  response = self.client.post(url, {"status": self.status.pk}, format="json", **self.header)
@@ -601,21 +623,29 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
601
623
  namespace=self.namespace,
602
624
  status=self.status,
603
625
  )
626
+ cf = CustomField.objects.create(key="ipcf", label="IP Custom Field", type="text")
627
+ cf.content_types.add(ContentType.objects.get_for_model(IPAddress))
604
628
  url = reverse("ipam-api:prefix-available-ips", kwargs={"pk": prefix.pk})
605
629
  self.add_permissions("ipam.view_prefix", "ipam.add_ipaddress", "extras.view_status")
606
630
 
607
631
  # Try to create seven IPs (only six are available)
608
- data = [{"description": f"Test IP {i}", "status": self.status.pk} for i in range(1, 8)] # 7 IPs
632
+ data = [
633
+ {"description": f"Test IP {i}", "status": self.status.pk, "custom_fields": {"ipcf": str(i)}}
634
+ for i in range(1, 8)
635
+ ] # 7 IPs
609
636
  response = self.client.post(url, data, format="json", **self.header)
610
637
  # This feels wrong to me (shouldn't it be a 4xx or 5xx?) but it's how the API has historically behaved.
611
638
  self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
612
639
  self.assertIn("detail", response.data)
613
640
 
614
641
  # Create all six available IPs in a single request
615
- data = [{"description": f"Test IP {i}", "status": self.status.pk} for i in range(1, 7)] # 6 IPs
642
+ data = data[:6]
616
643
  response = self.client.post(url, data, format="json", **self.header)
617
644
  self.assertHttpStatus(response, status.HTTP_201_CREATED)
618
645
  self.assertEqual(len(response.data), 6)
646
+ self.assertIn("custom_fields", response.data[0])
647
+ self.assertIn("ipcf", response.data[0]["custom_fields"])
648
+ self.assertEqual("1", response.data[0]["custom_fields"]["ipcf"])
619
649
 
620
650
 
621
651
  class PrefixLocationAssignmentTest(APIViewTestCases.APIViewTestCase):
@@ -73,6 +73,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
73
73
  tenants = Tenant.objects.all()[:2]
74
74
  namespace = Prefix.objects.first().namespace
75
75
  prefixes = Prefix.objects.filter(namespace=namespace)
76
+ vrf_statuses = Status.objects.get_for_model(VRF)
76
77
 
77
78
  cls.form_data = {
78
79
  "name": "VRF X",
@@ -82,9 +83,11 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
82
83
  "description": "A new VRF",
83
84
  "prefixes": [prefixes[1].id],
84
85
  "tags": [t.pk for t in Tag.objects.get_for_model(VRF)],
86
+ "status": vrf_statuses.first().pk,
85
87
  }
86
88
 
87
89
  cls.bulk_edit_data = {
90
+ "status": vrf_statuses.first().pk,
88
91
  "tenant": tenants[1].pk,
89
92
  "description": "New description",
90
93
  "namespace": prefixes[0].namespace.id,
@@ -862,6 +862,12 @@ td span.hover_copy:hover .hover_copy_button {
862
862
  .collapse-icon {
863
863
  float: right;
864
864
  cursor: pointer !important;
865
+ transition: transform 0.3s ease;
866
+ }
867
+
868
+ /* Rotates element 180 degrees */
869
+ .rotated180 {
870
+ transform: rotate(180deg);
865
871
  }
866
872
 
867
873
  /* Draggable homepage panels fade in effect on page load */