nautobot 2.4.20__py3-none-any.whl → 2.4.21__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.
Files changed (91) hide show
  1. nautobot/circuits/templates/circuits/circuit.html +1 -1
  2. nautobot/circuits/templates/circuits/circuittermination.html +1 -1
  3. nautobot/circuits/templates/circuits/circuittype.html +1 -1
  4. nautobot/circuits/templates/circuits/providernetwork.html +1 -1
  5. nautobot/core/cli/migrate_deprecated_templates.py +200 -0
  6. nautobot/core/jobs/__init__.py +2 -1
  7. nautobot/core/jobs/groups.py +31 -1
  8. nautobot/core/models/tree_queries.py +10 -5
  9. nautobot/core/signals.py +12 -1
  10. nautobot/core/templates/components/panel/panel.html +1 -1
  11. nautobot/core/templates/inc/image_attachments.html +2 -1
  12. nautobot/core/templatetags/helpers.py +22 -0
  13. nautobot/core/tests/runner.py +3 -0
  14. nautobot/core/tests/test_cli.py +40 -0
  15. nautobot/core/tests/test_forms.py +41 -0
  16. nautobot/core/tests/test_jobs.py +75 -1
  17. nautobot/core/tests/test_tree_queries.py +14 -1
  18. nautobot/core/ui/object_detail.py +41 -5
  19. nautobot/core/utils/filtering.py +11 -9
  20. nautobot/core/views/generic.py +3 -3
  21. nautobot/dcim/models/device_components.py +81 -68
  22. nautobot/dcim/templates/dcim/device/config.html +1 -1
  23. nautobot/dcim/templates/dcim/device/consoleports.html +1 -1
  24. nautobot/dcim/templates/dcim/device/consoleserverports.html +1 -1
  25. nautobot/dcim/templates/dcim/device/devicebays.html +1 -1
  26. nautobot/dcim/templates/dcim/device/frontports.html +1 -1
  27. nautobot/dcim/templates/dcim/device/interfaces.html +1 -1
  28. nautobot/dcim/templates/dcim/device/inventory.html +1 -1
  29. nautobot/dcim/templates/dcim/device/lldp_neighbors.html +1 -1
  30. nautobot/dcim/templates/dcim/device/modulebays.html +1 -1
  31. nautobot/dcim/templates/dcim/device/poweroutlets.html +1 -1
  32. nautobot/dcim/templates/dcim/device/powerports.html +1 -1
  33. nautobot/dcim/templates/dcim/device/rearports.html +1 -1
  34. nautobot/dcim/templates/dcim/device/status.html +1 -1
  35. nautobot/dcim/templates/dcim/device/wireless.html +1 -1
  36. nautobot/dcim/templates/dcim/device.html +1 -1
  37. nautobot/dcim/templates/dcim/device_interface_delete.html +1 -1
  38. nautobot/dcim/templates/dcim/devicetype.html +1 -1
  39. nautobot/dcim/templates/dcim/footer_convert_to_contact_or_team_record.html +14 -0
  40. nautobot/dcim/templates/dcim/interface_bulk_delete.html +1 -1
  41. nautobot/dcim/templates/dcim/inventoryitem_bulk_delete.html +1 -1
  42. nautobot/dcim/templates/dcim/location_retrieve.html +1 -242
  43. nautobot/dcim/templates/dcim/modulefamily_retrieve.html +1 -1
  44. nautobot/dcim/templates/dcim/powerfeed.html +1 -1
  45. nautobot/dcim/templates/dcim/powerpanel.html +1 -1
  46. nautobot/dcim/templates/dcim/virtualchassis.html +1 -1
  47. nautobot/dcim/tests/test_models.py +43 -3
  48. nautobot/dcim/tests/test_views.py +52 -21
  49. nautobot/dcim/views.py +203 -87
  50. nautobot/extras/api/views.py +9 -1
  51. nautobot/extras/filters/customfields.py +9 -3
  52. nautobot/extras/models/groups.py +42 -5
  53. nautobot/extras/signals.py +20 -19
  54. nautobot/extras/tables.py +31 -2
  55. nautobot/extras/templates/extras/computedfield.html +1 -1
  56. nautobot/extras/templates/extras/configcontext.html +1 -1
  57. nautobot/extras/templates/extras/configcontextschema_validation.html +1 -1
  58. nautobot/extras/templates/extras/customfield.html +1 -1
  59. nautobot/extras/templates/extras/dynamicgroup_retrieve.html +11 -5
  60. nautobot/extras/templates/extras/gitrepository_result.html +0 -2
  61. nautobot/extras/templates/extras/graphqlquery_retrieve.html +1 -96
  62. nautobot/extras/templates/extras/inc/graphqlquery_execute.html +71 -0
  63. nautobot/extras/templates/extras/object_dynamicgroups.html +2 -2
  64. nautobot/extras/templates/extras/secretsgroup.html +1 -1
  65. nautobot/extras/templates/extras/tag.html +1 -1
  66. nautobot/extras/tests/integration/test_dynamicgroups.py +5 -1
  67. nautobot/extras/tests/test_api.py +1 -0
  68. nautobot/extras/tests/test_changelog.py +28 -0
  69. nautobot/extras/tests/test_customfields.py +10 -2
  70. nautobot/extras/tests/test_dynamicgroups.py +37 -1
  71. nautobot/extras/views.py +49 -19
  72. nautobot/ipam/signals.py +71 -0
  73. nautobot/ipam/templates/ipam/prefix_delete.html +1 -1
  74. nautobot/ipam/templates/ipam/service.html +1 -1
  75. nautobot/ipam/templates/ipam/vlan.html +1 -1
  76. nautobot/ipam/templates/ipam/vlan_interfaces.html +1 -1
  77. nautobot/ipam/templates/ipam/vlan_vminterfaces.html +1 -1
  78. nautobot/ipam/tests/test_models.py +42 -0
  79. nautobot/users/templates/users/sessionkey_delete.html +1 -1
  80. nautobot/users/views.py +2 -2
  81. nautobot/virtualization/models.py +1 -68
  82. nautobot/virtualization/templates/virtualization/virtual_machine_vminterface_delete.html +1 -1
  83. nautobot/virtualization/templates/virtualization/virtualmachine.html +1 -1
  84. nautobot/virtualization/tests/test_models.py +42 -3
  85. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/METADATA +9 -9
  86. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/RECORD +90 -86
  87. nautobot-2.4.21.dist-info/entry_points.txt +4 -0
  88. nautobot-2.4.20.dist-info/entry_points.txt +0 -3
  89. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/LICENSE.txt +0 -0
  90. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/NOTICE +0 -0
  91. {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/WHEEL +0 -0
nautobot/dcim/views.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from collections import OrderedDict
2
2
  from copy import deepcopy
3
+ from functools import partial
3
4
  import logging
4
5
  import re
5
6
  import uuid
@@ -20,7 +21,7 @@ from django.template import Context
20
21
  from django.template.loader import render_to_string
21
22
  from django.urls import reverse
22
23
  from django.utils.encoding import iri_to_uri
23
- from django.utils.html import format_html, mark_safe
24
+ from django.utils.html import format_html, format_html_join, mark_safe
24
25
  from django.utils.http import url_has_allowed_host_and_scheme, urlencode
25
26
  from django.views.generic import View
26
27
  from django_tables2 import RequestConfig
@@ -278,6 +279,106 @@ class LocationTypeUIViewSet(NautobotUIViewSet):
278
279
  #
279
280
 
280
281
 
282
+ class LocationGeographicalInfoFieldsPanel(object_detail.ObjectFieldsPanel):
283
+ def get_data(self, context):
284
+ data = super().get_data(context)
285
+ obj = get_obj_from_context(context, self.context_object_key)
286
+
287
+ if obj and obj.latitude and obj.longitude:
288
+ data["GPS Coordinates"] = f"{obj.latitude}, {obj.longitude}"
289
+ else:
290
+ data["GPS Coordinates"] = None
291
+
292
+ return data
293
+
294
+ def render_value(self, key, value, context):
295
+ if key == "GPS Coordinates":
296
+ if value is not None:
297
+ return helpers.render_address(value)
298
+ return helpers.HTML_NONE
299
+
300
+ return super().render_value(key, value, context)
301
+
302
+
303
+ class LocationRackGroupsPanel(object_detail.Panel):
304
+ def render_rack_row(self, indent_px, url, name, count, elevation_url):
305
+ """Render a single <tr> for a rack group or summary row."""
306
+ return format_html(
307
+ """
308
+ <tr>
309
+ <td style="padding-left: {}px">
310
+ <i class="mdi mdi-folder-open"></i>
311
+ <a href="{}">{}</a>
312
+ </td>
313
+ <td>{}</td>
314
+ <td class="text-right noprint">
315
+ <a href="{}" class="btn btn-xs btn-primary" title="View elevations">
316
+ <i class="mdi mdi-server"></i>
317
+ </a>
318
+ </td>
319
+ </tr>
320
+ """,
321
+ indent_px,
322
+ url,
323
+ name,
324
+ count,
325
+ elevation_url,
326
+ )
327
+
328
+ def render_body_content(self, context):
329
+ """Render the <tbody> content for the Rack Groups table."""
330
+ obj = get_obj_from_context(context)
331
+ if not obj:
332
+ return ""
333
+
334
+ rack_groups = context.get("rack_groups", [])
335
+ rack_count = context.get("rack_count", 0)
336
+
337
+ rows = []
338
+
339
+ # Render each rack group row
340
+ for rack_group in rack_groups:
341
+ rows.append(
342
+ self.render_rack_row(
343
+ getattr(rack_group, "tree_depth", 0) * 8,
344
+ rack_group.get_absolute_url(),
345
+ str(rack_group),
346
+ rack_group.rack_count,
347
+ f"{reverse('dcim:rack_elevation_list')}?rack_group={rack_group.pk}",
348
+ )
349
+ )
350
+
351
+ # Add "All racks" row
352
+ rows.append(
353
+ self.render_rack_row(
354
+ 10,
355
+ "#",
356
+ "All racks",
357
+ rack_count,
358
+ f"{reverse('dcim:rack_elevation_list')}?location={obj.pk}",
359
+ )
360
+ )
361
+
362
+ return format_html_join("", "{}", ((row,) for row in rows))
363
+
364
+
365
+ class LocationImageAttachmentsTablePanel(object_detail.ObjectsTablePanel):
366
+ """
367
+ ObjectsTablePanel with a custom _get_table_add_url() implementation.
368
+
369
+ Needed because the URL is `/dcim/devices/<pk>/images/add/`, not `extras/image-attachments/add?location=<pk>`.
370
+ """
371
+
372
+ def _get_table_add_url(self, context):
373
+ obj = get_obj_from_context(context)
374
+ request = context["request"]
375
+ return_url = context.get("return_url", obj.get_absolute_url())
376
+
377
+ if not request.user.has_perms(["extras.add_imageattachment"]):
378
+ return None
379
+ return reverse("dcim:location_add_image", kwargs={"object_id": obj.pk}) + f"?return_url={return_url}"
380
+
381
+
281
382
  class LocationUIViewSet(NautobotUIViewSet):
282
383
  # We are only accessing the tree fields from the list view, where `with_tree_fields` is called dynamically
283
384
  # depending on whether the hierarchy is shown in the UI (note that `parent` itself is a normal foreign key, not a
@@ -292,70 +393,116 @@ class LocationUIViewSet(NautobotUIViewSet):
292
393
  bulk_update_form_class = forms.LocationBulkEditForm
293
394
  serializer_class = serializers.LocationSerializer
294
395
  breadcrumbs = AncestorsBreadcrumbs(detail_item_label=context_object_attr("name"))
396
+ view_titles = Titles(titles={"detail": "{{ object.name }}"})
397
+
398
+ object_detail_content = object_detail.ObjectDetailContent(
399
+ panels=(
400
+ object_detail.ObjectFieldsPanel(
401
+ weight=100,
402
+ section=SectionChoices.LEFT_HALF,
403
+ fields=[
404
+ "location_type",
405
+ "status",
406
+ "parent",
407
+ "tenant",
408
+ "facility",
409
+ "asn",
410
+ "time_zone",
411
+ "description",
412
+ ],
413
+ value_transforms={
414
+ "location_type": [partial(helpers.hyperlinked_object, field="name")],
415
+ "time_zone": [helpers.format_timezone],
416
+ },
417
+ key_transforms={
418
+ "asn": "AS Number",
419
+ },
420
+ ),
421
+ LocationGeographicalInfoFieldsPanel(
422
+ weight=110,
423
+ section=SectionChoices.LEFT_HALF,
424
+ label="Geographical Info",
425
+ fields=[
426
+ "physical_address",
427
+ "shipping_address",
428
+ ],
429
+ value_transforms={
430
+ "physical_address": [helpers.render_address],
431
+ "shipping_address": [helpers.render_address],
432
+ },
433
+ ),
434
+ object_detail.ObjectFieldsPanel(
435
+ weight=120,
436
+ section=SectionChoices.LEFT_HALF,
437
+ label="Contact Info",
438
+ fields=["contact_name", "contact_phone", "contact_email"],
439
+ value_transforms={
440
+ "contact_phone": [helpers.hyperlinked_phone_number],
441
+ "contact_email": [helpers.hyperlinked_email],
442
+ },
443
+ footer_content_template_path="dcim/footer_convert_to_contact_or_team_record.html",
444
+ ),
445
+ object_detail.StatsPanel(
446
+ weight=100,
447
+ label="Stats",
448
+ section=SectionChoices.RIGHT_HALF,
449
+ filter_name="location",
450
+ related_models=[
451
+ Rack,
452
+ Device,
453
+ Prefix,
454
+ VLAN,
455
+ (Circuit, "circuit_terminations__location__in"),
456
+ (VirtualMachine, "cluster__location__in"),
457
+ ],
458
+ ),
459
+ LocationRackGroupsPanel(
460
+ label="Rack Groups",
461
+ section=SectionChoices.RIGHT_HALF,
462
+ weight=200,
463
+ body_wrapper_template_path="components/panel/body_wrapper_generic_table.html",
464
+ ),
465
+ LocationImageAttachmentsTablePanel(
466
+ weight=300,
467
+ section=SectionChoices.RIGHT_HALF,
468
+ table_title="Images",
469
+ table_class=ImageAttachmentTable,
470
+ table_attribute="images",
471
+ related_field_name="location",
472
+ show_table_config_button=False,
473
+ ),
474
+ object_detail.ObjectsTablePanel(
475
+ section=SectionChoices.FULL_WIDTH,
476
+ weight=100,
477
+ table_title="Children",
478
+ table_class=tables.LocationTable,
479
+ table_attribute="children",
480
+ related_field_name="parent",
481
+ order_by_fields=["name"],
482
+ hide_hierarchy_ui=True,
483
+ ),
484
+ )
485
+ )
295
486
 
296
487
  def get_extra_context(self, request, instance):
297
488
  if instance is None:
298
489
  return super().get_extra_context(request, instance)
490
+
299
491
  # This query can get really expensive when there are big location trees in the DB. By casting it to a list we
300
492
  # ensure it is only performed once rather than as a subquery for each of the different count stats.
301
493
  related_locations = list(
302
494
  instance.descendants(include_self=True).restrict(request.user, "view").values_list("pk", flat=True)
303
495
  )
304
- prefix_count_queryset = Prefix.objects.restrict(request.user, "view").filter(locations__in=related_locations)
305
- vlan_count_queryset = VLAN.objects.restrict(request.user, "view").filter(locations__in=related_locations)
306
- circuit_count_queryset = Circuit.objects.restrict(request.user, "view").filter(
307
- circuit_terminations__location__in=related_locations
308
- )
309
- # When there is more than one location, the models that can be assigned to more then one location at the same
310
- # time need to be queried with `distinct`. We are avoiding `distinct` when this is not the case, as it incurs
311
- # a performance penalty.
312
- if len(related_locations) > 1:
313
- prefix_count_queryset = prefix_count_queryset.distinct()
314
- vlan_count_queryset = vlan_count_queryset.distinct()
315
- circuit_count_queryset = circuit_count_queryset.distinct()
316
- stats = {
317
- "prefix_count": prefix_count_queryset.count(),
318
- "vlan_count": vlan_count_queryset.count(),
319
- "circuit_count": circuit_count_queryset.count(),
320
- "rack_count": Rack.objects.restrict(request.user, "view").filter(location__in=related_locations).count(),
321
- "device_count": Device.objects.restrict(request.user, "view")
322
- .filter(location__in=related_locations)
323
- .count(),
324
- "vm_count": VirtualMachine.objects.restrict(request.user, "view")
325
- .filter(cluster__location__in=related_locations)
326
- .count(),
327
- }
496
+
328
497
  rack_groups = (
329
498
  RackGroup.objects.annotate(rack_count=count_related(Rack, "rack_group"))
330
499
  .restrict(request.user, "view")
331
500
  .filter(location__in=related_locations)
332
501
  )
333
- children = (
334
- Location.objects.restrict(request.user, "view")
335
- # We aren't accessing tree fields anywhere so this is safe (note that `parent` itself is a normal foreign
336
- # key, not a tree field). If we ever do access tree fields, this will perform worse, because django will
337
- # automatically issue a second query (similar to behavior for
338
- # https://docs.djangoproject.com/en/3.2/ref/models/querysets/#django.db.models.query.QuerySet.only)
339
- .without_tree_fields()
340
- .filter(parent=instance)
341
- .select_related("parent", "location_type")
342
- )
343
-
344
- children_table = tables.LocationTable(children, hide_hierarchy_ui=True)
345
- paginate = {
346
- "paginator_class": EnhancedPaginator,
347
- "per_page": get_paginate_count(request),
348
- }
349
- RequestConfig(request, paginate).configure(children_table)
350
502
 
351
503
  return {
352
- "children_table": children_table,
353
504
  "rack_groups": rack_groups,
354
- "stats": stats,
355
- "contact_association_permission": ["extras.add_contactassociation"],
356
- # show the button if any of these fields have non-empty value.
357
- "show_convert_to_contact_button": instance.contact_name or instance.contact_phone or instance.contact_email,
358
- **super().get_extra_context(request, instance),
505
+ "rack_count": Rack.objects.restrict(request.user, "view").filter(location__in=related_locations).count(),
359
506
  }
360
507
 
361
508
 
@@ -2182,34 +2329,6 @@ class DeviceUIViewSet(NautobotUIViewSet):
2182
2329
  Override base ObjectDetailContent to render dynamic-groups table as a separate view/tab instead of inline.
2183
2330
  """
2184
2331
 
2185
- class DeviceDynamicGroupsTextPanel(object_detail.BaseTextPanel):
2186
- """Panel displaying a note about caching of dynamic groups."""
2187
-
2188
- def __init__(
2189
- self,
2190
- *,
2191
- weight,
2192
- render_as=object_detail.BaseTextPanel.RenderOptions.MARKDOWN,
2193
- label="Dynamic Group caching",
2194
- **kwargs,
2195
- ):
2196
- super().__init__(weight=weight, render_as=render_as, label=label, **kwargs)
2197
-
2198
- def get_value(self, context):
2199
- dg_list_url = reverse("extras:dynamicgroup_list")
2200
- job_run_url = reverse(
2201
- "extras:job_run_by_class_path",
2202
- kwargs={"class_path": "nautobot.core.jobs.groups.RefreshDynamicGroupCaches"},
2203
- )
2204
- return (
2205
- "Dynamic group membership is cached for performance reasons, "
2206
- "therefore this page may not always be up-to-date.\n\n"
2207
- "You can refresh the membership of any specific group by viewing it from the list below or from the "
2208
- f"[Dynamic Groups list view]({dg_list_url}).\n\n"
2209
- "You can also refresh the membership of **all** groups by running the "
2210
- f"[Refresh Dynamic Group Caches job]({job_run_url})."
2211
- )
2212
-
2213
2332
  def __init__(self, **kwargs):
2214
2333
  super().__init__(**kwargs)
2215
2334
  # Remove inline tab definition
@@ -2225,7 +2344,7 @@ class DeviceUIViewSet(NautobotUIViewSet):
2225
2344
  url_name="dcim:device_dynamicgroups",
2226
2345
  related_object_attribute="dynamic_groups",
2227
2346
  panels=(
2228
- self.DeviceDynamicGroupsTextPanel(weight=100),
2347
+ object_detail.DynamicGroupsTextPanel(weight=100),
2229
2348
  object_detail.ObjectsTablePanel(
2230
2349
  weight=200,
2231
2350
  table_class=DynamicGroupTable,
@@ -2312,11 +2431,11 @@ class DeviceUIViewSet(NautobotUIViewSet):
2312
2431
  for powerport in instance.all_power_ports.all():
2313
2432
  utilization = powerport.get_power_draw()
2314
2433
  # Table row for each power-port
2315
- powerfeed = powerport.connected_endpoint
2316
- if powerfeed is not None and powerfeed.available_power:
2317
- available_power = powerfeed.available_power
2434
+ connected_endpoint = powerport.connected_endpoint
2435
+ if isinstance(connected_endpoint, PowerFeed) and connected_endpoint.available_power:
2436
+ available_power = connected_endpoint.available_power
2318
2437
  utilization_data = Context(
2319
- helpers.utilization_graph_raw_data(utilization["allocated"], powerfeed.available_power)
2438
+ helpers.utilization_graph_raw_data(utilization["allocated"], connected_endpoint.available_power)
2320
2439
  )
2321
2440
  utilization_graph = object_detail.render_component_template(
2322
2441
  "utilities/templatetags/utilization_graph.html", utilization_data
@@ -2335,13 +2454,10 @@ class DeviceUIViewSet(NautobotUIViewSet):
2335
2454
 
2336
2455
  # Indented table row for each leg of a three-phase power-port.
2337
2456
  for leg in utilization["legs"]:
2338
- if powerfeed is not None and powerfeed.available_power:
2339
- available_power = powerfeed.available_power / 3
2457
+ if isinstance(connected_endpoint, PowerFeed) and connected_endpoint.available_power:
2458
+ available_power = connected_endpoint.available_power / 3
2340
2459
  utilization_data = Context(
2341
- helpers.utilization_graph_raw_data(leg["allocated"], powerfeed.available_power / 3)
2342
- )
2343
- utilization_graph = object_detail.render_component_template(
2344
- "utilities/templatetags/utilization_graph.html", utilization_data
2460
+ helpers.utilization_graph_raw_data(leg["allocated"], connected_endpoint.available_power / 3)
2345
2461
  )
2346
2462
  else:
2347
2463
  available_power = helpers.HTML_NONE
@@ -911,7 +911,15 @@ class JobButtonViewSet(NotesViewSetMixin, ModelViewSet):
911
911
  #
912
912
 
913
913
 
914
- class ScheduledJobViewSet(ReadOnlyModelViewSet):
914
+ class ScheduledJobViewSet(
915
+ # DRF mixins:
916
+ # note no CreateModelMixin or UpdateModelMixin
917
+ mixins.DestroyModelMixin,
918
+ # Nautobot mixins:
919
+ BulkDestroyModelMixin,
920
+ # Base class
921
+ ReadOnlyModelViewSet,
922
+ ):
915
923
  """
916
924
  Retrieve a list of scheduled jobs
917
925
  """
@@ -1,3 +1,6 @@
1
+ from functools import reduce
2
+ import operator
3
+
1
4
  from django.db.models import Q
2
5
  from django.forms import IntegerField
3
6
  import django_filters
@@ -47,7 +50,10 @@ class CustomFieldFilterMixin:
47
50
  if value == "null" or value == ["null"]:
48
51
  return Q(**{f"{self.field_name}__exact": None}) & Q(**{f"{self.field_name}__isnull": False})
49
52
 
50
- value_match = Q(**{f"{self.field_name}__{self.lookup_expr}": value})
53
+ if isinstance(value, (list, tuple)):
54
+ value_match = reduce(operator.or_, [Q(**{f"{self.field_name}__{self.lookup_expr}": v}) for v in value])
55
+ else:
56
+ value_match = Q(**{f"{self.field_name}__{self.lookup_expr}": value})
51
57
  value_is_not_none = ~Q(**{f"{self.field_name}__exact": None})
52
58
  key_is_present_in_jsonb = Q(
53
59
  **{f"{self.field_name}__isnull": False}
@@ -88,7 +94,7 @@ class CustomFieldBooleanFilter(CustomFieldFilterMixin, django_filters.BooleanFil
88
94
  """Custom field single value filter for backwards compatibility"""
89
95
 
90
96
 
91
- class CustomFieldCharFilter(CustomFieldFilterMixin, django_filters.Filter):
97
+ class CustomFieldCharFilter(CustomFieldFilterMixin, django_filters.CharFilter):
92
98
  """Custom field single value filter for backwards compatibility"""
93
99
 
94
100
 
@@ -122,7 +128,7 @@ class CustomFieldMultiSelectFilter(CustomFieldSelectFilter):
122
128
  super().__init__(*args, **kwargs)
123
129
 
124
130
 
125
- class CustomFieldNumberFilter(CustomFieldFilterMixin, django_filters.Filter):
131
+ class CustomFieldNumberFilter(CustomFieldFilterMixin, django_filters.NumberFilter):
126
132
  """Custom field single value filter for backwards compatibility"""
127
133
 
128
134
  field_class = IntegerField
@@ -628,6 +628,10 @@ class DynamicGroup(PrimaryModel):
628
628
  else:
629
629
  # Validate against the filterset's internal form validation.
630
630
  filterset = self.filterset_class(self.filter) # pylint: disable=not-callable
631
+ # TODO: the below is more generous than one might expect. For example, passing a list of strings ["foo"]
632
+ # to a (single-input) CharFilter will quietly normalize the list to a string '["foo"]' instead of reporting
633
+ # any failure of is_valid(). We've had cases of such "should be invalid but isn't caught" DynamicGroups causing
634
+ # exceptions when trying to evaluate their membership; it would be good to be stricter here instead!
631
635
  if not filterset.is_valid():
632
636
  raise ValidationError(filterset.errors)
633
637
 
@@ -661,6 +665,22 @@ class DynamicGroup(PrimaryModel):
661
665
 
662
666
  # TODO limit most changes to self.group_type as well.
663
667
 
668
+ def save(self, *args, update_cached_members=True, **kwargs):
669
+ """
670
+ Save the DynamicGroup record.
671
+
672
+ Args:
673
+ update_cached_members (bool): If True, (re)calculate the cached members set of the related group(s) immediately.
674
+ Note that this is potentially quite expensive if there will be a large change in the members set!
675
+ If False (recommended), you can call `self.update_cached_members()` explicitly when ready.
676
+ """
677
+ super().save(*args, **kwargs)
678
+
679
+ if update_cached_members:
680
+ self.update_cached_members()
681
+ for ancestor in self.get_ancestors():
682
+ ancestor.update_cached_members()
683
+
664
684
  def _generate_query_for_filter(self, filter_field, value):
665
685
  """
666
686
  Return a `Q` object generated from a `filter_field` and `value`.
@@ -703,9 +723,12 @@ class DynamicGroup(PrimaryModel):
703
723
  # "ams02"]}`, the value being a list of location names (`["ams01", "ams02"]`).
704
724
  if value and isinstance(value, list) and isinstance(value[0], str) and not is_uuid(value[0]):
705
725
  model_field = django_filters.utils.get_model_field(self._model, filter_field.field_name)
706
- related_model = model_field.related_model
707
- lookup_kwargs = {f"{to_field_name}__in": value}
708
- gq_value = related_model.objects.filter(**lookup_kwargs)
726
+ if model_field is None:
727
+ gq_value = value
728
+ else:
729
+ related_model = model_field.related_model
730
+ lookup_kwargs = {f"{to_field_name}__in": value}
731
+ gq_value = related_model.objects.filter(**lookup_kwargs)
709
732
  else:
710
733
  gq_value = value
711
734
  query |= filter_field.generate_query(gq_value)
@@ -1172,12 +1195,26 @@ class DynamicGroupMembership(BaseModel):
1172
1195
  if self.group in self.parent_group.get_ancestors():
1173
1196
  raise ValidationError({"group": "Cannot add ancestor as a child"})
1174
1197
 
1175
- def save(self, *args, **kwargs):
1198
+ def save(self, *args, update_cached_members=True, **kwargs):
1199
+ """
1200
+ Save the DynamicGroupMembership record.
1201
+
1202
+ Args:
1203
+ update_cached_members (bool): If True, (re)calculate the cached members set of the related group(s) immediately.
1204
+ Note that this is potentially quite expensive if there will be a large change in the members set!
1205
+ If False (recommended), you can call `self.parent_group.update_cached_members()` explicitly when ready.
1206
+ """
1176
1207
  # For backwards compatibility
1177
1208
  if self.parent_group.group_type == DynamicGroupTypeChoices.TYPE_DYNAMIC_FILTER and not self.parent_group.filter:
1178
1209
  self.parent_group.group_type = DynamicGroupTypeChoices.TYPE_DYNAMIC_SET
1179
1210
  self.parent_group.save()
1180
- return super().save(*args, **kwargs)
1211
+
1212
+ super().save(*args, **kwargs)
1213
+
1214
+ if update_cached_members:
1215
+ self.parent_group.update_cached_members()
1216
+ for ancestor in self.parent_group.get_ancestors():
1217
+ ancestor.update_cached_members()
1181
1218
 
1182
1219
 
1183
1220
  class StaticGroupAssociationManager(BaseManager.from_queryset(RestrictedQuerySet)):
@@ -23,7 +23,7 @@ import redis.exceptions
23
23
  from nautobot.core.celery import app, import_jobs
24
24
  from nautobot.core.models import BaseModel
25
25
  from nautobot.core.utils.logging import sanitize
26
- from nautobot.extras.choices import JobResultStatusChoices, ObjectChangeActionChoices
26
+ from nautobot.extras.choices import ButtonClassChoices, JobResultStatusChoices, ObjectChangeActionChoices
27
27
  from nautobot.extras.constants import CHANGELOG_MAX_CHANGE_CONTEXT_DETAIL
28
28
  from nautobot.extras.models import (
29
29
  ComputedField,
@@ -542,24 +542,6 @@ m2m_changed.connect(dynamic_group_children_changed, sender=DynamicGroup.children
542
542
  pre_save.connect(dynamic_group_membership_created, sender=DynamicGroupMembership)
543
543
 
544
544
 
545
- def dynamic_group_update_cached_members(sender, instance, **kwargs):
546
- """
547
- When a DynamicGroup or DynamicGroupMembership is updated, update the cache of members for it and any parent groups.
548
- """
549
- if isinstance(instance, DynamicGroupMembership):
550
- group = instance.parent_group
551
- else:
552
- group = instance
553
-
554
- group.update_cached_members()
555
- for ancestor in group.get_ancestors():
556
- ancestor.update_cached_members()
557
-
558
-
559
- post_save.connect(dynamic_group_update_cached_members, sender=DynamicGroup)
560
- post_save.connect(dynamic_group_update_cached_members, sender=DynamicGroupMembership)
561
-
562
-
563
545
  #
564
546
  # Jobs
565
547
  #
@@ -644,6 +626,25 @@ def refresh_job_models(sender, *, apps, **kwargs):
644
626
  job_model.installed = False
645
627
  job_model.save()
646
628
 
629
+ # Wire up the JobButton for Dynamic Group member refresh
630
+ JobButton = apps.get_model("extras", "JobButton")
631
+ ContentType = apps.get_model("contenttypes", "ContentType") # pylint: disable=redefined-outer-name
632
+ DynamicGroup = apps.get_model("extras", "DynamicGroup") # pylint: disable=redefined-outer-name
633
+
634
+ dg_job_button, _ = JobButton.objects.get_or_create(
635
+ name="Refresh Dynamic Group Members Cache",
636
+ job=Job.objects.get(
637
+ module_name="nautobot.core.jobs.groups", job_class_name="RefreshDynamicGroupCacheJobButtonReceiver"
638
+ ),
639
+ defaults={
640
+ "enabled": True,
641
+ "text": "Refresh Members",
642
+ "button_class": ButtonClassChoices.CLASS_WARNING,
643
+ "confirmation": True,
644
+ },
645
+ )
646
+ dg_job_button.content_types.add(ContentType.objects.get_for_model(DynamicGroup))
647
+
647
648
 
648
649
  #
649
650
  # Metadata
nautobot/extras/tables.py CHANGED
@@ -1,4 +1,8 @@
1
+ import logging
2
+ from textwrap import dedent
3
+
1
4
  from django.conf import settings
5
+ from django.contrib.contenttypes.models import ContentType
2
6
  from django.db.models import QuerySet
3
7
  from django.utils.html import format_html, format_html_join
4
8
  import django_tables2 as tables
@@ -67,6 +71,8 @@ from .models import (
67
71
  )
68
72
  from .registry import registry
69
73
 
74
+ logger = logging.getLogger(__name__)
75
+
70
76
  ASSIGNED_OBJECT = """
71
77
  {% load helpers %}
72
78
  {{ record.assigned_object|hyperlinked_object }}
@@ -1222,8 +1228,31 @@ class ObjectChangeTable(BaseTable):
1222
1228
 
1223
1229
  def __init__(self, *args, **kwargs):
1224
1230
  super().__init__(*args, **kwargs)
1225
- # The `object_repr` column also uses the `changed_object` generic-foreign-key value
1226
- self.add_conditional_prefetch("object_repr", "changed_object")
1231
+ # Only prefetch if all content types are valid
1232
+ if all(ct.model_class() is not None for ct in ContentType.objects.all()):
1233
+ self.add_conditional_prefetch("object_repr", "changed_object")
1234
+ else:
1235
+ error_message = dedent("""\
1236
+ One or more ContentType entries in the database are invalid.
1237
+ This will likely cause performance degradation when viewing the Object Change log.
1238
+ An administrator can follow these steps to resolve common issues:
1239
+ - Run `nautobot-server remove_stale_contenttypes`
1240
+ - Run `nautobot-server migrate <app_label> zero` for any app labels which no longer exist
1241
+ - Manually dropping tables for any models which have been removed from Nautobot or its plugins from your database
1242
+ - Run ```
1243
+ from django.contrib.contenttypes.models import ContentType
1244
+ qs = ContentType.objects.filter(
1245
+ app_label__in=[
1246
+ "<app_label_of_removed_plugin_1>",
1247
+ "<app_label_of_removed_plugin_2>",
1248
+ ]
1249
+ ) | ContentType.objects.filter(model__icontains="<name_of_removed_model_1>")
1250
+ # Review the queryset before running delete
1251
+ qs.delete()
1252
+ ```
1253
+ Please ensure you fully understand the implications of these actions before proceeding.
1254
+ """)
1255
+ logger.warning(error_message)
1227
1256
 
1228
1257
 
1229
1258
  #
@@ -1,2 +1,2 @@
1
- {% extends 'extras/computedfield_retrieve.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
  {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -1,2 +1,2 @@
1
- {% extends 'extras/configcontext_retrieve.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
  {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -1,4 +1,4 @@
1
- {% extends 'extras/configcontextschema.html' %}
1
+ {% extends 'extras/configcontextschema_retrieve.html' %}
2
2
 
3
3
  <h1>{% block title %}{{ object }} - Validation{% endblock %}</h1>
4
4
 
@@ -1,2 +1,2 @@
1
- {% extends 'extras/customfield_retrieve.html' %}
1
+ {% extends 'generic/object_retrieve.html' %}
2
2
  {% comment %}3.0 TODO: remove this template, which only exists for backward compatibility with 2.4 and earlier{% endcomment %}
@@ -11,15 +11,21 @@
11
11
 
12
12
  {% block extra_tab_content %}
13
13
  <div id="members" role="tabpanel" class="tab-pane {% if not active_tab and not request.GET.tab or request.GET.tab == "members" %}active{% else %}fade{% endif %}">
14
- {% if members_list_url %}
15
- <div class="row">
16
- <div class="col-md-12">
14
+ <div class="row">
15
+ <div class="col-md-12">
16
+ {% if members_list_url %}
17
17
  <div class="alert alert-success" role="alert">
18
18
  You can bulk-add and bulk-remove members of this group from the <a href="{{ members_list_url }}">{{ members_verbose_name_plural|bettertitle }} list view</a>.
19
19
  </div>
20
- </div>
20
+ {% else %}
21
+ <div class="alert alert-warning" role="alert">
22
+ Dynamic group membership is cached for performance reasons, therefore this listing may not always
23
+ be up-to-date.<br>You can refresh the membership of this group asynchronously by clicking the
24
+ "Refresh Members" button above.
25
+ </div>
26
+ {% endif %}
21
27
  </div>
22
- {% endif %}
28
+ </div>
23
29
  <div class="row">
24
30
  <div class="col-md-12">
25
31
  {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Dynamic Group members' %}
@@ -3,8 +3,6 @@
3
3
  {% load log_levels %}
4
4
  {% load static %}
5
5
 
6
- {% block title %}{{ block.super }} - Synchronization Status{% endblock %}
7
-
8
6
  {% block content %}
9
7
  <div class="row">
10
8
  <div class="col-md-12">