Skip to content

Commit 510cb36

Browse files
authored
🚚 release (#118)
2 parents 6fd5183 + c0b7710 commit 510cb36

15 files changed

+462
-69
lines changed

‎docker/requirements-diode-netbox-plugin.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ coverage==7.6.0
44
grpcio==1.62.1
55
protobuf==5.28.1
66
pytest==8.0.2
7-
netboxlabs-netbox-branching
7+
netboxlabs-netbox-branching==0.5.7

‎netbox_diode_plugin/api/applier.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def _pre_apply(model_class: models.Model, change: Change, created: dict):
101101
# resolve foreign key references to new objects
102102
for ref_field in change.new_refs:
103103
v = _get_path(data, ref_field)
104-
if isinstance(v, (list, tuple)):
104+
if isinstance(v, list | tuple):
105105
ref_list = []
106106
for ref in v:
107107
if isinstance(ref, str):

‎netbox_diode_plugin/api/common.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from collections import defaultdict
1010
from dataclasses import dataclass, field
1111
from enum import Enum
12+
from zoneinfo import ZoneInfo
1213

1314
import netaddr
1415
from django.apps import apps
@@ -20,7 +21,6 @@
2021
from extras.models import CustomField
2122
from netaddr.eui import EUI
2223
from rest_framework import status
23-
from zoneinfo import ZoneInfo
2424

2525
logger = logging.getLogger("netbox.diode_data")
2626

@@ -166,7 +166,7 @@ def _validate_relations(self, change_data: dict, model: models.Model) -> tuple[l
166166
excluded_relation_fields = []
167167
rel_errors = defaultdict(list)
168168
for f in model._meta.get_fields():
169-
if isinstance(f, (GenericRelation, GenericForeignKey)):
169+
if isinstance(f, GenericRelation | GenericForeignKey):
170170
excluded_relation_fields.append(f.name)
171171
continue
172172
if not f.is_relation:
@@ -251,7 +251,7 @@ def error_from_validation_error(e, object_name):
251251
if e.detail:
252252
if isinstance(e.detail, dict):
253253
errors[object_name] = e.detail
254-
elif isinstance(e.detail, (list, tuple)):
254+
elif isinstance(e.detail, list | tuple):
255255
errors[object_name] = {
256256
NON_FIELD_ERRORS: e.detail
257257
}

‎netbox_diode_plugin/api/compat.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env python
2+
# Copyright 2025 NetBox Labs Inc
3+
"""Diode NetBox Plugin - API - Compatibility Transformations."""
4+
5+
import logging
6+
import re
7+
from collections import defaultdict
8+
from functools import cache
9+
10+
from django.conf import settings
11+
from packaging import version
12+
from utilities.release import load_release_data
13+
14+
logger = logging.getLogger(__name__)
15+
16+
_MIGRATIONS_BY_OBJECT_TYPE = defaultdict(list)
17+
18+
def apply_entity_migrations(data: dict, object_type: str):
19+
"""
20+
Applies migrations to diode entity data prior to diffing to improve compatibility with current NetBox version.
21+
22+
These represent cases like deprecated fields that have been replaced with new fields, but
23+
are supported for backwards compatibility.
24+
"""
25+
for migration in _MIGRATIONS_BY_OBJECT_TYPE.get(object_type, []):
26+
logger.debug(f"Applying migration {migration.__name__} for {object_type}")
27+
migration(data)
28+
29+
def _register_migration(func, min_version, max_version, object_type):
30+
"""Registers a migration function."""
31+
if in_version_range(min_version, max_version):
32+
logger.debug(f"Registering migration {func.__name__} for {object_type}.")
33+
_MIGRATIONS_BY_OBJECT_TYPE[object_type].append(func)
34+
else:
35+
logger.debug(f"Skipping migration {func.__name__} for {object_type}: {min_version} to {max_version}.")
36+
37+
@cache
38+
def _current_netbox_version():
39+
"""Returns the current version of NetBox."""
40+
try:
41+
return version.parse(settings.RELEASE.version)
42+
except Exception:
43+
logger.exception("Failed to determine current version of NetBox.")
44+
return (0, 0, 0)
45+
46+
def in_version_range(min_version: str | None, max_version: str | None):
47+
"""Returns True if the current version of NetBox is within the given version range."""
48+
min_version = version.parse(min_version) if min_version else None
49+
max_version = version.parse(max_version) if max_version else None
50+
current_version = _current_netbox_version()
51+
if min_version and current_version < min_version:
52+
return False
53+
if max_version and current_version > max_version:
54+
return False
55+
return True
56+
57+
def diode_migration(min_version: str, max_version: str | None, object_type: str):
58+
"""Decorator to mark a function as a diode migration."""
59+
def decorator(func):
60+
_register_migration(func, min_version, max_version, object_type)
61+
return func
62+
return decorator
63+
64+
@diode_migration(min_version="4.3.0", max_version=None, object_type="ipam.service")
65+
def _migrate_service_parent_object(data: dict):
66+
"""Transforms ipam.service device and virtual_machine references to parent_object."""
67+
device = data.pop("device", None)
68+
if device:
69+
if data.get("parent_object_device") is None:
70+
data["parent_object_device"] = device
71+
# else ignored.
72+
73+
virtual_machine = data.pop("virtual_machine", None)
74+
if virtual_machine:
75+
if data.get("parent_object_virtual_machine") is None:
76+
data["parent_object_virtual_machine"] = virtual_machine
77+
# else ignored.
78+
79+
@diode_migration(min_version="4.3.0", max_version=None, object_type="tenancy.contact")
80+
def _migrate_contact_group(data: dict):
81+
"""Transforms tenancy.contact group references to groups."""
82+
group = data.pop("group", None)
83+
if group:
84+
if data.get("groups") is None:
85+
data["groups"] = [group]
86+
# else ignored.

‎netbox_diode_plugin/api/differ.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def prechange_data_from_instance(instance) -> dict: # noqa: C901
8484
custom_field_values = instance.get_custom_fields()
8585
cfmap = {}
8686
for cf, value in custom_field_values.items():
87-
if isinstance(value, (datetime.datetime, datetime.date)):
87+
if isinstance(value, datetime.datetime | datetime.date):
8888
cfmap[cf.name] = value
8989
else:
9090
cfmap[cf.name] = cf.serialize(value)

‎netbox_diode_plugin/api/matcher.py

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import logging
66
from dataclasses import dataclass
77
from functools import cache, lru_cache
8-
from typing import Type
98

109
import netaddr
1110
from django.contrib.contenttypes.fields import ContentType
@@ -18,6 +17,7 @@
1817
from extras.models.customfields import CustomField
1918

2019
from .common import UnresolvedReference
20+
from .compat import in_version_range
2121
from .plugin_utils import content_type_id, get_object_type, get_object_type_model
2222

2323
logger = logging.getLogger(__name__)
@@ -163,18 +163,28 @@
163163
name="logical_service_name_no_device_or_vm",
164164
model_class=get_object_type_model("ipam.service"),
165165
condition=Q(device__isnull=True, virtual_machine__isnull=True),
166+
max_version="4.2.99",
166167
),
167168
ObjectMatchCriteria(
168169
fields=("name", "device"),
169170
name="logical_service_name_on_device",
170171
model_class=get_object_type_model("ipam.service"),
171172
condition=Q(device__isnull=False),
173+
max_version="4.2.99",
172174
),
173175
ObjectMatchCriteria(
174176
fields=("name", "virtual_machine"),
175177
name="logical_service_name_on_vm",
176178
model_class=get_object_type_model("ipam.service"),
177179
condition=Q(virtual_machine__isnull=False),
180+
max_version="4.2.99",
181+
),
182+
ObjectMatchCriteria(
183+
fields=("name", "parent_object_type", "parent_object_id"),
184+
name="logical_service_name_on_parent",
185+
model_class=get_object_type_model("ipam.service"),
186+
condition=Q(parent_object_type__isnull=False),
187+
min_version="4.3.0"
178188
),
179189
],
180190
"dcim.modulebay": lambda: [
@@ -202,6 +212,32 @@
202212
model_class=get_object_type_model("ipam.fhrpgroup"),
203213
)
204214
],
215+
"tenancy.contact": lambda: [
216+
ObjectMatchCriteria(
217+
# contacts are unconstrained in 4.3.0
218+
# in 4.2 they are constrained by unique name per group
219+
fields=("name", ),
220+
name="logical_contact_name",
221+
model_class=get_object_type_model("tenancy.contact"),
222+
min_version="4.3.0",
223+
)
224+
],
225+
"dcim.devicerole": lambda: [
226+
ObjectMatchCriteria(
227+
fields=("name",),
228+
name="logical_device_role_name_no_parent",
229+
model_class=get_object_type_model("dcim.devicerole"),
230+
condition=Q(parent__isnull=True),
231+
min_version="4.3.0",
232+
),
233+
ObjectMatchCriteria(
234+
fields=("slug",),
235+
name="logical_device_role_slug_no_parent",
236+
model_class=get_object_type_model("dcim.devicerole"),
237+
condition=Q(parent__isnull=True),
238+
min_version="4.3.0",
239+
)
240+
],
205241
}
206242

