diff --git a/netbox_cmdb/netbox_cmdb/api/cmdb/views.py b/netbox_cmdb/netbox_cmdb/api/cmdb/views.py index 3d2ea01..1315d80 100644 --- a/netbox_cmdb/netbox_cmdb/api/cmdb/views.py +++ b/netbox_cmdb/netbox_cmdb/api/cmdb/views.py @@ -1,57 +1,169 @@ +from dcim.models import Device, Site from django.db import transaction -from django.db.models import Q +from django.http import StreamingHttpResponse from drf_yasg.utils import swagger_auto_schema from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from rest_framework import serializers, status from rest_framework.response import Response from rest_framework.views import APIView -from netbox_cmdb.models.bgp import BGPPeerGroup, BGPSession, DeviceBGPSession -from netbox_cmdb.models.bgp_community_list import BGPCommunityList -from netbox_cmdb.models.prefix_list import PrefixList -from netbox_cmdb.models.route_policy import RoutePolicy -from netbox_cmdb.models.snmp import SNMP +from netbox_cmdb.helpers import cleaning -class DeleteAllCMDBObjectsRelatedToDeviceSerializer(serializers.Serializer): +class DeviceDecommissioningBaseSerializer(serializers.Serializer): device_name = serializers.CharField() -class DeleteAllCMDBObjectsRelatedToDevice(APIView): +class DeviceCMDBDecommissioningAPIView(APIView): permission_classes = [IsAuthenticatedOrLoginNotRequired] @swagger_auto_schema( - request_body=DeleteAllCMDBObjectsRelatedToDeviceSerializer, + request_body=DeviceDecommissioningBaseSerializer, responses={ status.HTTP_200_OK: "Objects related to device have been deleted successfully", status.HTTP_400_BAD_REQUEST: "Bad Request: Device name is required", + status.HTTP_404_NOT_FOUND: "Bad Request: Device not found", status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Server Error: Something went wrong on the server", }, ) - def post(self, request): + def delete(self, request): device_name = request.data.get("device_name", None) if device_name is None: return Response( - {"error": "Device name is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "device_name is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + devices = Device.objects.filter(name=device_name) + device_ids = [dev.id for dev in devices] + if not device_ids: + return Response( + {"error": "no matching devices found"}, status=status.HTTP_404_NOT_FOUND ) try: with transaction.atomic(): - # Delete objects in reverse order of dependencies - BGPSession.objects.filter( - Q(peer_a__device__name=device_name) | Q(peer_b__device__name=device_name) - ).delete() - DeviceBGPSession.objects.filter(device__name=device_name).delete() - BGPPeerGroup.objects.filter(device__name=device_name).delete() - RoutePolicy.objects.filter(device__name=device_name).delete() - PrefixList.objects.filter(device__name=device_name).delete() - BGPCommunityList.objects.filter(device_name=device_name).delete() - SNMP.objects.filter(device__name=device_name).delete() + deleted = cleaning.clean_cmdb_for_devices(device_ids) except Exception as e: return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response( - {"message": f"Objects related to device {device_name} have been deleted successfully"}, + { + "message": f"CMDB cleaned for {device_name}", + "deleted": deleted, + }, status=status.HTTP_200_OK, ) + + +class DeviceDecommissioningAPIView(APIView): + + permission_classes = [IsAuthenticatedOrLoginNotRequired] + + @swagger_auto_schema( + request_body=DeviceDecommissioningBaseSerializer, + responses={ + status.HTTP_200_OK: "Objects related to device have been deleted successfully", + status.HTTP_400_BAD_REQUEST: "Bad Request: Device name is required", + status.HTTP_404_NOT_FOUND: "Bad Request: Device not found", + status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Server Error: Something went wrong on the server", + }, + ) + def delete(self, request): + device_name = request.data.get("device_name", None) + if device_name is None: + return Response( + {"error": "device_name is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + devices = Device.objects.filter(name=device_name) + device_ids = [dev.id for dev in devices] + if not device_ids: + return Response( + {"error": "no matching devices found"}, status=status.HTTP_404_NOT_FOUND + ) + + try: + with transaction.atomic(): + deleted = cleaning.clean_cmdb_for_devices(device_ids) + for device in devices: + device.delete() + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response( + { + "message": f"{device_name} decommissionned", + "deleted": deleted, + }, + status=status.HTTP_200_OK, + ) + + +class SiteDecommissioningSerializer(serializers.Serializer): + site_name = serializers.CharField() + + +class SiteDecommissioningAPIView(APIView): + + permission_classes = [IsAuthenticatedOrLoginNotRequired] + + @swagger_auto_schema( + request_body=SiteDecommissioningSerializer, + responses={ + status.HTTP_200_OK: "Site have been deleted successfully", + status.HTTP_400_BAD_REQUEST: "Bad Request: Site name is required", + status.HTTP_404_NOT_FOUND: "Bad Request: Site not found", + status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Server Error: Something went wrong on the server", + }, + ) + def delete(self, request): + site_name = request.data.get("site_name", None) + if site_name is None: + return Response({"error": "site_name is required"}, status=status.HTTP_400_BAD_REQUEST) + + try: + site = Site.objects.get(name=site_name) + except Site.DoesNotExist: + return Response({"error": "site not found"}, status=status.HTTP_404_NOT_FOUND) + except Exception: + return Response( + {"error": "internal server error"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + devices = Device.objects.filter(site=site.id) + + def _start(): + CHUNK_SIZE = 20 + device_ids = [dev.id for dev in devices] + for i in range(0, len(device_ids), CHUNK_SIZE): + chunk = device_ids[i : i + CHUNK_SIZE] + try: + with transaction.atomic(): + cleaning.clean_cmdb_for_devices(chunk) + for dev in devices[i : i + CHUNK_SIZE]: + dev.delete() + yield f'{{"deleted": {[dev.name for dev in devices[i:i+CHUNK_SIZE]]}}}\n\n' + + except Exception as e: + StreamingHttpResponse.status_code = 500 + msg = {"error": str(e)} + yield f"{msg}\n\n" + return + + try: + with transaction.atomic(): + cleaning.clean_site_topology(site) + yield "{{'message': 'topology cleaned'}}\n\n" + except Exception as e: + StreamingHttpResponse.status_code = 500 + msg = {"error": str(e)} + yield f"{msg}\n\n" + return + + msg = { + "message": f"site {site_name} has been deleted successfully", + } + yield f"{msg}\n\n" + + return StreamingHttpResponse(_start(), content_type="text/plain") diff --git a/netbox_cmdb/netbox_cmdb/api/urls.py b/netbox_cmdb/netbox_cmdb/api/urls.py index d1930d4..160339c 100644 --- a/netbox_cmdb/netbox_cmdb/api/urls.py +++ b/netbox_cmdb/netbox_cmdb/api/urls.py @@ -13,7 +13,11 @@ from netbox_cmdb.api.prefix_list.views import PrefixListViewSet from netbox_cmdb.api.route_policy.views import RoutePolicyViewSet from netbox_cmdb.api.snmp.views import SNMPCommunityViewSet, SNMPViewSet -from netbox_cmdb.api.cmdb.views import DeleteAllCMDBObjectsRelatedToDevice +from netbox_cmdb.api.cmdb.views import ( + DeviceCMDBDecommissioningAPIView, + DeviceDecommissioningAPIView, + SiteDecommissioningAPIView, +) router = NetBoxRouter() @@ -35,9 +39,19 @@ name="asns-available-asn", ), path( - "cmdb/delete-all-objects/", - DeleteAllCMDBObjectsRelatedToDevice.as_view(), - name="asns-available-asn", + "management/device-cmdb-decommissioning/", + DeviceCMDBDecommissioningAPIView.as_view(), + name="device-cmdb-decommissioning", + ), + path( + "management/device-decommissioning/", + DeviceDecommissioningAPIView.as_view(), + name="device-decommissioning", + ), + path( + "management/site-decommissioning/", + SiteDecommissioningAPIView.as_view(), + name="site-decommissioning", ), ] urlpatterns += router.urls diff --git a/netbox_cmdb/netbox_cmdb/helpers/cleaning.py b/netbox_cmdb/netbox_cmdb/helpers/cleaning.py new file mode 100644 index 0000000..6f1e30f --- /dev/null +++ b/netbox_cmdb/netbox_cmdb/helpers/cleaning.py @@ -0,0 +1,58 @@ +from dcim.models import Location, Rack +from django.db.models import Q + +from netbox_cmdb.models.bgp import BGPPeerGroup, BGPSession, DeviceBGPSession +from netbox_cmdb.models.bgp_community_list import BGPCommunityList +from netbox_cmdb.models.prefix_list import PrefixList +from netbox_cmdb.models.route_policy import RoutePolicy +from netbox_cmdb.models.snmp import SNMP + + +def clean_cmdb_for_devices(device_ids: list[int]): + deleted_objects = { + "bgp_sessions": [], + "device_bgp_sessions": [], + "bgp_peer_groups": [], + "route_policies": [], + "prefix_lists": [], + "bgp_community_lists": [], + "snmp": [], + } + + bgp_sessions = BGPSession.objects.filter( + Q(peer_a__device__id__in=device_ids) | Q(peer_b__device__id__in=device_ids) + ) + device_bgp_sessions = DeviceBGPSession.objects.filter(device__id__in=device_ids) + bgp_peer_groups = BGPPeerGroup.objects.filter(device__id__in=device_ids) + route_policies = RoutePolicy.objects.filter(device__id__in=device_ids) + prefix_lists = PrefixList.objects.filter(device__id__in=device_ids) + bgp_community_lists = BGPCommunityList.objects.filter(device__id__in=device_ids) + snmp = SNMP.objects.filter(device__id__in=device_ids) + + deleted_objects["bgp_sessions"] = [str(val) for val in list(bgp_sessions)] + deleted_objects["device_bgp_sessions"] = [str(val) for val in list(device_bgp_sessions)] + deleted_objects["bgp_peer_groups"] = [str(val) for val in list(bgp_peer_groups)] + deleted_objects["route_policies"] = [str(val) for val in list(route_policies)] + deleted_objects["prefix_lists"] = [str(val) for val in list(prefix_lists)] + deleted_objects["bgp_community_lists"] = [str(val) for val in list(bgp_community_lists)] + deleted_objects["snmp"] = [str(val) for val in list(snmp)] + + bgp_sessions.delete() + device_bgp_sessions.delete() + bgp_peer_groups.delete() + route_policies.delete() + prefix_lists.delete() + bgp_community_lists.delete() + snmp.delete() + + return deleted_objects + + +def clean_site_topology(site): + racks = Rack.objects.filter(site=site.id) + racks.delete() + + locations = Location.objects.filter(site=site.id) + locations.delete() + + site.delete() diff --git a/netbox_cmdb/netbox_cmdb/template_content.py b/netbox_cmdb/netbox_cmdb/template_content.py index e573140..b869993 100644 --- a/netbox_cmdb/netbox_cmdb/template_content.py +++ b/netbox_cmdb/netbox_cmdb/template_content.py @@ -1,15 +1,22 @@ from extras.plugins import PluginTemplateExtension -class Decommisioning(PluginTemplateExtension): - model = "dcim.device" - +class DecommissioningBase(PluginTemplateExtension): def buttons(self): return ( - f'Decommission' ) -template_extensions = [Decommisioning] +class DeviceDecommissioning(DecommissioningBase): + model = "dcim.device" + obj = "device" + + +class SiteDecommissioning(DecommissioningBase): + model = "dcim.site" + obj = "site" + + +template_extensions = [DeviceDecommissioning, SiteDecommissioning] diff --git a/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning.html b/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning.html deleted file mode 100644 index c456889..0000000 --- a/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning.html +++ /dev/null @@ -1,43 +0,0 @@ -{% extends "base/layout.html" %} - -{% block content-wrapper %} -
- {% if error %} -
{{ error }}
- {% else %} -
-
Deleted objects
- -
-
Device
-
- - - - -
{{ deleted_device }}
-
-
- - {% for key, objects in deleted_objects.items %} - {% if objects %} -
-
{{ key }}
-
- - {% for obj in objects %} - - - - {% endfor %} -
{{ obj }}
-
-
- {% endif %} - {% endfor %} - - -
- {% endif %} -
-{% endblock content-wrapper%} diff --git a/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/base.html b/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/base.html new file mode 100644 index 0000000..55093e1 --- /dev/null +++ b/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/base.html @@ -0,0 +1,33 @@ +{% extends "base/layout.html" %} +{% block title %} + {{ object.name }} decommissioning +{% endblock %} + +{% block content-wrapper %} +
+
+ +
+
+
+ {% if error %} +
{{ error }}
+ {% else %} +
+

+ Warning: this action will remove both + CMDB assets and DCIM of concerned asset(s) +

+
+ {% csrf_token %} + +
+
+ {% endif %} + +
+
+{% endblock content-wrapper%} diff --git a/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/device_summary.html b/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/device_summary.html new file mode 100644 index 0000000..415e94a --- /dev/null +++ b/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/device_summary.html @@ -0,0 +1,35 @@ +
+ {% if error %} +
{{ error }}
+ {% endif %} + +
Deleted objects
+ +
+
Device
+
+ + + + +
{{ deleted_device }}
+
+
+ + {% for key, objects in deleted_objects.items %} + {% if objects %} +
+
{{ key }}
+
+ + {% for obj in objects %} + + + + {% endfor %} +
{{ obj }}
+
+
+ {% endif %} + {% endfor %} +
diff --git a/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/site_progressive.html b/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/site_progressive.html new file mode 100644 index 0000000..41a03c8 --- /dev/null +++ b/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/site_progressive.html @@ -0,0 +1,29 @@ +{% if not stop %} +
+ {% csrf_token %} + +
+{% endif %} + +{# --- #} +{# OOB components #} +{# -- #} + +
+ {{ status | safe }} +
+ +{% if error %} +
+ {{ error | safe }} +
+{% endif %} + +
+ {{ message | safe }} +
diff --git a/netbox_cmdb/netbox_cmdb/urls.py b/netbox_cmdb/netbox_cmdb/urls.py index eac2fe6..6943f3b 100644 --- a/netbox_cmdb/netbox_cmdb/urls.py +++ b/netbox_cmdb/netbox_cmdb/urls.py @@ -20,15 +20,16 @@ BGPSessionEditView, BGPSessionListView, BGPSessionView, - DecommissioningView, DeviceBGPSessionEditView, DeviceBGPSessionListView, DeviceBGPSessionView, + DeviceDecommissioningView, DeviceBGPSessionDeleteView, RoutePolicyDeleteView, RoutePolicyEditView, RoutePolicyListView, RoutePolicyView, + SiteDecommissioningView, SNMPCommunityDeleteView, SNMPCommunityEditView, SNMPCommunityListView, @@ -39,9 +40,14 @@ urlpatterns = [ path( - "decommisioning//delete", - DecommissioningView.as_view(), - name="decommisioning_delete", + "decommissioning/device//delete", + DeviceDecommissioningView.as_view(), + name="device_decommissioning_delete", + ), + path( + "decommissioning/site//delete", + SiteDecommissioningView.as_view(), + name="site_decommissioning_delete", ), # ASN path("asn/", ASNListView.as_view(), name="asn_list"), diff --git a/netbox_cmdb/netbox_cmdb/views.py b/netbox_cmdb/netbox_cmdb/views.py index 47ee2c5..7af3e40 100644 --- a/netbox_cmdb/netbox_cmdb/views.py +++ b/netbox_cmdb/netbox_cmdb/views.py @@ -1,8 +1,10 @@ """Views.""" -from dcim.models import Device +import math +from datetime import datetime + +from dcim.models import Device, Site from django.db import transaction -from django.db.models import Q from django.shortcuts import render from netbox.views.generic import ( ObjectDeleteView, @@ -12,7 +14,6 @@ ) from netbox.views.generic.bulk_views import BulkDeleteView from utilities.forms import ConfirmationForm -from utilities.htmx import is_htmx from utilities.utils import count_related from netbox_cmdb.filtersets import ( @@ -34,6 +35,7 @@ SNMPCommunityGroupForm, SNMPGroupForm, ) +from netbox_cmdb.helpers import cleaning from netbox_cmdb.models.bgp import ( ASN, AfiSafi, @@ -41,8 +43,6 @@ BGPSession, DeviceBGPSession, ) -from netbox_cmdb.models.bgp_community_list import BGPCommunityList -from netbox_cmdb.models.prefix_list import PrefixList from netbox_cmdb.models.route_policy import RoutePolicy from netbox_cmdb.models.snmp import SNMP, SNMPCommunity from netbox_cmdb.tables import ( @@ -56,107 +56,171 @@ ) -## Decommission a device -class DecommissioningView(ObjectDeleteView): +class DecommissioningBaseView(ObjectDeleteView): + template_name = "netbox_cmdb/decommissioning/base.html" + site_template_name = "netbox_cmdb/decommissioning/site_progressive.html" + device_template_name = "netbox_cmdb/decommissioning/device_summary.html" + base_form_url = "" + + +class DeviceDecommissioningView(DecommissioningBaseView): + base_form_url = "/plugins/cmdb/decommissioning/device" queryset = Device.objects.all() - template_name = "netbox_cmdb/decommissioning.html" def get(self, request, *args, **kwargs): - """ - GET request handler. - - Args: - request: The current request - """ - obj = self.get_object(**kwargs) + device = self.get_object(**kwargs) form = ConfirmationForm(initial=request.GET) - # If this is an HTMX request, return only the rendered deletion form as modal content - if is_htmx(request): - # form_url = reverse("decommisioning_delete", kwargs={'pk': obj.pk}) - form_url = f"/plugins/cmdb/decommisioning/{kwargs['pk']}/delete" + return render( + request, + self.template_name, + { + "object": device, + "object_type": "device", + "form": form, + "return_url": self.get_return_url(request, device), + **self.get_extra_context(request, device), + }, + ) + + def post(self, request, *args, **kwargs): + # Fetch the device to delete + device = self.get_object(**kwargs) + + try: + with transaction.atomic(): + deleted = cleaning.clean_cmdb_for_devices([device.id]) + device.delete() + except Exception as error: return render( request, - "htmx/delete_form.html", - { - "object": obj, - "object_type": self.queryset.model._meta.verbose_name, - "form": form, - "form_url": form_url, - **self.get_extra_context(request, obj), - }, + self.device_template_name, + context={"error": f"Failed to clean device: {error}"}, ) + return render( + request, + self.device_template_name, + context={"deleted_device": device.name, "deleted_objects": deleted}, + ) + + +class SiteDecommissioningView(DecommissioningBaseView): + base_form_url = "/plugins/cmdb/decommissioning/site" + queryset = Site.objects.all() + + def get(self, request, *args, **kwargs): + site = self.get_object(**kwargs) + form = ConfirmationForm(initial=request.GET) + return render( request, self.template_name, { - "object": obj, + "object": site, + "object_type": "site", "form": form, - "return_url": self.get_return_url(request, obj), - **self.get_extra_context(request, obj), + "return_url": self.get_return_url(request, site), + **self.get_extra_context(request, site), }, ) def post(self, request, *args, **kwargs): # Fetch the device to delete - device = self.get_object(**kwargs) - deleted_objects = { - "bgp_sessions": [], - "device_bgp_sessions": [], - "bgp_peer_groups": [], - "route_policies": [], - "prefix_lists": [], - "bgp_community_lists": [], - "snmp": [], - } - - device_name = device.name + site = self.get_object(**kwargs) + devices = Device.objects.filter(site=site.id) + + # Get list of devices to delete + CHUNK_SIZE = 20 + device_ids = [dev.id for dev in devices] + remaining_chunks = math.ceil(len(device_ids) / CHUNK_SIZE) + + # We avoid an infinite loop if we fail to delete devices + if request.POST.get("chunks"): + previously_remaining_chunks = int(request.POST["chunks"]) + if remaining_chunks >= previously_remaining_chunks: + return render( + request, + self.site_template_name, + context={ + "object": site, + "object_type": "site", + "status": 'Failed', + "error": "devices are not being deleted, stopping here", + "chunks": remaining_chunks, + "stop": True, + }, + ) + + if not device_ids: + try: + with transaction.atomic(): + cleaning.clean_site_topology(site) + except Exception as error: + return render( + request, + self.site_template_name, + context={ + "object": site, + "object_type": "site", + "status": 'Failed', + "error": f"Topology cleaning failure: {error}", + "chunks": remaining_chunks, + "stop": True, + }, + ) + + chunk = device_ids[0:CHUNK_SIZE] try: with transaction.atomic(): - bgp_sessions = BGPSession.objects.filter( - Q(peer_a__device__id=device.id) | Q(peer_b__device__id=device.id) - ) - device_bgp_sessions = DeviceBGPSession.objects.filter(device__id=device.id) - bgp_peer_groups = BGPPeerGroup.objects.filter(device__id=device.id) - route_policies = RoutePolicy.objects.filter(device__id=device.id) - prefix_lists = PrefixList.objects.filter(device__id=device.id) - bgp_community_lists = BGPCommunityList.objects.filter(device__id=device.id) - snmp = SNMP.objects.filter(device__id=device.id) - - deleted_objects["bgp_sessions"] = [str(val) for val in list(bgp_sessions)] - deleted_objects["device_bgp_sessions"] = [ - str(val) for val in list(device_bgp_sessions) - ] - deleted_objects["bgp_peer_groups"] = [str(val) for val in list(bgp_peer_groups)] - deleted_objects["route_policies"] = [str(val) for val in list(route_policies)] - deleted_objects["prefix_lists"] = [str(val) for val in list(prefix_lists)] - deleted_objects["bgp_community_lists"] = [ - str(val) for val in list(bgp_community_lists) - ] - deleted_objects["snmp"] = [str(val) for val in list(snmp)] - - bgp_sessions.delete() - device_bgp_sessions.delete() - bgp_peer_groups.delete() - route_policies.delete() - prefix_lists.delete() - bgp_community_lists.delete() - snmp.delete() - - except Exception as e: - # Render the template with an error message - return render(request, self.template_name, context={"error": str(e)}) - - # Call the parent class's post method to delete the device - super().post(request, *args, **kwargs) - - # Return the HTML response with the list of deleted objects + cleaning.clean_cmdb_for_devices(chunk) + device_names = [dev.name for dev in devices[0:CHUNK_SIZE]] + for dev in devices[0:CHUNK_SIZE]: + dev.delete() + + except Exception as error: + return render( + request, + self.site_template_name, + context={ + "object": site, + "object_type": "site", + "status": 'Failed', + "error": f"Device cleaning failure: {error}", + "chunks": remaining_chunks, + "stop": True, + }, + ) + + # Tell the client to continue requesting deletion + if remaining_chunks: + status = f"Number of devices to delete: {len(device_ids) - len(chunk)}" + message = f'
{datetime.now().strftime("%H:%M:%S")} - deleted: {device_names}
' + + return render( + request, + self.site_template_name, + context={ + "object": site, + "object_type": "site", + "status": status, + "message": message, + "chunks": remaining_chunks, + }, + ) + return render( request, - self.template_name, - context={"deleted_device": device_name, "deleted_objects": deleted_objects}, + self.site_template_name, + context={ + "object": site, + "object_type": "site", + "status": 'Success', + "message": f'
{site.name}: site, racks, locations, devices and CMDB deleted
', + "chunks": remaining_chunks, + "stop": True, + }, )