nautobot 2.4.21__py3-none-any.whl → 2.4.23__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 (62) hide show
  1. nautobot/apps/choices.py +4 -0
  2. nautobot/apps/utils.py +8 -0
  3. nautobot/circuits/views.py +6 -2
  4. nautobot/core/cli/migrate_deprecated_templates.py +28 -9
  5. nautobot/core/filters.py +4 -0
  6. nautobot/core/forms/__init__.py +2 -0
  7. nautobot/core/forms/widgets.py +21 -2
  8. nautobot/core/jobs/bulk_actions.py +12 -6
  9. nautobot/core/jobs/cleanup.py +13 -1
  10. nautobot/core/settings.py +6 -0
  11. nautobot/core/settings_funcs.py +11 -1
  12. nautobot/core/templates/widgets/number_input_with_choices.html +44 -0
  13. nautobot/core/templatetags/helpers.py +9 -7
  14. nautobot/core/tests/nautobot_config.py +3 -0
  15. nautobot/core/tests/test_jobs.py +118 -0
  16. nautobot/core/tests/test_templatetags_helpers.py +6 -0
  17. nautobot/core/tests/test_ui.py +49 -1
  18. nautobot/core/tests/test_utils.py +41 -1
  19. nautobot/core/ui/object_detail.py +7 -2
  20. nautobot/core/urls.py +7 -8
  21. nautobot/core/utils/filtering.py +11 -1
  22. nautobot/core/utils/lookup.py +46 -0
  23. nautobot/core/views/mixins.py +23 -17
  24. nautobot/core/views/utils.py +3 -3
  25. nautobot/dcim/api/serializers.py +3 -0
  26. nautobot/dcim/choices.py +49 -0
  27. nautobot/dcim/constants.py +7 -0
  28. nautobot/dcim/filters/__init__.py +7 -0
  29. nautobot/dcim/forms.py +89 -3
  30. nautobot/dcim/migrations/0075_interface_duplex_interface_speed_and_more.py +32 -0
  31. nautobot/dcim/models/device_component_templates.py +33 -1
  32. nautobot/dcim/models/device_components.py +21 -0
  33. nautobot/dcim/tables/devices.py +14 -0
  34. nautobot/dcim/tables/devicetypes.py +8 -1
  35. nautobot/dcim/templates/dcim/interface.html +8 -0
  36. nautobot/dcim/templates/dcim/interface_edit.html +2 -0
  37. nautobot/dcim/tests/test_api.py +186 -6
  38. nautobot/dcim/tests/test_filters.py +32 -0
  39. nautobot/dcim/tests/test_forms.py +110 -8
  40. nautobot/dcim/tests/test_graphql.py +44 -1
  41. nautobot/dcim/tests/test_models.py +265 -0
  42. nautobot/dcim/tests/test_tables.py +160 -0
  43. nautobot/dcim/tests/test_views.py +64 -1
  44. nautobot/dcim/views.py +86 -77
  45. nautobot/extras/forms/forms.py +3 -1
  46. nautobot/extras/jobs.py +48 -2
  47. nautobot/extras/models/models.py +19 -0
  48. nautobot/extras/models/relationships.py +3 -1
  49. nautobot/extras/templates/extras/plugin_detail.html +2 -2
  50. nautobot/extras/urls.py +0 -14
  51. nautobot/extras/views.py +1 -1
  52. nautobot/ipam/ui.py +0 -17
  53. nautobot/ipam/views.py +2 -2
  54. nautobot/project-static/js/forms.js +92 -14
  55. nautobot/virtualization/tests/test_models.py +4 -2
  56. nautobot/virtualization/views.py +1 -0
  57. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/METADATA +4 -4
  58. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/RECORD +62 -59
  59. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/LICENSE.txt +0 -0
  60. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/NOTICE +0 -0
  61. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/WHEEL +0 -0
  62. {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/entry_points.txt +0 -0
nautobot/dcim/views.py CHANGED
@@ -485,25 +485,31 @@ class LocationUIViewSet(NautobotUIViewSet):
485
485
  )
486
486
 
487
487
  def get_extra_context(self, request, instance):
488
- if instance is None:
489
- return super().get_extra_context(request, instance)
488
+ context = super().get_extra_context(request, instance)
490
489
 