207243
@dataclass
@@ -221,9 +257,12 @@ class ObjectMatchCriteria:
221257
fields: tuple[str] | None = None
222258
expressions: tuple | None = None
223259
condition: Q | None = None
224-
model_class: Type[models.Model] | None = None
260+
model_class: type[models.Model] | None = None
225261
name: str | None = None
226262

263+
min_version: str | None = None
264+
max_version: str | None = None
265+
227266
def __hash__(self):
228267
"""Hash the object match criteria."""
229268
return hash((self.fields, self.expressions, self.condition, self.model_class.__name__, self.name))
@@ -365,7 +404,7 @@ def _build_expressions_queryset(self, data) -> models.QuerySet:
365404
"""Builds a queryset for the constraint with the given data."""
366405
data = self._prepare_data(data)
367406
replacements = {
368-
F(field): Value(value) if isinstance(value, (str, int, float, bool)) else value
407+
F(field): Value(value) if isinstance(value, str | int | float | bool) else value
369408
for field, value in data.items()
370409
}
371410

@@ -413,7 +452,10 @@ class CustomFieldMatcher:
413452

414453
name: str
415454
custom_field: str
416-
model_class: Type[models.Model]
455+
model_class: type[models.Model]
456+
457+
min_version: str | None = None
458+
max_version: str | None = None
417459

