Skip to content

Commit 688d014

Browse files
mfiedorowiczltucker
authored andcommitted
fix: scope support on apply change set (#64)
Signed-off-by: Michal Fiedorowicz <[email protected]>
1 parent dacbfd9 commit 688d014

File tree

3 files changed

+140
-29
lines changed

3 files changed

+140
-29
lines changed

netbox_diode_plugin/api/urls.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
from django.urls import include, path
66
from netbox.api.routers import NetBoxRouter
77

8-
from .views import ApplyChangeSetView, ObjectStateView
8+
from .views import ApplyChangeSetView, ObjectStateView, GenerateDiffView
99

1010
router = NetBoxRouter()
1111

1212
urlpatterns = [
1313
path("object-state/", ObjectStateView.as_view()),
1414
path("apply-change-set/", ApplyChangeSetView.as_view()),
15+
path("generate-diff/", GenerateDiffView.as_view()),
1516
path("", include(router.urls)),
1617
]

netbox_diode_plugin/api/views.py

+69-26
Original file line numberDiff line numberDiff line change
@@ -35,31 +35,26 @@ def dynamic_import(name):
3535
return mod
3636

3737

38-
def _get_index_class_fields(object_type):
38+
def _get_index_class_fields(object_type: str | NetBoxType):
3939
"""
4040
Given an object type name (e.g., 'dcim.site'), dynamically find and return the corresponding Index class fields.
4141
4242
:param object_type: Object type name in the format 'app_label.model_name'
4343
:return: The corresponding model and its Index class (e.g., SiteIndex) field names or None.
4444
"""
4545
try:
46-
# Extract app_label and model_name from 'dcim.site'
47-
app_label, model_name = object_type.split('.')
46+
if isinstance(object_type, str):
47+
app_label, model_name = object_type.split('.')
48+
else:
49+
app_label, model_name = object_type.app_label, object_type.model
4850

49-
# Get the model class dynamically
5051
model = apps.get_model(app_label, model_name)
5152

52-
# TagIndex registered in the netbox_diode_plugin
5353
if app_label == "extras" and model_name == "tag":
5454
app_label = "netbox_diode_plugin"
5555

56-
# Import the module where index classes are defined (adjust if needed)
5756
index_module = dynamic_import(f"{app_label}.search.{model.__name__}Index")
58-
59-
# Retrieve the index class fields tuple
6057
fields = getattr(index_module, "fields", None)
61-
62-
# Extract the field names list from the tuple
6358
field_names = [field[0] for field in fields]
6459

6560
return model, field_names
@@ -244,12 +239,13 @@ class ApplyChangeSetView(views.APIView):
244239
permission_classes = [IsAuthenticated, IsDiodeWriter]
245240

246241
@staticmethod
247-
def _get_object_type_model(object_type: str):
242+
def _get_object_type_model(object_type: str | NetBoxType):
248243
"""Get the object type model from object_type."""
249-
app_label, model_name = object_type.split(".")
250-
object_content_type = NetBoxType.objects.get_by_natural_key(
251-
app_label, model_name
252-
)
244+
if isinstance(object_type, str):
245+
app_label, model_name = object_type.split(".")
246+
object_content_type = NetBoxType.objects.get_by_natural_key(app_label, model_name)
247+
else:
248+
object_content_type = object_type
253249
return object_content_type, object_content_type.model_class()
254250

255251
def _get_assigned_object_type(self, model_name: str):
@@ -274,19 +270,19 @@ def _get_serializer(
274270
object_data: dict,
275271
):
276272
"""Get the serializer for the object type."""
277-
object_type_model, object_type_model_class = self._get_object_type_model(object_type)
273+
_, object_type_model_class = self._get_object_type_model(object_type)
278274

279275
if change_type == "create":
280-
return self._get_serializer_to_create(object_data, object_type, object_type_model, object_type_model_class)
276+
return self._get_serializer_to_create(object_data, object_type, object_type_model_class)
281277

282278
if change_type == "update":
283279
return self._get_serializer_to_update(object_data, object_id, object_type, object_type_model_class)
284280

285281
raise ValidationError("Invalid change_type")
286282

287-
def _get_serializer_to_create(self, object_data, object_type, object_type_model, object_type_model_class):
283+
def _get_serializer_to_create(self, object_data, object_type, object_type_model_class):
288284
# Get object data fields that are not dictionaries or lists
289-
fields = self._get_fields_to_find_existing_objects(object_data, object_type, object_type_model)
285+
fields = self._get_fields_to_find_existing_objects(object_data, object_type)
290286
# Check if the object already exists
291287
try:
292288
instance = object_type_model_class.objects.get(**fields)
@@ -351,10 +347,11 @@ def _get_serializer_to_update(self, object_data, object_id, object_type, object_
351347
)
352348
return serializer
353349

354-
def _get_fields_to_find_existing_objects(self, object_data, object_type, object_type_model):
350+
def _get_fields_to_find_existing_objects(self, object_data, object_type):
355351
fields = {}
356352
for key, value in object_data.items():
357353
self._add_nested_opts(fields, key, value)
354+
358355
match object_type:
359356
case "dcim.interface" | "virtualization.vminterface":
360357
mac_address = fields.pop("mac_address", None)
@@ -364,7 +361,18 @@ def _get_fields_to_find_existing_objects(self, object_data, object_type, object_
364361
fields.pop("assigned_object_type")
365362
fields["assigned_object_type_id"] = fields.pop("assigned_object_id")
366363
case "ipam.prefix" | "virtualization.cluster":
367-
fields["scope_type"] = object_type_model
364+
if scope_type := object_data.get("scope_type"):
365+
scope_type_model, _ = self._get_object_type_model(scope_type)
366+
fields["scope_type"] = scope_type_model
367+
case "virtualization.virtualmachine":
368+
if cluster_scope_type := fields.get("cluster__scope_type"):
369+
cluster_scope_type_model, _ = self._get_object_type_model(cluster_scope_type)
370+
fields["cluster__scope_type"] = cluster_scope_type_model
371+
case "virtualization.vminterface":
372+
if cluster_scope_type := fields.get("virtual_machine__cluster__scope_type"):
373+
cluster_scope_type_model, _ = self._get_object_type_model(cluster_scope_type)
374+
fields["virtual_machine__cluster__scope_type"] = cluster_scope_type_model
375+
368376
return fields
369377

370378
def _retrieve_primary_ip_address(self, primary_ip_attr: str, object_data: dict):
@@ -515,13 +523,18 @@ def _handle_interface_mac_address_compat(self, instance, object_type: str, obje
515523
instance.save()
516524
return None
517525

518-
def _handle_scope(self, object_data: dict) -> Optional[Dict[str, Any]]:
526+
def _handle_scope(self, object_data: dict, is_nested: bool = False) -> Optional[Dict[str, Any]]:
519527
"""Handle scope object."""
520528
if object_data.get("site"):
521529
site = object_data.pop("site")
522530
scope_type = "dcim.site"
523-
_, object_type_model_class = self._get_object_type_model(scope_type)
524-
object_data["scope_type"] = scope_type
531+
object_type_model, object_type_model_class = self._get_object_type_model(scope_type)
532+
# Scope type of the nested object happens to be resolved differently than in the top-level object
533+
# and is expected to be a content type object instead of "app_label.model_name" string format
534+
if is_nested:
535+
object_data["scope_type"] = object_type_model
536+
else:
537+
object_data["scope_type"] = scope_type
525538
site_id = site.get("id", None)
526539
if site_id is None:
527540
try:
@@ -544,9 +557,18 @@ def _transform_object_data(self, object_type: str, object_data: dict) -> Optiona
544557
case "ipam.ipaddress":
545558
errors = self._handle_ipaddress_assigned_object(object_data)
546559
case "ipam.prefix":
547-
errors = self._handle_scope(object_data)
560+
errors = self._handle_scope(object_data, False)
548561
case "virtualization.cluster":
549-
errors = self._handle_scope(object_data)
562+
errors = self._handle_scope(object_data, False)
563+
case "virtualization.virtualmachine":
564+
if cluster_object_data := object_data.get("cluster"):
565+
errors = self._handle_scope(cluster_object_data, True)
566+
object_data["cluster"] = cluster_object_data
567+
case "virtualization.vminterface":
568+
cluster_object_data = object_data.get("virtual_machine", {}).get("cluster")
569+
if cluster_object_data is not None:
570+
errors = self._handle_scope(cluster_object_data, True)
571+
object_data["virtual_machine"]["cluster"] = cluster_object_data
550572
case _:
551573
pass
552574

@@ -651,3 +673,24 @@ class ApplyChangeSetException(Exception):
651673
"""ApplyChangeSetException used to cause atomic transaction rollback."""
652674

653675
pass
676+
677+
#####
678+
679+
import logging
680+
logger = logging.getLogger("netbox.diode_data")
681+
682+
683+
class GenerateDiffView(views.APIView):
684+
"""GenerateDiff view."""
685+
686+
permission_classes = [IsAuthenticated, IsDiodeWriter]
687+
688+
def post(self, request, *args, **kwargs):
689+
"""Generate diff for entity."""
690+
691+
entity = request.data.get("entity")
692+
object_type = request.data.get("object_type")
693+
694+
logger.error(f"generate diff called with entity: {entity} and object_type: {object_type}")
695+
696+
return Response({}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

netbox_diode_plugin/tests/test_api_apply_change_set.py

+69-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
Site,
1515
)
1616
from django.contrib.auth import get_user_model
17+
from django.contrib.contenttypes.models import ContentType
1718
from ipam.models import ASN, RIR, IPAddress, Prefix
1819
from netaddr import IPNetwork
1920
from rest_framework import status
@@ -101,9 +102,13 @@ def setUp(self):
101102
name="Cluster Type 1", slug="cluster-type-1"
102103
)
103104

105+
self.cluster_types = (cluster_type,)
106+
107+
site_content_type = ContentType.objects.get_for_model(Site)
108+
104109
self.clusters = (
105-
Cluster(name="Cluster 1", type=cluster_type),
106-
Cluster(name="Cluster 2", type=cluster_type),
110+
Cluster(name="Cluster 1", type=cluster_type, scope_type=site_content_type, scope_id=self.sites[0].id),
111+
Cluster(name="Cluster 2", type=cluster_type, scope_type=site_content_type, scope_id=self.sites[0].id),
107112
)
108113
Cluster.objects.bulk_create(self.clusters)
109114

@@ -1154,3 +1159,65 @@ def test_create_prefix_with_unknown_site_fails(self):
11541159
response.json().get("errors")[0].get("site"),
11551160
)
11561161
self.assertFalse(Prefix.objects.filter(prefix="192.168.0.0/24").exists())
1162+
1163+
def test_create_virtualization_cluster_with_site_stored_as_scope(self):
1164+
"""Test create cluster with site stored as scope."""
1165+
payload = {
1166+
"change_set_id": str(uuid.uuid4()),
1167+
"change_set": [
1168+
{
1169+
"change_id": str(uuid.uuid4()),
1170+
"change_type": "create",
1171+
"object_version": None,
1172+
"object_type": "virtualization.cluster",
1173+
"object_id": None,
1174+
"data": {
1175+
"name": "Cluster 3",
1176+
"type": {
1177+
"name": self.cluster_types[0].name,
1178+
},
1179+
"site": {
1180+
"name": self.sites[0].name,
1181+
},
1182+
},
1183+
},
1184+
],
1185+
}
1186+
response = self.send_request(payload)
1187+
1188+
self.assertEqual(response.json().get("result"), "success")
1189+
self.assertEqual(Cluster.objects.get(name="Cluster 3").scope, self.sites[0])
1190+
1191+
def test_create_virtualmachine_with_cluster_site_stored_as_scope(self):
1192+
"""Test create virtualmachine with cluster site stored as scope."""
1193+
payload = {
1194+
"change_set_id": str(uuid.uuid4()),
1195+
"change_set": [
1196+
{
1197+
"change_id": str(uuid.uuid4()),
1198+
"change_type": "create",
1199+
"object_version": None,
1200+
"object_type": "virtualization.virtualmachine",
1201+
"object_id": None,
1202+
"data": {
1203+
"name": "VM foobar",
1204+
"site": {
1205+
"name": self.sites[0].name,
1206+
},
1207+
"cluster": {
1208+
"name": self.clusters[0].name,
1209+
"type": {
1210+
"name": self.cluster_types[0].name,
1211+
},
1212+
"site": {
1213+
"name": self.sites[0].name,
1214+
},
1215+
},
1216+
},
1217+
},
1218+
],
1219+
}
1220+
response = self.send_request(payload)
1221+
1222+
self.assertEqual(response.json().get("result"), "success")
1223+
self.assertEqual(VirtualMachine.objects.get(name="VM foobar", site_id=self.sites[0].id).cluster.scope, self.sites[0])

0 commit comments

Comments
 (0)