Repository: netbox-community/netbox

Issue:
Unable to update Site when bulk-editing VLAN Groups
### NetBox version

v3.6.2

### Python version

3.8

### Steps to Reproduce

1. Select one or more VLAN Groups
2. Click on **Edit Selected**
3. Select a **Site** and submit the form

![msedge_uHjHsHnN5F](https://github.com/netbox-community/netbox/assets/1522389/e9b639ad-37f9-4d62-b90d-5037d8fffaab)


### Expected Behavior

Ideally, the VLAN Group Bulk Edit form should reference the **scope** of the VLAN to edit, not a site specifically.


### Observed Behavior

```
<class 'TypeError'>

Direct assignment to the reverse side of a related set is prohibited. Use site.set() instead.

Python version: 3.8.10
NetBox version: 3.6.2
Plugins: 
  netbox_demo: 0.3.1
```


Discussion:

Resolving patch:
diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py
--- a/netbox/ipam/forms/bulk_edit.py
+++ b/netbox/ipam/forms/bulk_edit.py
@@ -1,7 +1,8 @@
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 
-from dcim.models import Region, Site, SiteGroup
+from dcim.models import Location, Rack, Region, Site, SiteGroup
 from ipam.choices import *
 from ipam.constants import *
 from ipam.models import *
@@ -10,9 +11,10 @@
 from tenancy.models import Tenant
 from utilities.forms import add_blank_choice
 from utilities.forms.fields import (
-    CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
+    CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
 )
 from utilities.forms.widgets import BulkEditNullBooleanSelect
+from virtualization.models import Cluster, ClusterGroup
 
 __all__ = (
     'AggregateBulkEditForm',
@@ -407,11 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
-    site = DynamicModelChoiceField(
-        label=_('Site'),
-        queryset=Site.objects.all(),
-        required=False
-    )
     min_vid = forms.IntegerField(
         min_value=VLAN_VID_MIN,
         max_value=VLAN_VID_MAX,
@@ -429,12 +426,84 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
         max_length=200,
         required=False
     )
+    scope_type = ContentTypeChoiceField(
+        label=_('Scope type'),
+        queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
+        required=False
+    )
+    scope_id = forms.IntegerField(
+        required=False,
+        widget=forms.HiddenInput()
+    )
+    region = DynamicModelChoiceField(
+        label=_('Region'),
+        queryset=Region.objects.all(),
+        required=False
+    )
+    sitegroup = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group')
+    )
+    site = DynamicModelChoiceField(
+        label=_('Site'),
+        queryset=Site.objects.all(),
+        required=False,
+        query_params={
+            'region_id': '$region',
+            'group_id': '$sitegroup',
+        }
+    )
+    location = DynamicModelChoiceField(
+        label=_('Location'),
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site',
+        }
+    )
+    rack = DynamicModelChoiceField(
+        label=_('Rack'),
+        queryset=Rack.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site',
+            'location_id': '$location',
+        }
+    )
+    clustergroup = DynamicModelChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        label=_('Cluster group')
+    )
+    cluster = DynamicModelChoiceField(
+        label=_('Cluster'),
+        queryset=Cluster.objects.all(),
+        required=False,
+        query_params={
+            'group_id': '$clustergroup',
+        }
+    )
 
     model = VLANGroup
     fieldsets = (
         (None, ('site', 'min_vid', 'max_vid', 'description')),
+        (_('Scope'), ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
     )
-    nullable_fields = ('site', 'description')
+    nullable_fields = ('description',)
+
+    def clean(self):
+        super().clean()
+
+        # Assign scope based on scope_type
+        if self.cleaned_data.get('scope_type'):
+            scope_field = self.cleaned_data['scope_type'].model
+            if scope_obj := self.cleaned_data.get(scope_field):
+                self.cleaned_data['scope_id'] = scope_obj.pk
+                self.changed_data.append('scope_id')
+            else:
+                self.cleaned_data.pop('scope_type')
+                self.changed_data.remove('scope_type')
 
 
 class VLANBulkEditForm(NetBoxModelBulkEditForm):
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -3,6 +3,7 @@
 from copy import deepcopy
 
 from django.contrib import messages
+from django.contrib.contenttypes.fields import GenericRel
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
 from django.db import transaction, IntegrityError
@@ -519,9 +520,11 @@ def _update_objects(self, form, request):
                 model_field = self.queryset.model._meta.get_field(name)
                 if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
                     m2m_fields[name] = model_field
+                elif isinstance(model_field, GenericRel):
+                    # Ignore generic relations (these may be used for other purposes in the form)
+                    continue
                 else:
                     model_fields[name] = model_field
-
             except FieldDoesNotExist:
                 # This form field is used to modify a field rather than set its value directly
                 model_fields[name] = None


Test patch: