From d92fd3a872f116b5899e47e0bc2f8268efcf7ad9 Mon Sep 17 00:00:00 2001 From: Kevin Petremann Date: Wed, 21 Aug 2024 13:06:48 +0200 Subject: [PATCH 1/9] feat(ui): site decommisioning --- netbox_cmdb/netbox_cmdb/helpers/cleaning.py | 58 +++++ netbox_cmdb/netbox_cmdb/template_content.py | 19 +- .../netbox_cmdb/decommissioning.html | 43 ---- .../netbox_cmdb/decommissioning/base.html | 33 +++ .../decommissioning/device_summary.html | 35 +++ .../decommissioning/site_progressive.html | 29 +++ netbox_cmdb/netbox_cmdb/urls.py | 16 +- netbox_cmdb/netbox_cmdb/views.py | 234 ++++++++++++------ 8 files changed, 333 insertions(+), 134 deletions(-) create mode 100644 netbox_cmdb/netbox_cmdb/helpers/cleaning.py delete mode 100644 netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning.html create mode 100644 netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/base.html create mode 100644 netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/device_summary.html create mode 100644 netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/site_progressive.html 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..b988644 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 DecommisioningBase(PluginTemplateExtension): def buttons(self): return ( - f'Decommission' ) -template_extensions = [Decommisioning] +class DeviceDecommisioning(DecommisioningBase): + model = "dcim.device" + obj = "device" + + +class SiteDecommisioning(DecommisioningBase): + model = "dcim.site" + obj = "site" + + +template_extensions = [DeviceDecommisioning, SiteDecommisioning] 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..3e5310d --- /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..77d25ee --- /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..923584d 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, - DeviceBGPSessionDeleteView, + DeviceDecommissioningView, + DeviecBGPSessionDeleteView, RoutePolicyDeleteView, RoutePolicyEditView, RoutePolicyListView, RoutePolicyView, + SiteDecommissioningView, SNMPCommunityDeleteView, SNMPCommunityEditView, SNMPCommunityListView, @@ -39,9 +40,14 @@ urlpatterns = [ path( - "decommisioning//delete", - DecommissioningView.as_view(), - name="decommisioning_delete", + "decommisioning/device//delete", + DeviceDecommissioningView.as_view(), + name="device_decommisioning_delete", + ), + path( + "decommisioning/site//delete", + SiteDecommissioningView.as_view(), + name="site_decommisioning_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..b4e508e 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,10 +56,11 @@ ) -## Decommission a device -class DecommissioningView(ObjectDeleteView): - queryset = Device.objects.all() - template_name = "netbox_cmdb/decommissioning.html" +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 = "" def get(self, request, *args, **kwargs): """ @@ -68,98 +69,171 @@ def get(self, request, *args, **kwargs): Args: request: The current request """ - obj = self.get_object(**kwargs) + object = 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, - "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), - }, - ) + devices = [] + + if isinstance(object, Device): + devices.append(object) + object_type = "device" + elif isinstance(object, Site): + devices = Device.objects.filter(site=object.id) + object_type = "site" + else: + error = f"{type(object)} decommissioning is not supported" + return render(request, self.template_name, context={"error": error}) return render( request, self.template_name, { - "object": obj, + "object": object, + "object_type": object_type, "form": form, - "return_url": self.get_return_url(request, obj), - **self.get_extra_context(request, obj), + "return_url": self.get_return_url(request, object), + **self.get_extra_context(request, object), }, ) 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 + object = self.get_object(**kwargs) + + if isinstance(object, Device): + return self._device_cleaning(request, object, *args, **kwargs) + + elif isinstance(object, Site): + return self._site_cleaning(request, object, *args, **kwargs) + else: + error = f"{type(object)} decommissioning is not supported" + return render(request, self.template_name, context={"error": error}) + + def _device_cleaning(self, request, device, *args, **kwargs): try: with transaction.atomic(): - bgp_sessions = BGPSession.objects.filter( - Q(peer_a__device__id=device.id) | Q(peer_b__device__id=device.id) + deleted = cleaning.clean_cmdb_for_devices([device.id]) + device.delete() + except Exception as error: + return render( + request, + 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}, + ) + + def _site_cleaning(self, request, site, *args, **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, + }, ) - 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 + + chunk = device_ids[0:CHUNK_SIZE] + + try: + with transaction.atomic(): + 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, + }, ) +class DeviceDecommissioningView(DecommissioningBaseView): + base_form_url = "/plugins/cmdb/decommisioning/device" + queryset = Device.objects.all() + + +class SiteDecommissioningView(DecommissioningBaseView): + base_form_url = "/plugins/cmdb/decommisioning/site" + queryset = Site.objects.all() + + ## ASN views class ASNListView(ObjectListView): queryset = ASN.objects.all() From 3e1a81de5e10b05500cb6a11843c4ed72f3129a1 Mon Sep 17 00:00:00 2001 From: Kevin Petremann Date: Wed, 21 Aug 2024 14:37:49 +0200 Subject: [PATCH 2/9] refactor(decomm): factorize for webAPI and webUI --- netbox_cmdb/netbox_cmdb/api/cmdb/views.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/netbox_cmdb/netbox_cmdb/api/cmdb/views.py b/netbox_cmdb/netbox_cmdb/api/cmdb/views.py index 3d2ea01..dfa4dde 100644 --- a/netbox_cmdb/netbox_cmdb/api/cmdb/views.py +++ b/netbox_cmdb/netbox_cmdb/api/cmdb/views.py @@ -1,3 +1,4 @@ +from dcim.models import Device from django.db import transaction from django.db.models import Q from drf_yasg.utils import swagger_auto_schema @@ -6,6 +7,7 @@ from rest_framework.response import Response from rest_framework.views import APIView +from netbox_cmdb.helpers import cleaning 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 @@ -26,6 +28,7 @@ class DeleteAllCMDBObjectsRelatedToDevice(APIView): 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", }, ) @@ -36,22 +39,20 @@ def post(self, 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"Objects related to device {device_name} have been deleted successfully: {deleted}"}, status=status.HTTP_200_OK, ) From a598c52bfdd9304bc104a92ec3dbbefe57df9e09 Mon Sep 17 00:00:00 2001 From: Kevin Petremann Date: Wed, 21 Aug 2024 16:03:31 +0200 Subject: [PATCH 3/9] feat(api): add decommission-site endpoint --- netbox_cmdb/netbox_cmdb/api/cmdb/views.py | 89 ++++++++++++++++++++--- netbox_cmdb/netbox_cmdb/api/urls.py | 9 ++- 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/netbox_cmdb/netbox_cmdb/api/cmdb/views.py b/netbox_cmdb/netbox_cmdb/api/cmdb/views.py index dfa4dde..ce0b5b9 100644 --- a/netbox_cmdb/netbox_cmdb/api/cmdb/views.py +++ b/netbox_cmdb/netbox_cmdb/api/cmdb/views.py @@ -1,6 +1,6 @@ -from dcim.models import Device +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 @@ -8,11 +8,6 @@ from rest_framework.views import APIView from netbox_cmdb.helpers import cleaning -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 class DeleteAllCMDBObjectsRelatedToDeviceSerializer(serializers.Serializer): @@ -36,7 +31,7 @@ def post(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) @@ -53,6 +48,82 @@ def post(self, request): 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: {deleted}"}, + { + "message": f"objects related to device {device_name} have been deleted successfully", + "deleted": deleted, + }, status=status.HTTP_200_OK, ) + + +class DecommissionSiteSerializer(serializers.Serializer): + site_name = serializers.CharField() + + +class DecommissionSite(APIView): + + permission_classes = [IsAuthenticatedOrLoginNotRequired] + + @swagger_auto_schema( + request_body=DecommissionSiteSerializer, + 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 post(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) + + import logging + + 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(): + logging.warning("cleaning site") + cleaning.clean_site_topology(site) + yield "{{'message': 'topology cleaned'}}\n\n" + except Exception as e: + logging.warning("error: %s", 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..d7cdaec 100644 --- a/netbox_cmdb/netbox_cmdb/api/urls.py +++ b/netbox_cmdb/netbox_cmdb/api/urls.py @@ -13,7 +13,7 @@ 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 DeleteAllCMDBObjectsRelatedToDevice, DecommissionSite router = NetBoxRouter() @@ -37,7 +37,12 @@ path( "cmdb/delete-all-objects/", DeleteAllCMDBObjectsRelatedToDevice.as_view(), - name="asns-available-asn", + name="delete-all-objects", + ), + path( + "cmdb/decommission-site/", + DecommissionSite.as_view(), + name="decommission-site", ), ] urlpatterns += router.urls From 02861eeb9fc9058c9fa9bb6e6239869a1cbf96ab Mon Sep 17 00:00:00 2001 From: Kevin Petremann Date: Wed, 21 Aug 2024 16:03:51 +0200 Subject: [PATCH 4/9] refactor(api): decom and delete now in management --- netbox_cmdb/netbox_cmdb/api/urls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox_cmdb/netbox_cmdb/api/urls.py b/netbox_cmdb/netbox_cmdb/api/urls.py index d7cdaec..e98f5d9 100644 --- a/netbox_cmdb/netbox_cmdb/api/urls.py +++ b/netbox_cmdb/netbox_cmdb/api/urls.py @@ -35,12 +35,12 @@ name="asns-available-asn", ), path( - "cmdb/delete-all-objects/", + "management/delete-all-objects/", DeleteAllCMDBObjectsRelatedToDevice.as_view(), name="delete-all-objects", ), path( - "cmdb/decommission-site/", + "management/decommission-site/", DecommissionSite.as_view(), name="decommission-site", ), From e7a95e8895f26d560a7944722ecf400ddc0f638b Mon Sep 17 00:00:00 2001 From: Kevin Petremann Date: Wed, 21 Aug 2024 16:05:54 +0200 Subject: [PATCH 5/9] chore: remove extra debug logging --- netbox_cmdb/netbox_cmdb/api/cmdb/views.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/netbox_cmdb/netbox_cmdb/api/cmdb/views.py b/netbox_cmdb/netbox_cmdb/api/cmdb/views.py index ce0b5b9..4b898d0 100644 --- a/netbox_cmdb/netbox_cmdb/api/cmdb/views.py +++ b/netbox_cmdb/netbox_cmdb/api/cmdb/views.py @@ -89,8 +89,6 @@ def post(self, request): devices = Device.objects.filter(site=site.id) - import logging - def _start(): CHUNK_SIZE = 20 device_ids = [dev.id for dev in devices] @@ -111,11 +109,9 @@ def _start(): try: with transaction.atomic(): - logging.warning("cleaning site") cleaning.clean_site_topology(site) yield "{{'message': 'topology cleaned'}}\n\n" except Exception as e: - logging.warning("error: %s", e) StreamingHttpResponse.status_code = 500 msg = {"error": str(e)} yield f"{msg}\n\n" From 7afedcbf730c709d17d2601f7f972af2ab74726e Mon Sep 17 00:00:00 2001 From: Kevin Petremann Date: Wed, 21 Aug 2024 16:14:55 +0200 Subject: [PATCH 6/9] fix: import renamed --- netbox_cmdb/netbox_cmdb/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_cmdb/netbox_cmdb/urls.py b/netbox_cmdb/netbox_cmdb/urls.py index 923584d..748980d 100644 --- a/netbox_cmdb/netbox_cmdb/urls.py +++ b/netbox_cmdb/netbox_cmdb/urls.py @@ -24,7 +24,7 @@ DeviceBGPSessionListView, DeviceBGPSessionView, DeviceDecommissioningView, - DeviecBGPSessionDeleteView, + DeviceBGPSessionDeleteView, RoutePolicyDeleteView, RoutePolicyEditView, RoutePolicyListView, From af271b25f758da9eae7a04eb7c34161ba5846dc6 Mon Sep 17 00:00:00 2001 From: Kevin Petremann Date: Wed, 21 Aug 2024 16:18:38 +0200 Subject: [PATCH 7/9] refactor(decommission): delete instead of post --- netbox_cmdb/netbox_cmdb/api/cmdb/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox_cmdb/netbox_cmdb/api/cmdb/views.py b/netbox_cmdb/netbox_cmdb/api/cmdb/views.py index 4b898d0..6e5ab14 100644 --- a/netbox_cmdb/netbox_cmdb/api/cmdb/views.py +++ b/netbox_cmdb/netbox_cmdb/api/cmdb/views.py @@ -27,7 +27,7 @@ class DeleteAllCMDBObjectsRelatedToDevice(APIView): 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( @@ -73,7 +73,7 @@ class DecommissionSite(APIView): status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Server Error: Something went wrong on the server", }, ) - def post(self, request): + 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) From a1267ef262e59758399fe608f42b05d9fecccada Mon Sep 17 00:00:00 2001 From: Kevin Petremann Date: Thu, 22 Aug 2024 10:42:22 +0200 Subject: [PATCH 8/9] refactor: better View class use and fix typo --- netbox_cmdb/netbox_cmdb/template_content.py | 10 +-- .../netbox_cmdb/decommissioning/base.html | 2 +- .../decommissioning/site_progressive.html | 2 +- netbox_cmdb/netbox_cmdb/urls.py | 8 +- netbox_cmdb/netbox_cmdb/views.py | 80 ++++++++----------- 5 files changed, 46 insertions(+), 56 deletions(-) diff --git a/netbox_cmdb/netbox_cmdb/template_content.py b/netbox_cmdb/netbox_cmdb/template_content.py index b988644..b869993 100644 --- a/netbox_cmdb/netbox_cmdb/template_content.py +++ b/netbox_cmdb/netbox_cmdb/template_content.py @@ -1,22 +1,22 @@ from extras.plugins import PluginTemplateExtension -class DecommisioningBase(PluginTemplateExtension): +class DecommissioningBase(PluginTemplateExtension): def buttons(self): return ( - f'Decommission' ) -class DeviceDecommisioning(DecommisioningBase): +class DeviceDecommissioning(DecommissioningBase): model = "dcim.device" obj = "device" -class SiteDecommisioning(DecommisioningBase): +class SiteDecommissioning(DecommissioningBase): model = "dcim.site" obj = "site" -template_extensions = [DeviceDecommisioning, SiteDecommisioning] +template_extensions = [DeviceDecommissioning, SiteDecommissioning] diff --git a/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/base.html b/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/base.html index 3e5310d..55093e1 100644 --- a/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/base.html +++ b/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/base.html @@ -20,7 +20,7 @@

{% csrf_token %} 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 index 77d25ee..41a03c8 100644 --- a/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/site_progressive.html +++ b/netbox_cmdb/netbox_cmdb/templates/netbox_cmdb/decommissioning/site_progressive.html @@ -3,7 +3,7 @@ hx-target="#_content" hx-trigger="load" hx-include="*" - hx-post="/plugins/cmdb/decommisioning/{{ object_type }}/{{ object.id }}/delete" + hx-post="/plugins/cmdb/decommissioning/{{ object_type }}/{{ object.id }}/delete" > {% csrf_token %} diff --git a/netbox_cmdb/netbox_cmdb/urls.py b/netbox_cmdb/netbox_cmdb/urls.py index 748980d..6943f3b 100644 --- a/netbox_cmdb/netbox_cmdb/urls.py +++ b/netbox_cmdb/netbox_cmdb/urls.py @@ -40,14 +40,14 @@ urlpatterns = [ path( - "decommisioning/device//delete", + "decommissioning/device//delete", DeviceDecommissioningView.as_view(), - name="device_decommisioning_delete", + name="device_decommissioning_delete", ), path( - "decommisioning/site//delete", + "decommissioning/site//delete", SiteDecommissioningView.as_view(), - name="site_decommisioning_delete", + 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 b4e508e..7af3e40 100644 --- a/netbox_cmdb/netbox_cmdb/views.py +++ b/netbox_cmdb/netbox_cmdb/views.py @@ -62,54 +62,31 @@ class DecommissioningBaseView(ObjectDeleteView): device_template_name = "netbox_cmdb/decommissioning/device_summary.html" base_form_url = "" - def get(self, request, *args, **kwargs): - """ - GET request handler. - Args: - request: The current request - """ - object = self.get_object(**kwargs) +class DeviceDecommissioningView(DecommissioningBaseView): + base_form_url = "/plugins/cmdb/decommissioning/device" + queryset = Device.objects.all() + + def get(self, request, *args, **kwargs): + device = self.get_object(**kwargs) form = ConfirmationForm(initial=request.GET) - devices = [] - - if isinstance(object, Device): - devices.append(object) - object_type = "device" - elif isinstance(object, Site): - devices = Device.objects.filter(site=object.id) - object_type = "site" - else: - error = f"{type(object)} decommissioning is not supported" - return render(request, self.template_name, context={"error": error}) return render( request, self.template_name, { - "object": object, - "object_type": object_type, + "object": device, + "object_type": "device", "form": form, - "return_url": self.get_return_url(request, object), - **self.get_extra_context(request, object), + "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 - object = self.get_object(**kwargs) + device = self.get_object(**kwargs) - if isinstance(object, Device): - return self._device_cleaning(request, object, *args, **kwargs) - - elif isinstance(object, Site): - return self._site_cleaning(request, object, *args, **kwargs) - - else: - error = f"{type(object)} decommissioning is not supported" - return render(request, self.template_name, context={"error": error}) - - def _device_cleaning(self, request, device, *args, **kwargs): try: with transaction.atomic(): deleted = cleaning.clean_cmdb_for_devices([device.id]) @@ -127,7 +104,30 @@ def _device_cleaning(self, request, device, *args, **kwargs): context={"deleted_device": device.name, "deleted_objects": deleted}, ) - def _site_cleaning(self, request, site, *args, **kwargs): + +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": site, + "object_type": "site", + "form": form, + "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 + site = self.get_object(**kwargs) devices = Device.objects.filter(site=site.id) # Get list of devices to delete @@ -224,16 +224,6 @@ def _site_cleaning(self, request, site, *args, **kwargs): ) -class DeviceDecommissioningView(DecommissioningBaseView): - base_form_url = "/plugins/cmdb/decommisioning/device" - queryset = Device.objects.all() - - -class SiteDecommissioningView(DecommissioningBaseView): - base_form_url = "/plugins/cmdb/decommisioning/site" - queryset = Site.objects.all() - - ## ASN views class ASNListView(ObjectListView): queryset = ASN.objects.all() From b492af169f1d8c0ab6bff69d1c5d4fa9f389c9d8 Mon Sep 17 00:00:00 2001 From: Kevin Petremann Date: Thu, 22 Aug 2024 14:39:26 +0200 Subject: [PATCH 9/9] feat(decomm): endpoint to decomm a device + rationalize endpoints --- netbox_cmdb/netbox_cmdb/api/cmdb/views.py | 62 +++++++++++++++++++---- netbox_cmdb/netbox_cmdb/api/urls.py | 23 ++++++--- 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/netbox_cmdb/netbox_cmdb/api/cmdb/views.py b/netbox_cmdb/netbox_cmdb/api/cmdb/views.py index 6e5ab14..1315d80 100644 --- a/netbox_cmdb/netbox_cmdb/api/cmdb/views.py +++ b/netbox_cmdb/netbox_cmdb/api/cmdb/views.py @@ -10,16 +10,16 @@ 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", @@ -31,7 +31,7 @@ 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) @@ -49,23 +49,67 @@ def delete(self, request): 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 DecommissionSiteSerializer(serializers.Serializer): +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 DecommissionSite(APIView): +class SiteDecommissioningAPIView(APIView): permission_classes = [IsAuthenticatedOrLoginNotRequired] @swagger_auto_schema( - request_body=DecommissionSiteSerializer, + request_body=SiteDecommissioningSerializer, responses={ status.HTTP_200_OK: "Site have been deleted successfully", status.HTTP_400_BAD_REQUEST: "Bad Request: Site name is required", @@ -76,7 +120,7 @@ class DecommissionSite(APIView): 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) + return Response({"error": "site_name is required"}, status=status.HTTP_400_BAD_REQUEST) try: site = Site.objects.get(name=site_name) diff --git a/netbox_cmdb/netbox_cmdb/api/urls.py b/netbox_cmdb/netbox_cmdb/api/urls.py index e98f5d9..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, DecommissionSite +from netbox_cmdb.api.cmdb.views import ( + DeviceCMDBDecommissioningAPIView, + DeviceDecommissioningAPIView, + SiteDecommissioningAPIView, +) router = NetBoxRouter() @@ -35,14 +39,19 @@ name="asns-available-asn", ), path( - "management/delete-all-objects/", - DeleteAllCMDBObjectsRelatedToDevice.as_view(), - name="delete-all-objects", + "management/device-cmdb-decommissioning/", + DeviceCMDBDecommissioningAPIView.as_view(), + name="device-cmdb-decommissioning", + ), + path( + "management/device-decommissioning/", + DeviceDecommissioningAPIView.as_view(), + name="device-decommissioning", ), path( - "management/decommission-site/", - DecommissionSite.as_view(), - name="decommission-site", + "management/site-decommissioning/", + SiteDecommissioningAPIView.as_view(), + name="site-decommissioning", ), ] urlpatterns += router.urls