491
- # This query can get really expensive when there are big location trees in the DB. By casting it to a list we
492
- # ensure it is only performed once rather than as a subquery for each of the different count stats.
493
- related_locations = list(
494
- instance.descendants(include_self=True).restrict(request.user, "view").values_list("pk", flat=True)
495
- )
490
+ if self.action == "retrieve":
491
+ # This query can get really expensive when there are big location trees in the DB. By casting it to a list we
492
+ # ensure it is only performed once rather than as a subquery for each of the different count stats.
493
+ related_locations = list(
494
+ instance.descendants(include_self=True).restrict(request.user, "view").values_list("pk", flat=True)
495
+ )
496
496
 
497
- rack_groups = (
498
- RackGroup.objects.annotate(rack_count=count_related(Rack, "rack_group"))
499
- .restrict(request.user, "view")
500
- .filter(location__in=related_locations)
501
- )
497
+ rack_groups = (
498
+ RackGroup.objects.annotate(rack_count=count_related(Rack, "rack_group"))
499
+ .restrict(request.user, "view")
500
+ .filter(location__in=related_locations)
501
+ )
502
502
 
503
- return {
504
- "rack_groups": rack_groups,
505
- "rack_count": Rack.objects.restrict(request.user, "view").filter(location__in=related_locations).count(),
506
- }
503
+ context.update(
504
+ {
505
+ "rack_groups": rack_groups,
506
+ "rack_count": Rack.objects.restrict(request.user, "view")
507
+ .filter(location__in=related_locations)
508
+ .count(),
509
+ }
510
+ )
511
+
512
+ return context
507
513
 
508
514
 
509
515
  class MigrateLocationDataToContactView(generic.ObjectEditView):
@@ -1480,65 +1486,68 @@ class ModuleTypeUIViewSet(
1480
1486
  return super().get_required_permission()
1481
1487
 
1482
1488
  def get_extra_context(self, request, instance):
1483
- if not instance:
1484
- return {}
1489
+ context = super().get_extra_context(request, instance)
1490
+ if self.action == "retrieve":
1491
+ instance_count = Module.objects.restrict(request.user).filter(module_type=instance).count()
1485
1492
 
1486
- instance_count = Module.objects.restrict(request.user).filter(module_type=instance).count()
1493
+ # Component tables
1494
+ consoleport_table = tables.ConsolePortTemplateTable(
1495
+ ConsolePortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1496
+ orderable=False,
1497
+ )
1498
+ consoleserverport_table = tables.ConsoleServerPortTemplateTable(
1499
+ ConsoleServerPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1500
+ orderable=False,
1501
+ )
1502
+ powerport_table = tables.PowerPortTemplateTable(
1503
+ PowerPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1504
+ orderable=False,
1505
+ )
1506
+ poweroutlet_table = tables.PowerOutletTemplateTable(
1507
+ PowerOutletTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1508
+ orderable=False,
1509
+ )
1510
+ interface_table = tables.InterfaceTemplateTable(
1511
+ list(InterfaceTemplate.objects.restrict(request.user, "view").filter(module_type=instance)),
1512
+ orderable=False,
1513
+ )
1514
+ front_port_table = tables.FrontPortTemplateTable(
1515
+ FrontPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1516
+ orderable=False,
1517
+ )
1518
+ rear_port_table = tables.RearPortTemplateTable(
1519
+ RearPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1520
+ orderable=False,
1521
+ )
1522
+ modulebay_table = tables.ModuleBayTemplateTable(
1523
+ ModuleBayTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1524
+ orderable=False,
1525
+ )
1526
+ if request.user.has_perm("dcim.change_moduletype"):
1527
+ consoleport_table.columns.show("pk")
1528
+ consoleserverport_table.columns.show("pk")
1529
+ powerport_table.columns.show("pk")
1530
+ poweroutlet_table.columns.show("pk")
1531
+ interface_table.columns.show("pk")
1532
+ front_port_table.columns.show("pk")
1533
+ rear_port_table.columns.show("pk")
1534
+ modulebay_table.columns.show("pk")
1487
1535
 
1488
- # Component tables
1489
- consoleport_table = tables.ConsolePortTemplateTable(
1490
- ConsolePortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1491
- orderable=False,
1492
- )
1493
- consoleserverport_table = tables.ConsoleServerPortTemplateTable(
1494
- ConsoleServerPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1495
- orderable=False,
1496
- )
1497
- powerport_table = tables.PowerPortTemplateTable(
1498
- PowerPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1499
- orderable=False,
1500
- )
1501
- poweroutlet_table = tables.PowerOutletTemplateTable(
1502
- PowerOutletTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1503
- orderable=False,
1504
- )
1505
- interface_table = tables.InterfaceTemplateTable(
1506
- list(InterfaceTemplate.objects.restrict(request.user, "view").filter(module_type=instance)),
1507
- orderable=False,
1508
- )
1509
- front_port_table = tables.FrontPortTemplateTable(
1510
- FrontPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1511
- orderable=False,
1512
- )
1513
- rear_port_table = tables.RearPortTemplateTable(
1514
- RearPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1515
- orderable=False,
1516
- )
1517
- modulebay_table = tables.ModuleBayTemplateTable(
1518
- ModuleBayTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
1519
- orderable=False,
1520
- )
1521
- if request.user.has_perm("dcim.change_moduletype"):
1522
- consoleport_table.columns.show("pk")
1523
- consoleserverport_table.columns.show("pk")
1524
- powerport_table.columns.show("pk")
1525
- poweroutlet_table.columns.show("pk")
1526
- interface_table.columns.show("pk")
1527
- front_port_table.columns.show("pk")
1528
- rear_port_table.columns.show("pk")
1529
- modulebay_table.columns.show("pk")
1536
+ context.update(
1537
+ {
1538
+ "instance_count": instance_count,
1539
+ "consoleport_table": consoleport_table,
1540
+ "consoleserverport_table": consoleserverport_table,
1541
+ "powerport_table": powerport_table,
1542
+ "poweroutlet_table": poweroutlet_table,
1543
+ "interface_table": interface_table,
1544
+ "front_port_table": front_port_table,
1545
+ "rear_port_table": rear_port_table,
1546
+ "modulebay_table": modulebay_table,
1547
+ }
1548
+ )
1530
1549
 
1531
- return {
1532
- "instance_count": instance_count,
1533
- "consoleport_table": consoleport_table,
1534
- "consoleserverport_table": consoleserverport_table,
1535
- "powerport_table": powerport_table,
1536
- "poweroutlet_table": poweroutlet_table,
1537
- "interface_table": interface_table,
1538
- "front_port_table": front_port_table,
1539
- "rear_port_table": rear_port_table,
1540
- "modulebay_table": modulebay_table,
1541
- }
1550
+ return context
1542
1551
 
1543
1552
  @action(
1544
1553
  detail=False,
@@ -2302,7 +2311,7 @@ class DeviceUIViewSet(NautobotUIViewSet):
2302
2311
 
2303
2312
  def get_queryset(self):
2304
2313
  queryset = super().get_queryset()
2305
- if self.detail: # TODO: change to self.action == "retrieve" as a part of addressing NAUTOBOT-1051
2314
+ if self.action == "retrieve":
2306
2315
  queryset = queryset.select_related(
2307
2316
  "cluster__cluster_group",
2308
2317
  "controller_managed_device_group__controller",
@@ -3062,7 +3071,7 @@ class DeviceUIViewSet(NautobotUIViewSet):
3062
3071
  def get_extra_context(self, request, instance):
3063
3072
  extra_context = super().get_extra_context(request, instance)
3064
3073
 
3065
- if self.detail: # TODO: change to `if self.action == "retrieve"` as a part of addressing NAUTOBOT-1051
3074
+ if self.action == "retrieve":
3066
3075
  # VirtualChassis members
3067
3076
  if instance.virtual_chassis is not None:
3068
3077
  vc_members = (
@@ -5816,7 +5825,7 @@ class ControllerUIViewSet(NautobotUIViewSet):
5816
5825
  object_detail.DistinctViewTab(
5817
5826
  weight=700,
5818
5827
  tab_id="wireless_networks",
5819
- url_name="dcim:controller_wirelessnetworks",
5828
+ url_name="dcim:controller_wireless_networks",
5820
5829
  label="Wireless Networks",
5821
5830
  related_object_attribute="wireless_network_assignments",
5822
5831
  panels=(
@@ -5840,12 +5849,12 @@ class ControllerUIViewSet(NautobotUIViewSet):
5840
5849
  @action(
5841
5850
  detail=True,
5842
5851
  url_path="wireless-networks",
5843
- url_name="wirelessnetworks",
5852
+ url_name="wireless_networks",
5844
5853
  methods=["get"],
5845
5854
  custom_view_base_action="view",
5846
5855
  custom_view_additional_permissions=["wireless.view_controllermanageddevicegroupwirelessnetworkassignment"],
5847
5856
  )
5848
- def wirelessnetworks(self, request, *args, **kwargs):
5857
+ def wireless_networks(self, request, *args, **kwargs):
5849
5858
  return Response({})
5850
5859
 
5851
5860
 
@@ -386,7 +386,9 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
386
386
  role = DynamicModelMultipleChoiceField(
387
387
  queryset=Role.objects.get_for_models([Device, VirtualMachine]), to_field_name="name", required=False
388
388
  )
389
- type = DynamicModelMultipleChoiceField(queryset=DeviceType.objects.all(), to_field_name="model", required=False)
389
+ device_type = DynamicModelMultipleChoiceField(
390
+ queryset=DeviceType.objects.all(), to_field_name="model", required=False
391
+ )
390
392
  platform = DynamicModelMultipleChoiceField(queryset=Platform.objects.all(), to_field_name="name", required=False)
391
393
  cluster_group = DynamicModelMultipleChoiceField(
392
394
  queryset=ClusterGroup.objects.all(), to_field_name="name", required=False
nautobot/extras/jobs.py CHANGED
@@ -29,6 +29,7 @@ from django.db.models.query import QuerySet
29
29
  from django.forms import ValidationError
30
30
  from django.utils.functional import classproperty
31
31
  import netaddr
32
+ from prometheus_client import Counter
32
33
  import yaml
33
34
 
34
35
  from nautobot.core.celery import import_jobs, nautobot_task
@@ -88,6 +89,27 @@ __all__ = [
88
89
 
89
90
  logger = logging.getLogger(__name__)
90
91
 
92
+ started_jobs_counter = Counter(
93
+ name="nautobot_worker_started_jobs",
94
+ documentation="Job executions that started running",
95
+ labelnames=("job_class_name", "module_name"),
96
+ )
97
+ finished_jobs_counter = Counter(
98
+ name="nautobot_worker_finished_jobs",
99
+ documentation="Job executions that finished running",
100
+ labelnames=("job_class_name", "module_name", "status"),
101
+ )
102
+ exception_jobs_counter = Counter(
103
+ name="nautobot_worker_exception_jobs",
104
+ documentation="Job executions that raised an exception",
105
+ labelnames=("job_class_name", "module_name", "exception_type"),
106
+ )
107
+ singleton_conflict_counter = Counter(
108
+ name="nautobot_worker_singleton_conflict",
109
+ documentation="Job executions that ran into a singleton lock",
110
+ labelnames=("job_class_name", "module_name"),
111
+ )
112
+
91
113
 
92
114
  class RunJobTaskFailed(Exception):
93
115
  """Celery task failed for some reason."""
@@ -1198,6 +1220,9 @@ def _prepare_job(job_class_path, request, kwargs) -> tuple[Job, dict]:
1198
1220
  extra={"object": job.job_model, "grouping": "initialization"},
1199
1221
  )
1200
1222
  else:
1223
+ singleton_conflict_counter.labels(
1224
+ job_class_name=job.job_model.job_class_name, module_name=job.job_model.module_name
1225
+ ).inc()
1201
1226
  # TODO 3.0: maybe change to logger.failure() and return cleanly, as this is an "acceptable" failure?
1202
1227
  job.logger.error(
1203
1228
  "Job %s is a singleton and already running.",
@@ -1281,6 +1306,9 @@ def run_job(self, job_class_path, *args, **kwargs):
1281
1306
 
1282
1307
  result = None
1283
1308
  status = None
1309
+ started_jobs_counter.labels(
1310
+ job_class_name=job.job_model.job_class_name, module_name=job.job_model.module_name
1311
+ ).inc()
1284
1312
  try:
1285
1313
  before_start_result = job.before_start(self.request.id, args, kwargs)
1286
1314
  if not job._failed:
@@ -1297,6 +1325,9 @@ def run_job(self, job_class_path, *args, **kwargs):
1297
1325
  job.on_success(result, self.request.id, args, kwargs)
1298
1326
  else:
1299
1327
  job.on_failure(result, self.request.id, args, kwargs, None)
1328
+ finished_jobs_counter.labels(
1329
+ job_class_name=job.job_model.job_class_name, module_name=job.job_model.module_name, status=status
1330
+ ).inc()
1300
1331
 
1301
1332
  job.after_return(status, result, self.request.id, args, kwargs, None)
1302
1333
 
@@ -1313,11 +1344,21 @@ def run_job(self, job_class_path, *args, **kwargs):
1313
1344
  # We don't want to overwrite the manual state update that we did above, so:
1314
1345
  raise Ignore()
1315
1346
 
1316
- except Reject:
1347
+ except Reject as exc:
1348
+ exception_jobs_counter.labels(
1349
+ job_class_name=job.job_model.job_class_name,
1350
+ module_name=job.job_model.module_name,
1351
+ exception_type=type(exc).__name__,
1352
+ ).inc()
1317
1353
  status = status or JobResultStatusChoices.STATUS_REJECTED
1318
1354
  raise
1319
1355
 
1320
- except Ignore:
1356
+ except Ignore as exc:
1357
+ exception_jobs_counter.labels(
1358
+ job_class_name=job.job_model.job_class_name,
1359
+ module_name=job.job_model.module_name,
1360
+ exception_type=type(exc).__name__,
1361
+ ).inc()
1321
1362
  status = status or JobResultStatusChoices.STATUS_IGNORED
1322
1363
  raise
1323
1364
 
@@ -1330,6 +1371,11 @@ def run_job(self, job_class_path, *args, **kwargs):
1330
1371
  "exc_type": type(exc).__name__,
1331
1372
  "exc_message": sanitize(str(exc)),
1332
1373
  }
1374
+ exception_jobs_counter.labels(
1375
+ job_class_name=job.job_model.job_class_name,
1376
+ module_name=job.job_model.module_name,
1377
+ exception_type=type(exc).__name__,
1378
+ ).inc()
1333
1379
  raise
1334
1380
 
1335
1381
  finally:
@@ -26,6 +26,7 @@ from nautobot.core.models import BaseManager, BaseModel
26
26
  from nautobot.core.models.fields import ForeignKeyWithAutoRelatedName, LaxURLField
27
27
  from nautobot.core.models.generics import OrganizationalModel, PrimaryModel
28
28
  from nautobot.core.utils.data import deepmerge, render_jinja2
29
+ from nautobot.core.utils.lookup import get_filterset_for_model, get_model_for_view_name
29
30
  from nautobot.extras.choices import (
30
31
  ButtonClassChoices,
31
32
  WebhookHttpMethodChoices,
@@ -903,6 +904,24 @@ class SavedView(BaseModel, ChangeLoggedModel):
903
904
 
904
905
  super().save(*args, **kwargs)
905
906
 
907
+ @property
908
+ def model(self):
909
+ """
910
+ Return the model class associated with this SavedView, based on the 'view' field.
911
+ """
912
+ return get_model_for_view_name(self.view)
913
+
914
+ def get_filtered_queryset(self, user):
915
+ """
916
+ Return a queryset for the associated model, filtered by this SavedView's filter_params.
917
+ """
918
+ model = self.model
919
+ if model is None:
920
+ return None
921
+ filter_params = self.config.get("filter_params", {})
922
+ filterset_class = get_filterset_for_model(model)
923
+ return filterset_class(filter_params, model.objects.restrict(user)).qs
924
+
906
925
 
907
926
  @extras_features("graphql")
908
927
  class UserSavedViewAssociation(BaseModel):
@@ -284,7 +284,9 @@ class RelationshipModel(models.Model):
284
284
  Q(**side_query_params) | Q(**peer_side_query_params)
285
285
  ).distinct()
286
286
  if not relationship.has_many(peer_side):
287
- resp[side][relationship] = resp[side][relationship].first()
287
+ resp[RelationshipSideChoices.SIDE_PEER][relationship] = resp[
288
+ RelationshipSideChoices.SIDE_PEER
289
+ ][relationship].first()
288
290
  else:
289
291
  # Maybe an uninstalled App?
290
292
  # We can't provide a relevant queryset, but we can provide a descriptive string
@@ -83,11 +83,11 @@
83
83
  <table class="table table-hover panel-body attr-table">
84
84
  <tr>
85
85
  <td>Min Nautobot Version</td>
86
- <td>v{{ object.min_version | placeholder }}</td>
86
+ <td>{% if object.min_version %}v{{ object.min_version }}{% else %}{{ None|placeholder }}{% endif %}</td>
87
87
  </tr>
88
88
  <tr>
89
89
  <td>Max Nautobot Version</td>
90
- <td>v{{ object.max_version | placeholder }}</td>
90
+ <td>{% if object.max_version %}v{{ object.max_version }}{% else %}{{ None|placeholder }}{% endif %}</td>
91
91
  </tr>
92
92
  </table>
93
93
  </div>
nautobot/extras/urls.py CHANGED
@@ -4,7 +4,6 @@ from nautobot.core.views.routers import NautobotUIViewSetRouter
4
4
  from nautobot.extras import views
5
5
  from nautobot.extras.models import (
6
6
  Job,
7
- Relationship,
8
7
  )
9
8
 
10
9
  app_name = "extras"
@@ -111,19 +110,6 @@ urlpatterns = [
111
110
  path("jobs/<str:class_path>/run/", views.JobRunView.as_view(), name="job_run_by_class_path"),
112
111
  path("jobs/edit/", views.JobBulkEditView.as_view(), name="job_bulk_edit"),
113
112
  path("jobs/delete/", views.JobBulkDeleteView.as_view(), name="job_bulk_delete"),
114
- # Custom relationships
115
- path(
116
- "relationships/<uuid:pk>/changelog/",
117
- views.ObjectChangeLogView.as_view(),
118
- name="relationship_changelog",
119
- kwargs={"model": Relationship},
120
- ),
121
- path(
122
- "relationships/<uuid:pk>/notes/",
123
- views.ObjectNotesView.as_view(),
124
- name="relationship_notes",
125
- kwargs={"model": Relationship},
126
- ),
127
113
  # Secrets
128
114
  path(
129
115
  "secrets/provider/<str:provider_slug>/form/",
nautobot/extras/views.py CHANGED
@@ -1194,7 +1194,7 @@ class GitRepositoryUIViewSet(NautobotUIViewSet):
1194
1194
  context = {
1195
1195
  **super().get_extra_context(request, instance),
1196
1196
  "result": job_result or {},
1197
- "base_template": "extras/configcontextschema_retrieve.html",
1197
+ "base_template": "extras/gitrepository_retrieve.html",
1198
1198
  "object": instance,
1199
1199
  "active_tab": "result",
1200
1200
  "verbose_name": instance._meta.verbose_name,
nautobot/ipam/ui.py CHANGED
@@ -10,29 +10,12 @@ from nautobot.core.ui.object_detail import (
10
10
  Button,
11
11
  KeyValueTablePanel,
12
12
  ObjectFieldsPanel,
13
- ObjectsTablePanel,
14
13
  )
15
14
  from nautobot.core.views.utils import get_obj_from_context
16
15
 
17
16
  logger = logging.getLogger(__name__)
18
17
 
19
18
 
20
- # TODO: can be removed as a part of NAUTOBOT-1051
21
- class PrefixChildTablePanel(ObjectsTablePanel):
22
- def should_render(self, context: Context):
23
- if not super().should_render(context):
24
- return False
25
- return context.get("active_tab") == "prefixes"
26
-
27
-
28
- # TODO: can be removed as a part of NAUTOBOT-1051
29
- class IPAddressTablePanel(ObjectsTablePanel):
30
- def should_render(self, context: Context):
31
- if not super().should_render(context):
32
- return False
33
- return context.get("active_tab") == "ip-addresses"
34
-
35
-
36
19
  class AddChildPrefixButton(Button):
37
20
  """Custom button to add a child prefix inside a Prefix detail view."""
38
21
 
nautobot/ipam/views.py CHANGED
@@ -432,7 +432,7 @@ class PrefixUIViewSet(NautobotUIViewSet):
432
432
  related_object_attribute="default_descendants",
433
433
  url_name="ipam:prefix_prefixes",
434
434
  panels=(
435
- ui.PrefixChildTablePanel(
435
+ object_detail.ObjectsTablePanel(
436
436
  section=SectionChoices.FULL_WIDTH,
437
437
  weight=100,
438
438
  context_table_key="prefix_table",
@@ -449,7 +449,7 @@ class PrefixUIViewSet(NautobotUIViewSet):
449
449
  related_object_attribute="all_ips",
450
450
  url_name="ipam:prefix_ipaddresses",
451
451
  panels=[
452
- ui.IPAddressTablePanel(
452
+ object_detail.ObjectsTablePanel(
453
453
  section=SectionChoices.FULL_WIDTH,
454
454
  weight=100,
455
455
  context_table_key="ip_table",
@@ -223,6 +223,19 @@ function initializeFormActionClick(context){
223
223
  function initializeBulkEditNullification(context){
224
224
  this_context = $(context);
225
225
  this_context.find('input:checkbox[name=_nullify]').click(function() {
226
+ var $field = $('#id_' + this.value);
227
+
228
+ // If this is a NumberWithSelect (input-group + caret menu), don't hide the
229
+ // field. Some other fields (e.g.Interface: LAG, Bridge) currently do nothing
230
+ // when _nullify is checked, so this is consistent.
231
+ var $group = $field.closest('.input-group');
232
+ var isNumberWithSelect = $group.length &&
233
+ $group.find('.input-group-btn .dropdown-menu a.set_value').length > 0;
234
+ if (isNumberWithSelect) {
235
+ return; // no UI change; _nullify still submitted
236
+ }
237
+
238
+ // Existing behavior for other fields
226
239
  $('#id_' + this.value).toggle('disabled');
227
240
  });
228
241
  }
@@ -590,19 +603,57 @@ function initializeVLANModeSelection(context){
590
603
 
591
604
  function initializeMultiValueChar(context, dropdownParent=null){
592
605
  this_context = $(context);
593
- this_context.find('.nautobot-select2-multi-value-char').select2({
594
- allowClear: true,
595
- tags: true,
596
- theme: "bootstrap",
597
- placeholder: "---------",
598
- multiple: true,
599
- dropdownParent: dropdownParent,
600
- width: "off",
601
- "language": {
602
- "noResults": function(){
603
- return "Type something to add it as an option";
604
- }
605
- },
606
+ this_context.find('.nautobot-select2-multi-value-char').each(function(){
607
+ var $el = $(this);
608
+ $el.select2({
609
+ allowClear: true,
610
+ tags: true,
611
+ theme: "bootstrap",
612
+ placeholder: "---------",
613
+ multiple: true,
614
+ dropdownParent: dropdownParent,
615
+ width: "off",
616
+ tokenSeparators: [',', ' '],
617
+ "language": {
618
+ "noResults": function(){
619
+ return "Type something to add it as an option";
620
+ }
621
+ },
622
+ });
623
+
624
+ // Ensure pressing Enter in the Select2 search adds the current token instead of submitting the form
625
+ $el.on('select2:open', function(){
626
+ const container = document.querySelector('.select2-container--open');
627
+ if (!container) return;
628
+ const search = container.querySelector('input.select2-search__field');
629
+ if (!search) return;
630
+
631
+ // Avoid stacking multiple handlers
632
+ if (search.getAttribute('data-enter-binds')) return;
633
+ search.setAttribute('data-enter-binds', '1');
634
+
635
+ search.addEventListener('keydown', function(e){
636
+ if (e.key === 'Enter'){
637
+ e.preventDefault();
638
+ e.stopPropagation();
639
+ const val = this.value.trim();
640
+ if (!val) return;
641
+ const sel = $el.get(0);
642
+ // If option doesn't exist, create it; otherwise select it
643
+ let found = Array.prototype.find.call(sel.options, function(opt){ return String(opt.value) === String(val); });
644
+ if (!found) {
645
+ sel.add(new Option(val, val, true, true));
646
+ } else {
647
+ found.selected = true;
648
+ }
649
+ // Clear the search box and notify Select2
650
+ this.value = '';
651
+ $($el).trigger('change');
652
+ // Close the dropdown so it doesn't linger after add
653
+ try { $el.select2('close'); } catch (e) {}
654
+ }
655
+ });
656
+ });
606
657
  });
607
658
  }
608
659
 
@@ -714,7 +765,34 @@ function initializeDynamicFilterForm(context){
714
765
  lookup_type_value = $(this).find(".lookup_type-select").val();
715
766
  lookup_value = $(this).find(".lookup_value-input");
716
767
  lookup_value.attr("name", lookup_type_value);
717
- })
768
+ });
769
+
770
+ // Pre-populate filter selects (default + advanced) from current URL query params,
771
+ // including free-form values that are not part of the preset choices.
772
+ (function prepopulateFilterSelectsFromURL(){
773
+ const urlParams = new URLSearchParams(window.location.search);
774
+ // Only target Select2 tag controls inside the filter UI (default sidebar and advanced modal).
775
+ // Avoids touching unrelated Select2 tagging fields elsewhere on the page (e.g., tags inputs).
776
+ const selector = '#default-filter form select.nautobot-select2-multi-value-char, #advanced-filter select.nautobot-select2-multi-value-char';
777
+ this_context.find(selector).each(function(){
778
+ const sel = this;
779
+ const name = sel.getAttribute('name');
780
+ if (!name) { return; }
781
+ const values = urlParams.getAll(name);
782
+ if (!values.length) { return; }
783
+ values.forEach(function(v){
784
+ let found = Array.prototype.find.call(sel.options, function(opt){ return String(opt.value) === String(v); });
785
+ if (!found) {
786
+ sel.add(new Option(v, v, true, true));
787
+ } else {
788
+ found.selected = true;
789
+ }
790
+ });
791
+ if (window.jQuery && $(sel).data('select2')) {
792
+ $(sel).trigger('change');
793
+ }
794
+ });
795
+ })();
718
796
 
719
797
  // Remove applied filters
720
798
  this_context.find(".remove-filter-param").on("click", function(){
@@ -131,6 +131,7 @@ class VMInterfaceTestCase(TestCase): # TODO: change to BaseModelTestCase
131
131
  name="Int1", virtual_machine=self.virtualmachine, status=self.int_status
132
132
  )
133
133
  ips = list(IPAddress.objects.all()[:10])
134
+ self.assertEqual(len(ips), 10)
134
135
 
135
136
  # baseline (no vm_interface to ip address relationships exists)
136
137
  self.assertFalse(IPAddressToInterface.objects.filter(vm_interface=vm_interface).exists())
@@ -171,7 +172,8 @@ class VMInterfaceTestCase(TestCase): # TODO: change to BaseModelTestCase
171
172
  vm_interface = VMInterface.objects.create(
172
173
  name="Int1", virtual_machine=self.virtualmachine, status=self.int_status
173
174
  )
174
- ips = list(IPAddress.objects.all()[:10])
175
+ ips = list(IPAddress.objects.filter(ip_version=4)[:10])
176
+ self.assertEqual(len(ips), 10)
175
177
 
176
178
  # baseline (no vm_interface to ip address relationships exists)
177
179
  self.assertFalse(IPAddressToInterface.objects.filter(vm_interface=vm_interface).exists())
@@ -219,7 +221,7 @@ class VMInterfaceTestCase(TestCase): # TODO: change to BaseModelTestCase
219
221
  self.virtualmachine.refresh_from_db()
220
222
  self.assertEqual(self.virtualmachine.primary_ip4, None)
221
223
  # NOTE: This effectively tests what happens when you pass remove_ip_addresses None; it
222
- # NOTE: does not remove a v6 address, because there are no v6 IPs created in this test
224
+ # NOTE: does not remove a v6 address, because there are no v6 IPs used in this test
223
225
  # NOTE: class.
224
226
  count = vm_interface.remove_ip_addresses(self.virtualmachine.primary_ip6)
225
227
  self.assertEqual(count, 0)
@@ -379,6 +379,7 @@ class VirtualMachineUIViewSet(NautobotUIViewSet):
379
379
  detail=True,
380
380
  url_path="config-context",
381
381
  url_name="configcontext",
382
+ custom_view_base_action="view",
382
383
  custom_view_additional_permissions=["extras.view_configcontext"],
383
384
  )
384
385
  def config_context(self, request, pk):