418460
def fingerprint(self, data: dict) -> str|None:
419461
"""Fingerprint the custom field value."""
@@ -448,9 +490,12 @@ class GlobalIPNetworkIPMatcher:
448490

449491
ip_fields: tuple[str]
450492
vrf_field: str
451-
model_class: Type[models.Model]
493+
model_class: type[models.Model]
452494
name: str
453495

496+
min_version: str | None = None
497+
max_version: str | None = None
498+
454499
def _check_condition(self, data: dict) -> bool:
455500
"""Check the condition for the custom field."""
456501
return data.get(self.vrf_field, None) is None
@@ -508,9 +553,12 @@ class VRFIPNetworkIPMatcher:
508553

509554
ip_fields: tuple[str]
510555
vrf_field: str
511-
model_class: Type[models.Model]
556+
model_class: type[models.Model]
512557
name: str
513558

559+
min_version: str | None = None
560+
max_version: str | None = None
561+
514562
def _check_condition(self, data: dict) -> bool:
515563
"""Check the condition for the custom field."""
516564
return data.get(self.vrf_field, None) is not None
@@ -583,7 +631,10 @@ class AutoSlugMatcher:
583631

584632
name: str
585633
slug_field: str
586-
model_class: Type[models.Model]
634+
model_class: type[models.Model]
635+
636+
min_version: str | None = None
637+
max_version: str | None = None
587638

588639
def fingerprint(self, data: dict) -> str|None:
589640
"""Fingerprint the custom field value."""
@@ -650,7 +701,10 @@ def _get_autoslug_matchers(model_class) -> list:
650701
@lru_cache(maxsize=256)
651702
def _get_model_matchers(model_class) -> list[ObjectMatchCriteria]:
652703
object_type = get_object_type(model_class)
653-
matchers = _LOGICAL_MATCHERS.get(object_type, lambda: [])()
704+
matchers = [
705+
x for x in _LOGICAL_MATCHERS.get(object_type, lambda: [])()
706+
if in_version_range(x.min_version, x.max_version)
707+
]
654708

655709
# collect single fields that are unique
656710
for field in model_class._meta.fields:
@@ -750,7 +804,7 @@ def _fingerprint_all(data: dict, object_type: str|None = None) -> str:
750804
if k.startswith("_"):
751805
continue
752806
values.append(k)
753-
if isinstance(v, (list, tuple)):
807+
if isinstance(v, list | tuple):
754808
values.extend(sorted(v))
755809
elif isinstance(v, dict):
756810
values.append(_fingerprint_all(v))

‎netbox_diode_plugin/api/plugin_utils.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,8 @@ class RefInfo:
544544
'ipam.service': {
545545
'device': RefInfo(object_type='dcim.device', field_name='device'),
546546
'ipaddresses': RefInfo(object_type='ipam.ipaddress', field_name='ipaddresses', is_many=True),
547+
'parent_object_device': RefInfo(object_type='dcim.device', field_name='parent_object', is_generic=True),
548+
'parent_object_virtual_machine': RefInfo(object_type='virtualization.virtualmachine', field_name='parent_object', is_generic=True),
547549
'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True),
548550
'virtual_machine': RefInfo(object_type='virtualization.virtualmachine', field_name='virtual_machine'),
549551
},
@@ -576,6 +578,7 @@ class RefInfo:
576578
},
577579
'tenancy.contact': {
578580
'group': RefInfo(object_type='tenancy.contactgroup', field_name='group'),
581+
'groups': RefInfo(object_type='tenancy.contactgroup', field_name='groups', is_many=True),
579582
'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True),
580583
},
581584
'tenancy.contactassignment': {
@@ -948,13 +951,13 @@ def get_json_ref_info(object_type: str|Type[models.Model], json_field_name: str)
948951
'ipam.rir': frozenset(['custom_fields', 'description', 'is_private', 'name', 'slug', 'tags']),
949952
'ipam.role': frozenset(['custom_fields', 'description', 'name', 'slug', 'tags', 'weight']),
950953
'ipam.routetarget': frozenset(['comments', 'custom_fields', 'description', 'name', 'tags', 'tenant']),
951-
'ipam.service': frozenset(['comments', 'custom_fields', 'description', 'device', 'ipaddresses', 'name', 'ports', 'protocol', 'tags', 'virtual_machine']),
954+
'ipam.service': frozenset(['comments', 'custom_fields', 'description', 'device', 'ipaddresses', 'name', 'parent_object_id', 'parent_object_type', 'ports', 'protocol', 'tags', 'virtual_machine']),
952955
'ipam.vlan': frozenset(['comments', 'custom_fields', 'description', 'group', 'name', 'qinq_role', 'qinq_svlan', 'role', 'site', 'status', 'tags', 'tenant', 'vid']),
953956
'ipam.vlangroup': frozenset(['custom_fields', 'description', 'name', 'scope_id', 'scope_type', 'slug', 'tags', 'vid_ranges']),
954957
'ipam.vlantranslationpolicy': frozenset(['description', 'name']),
955958
'ipam.vlantranslationrule': frozenset(['description', 'local_vid', 'policy', 'remote_vid']),
956959
'ipam.vrf': frozenset(['comments', 'custom_fields', 'description', 'enforce_unique', 'export_targets', 'import_targets', 'name', 'rd', 'tags', 'tenant']),
957-
'tenancy.contact': frozenset(['address', 'comments', 'custom_fields', 'description', 'email', 'group', 'link', 'name', 'phone', 'tags', 'title']),
960+
'tenancy.contact': frozenset(['address', 'comments', 'custom_fields', 'description', 'email', 'group', 'groups', 'link', 'name', 'phone', 'tags', 'title']),
958961
'tenancy.contactassignment': frozenset(['contact', 'custom_fields', 'object_id', 'object_type', 'priority', 'role', 'tags']),
959962
'tenancy.contactgroup': frozenset(['custom_fields', 'description', 'name', 'parent', 'slug', 'tags']),
960963
'tenancy.contactrole': frozenset(['custom_fields', 'description', 'name', 'slug', 'tags']),

‎netbox_diode_plugin/api/supported_models.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import logging
77
import time
88
from functools import lru_cache
9-
from typing import List, Type
109

1110
from django.apps import apps
1211
from django.db import models
@@ -82,9 +81,9 @@ def extract_supported_models() -> dict[str, dict]:
8281
return extracted_models
8382

8483

85-
def get_prerequisites(model_class, fields) -> List[dict[str, str]]:
84+
def get_prerequisites(model_class, fields) -> list[dict[str, str]]:
8685
"""Get the prerequisite models for the model."""
87-
prerequisites: List[dict[str, str]] = []
86+
prerequisites: list[dict[str, str]] = []
8887
prerequisite_models = getattr(model_class, "prerequisite_models", [])
8988

9089
for prereq in prerequisite_models:
@@ -252,7 +251,7 @@ def get_serializer_for_model(model, prefix=""):
252251
return netbox_get_serializer_for_model(model, prefix)
253252

254253

255-
def discover_models(root_packages: List[str]) -> list[Type[models.Model]]:
254+
def discover_models(root_packages: list[str]) -> list[type[models.Model]]:
256255
"""Discovers all model classes in specified root packages."""
257256
discovered_models = []
258257

0 commit comments

Comments
 (0)