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.
- nautobot/circuits/templates/circuits/circuit.html +1 -1
- nautobot/circuits/templates/circuits/circuittermination.html +1 -1
- nautobot/circuits/templates/circuits/circuittype.html +1 -1
- nautobot/circuits/templates/circuits/providernetwork.html +1 -1
- nautobot/core/cli/migrate_deprecated_templates.py +200 -0
- nautobot/core/jobs/__init__.py +2 -1
- nautobot/core/jobs/groups.py +31 -1
- nautobot/core/models/tree_queries.py +10 -5
- nautobot/core/signals.py +12 -1
- nautobot/core/templates/components/panel/panel.html +1 -1
- nautobot/core/templates/inc/image_attachments.html +2 -1
- nautobot/core/templatetags/helpers.py +22 -0
- nautobot/core/tests/runner.py +3 -0
- nautobot/core/tests/test_cli.py +40 -0
- nautobot/core/tests/test_forms.py +41 -0
- nautobot/core/tests/test_jobs.py +75 -1
- nautobot/core/tests/test_tree_queries.py +14 -1
- nautobot/core/ui/object_detail.py +41 -5
- nautobot/core/utils/filtering.py +11 -9
- nautobot/core/views/generic.py +3 -3
- nautobot/dcim/models/device_components.py +81 -68
- nautobot/dcim/templates/dcim/device/config.html +1 -1
- nautobot/dcim/templates/dcim/device/consoleports.html +1 -1
- nautobot/dcim/templates/dcim/device/consoleserverports.html +1 -1
- nautobot/dcim/templates/dcim/device/devicebays.html +1 -1
- nautobot/dcim/templates/dcim/device/frontports.html +1 -1
- nautobot/dcim/templates/dcim/device/interfaces.html +1 -1
- nautobot/dcim/templates/dcim/device/inventory.html +1 -1
- nautobot/dcim/templates/dcim/device/lldp_neighbors.html +1 -1
- nautobot/dcim/templates/dcim/device/modulebays.html +1 -1
- nautobot/dcim/templates/dcim/device/poweroutlets.html +1 -1
- nautobot/dcim/templates/dcim/device/powerports.html +1 -1
- nautobot/dcim/templates/dcim/device/rearports.html +1 -1
- nautobot/dcim/templates/dcim/device/status.html +1 -1
- nautobot/dcim/templates/dcim/device/wireless.html +1 -1
- nautobot/dcim/templates/dcim/device.html +1 -1
- nautobot/dcim/templates/dcim/device_interface_delete.html +1 -1
- nautobot/dcim/templates/dcim/devicetype.html +1 -1
- nautobot/dcim/templates/dcim/footer_convert_to_contact_or_team_record.html +14 -0
- nautobot/dcim/templates/dcim/interface_bulk_delete.html +1 -1
- nautobot/dcim/templates/dcim/inventoryitem_bulk_delete.html +1 -1
- nautobot/dcim/templates/dcim/location_retrieve.html +1 -242
- nautobot/dcim/templates/dcim/modulefamily_retrieve.html +1 -1
- nautobot/dcim/templates/dcim/powerfeed.html +1 -1
- nautobot/dcim/templates/dcim/powerpanel.html +1 -1
- nautobot/dcim/templates/dcim/virtualchassis.html +1 -1
- nautobot/dcim/tests/test_models.py +43 -3
- nautobot/dcim/tests/test_views.py +52 -21
- nautobot/dcim/views.py +203 -87
- nautobot/extras/api/views.py +9 -1
- nautobot/extras/filters/customfields.py +9 -3
- nautobot/extras/models/groups.py +42 -5
- nautobot/extras/signals.py +20 -19
- nautobot/extras/tables.py +31 -2
- nautobot/extras/templates/extras/computedfield.html +1 -1
- nautobot/extras/templates/extras/configcontext.html +1 -1
- nautobot/extras/templates/extras/configcontextschema_validation.html +1 -1
- nautobot/extras/templates/extras/customfield.html +1 -1
- nautobot/extras/templates/extras/dynamicgroup_retrieve.html +11 -5
- nautobot/extras/templates/extras/gitrepository_result.html +0 -2
- nautobot/extras/templates/extras/graphqlquery_retrieve.html +1 -96
- nautobot/extras/templates/extras/inc/graphqlquery_execute.html +71 -0
- nautobot/extras/templates/extras/object_dynamicgroups.html +2 -2
- nautobot/extras/templates/extras/secretsgroup.html +1 -1
- nautobot/extras/templates/extras/tag.html +1 -1
- nautobot/extras/tests/integration/test_dynamicgroups.py +5 -1
- nautobot/extras/tests/test_api.py +1 -0
- nautobot/extras/tests/test_changelog.py +28 -0
- nautobot/extras/tests/test_customfields.py +10 -2
- nautobot/extras/tests/test_dynamicgroups.py +37 -1
- nautobot/extras/views.py +49 -19
- nautobot/ipam/signals.py +71 -0
- nautobot/ipam/templates/ipam/prefix_delete.html +1 -1
- nautobot/ipam/templates/ipam/service.html +1 -1
- nautobot/ipam/templates/ipam/vlan.html +1 -1
- nautobot/ipam/templates/ipam/vlan_interfaces.html +1 -1
- nautobot/ipam/templates/ipam/vlan_vminterfaces.html +1 -1
- nautobot/ipam/tests/test_models.py +42 -0
- nautobot/users/templates/users/sessionkey_delete.html +1 -1
- nautobot/users/views.py +2 -2
- nautobot/virtualization/models.py +1 -68
- nautobot/virtualization/templates/virtualization/virtual_machine_vminterface_delete.html +1 -1
- nautobot/virtualization/templates/virtualization/virtualmachine.html +1 -1
- nautobot/virtualization/tests/test_models.py +42 -3
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/METADATA +9 -9
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/RECORD +90 -86
- nautobot-2.4.21.dist-info/entry_points.txt +4 -0
- nautobot-2.4.20.dist-info/entry_points.txt +0 -3
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.20.dist-info → nautobot-2.4.21.dist-info}/NOTICE +0 -0
- {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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
2316
|
-
if
|
|
2317
|
-
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"],
|
|
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
|
|
2339
|
-
available_power =
|
|
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"],
|
|
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
|
nautobot/extras/api/views.py
CHANGED
|
@@ -911,7 +911,15 @@ class JobButtonViewSet(NotesViewSetMixin, ModelViewSet):
|
|
|
911
911
|
#
|
|
912
912
|
|
|
913
913
|
|
|
914
|
-
class ScheduledJobViewSet(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
131
|
+
class CustomFieldNumberFilter(CustomFieldFilterMixin, django_filters.NumberFilter):
|
|
126
132
|
"""Custom field single value filter for backwards compatibility"""
|
|
127
133
|
|
|
128
134
|
field_class = IntegerField
|
nautobot/extras/models/groups.py
CHANGED
|
@@ -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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
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)):
|
nautobot/extras/signals.py
CHANGED
|
@@ -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
|
-
#
|
|
1226
|
-
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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
|
-
|
|
15
|
-
<div class="
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' %}
|