Browse Source

fix(users): Restore owner Related Objects panel

Owners are referenced through a hidden reverse relationship
(related_name='+'), which related-model discovery skipped, so the panel
rendered empty however many objects were assigned. OwnerView now opts
into hidden relationships via a new include_hidden argument, which
leaves discovery unchanged for every other caller.

Fixes #23203
Martin Hauser 8 hours ago
parent
commit
2a88f3f625

+ 49 - 0
netbox/users/tests/test_views.py

@@ -1,6 +1,7 @@
 from django.urls import reverse
 
 from core.models import ObjectType
+from dcim.models import Site
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from users.constants import TOKEN_PREFIX
 from users.models import *
@@ -639,3 +640,51 @@ class OwnerTestCase(ViewTestCases.AdminModelViewTestCase):
         cls.bulk_edit_data = {
             'description': 'New description',
         }
+
+    def test_related_objects_list_owned_objects(self):
+        """An object assigned to an owner appears among the owner's related models."""
+        owner = Owner.objects.get(name='Owner 1')
+        Site.objects.create(name='Site 1', slug='site-1', owner=owner)
+        self.add_permissions('users.view_owner', 'dcim.view_site')
+
+        response = self.client.get(owner.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+
+        related = {roc.queryset.model: roc for roc in response.context['related_models']}
+        self.assertIn(Site, related)
+        self.assertEqual(related[Site].filter_param, 'owner_id')
+        self.assertEqual(related[Site].queryset.count(), 1)
+
+    def test_related_objects_honor_object_permissions(self):
+        """An owned object the user cannot view is omitted from the owner's related models."""
+        owner = Owner.objects.get(name='Owner 1')
+        Site.objects.create(name='Site 1', slug='site-1', owner=owner)
+        self.add_permissions('users.view_owner')
+
+        response = self.client.get(owner.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+
+        self.assertEqual(response.context['related_models'], [])
+
+    def test_related_objects_honor_constrained_permissions(self):
+        """A related model reports only the owned objects the user is permitted to view."""
+        owner = Owner.objects.get(name='Owner 1')
+        site1 = Site.objects.create(name='Site 1', slug='site-1', owner=owner)
+        Site.objects.create(name='Site 2', slug='site-2', owner=owner)
+        self.add_permissions('users.view_owner')
+
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            constraints={'pk': site1.pk},
+            actions=['view']
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Site))
+
+        response = self.client.get(owner.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+
+        related = {roc.queryset.model: roc for roc in response.context['related_models']}
+        self.assertIn(Site, related)
+        self.assertEqual(list(related[Site].queryset), [site1])

+ 1 - 0
netbox/users/views.py

@@ -461,6 +461,7 @@ class OwnerView(GetRelatedModelsMixin, generic.ObjectView):
                 request,
                 instance,
                 omit=(Group, User),
+                include_hidden=True,
             ),
         }
 

+ 8 - 4
netbox/utilities/relations.py

@@ -5,15 +5,19 @@ __all__ = (
 )
 
 
-def get_related_models(model, ordered=True):
+def get_related_models(model, ordered=True, include_hidden=False):
     """
     Return a list of all models which have a ForeignKey to the given model and the name of the field. For example,
-    `get_related_models(Tenant)` will return all models which have a ForeignKey relationship to Tenant.
+    `get_related_models(Tenant)` will return all models which have a ForeignKey relationship to Tenant. Set
+    `include_hidden` to also return relationships declared with `related_name='+'`, excluding the
+    automatically created models behind many-to-many fields.
     """
     related_models = [
         (field.related_model, field.remote_field.name)
-        for field in model._meta.related_objects
-        if type(field) is ManyToOneRel and not getattr(field.related_model, '_netbox_private', False)
+        for field in model._meta.get_fields(include_hidden=include_hidden)
+        if type(field) is ManyToOneRel
+        and not field.related_model._meta.auto_created
+        and not getattr(field.related_model, '_netbox_private', False)
     ]
 
     if ordered:

+ 41 - 0
netbox/utilities/tests/test_relations.py

@@ -0,0 +1,41 @@
+from django.test import TestCase
+
+from dcim.models import Site
+from tenancy.models import Tenant
+from users.models import Owner, User, UserConfig
+from utilities.relations import get_related_models
+
+
+class GetRelatedModelsTestCase(TestCase):
+    """
+    Validate the operation of get_related_models().
+    """
+    def test_visible_relationships_are_returned(self):
+        """An ordinary reverse ForeignKey relationship is reported."""
+        self.assertIn((Site, 'tenant'), get_related_models(Tenant))
+
+    def test_hidden_relationships_are_omitted_by_default(self):
+        """Relationships declared with related_name='+' are not reported unless requested."""
+        self.assertEqual(get_related_models(Owner), [])
+
+    def test_hidden_relationships_are_returned_on_request(self):
+        """include_hidden reports the relationships hidden by related_name='+'."""
+        self.assertIn((Site, 'owner'), get_related_models(Owner, include_hidden=True))
+
+    def test_intermediary_models_are_excluded(self):
+        """The auto-created models behind Owner's many-to-many fields are not reported."""
+        related = get_related_models(Owner, include_hidden=True)
+
+        self.assertEqual([model for model, _ in related if model._meta.auto_created], [])
+
+    def test_private_models_are_excluded(self):
+        """A model flagged _netbox_private is not reported even when hidden relationships are requested."""
+        related = get_related_models(User, include_hidden=True)
+
+        self.assertNotIn(UserConfig, [model for model, _ in related])
+
+    def test_results_are_sorted_by_verbose_name(self):
+        """Ordered results are sorted by the related model's verbose name."""
+        related = get_related_models(Owner, include_hidden=True)
+
+        self.assertEqual(related, sorted(related, key=lambda x: x[0]._meta.verbose_name.lower()))

+ 3 - 2
netbox/utilities/views.py

@@ -200,7 +200,7 @@ class GetRelatedModelsMixin:
         def name(self):
             return self.label or title(_(self.queryset.model._meta.verbose_name_plural))
 
-    def get_related_models(self, request, instance, omit=None, extra=None):
+    def get_related_models(self, request, instance, omit=None, extra=None, include_hidden=False):
         """
         Get related models of the view's `queryset` model without those listed in `omit`. Will be sorted alphabetical.
 
@@ -212,12 +212,13 @@ class GetRelatedModelsMixin:
                 provide a `_list` view.
             extra: Add extra models to the list of automatically determined related models. Can be used to add indirect
                 relationships.
+            include_hidden: Also match relationships declared with `related_name='+'`.
         """
         omit = omit or []
         model = self.queryset.model
         related = filter(
             lambda m: m[0] is not model and m[0] not in omit,
-            get_related_models(model, False)
+            get_related_models(model, ordered=False, include_hidden=include_hidden)
         )
 
         related_models = [