From 8dc1d68aee04ef51e2f17f381fa7129a0787cc7e Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Mon, 24 Feb 2025 15:20:18 +0100 Subject: [PATCH 1/8] port generic field to 4.2 --- netbox/dcim/api/views.py | 20 +++- netbox/dcim/models/cables.py | 15 ++- netbox/utilities/generics/field.py | 143 +++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 netbox/utilities/generics/field.py diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d6ddd466b..748367eda 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,3 +1,5 @@ +from django.contrib.contenttypes.prefetch import GenericPrefetch +from django.db.models import Prefetch from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes @@ -432,7 +434,23 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = Interface.objects.prefetch_related( - '_path', 'cable__terminations', + # '_path', + # 'cable__terminations', + GenericPrefetch( + "cable__terminations__termination", + [ + Interface.objects.prefetch_related("device"), + ], + ), + Prefetch( + "_path", + CablePath.objects.prefetch_related( + GenericPrefetch("path_objects", [ + Interface.objects.prefetch_related("device"), + Cable.objects.prefetch_related("terminations"), + ]), + ) + ), 'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination 'ip_addresses', # Referenced by Interface.count_ipaddresses() 'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups() diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 64f0b8560..7ec94ac44 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -17,6 +17,7 @@ from dcim.utils import decompile_path_node, object_to_path_node from netbox.models import ChangeLoggedModel, PrimaryModel from utilities.conversion import to_meters from utilities.fields import ColorField +from utilities.generics.field import GenericArrayForeignKey from utilities.querysets import RestrictedQuerySet from wireless.models import WirelessLink from .device_components import FrontPort, RearPort, PathEndpoint @@ -490,7 +491,7 @@ class CablePath(models.Model): return ObjectType.objects.get_for_id(ct_id) @property - def path_objects(self): + def path_objects_old(self): """ Cache and return the complete path as lists of objects, derived from their annotation within the path. """ @@ -498,6 +499,18 @@ class CablePath(models.Model): self._path_objects = self._get_path() return self._path_objects + @property + def _path_decompiled(self): + res = [] + for step in self.path: + nodes = [] + for node in step: + nodes.append(decompile_path_node(node)) + res.append(nodes) + return res + + path_objects = GenericArrayForeignKey("_path_decompiled") + @property def origins(self): """ diff --git a/netbox/utilities/generics/field.py b/netbox/utilities/generics/field.py new file mode 100644 index 000000000..e4df05ba2 --- /dev/null +++ b/netbox/utilities/generics/field.py @@ -0,0 +1,143 @@ +from collections import defaultdict + +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.db.models.fields import Field +from django.db.models.fields.mixins import FieldCacheMixin +from django.utils.functional import cached_property + + +class GenericArrayForeignKey(FieldCacheMixin, Field): + """ + Provide a generic many-to-many relation through an array field + """ + + many_to_many = True + many_to_one = False + one_to_many = False + one_to_one = False + + def __init__(self, field, for_concrete_model=True): + super().__init__(editable=False) + self.field = field + self.for_concrete_model = for_concrete_model + self.is_relation = True + + def contribute_to_class(self, cls, name, **kwargs): + super().contribute_to_class(cls, name, private_only=True, **kwargs) + # GenericForeignKey is its own descriptor. + setattr(cls, self.attname, self) + + @cached_property + def cache_name(self): + return self.name + + def get_cache_name(self): + return self.cache_name + + def _get_ids(self, instance): + return getattr(instance, self.field) + + def get_content_type_by_id(self, id=None, using=None): + return ContentType.objects.db_manager(using).get_for_id(id) + + def get_content_type_of_obj(self, obj=None): + return ContentType.objects.db_manager(obj._state.db).get_for_model( + obj, for_concrete_model=self.for_concrete_model + ) + + def get_content_type_for_model(self, using=None, model=None): + return ContentType.objects.db_manager(using).get_for_model( + model, for_concrete_model=self.for_concrete_model + ) + + def get_prefetch_querysets(self, instances, querysets=None): + custom_queryset_dict = {} + if querysets is not None: + for queryset in querysets: + ct_id = self.get_content_type_for_model( + model=queryset.query.model, using=queryset.db + ).pk + if ct_id in custom_queryset_dict: + raise ValueError( + "Only one queryset is allowed for each content type." + ) + custom_queryset_dict[ct_id] = queryset + + # For efficiency, group the instances by content type and then do one + # query per model + fk_dict = defaultdict(set) # type id, db -> model ids + for instance in instances: + for step in self._get_ids(instance): + for ct_id, fk_val in step: + fk_dict[(ct_id, instance._state.db)].add(fk_val) + + rel_objects = [] + for (ct_id, db), fkeys in fk_dict.items(): + if ct_id in custom_queryset_dict: + rel_objects.extend(custom_queryset_dict[ct_id].filter(pk__in=fkeys)) + else: + ct = self.get_content_type_by_id(id=ct_id, using=db) + rel_objects.extend(ct.get_all_objects_for_this_type(pk__in=fkeys)) + + # reorganize objects to fix usage + items = { + (self.get_content_type_of_obj(obj=rel_obj).pk, rel_obj.pk, rel_obj._state.db): rel_obj + for rel_obj in rel_objects + } + lists = [] + lists_keys = {} + for instance in instances: + data = [] + lists.append(data) + lists_keys[instance] = id(data) + for step in self._get_ids(instance): + nodes = [] + for ct, fk in step: + if rel_obj := items.get((ct, fk, instance._state.db)): + nodes.append(rel_obj) + data.append(nodes) + + return ( + lists, + lambda obj: id(obj), + lambda obj: lists_keys[obj], + True, + self.cache_name, + False, + ) + + def __get__(self, instance, cls=None): + if instance is None: + return self + rel_objects = self.get_cached_value(instance, default=None) + expected_ids = self._get_ids(instance) + # check cache actual + if rel_objects is not None: + actual = [ + [ + (self.get_content_type_of_obj(obj=item).id, item.pk) + for item in step + ] + for step in rel_objects + ] + if expected_ids == actual: + return rel_objects + # reload value + if expected_ids is None: + self.set_cached_value(instance, rel_objects) + return rel_objects + data = [] + for step in self._get_ids(instance): + rel_objects = [] + for ct_id, pk_val in step: + ct = self.get_content_type_by_id(id=ct_id, using=instance._state.db) + try: + rel_obj = ct.get_object_for_this_type(pk=pk_val) + rel_objects.append(rel_obj) + except ObjectDoesNotExist: + pass + data.append(rel_objects) + self.set_cached_value(instance, data) + return data + From 7794c6cfcb236b57ec46207a86946434958e5dcf Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Mon, 24 Feb 2025 15:28:45 +0100 Subject: [PATCH 2/8] Fix link peers --- netbox/dcim/api/views.py | 5 ++--- netbox/dcim/models/device_components.py | 7 +++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 748367eda..cfdd5d209 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -439,15 +439,14 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): GenericPrefetch( "cable__terminations__termination", [ - Interface.objects.prefetch_related("device"), + Interface.objects.select_related("device", "cable"), ], ), Prefetch( "_path", CablePath.objects.prefetch_related( GenericPrefetch("path_objects", [ - Interface.objects.prefetch_related("device"), - Cable.objects.prefetch_related("terminations"), + Interface.objects.select_related("device"), ]), ) ), diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9438b741f..0ae00f5c4 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -189,8 +189,11 @@ class CabledObjectModel(models.Model): @cached_property def link_peers(self): if self.cable: - peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination') - return [peer.termination for peer in peers] + return [ + peer.termination + for peer in self.cable.terminations.all() + if peer.cable_end != self.cable_end + ] return [] @property From 9c3e7f2c5d0f11dcd8227c7d4eb2655f3431bbe7 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Mon, 24 Feb 2025 15:38:19 +0100 Subject: [PATCH 3/8] Cleanup --- netbox/dcim/api/views.py | 14 ++++------- netbox/dcim/models/cables.py | 45 ------------------------------------ 2 files changed, 5 insertions(+), 54 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index cfdd5d209..b74b4bb36 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -434,21 +434,17 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = Interface.objects.prefetch_related( - # '_path', - # 'cable__terminations', GenericPrefetch( "cable__terminations__termination", [ Interface.objects.select_related("device", "cable"), ], ), - Prefetch( - "_path", - CablePath.objects.prefetch_related( - GenericPrefetch("path_objects", [ - Interface.objects.select_related("device"), - ]), - ) + GenericPrefetch( + "_path__path_objects", + [ + Interface.objects.select_related("device", "cable"), + ], ), 'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination 'ip_addresses', # Referenced by Interface.count_ipaddresses() diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 7ec94ac44..fbbb99998 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -490,15 +490,6 @@ class CablePath(models.Model): ct_id, _ = decompile_path_node(self.path[-1][0]) return ObjectType.objects.get_for_id(ct_id) - @property - def path_objects_old(self): - """ - Cache and return the complete path as lists of objects, derived from their annotation within the path. - """ - if not hasattr(self, '_path_objects'): - self._path_objects = self._get_path() - return self._path_objects - @property def _path_decompiled(self): res = [] @@ -747,42 +738,6 @@ class CablePath(models.Model): self.delete() retrace.alters_data = True - def _get_path(self): - """ - Return the path as a list of prefetched objects. - """ - # Compile a list of IDs to prefetch for each type of model in the path - to_prefetch = defaultdict(list) - for node in self._nodes: - ct_id, object_id = decompile_path_node(node) - to_prefetch[ct_id].append(object_id) - - # Prefetch path objects using one query per model type. Prefetch related devices where appropriate. - prefetched = {} - for ct_id, object_ids in to_prefetch.items(): - model_class = ObjectType.objects.get_for_id(ct_id).model_class() - queryset = model_class.objects.filter(pk__in=object_ids) - if hasattr(model_class, 'device'): - queryset = queryset.prefetch_related('device') - prefetched[ct_id] = { - obj.id: obj for obj in queryset - } - - # Replicate the path using the prefetched objects. - path = [] - for step in self.path: - nodes = [] - for node in step: - ct_id, object_id = decompile_path_node(node) - try: - nodes.append(prefetched[ct_id][object_id]) - except KeyError: - # Ignore stale (deleted) object IDs - pass - path.append(nodes) - - return path - def get_cable_ids(self): """ Return all Cable IDs within the path. From 83ca0ef95559f00fe029cf1724d075518d4ae441 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Mon, 24 Feb 2025 15:43:07 +0100 Subject: [PATCH 4/8] Add missed init --- netbox/utilities/generics/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 netbox/utilities/generics/__init__.py diff --git a/netbox/utilities/generics/__init__.py b/netbox/utilities/generics/__init__.py new file mode 100644 index 000000000..e69de29bb From 5c8831774591d8e63829db7a648a4ea9a010f985 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Thu, 6 Mar 2025 16:01:59 +0100 Subject: [PATCH 5/8] cleanup unused imports and extra line --- netbox/dcim/api/views.py | 1 - netbox/dcim/models/cables.py | 1 - netbox/utilities/generics/field.py | 1 - 3 files changed, 3 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index b74b4bb36..1b2775554 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,5 +1,4 @@ from django.contrib.contenttypes.prefetch import GenericPrefetch -from django.db.models import Prefetch from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index fbbb99998..70567062c 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -1,5 +1,4 @@ import itertools -from collections import defaultdict from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ValidationError diff --git a/netbox/utilities/generics/field.py b/netbox/utilities/generics/field.py index e4df05ba2..c5b291b5a 100644 --- a/netbox/utilities/generics/field.py +++ b/netbox/utilities/generics/field.py @@ -140,4 +140,3 @@ class GenericArrayForeignKey(FieldCacheMixin, Field): data.append(rel_objects) self.set_cached_value(instance, data) return data - From d103e13732a8c4b38b353ac9dd345a950822fc78 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Fri, 7 Mar 2025 17:29:04 +0100 Subject: [PATCH 6/8] Removed automatic cache invalidation --- netbox/utilities/generics/field.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/netbox/utilities/generics/field.py b/netbox/utilities/generics/field.py index c5b291b5a..1774d0b9b 100644 --- a/netbox/utilities/generics/field.py +++ b/netbox/utilities/generics/field.py @@ -110,20 +110,12 @@ class GenericArrayForeignKey(FieldCacheMixin, Field): def __get__(self, instance, cls=None): if instance is None: return self - rel_objects = self.get_cached_value(instance, default=None) + rel_objects = self.get_cached_value(instance, default=...) expected_ids = self._get_ids(instance) - # check cache actual - if rel_objects is not None: - actual = [ - [ - (self.get_content_type_of_obj(obj=item).id, item.pk) - for item in step - ] - for step in rel_objects - ] - if expected_ids == actual: - return rel_objects - # reload value + # we do not check if cache actual + if rel_objects is not ...: + return rel_objects + # load value if expected_ids is None: self.set_cached_value(instance, rel_objects) return rel_objects From 7d64d3b5ed07cb845f46d948a57d82f12810ec21 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Wed, 12 Mar 2025 16:22:11 +0100 Subject: [PATCH 7/8] Move GenericArrayForeignKey to fields module --- netbox/dcim/models/cables.py | 3 +- netbox/utilities/fields.py | 133 +++++++++++++++++++++++++ netbox/utilities/generics/__init__.py | 0 netbox/utilities/generics/field.py | 134 -------------------------- 4 files changed, 134 insertions(+), 136 deletions(-) delete mode 100644 netbox/utilities/generics/__init__.py delete mode 100644 netbox/utilities/generics/field.py diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 899a05812..d6302ed3b 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -14,8 +14,7 @@ from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node from netbox.models import ChangeLoggedModel, PrimaryModel from utilities.conversion import to_meters -from utilities.fields import ColorField -from utilities.generics.field import GenericArrayForeignKey +from utilities.fields import ColorField, GenericArrayForeignKey from utilities.querysets import RestrictedQuerySet from wireless.models import WirelessLink from .device_components import FrontPort, RearPort, PathEndpoint diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 1d16a1d3f..c205f41d6 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -1,7 +1,11 @@ from collections import defaultdict from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.db import models +from django.db.models.fields.mixins import FieldCacheMixin +from django.utils.functional import cached_property from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -11,6 +15,7 @@ from .validators import ColorValidator __all__ = ( 'ColorField', 'CounterCacheField', + 'GenericArrayForeignKey', 'NaturalOrderingField', 'RestrictedGenericForeignKey', ) @@ -186,3 +191,131 @@ class CounterCacheField(models.BigIntegerField): kwargs["to_model"] = self.to_model_name kwargs["to_field"] = self.to_field_name return name, path, args, kwargs + + + +class GenericArrayForeignKey(FieldCacheMixin, models.Field): + """ + Provide a generic many-to-many relation through an array field + """ + + many_to_many = True + many_to_one = False + one_to_many = False + one_to_one = False + + def __init__(self, field, for_concrete_model=True): + super().__init__(editable=False) + self.field = field + self.for_concrete_model = for_concrete_model + self.is_relation = True + + def contribute_to_class(self, cls, name, **kwargs): + super().contribute_to_class(cls, name, private_only=True, **kwargs) + # GenericForeignKey is its own descriptor. + setattr(cls, self.attname, self) + + @cached_property + def cache_name(self): + return self.name + + def get_cache_name(self): + return self.cache_name + + def _get_ids(self, instance): + return getattr(instance, self.field) + + def get_content_type_by_id(self, id=None, using=None): + return ContentType.objects.db_manager(using).get_for_id(id) + + def get_content_type_of_obj(self, obj=None): + return ContentType.objects.db_manager(obj._state.db).get_for_model( + obj, for_concrete_model=self.for_concrete_model + ) + + def get_content_type_for_model(self, using=None, model=None): + return ContentType.objects.db_manager(using).get_for_model( + model, for_concrete_model=self.for_concrete_model + ) + + def get_prefetch_querysets(self, instances, querysets=None): + custom_queryset_dict = {} + if querysets is not None: + for queryset in querysets: + ct_id = self.get_content_type_for_model( + model=queryset.query.model, using=queryset.db + ).pk + if ct_id in custom_queryset_dict: + raise ValueError( + "Only one queryset is allowed for each content type." + ) + custom_queryset_dict[ct_id] = queryset + + # For efficiency, group the instances by content type and then do one + # query per model + fk_dict = defaultdict(set) # type id, db -> model ids + for instance in instances: + for step in self._get_ids(instance): + for ct_id, fk_val in step: + fk_dict[(ct_id, instance._state.db)].add(fk_val) + + rel_objects = [] + for (ct_id, db), fkeys in fk_dict.items(): + if ct_id in custom_queryset_dict: + rel_objects.extend(custom_queryset_dict[ct_id].filter(pk__in=fkeys)) + else: + ct = self.get_content_type_by_id(id=ct_id, using=db) + rel_objects.extend(ct.get_all_objects_for_this_type(pk__in=fkeys)) + + # reorganize objects to fix usage + items = { + (self.get_content_type_of_obj(obj=rel_obj).pk, rel_obj.pk, rel_obj._state.db): rel_obj + for rel_obj in rel_objects + } + lists = [] + lists_keys = {} + for instance in instances: + data = [] + lists.append(data) + lists_keys[instance] = id(data) + for step in self._get_ids(instance): + nodes = [] + for ct, fk in step: + if rel_obj := items.get((ct, fk, instance._state.db)): + nodes.append(rel_obj) + data.append(nodes) + + return ( + lists, + lambda obj: id(obj), + lambda obj: lists_keys[obj], + True, + self.cache_name, + False, + ) + + def __get__(self, instance, cls=None): + if instance is None: + return self + rel_objects = self.get_cached_value(instance, default=...) + expected_ids = self._get_ids(instance) + # we do not check if cache actual + if rel_objects is not ...: + return rel_objects + # load value + if expected_ids is None: + self.set_cached_value(instance, rel_objects) + return rel_objects + data = [] + for step in self._get_ids(instance): + rel_objects = [] + for ct_id, pk_val in step: + ct = self.get_content_type_by_id(id=ct_id, using=instance._state.db) + try: + rel_obj = ct.get_object_for_this_type(pk=pk_val) + rel_objects.append(rel_obj) + except ObjectDoesNotExist: + pass + data.append(rel_objects) + self.set_cached_value(instance, data) + return data diff --git a/netbox/utilities/generics/__init__.py b/netbox/utilities/generics/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/netbox/utilities/generics/field.py b/netbox/utilities/generics/field.py deleted file mode 100644 index 1774d0b9b..000000000 --- a/netbox/utilities/generics/field.py +++ /dev/null @@ -1,134 +0,0 @@ -from collections import defaultdict - -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.db.models.fields import Field -from django.db.models.fields.mixins import FieldCacheMixin -from django.utils.functional import cached_property - - -class GenericArrayForeignKey(FieldCacheMixin, Field): - """ - Provide a generic many-to-many relation through an array field - """ - - many_to_many = True - many_to_one = False - one_to_many = False - one_to_one = False - - def __init__(self, field, for_concrete_model=True): - super().__init__(editable=False) - self.field = field - self.for_concrete_model = for_concrete_model - self.is_relation = True - - def contribute_to_class(self, cls, name, **kwargs): - super().contribute_to_class(cls, name, private_only=True, **kwargs) - # GenericForeignKey is its own descriptor. - setattr(cls, self.attname, self) - - @cached_property - def cache_name(self): - return self.name - - def get_cache_name(self): - return self.cache_name - - def _get_ids(self, instance): - return getattr(instance, self.field) - - def get_content_type_by_id(self, id=None, using=None): - return ContentType.objects.db_manager(using).get_for_id(id) - - def get_content_type_of_obj(self, obj=None): - return ContentType.objects.db_manager(obj._state.db).get_for_model( - obj, for_concrete_model=self.for_concrete_model - ) - - def get_content_type_for_model(self, using=None, model=None): - return ContentType.objects.db_manager(using).get_for_model( - model, for_concrete_model=self.for_concrete_model - ) - - def get_prefetch_querysets(self, instances, querysets=None): - custom_queryset_dict = {} - if querysets is not None: - for queryset in querysets: - ct_id = self.get_content_type_for_model( - model=queryset.query.model, using=queryset.db - ).pk - if ct_id in custom_queryset_dict: - raise ValueError( - "Only one queryset is allowed for each content type." - ) - custom_queryset_dict[ct_id] = queryset - - # For efficiency, group the instances by content type and then do one - # query per model - fk_dict = defaultdict(set) # type id, db -> model ids - for instance in instances: - for step in self._get_ids(instance): - for ct_id, fk_val in step: - fk_dict[(ct_id, instance._state.db)].add(fk_val) - - rel_objects = [] - for (ct_id, db), fkeys in fk_dict.items(): - if ct_id in custom_queryset_dict: - rel_objects.extend(custom_queryset_dict[ct_id].filter(pk__in=fkeys)) - else: - ct = self.get_content_type_by_id(id=ct_id, using=db) - rel_objects.extend(ct.get_all_objects_for_this_type(pk__in=fkeys)) - - # reorganize objects to fix usage - items = { - (self.get_content_type_of_obj(obj=rel_obj).pk, rel_obj.pk, rel_obj._state.db): rel_obj - for rel_obj in rel_objects - } - lists = [] - lists_keys = {} - for instance in instances: - data = [] - lists.append(data) - lists_keys[instance] = id(data) - for step in self._get_ids(instance): - nodes = [] - for ct, fk in step: - if rel_obj := items.get((ct, fk, instance._state.db)): - nodes.append(rel_obj) - data.append(nodes) - - return ( - lists, - lambda obj: id(obj), - lambda obj: lists_keys[obj], - True, - self.cache_name, - False, - ) - - def __get__(self, instance, cls=None): - if instance is None: - return self - rel_objects = self.get_cached_value(instance, default=...) - expected_ids = self._get_ids(instance) - # we do not check if cache actual - if rel_objects is not ...: - return rel_objects - # load value - if expected_ids is None: - self.set_cached_value(instance, rel_objects) - return rel_objects - data = [] - for step in self._get_ids(instance): - rel_objects = [] - for ct_id, pk_val in step: - ct = self.get_content_type_by_id(id=ct_id, using=instance._state.db) - try: - rel_obj = ct.get_object_for_this_type(pk=pk_val) - rel_objects.append(rel_obj) - except ObjectDoesNotExist: - pass - data.append(rel_objects) - self.set_cached_value(instance, data) - return data From 906654d8078d32f584913e8f01f40b826994bae8 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Wed, 12 Mar 2025 19:09:46 +0100 Subject: [PATCH 8/8] Fix empty line, comments --- netbox/utilities/fields.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index c205f41d6..05f61a147 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -193,10 +193,9 @@ class CounterCacheField(models.BigIntegerField): return name, path, args, kwargs - class GenericArrayForeignKey(FieldCacheMixin, models.Field): """ - Provide a generic many-to-many relation through an array field + Provide a generic many-to-many relation through an 2d array field """ many_to_many = True @@ -212,7 +211,7 @@ class GenericArrayForeignKey(FieldCacheMixin, models.Field): def contribute_to_class(self, cls, name, **kwargs): super().contribute_to_class(cls, name, private_only=True, **kwargs) - # GenericForeignKey is its own descriptor. + # GenericArrayForeignKey is its own descriptor. setattr(cls, self.attname, self) @cached_property