Repository: netbox-community/netbox
Pull request: Issue #14962 VM to merge directly related site context
<!--
    Thank you for your interest in contributing to NetBox! Please note that
    our contribution policy requires that a feature request or bug report be
    approved and assigned prior to opening a pull request. This helps avoid
    waste time and effort on a proposed change that we might not be able to
    accept.

    IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED
    TO YOU, IT WILL BE CLOSED AUTOMATICALLY.

    Please specify your assigned issue number on the line below.
-->
### Fixes: #14962 

<!--
    Please include a summary of the proposed changes below.
-->
Update the code such that site attached context data is accessed via the site attribute on the virtual machine rather than the site attribute on the optionally associated cluster.
Noting that the `full_clean` of a virtual machine will transpose the cluster's site directly onto the virtual machine if it is not already set.


Problem statement:
VM Rendered Config Context does not consider directly related site
### Deployment Type

Self-hosted

### NetBox Version

v3.7.1

### Python Version

3.8

### Steps to Reproduce

1. Create a site
2. Create a Config Context and apply it to new site (step 1)
3. Create a VM and set its site to the one created above
4. DO NOT assign the VM to a cluster
5. Inspect the rendered context in the UI.  Note that the site config shows as a source context but it is not merged into the rendered context.
6. Create a VM Cluster and assign its site to that created in step 1.
7. Update the VM to be a member of that cluster
8. Note that the rendered context for the VM now includes the sites config context

### Expected Behavior

The VMs config context should include the sites context when it is directly related via the VM's site attribute with out requiring the cluster.

### Observed Behavior

The VM context does not include the site context when it is directly related and not accessible via a cluster

Hints from issue discussion:
I have a PR ready to resolve this
> 1. Create a site with some config context

Sites do not have config context. Please elaborate on _exactly_ what actions you're taking and what data you're providing at each step, so that someone else can confidently attempt to reproduce the bug.

Commit message: Issue #14962 VM to merge directly related site context

Patch to review:
diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py
index 478dedf92d6..c1de291ed24 100644
--- a/netbox/extras/querysets.py
+++ b/netbox/extras/querysets.py
@@ -120,17 +120,14 @@ def _get_config_context_filters(self):
         if self.model._meta.model_name == 'device':
             base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
             base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
-            base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
-            base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
-            region_field = 'site__region'
-            sitegroup_field = 'site__group'
 
         elif self.model._meta.model_name == 'virtualmachine':
-            base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
-            base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
             base_query.add(Q(device_types=None), Q.AND)
-            region_field = 'cluster__site__region'
-            sitegroup_field = 'cluster__site__group'
+
+        base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
+        base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
+        region_field = 'site__region'
+        sitegroup_field = 'site__group'
 
         base_query.add(
             (Q(
diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py
index ef939840119..26c951601c7 100644
--- a/netbox/extras/tests/test_models.py
+++ b/netbox/extras/tests/test_models.py
@@ -270,7 +270,12 @@ def test_annotation_same_as_get_for_object_virtualmachine_relations(self):
         tag = Tag.objects.first()
         cluster_type = ClusterType.objects.create(name="Cluster Type")
         cluster_group = ClusterGroup.objects.create(name="Cluster Group")
-        cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type)
+        cluster = Cluster.objects.create(
+            name="Cluster",
+            group=cluster_group,
+            type=cluster_type,
+            site=site,
+        )
 
         region_context = ConfigContext.objects.create(
             name="region",
@@ -354,6 +359,106 @@ def test_annotation_same_as_get_for_object_virtualmachine_relations(self):
         annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
         self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
 
+    def test_annotation_same_as_get_for_object_virtualmachine_relations_direct_site(self):
+        region = Region.objects.first()
+        sitegroup = SiteGroup.objects.first()
+        site = Site.objects.first()
+        platform = Platform.objects.first()
+        tenantgroup = TenantGroup.objects.first()
+        tenant = Tenant.objects.first()
+        tag = Tag.objects.first()
+        cluster_type = ClusterType.objects.create(name="Cluster Type")
+        cluster_group = ClusterGroup.objects.create(name="Cluster Group")
+        cluster = Cluster.objects.create(
+            name="Cluster",
+            group=cluster_group,
+            type=cluster_type,
+            site=site,
+        )
+
+        region_context = ConfigContext.objects.create(
+            name="region",
+            weight=100,
+            data={"region": 1}
+        )
+        region_context.regions.add(region)
+
+        sitegroup_context = ConfigContext.objects.create(
+            name="sitegroup",
+            weight=100,
+            data={"sitegroup": 1}
+        )
+        sitegroup_context.site_groups.add(sitegroup)
+
+        site_context = ConfigContext.objects.create(
+            name="site",
+            weight=100,
+            data={"site": 1}
+        )
+        site_context.sites.add(site)
+
+        platform_context = ConfigContext.objects.create(
+            name="platform",
+            weight=100,
+            data={"platform": 1}
+        )
+        platform_context.platforms.add(platform)
+
+        tenant_group_context = ConfigContext.objects.create(
+            name="tenant group",
+            weight=100,
+            data={"tenant_group": 1}
+        )
+        tenant_group_context.tenant_groups.add(tenantgroup)
+
+        tenant_context = ConfigContext.objects.create(
+            name="tenant",
+            weight=100,
+            data={"tenant": 1}
+        )
+        tenant_context.tenants.add(tenant)
+
+        tag_context = ConfigContext.objects.create(
+            name="tag",
+            weight=100,
+            data={"tag": 1}
+        )
+        tag_context.tags.add(tag)
+
+        cluster_type_context = ConfigContext.objects.create(
+            name="cluster type",
+            weight=100,
+            data={"cluster_type": 1}
+        )
+        cluster_type_context.cluster_types.add(cluster_type)
+
+        cluster_group_context = ConfigContext.objects.create(
+            name="cluster group",
+            weight=100,
+            data={"cluster_group": 1}
+        )
+        cluster_group_context.cluster_groups.add(cluster_group)
+
+        cluster_context = ConfigContext.objects.create(
+            name="cluster",
+            weight=100,
+            data={"cluster": 1}
+        )
+        cluster_context.clusters.add(cluster)
+
+        virtual_machine = VirtualMachine.objects.create(
+            name="VM 2",
+            site=site,
+            tenant=tenant,
+            platform=platform,
+            role=DeviceRole.objects.first()
+        )
+        virtual_machine.tags.add(tag)
+
+        annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
+
+        self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
+
     def test_multiple_tags_return_distinct_objects(self):
         """
         Tagged items use a generic relationship, which results in duplicate rows being returned when queried.


Code review comments:
diff_hunk: @@ -354,6 +359,106 @@ def test_annotation_same_as_get_for_object_virtualmachine_relations(self):
         annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
         self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
 
+    def test_annotation_same_as_get_for_object_virtualmachine_relations_direct_site(self):
+        region = Region.objects.first()
+        sitegroup = SiteGroup.objects.first()
+        site = Site.objects.first()
+        platform = Platform.objects.first()
+        tenantgroup = TenantGroup.objects.first()
+        tenant = Tenant.objects.first()
+        tag = Tag.objects.first()
+        cluster_type = ClusterType.objects.create(name="Cluster Type")
+        cluster_group = ClusterGroup.objects.create(name="Cluster Group")
+        cluster = Cluster.objects.create(
+            name="Cluster",
+            group=cluster_group,
+            type=cluster_type,
+            site=site,
+        )
+
+        region_context = ConfigContext.objects.create(
+            name="region",
+            weight=100,
+            data={"region": 1}
+        )
+        region_context.regions.add(region)
+
+        sitegroup_context = ConfigContext.objects.create(
+            name="sitegroup",
+            weight=100,
+            data={"sitegroup": 1}
+        )
+        sitegroup_context.site_groups.add(sitegroup)
+
+        site_context = ConfigContext.objects.create(
+            name="site",
+            weight=100,
+            data={"site": 1}
+        )
+        site_context.sites.add(site)
+
+        platform_context = ConfigContext.objects.create(
+            name="platform",
+            weight=100,
+            data={"platform": 1}
+        )
+        platform_context.platforms.add(platform)
+
+        tenant_group_context = ConfigContext.objects.create(
+            name="tenant group",
+            weight=100,
+            data={"tenant_group": 1}
+        )
+        tenant_group_context.tenant_groups.add(tenantgroup)
+
+        tenant_context = ConfigContext.objects.create(
+            name="tenant",
+            weight=100,
+            data={"tenant": 1}
+        )
+        tenant_context.tenants.add(tenant)
+
+        tag_context = ConfigContext.objects.create(
+            name="tag",
+            weight=100,
+            data={"tag": 1}
+        )
+        tag_context.tags.add(tag)
+
+        cluster_type_context = ConfigContext.objects.create(
+            name="cluster type",
+            weight=100,
+            data={"cluster_type": 1}
+        )
+        cluster_type_context.cluster_types.add(cluster_type)
+
+        cluster_group_context = ConfigContext.objects.create(
+            name="cluster group",
+            weight=100,
+            data={"cluster_group": 1}
+        )
+        cluster_group_context.cluster_groups.add(cluster_group)
+
+        cluster_context = ConfigContext.objects.create(
+            name="cluster",
+            weight=100,
+            data={"cluster": 1}
+        )
+        cluster_context.clusters.add(cluster)
+
+        virtual_machine = VirtualMachine.objects.create(
+            name="VM 2",
+            site=site,
+            tenant=tenant,
+            platform=platform,
+            role=DeviceRole.objects.first()
+        )
+        virtual_machine.tags.add(tag)
+
+        annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
+
+        self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
path: netbox/extras/tests/test_models.py
text: @user1:
This doesn't actually validate that the rendered config context is correct for the VM, just that it's consistent between the two retrieval methods.