Selaa lähdekoodia

17170 Add ability to add contacts to multiple contact groups (#18885)

* 17170 Allow multiple Group assignments for Contacts

* 17170 update docs

* 17170 update api, detail view, graphql

* 17170 fixes

* 17170 fixes

* 17170 fixes

* 17170 fixes

* 17170 fixes

* 17170 fixes

* 17170 fix bulk import

* 17170 test fixes

* 17170 test fixes

* 17170 test fixes

* 17178 review changes

* 17178 review changes

* 17178 review changes

* 17178 review changes

* 17178 review changes

* 17178 review changes

* 17170 update migration

* 17170 bulk edit form
Arthur Hanson 11 kuukautta sitten
vanhempi
commit
af5ec19430

+ 4 - 2
docs/models/tenancy/contact.md

@@ -4,9 +4,11 @@ A contact represents an individual or group that has been associated with an obj
 
 
 ## Fields
 ## Fields
 
 
-### Group
+### Groups
 
 
-The [contact group](./contactgroup.md) to which this contact is assigned (if any).
+The [contact groups](./contactgroup.md) to which this contact is assigned (if any).
+
+!!! info "This field was renamed from `group` to `groups` in NetBox v4.3, and now supports the assignment of a contact to more than one group."
 
 
 ### Name
 ### Name
 
 

+ 12 - 2
netbox/templates/tenancy/contact.html

@@ -18,8 +18,18 @@
         <h2 class="card-header">{% trans "Contact" %}</h2>
         <h2 class="card-header">{% trans "Contact" %}</h2>
         <table class="table table-hover attr-table">
         <table class="table table-hover attr-table">
           <tr>
           <tr>
-            <th scope="row">{% trans "Group" %}</th>
-            <td>{{ object.group|linkify|placeholder }}</td>
+            <th scope="row">{% trans "Groups" %}</th>
+            <td>
+              {% if object.groups.all|length > 0 %}
+                <ol class="list-unstyled mb-0">
+                  {% for group in object.groups.all %}
+                    <li>{{ group|linkify|placeholder }}</li>
+                  {% endfor %}
+                </ol>
+              {% else %}
+                {{ ''|placeholder }}
+              {% endif %}
+            </td>
           </tr>
           </tr>
           <tr>
           <tr>
             <th scope="row">{% trans "Name" %}</th>
             <th scope="row">{% trans "Name" %}</th>

+ 8 - 3
netbox/tenancy/api/serializers_/contacts.py

@@ -3,7 +3,7 @@ from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
 from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
 from tenancy.choices import ContactPriorityChoices
 from tenancy.choices import ContactPriorityChoices
 from tenancy.models import ContactAssignment, Contact, ContactGroup, ContactRole
 from tenancy.models import ContactAssignment, Contact, ContactGroup, ContactRole
@@ -43,12 +43,17 @@ class ContactRoleSerializer(NetBoxModelSerializer):
 
 
 
 
 class ContactSerializer(NetBoxModelSerializer):
 class ContactSerializer(NetBoxModelSerializer):
-    group = ContactGroupSerializer(nested=True, required=False, allow_null=True, default=None)
+    groups = SerializedPKRelatedField(
+        queryset=ContactGroup.objects.all(),
+        serializer=ContactGroupSerializer,
+        required=False,
+        many=True
+    )
 
 
     class Meta:
     class Meta:
         model = Contact
         model = Contact
         fields = [
         fields = [
-            'id', 'url', 'display_url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link',
+            'id', 'url', 'display_url', 'display', 'groups', 'name', 'title', 'phone', 'email', 'address', 'link',
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 1 - 1
netbox/tenancy/api/views.py

@@ -44,7 +44,7 @@ class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
     queryset = ContactGroup.objects.add_related_count(
     queryset = ContactGroup.objects.add_related_count(
         ContactGroup.objects.all(),
         ContactGroup.objects.all(),
         Contact,
         Contact,
-        'group',
+        'groups',
         'contact_count',
         'contact_count',
         cumulative=True
         cumulative=True
     )
     )

+ 11 - 6
netbox/tenancy/filtersets.py

@@ -46,6 +46,11 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label=_('Contact group (slug)'),
         label=_('Contact group (slug)'),
     )
     )
+    contact_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='contact',
+        queryset=Contact.objects.all(),
+        label=_('Contact (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = ContactGroup
         model = ContactGroup
@@ -62,15 +67,15 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet):
 class ContactFilterSet(NetBoxModelFilterSet):
 class ContactFilterSet(NetBoxModelFilterSet):
     group_id = TreeNodeMultipleChoiceFilter(
     group_id = TreeNodeMultipleChoiceFilter(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
-        field_name='group',
+        field_name='groups',
         lookup_expr='in',
         lookup_expr='in',
         label=_('Contact group (ID)'),
         label=_('Contact group (ID)'),
     )
     )
     group = TreeNodeMultipleChoiceFilter(
     group = TreeNodeMultipleChoiceFilter(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
-        field_name='group',
-        lookup_expr='in',
+        field_name='groups',
         to_field_name='slug',
         to_field_name='slug',
+        lookup_expr='in',
         label=_('Contact group (slug)'),
         label=_('Contact group (slug)'),
     )
     )
 
 
@@ -105,13 +110,13 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
     )
     )
     group_id = TreeNodeMultipleChoiceFilter(
     group_id = TreeNodeMultipleChoiceFilter(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
-        field_name='contact__group',
+        field_name='contact__groups',
         lookup_expr='in',
         lookup_expr='in',
         label=_('Contact group (ID)'),
         label=_('Contact group (ID)'),
     )
     )
     group = TreeNodeMultipleChoiceFilter(
     group = TreeNodeMultipleChoiceFilter(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
-        field_name='contact__group',
+        field_name='contact__groups',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label=_('Contact group (slug)'),
         label=_('Contact group (slug)'),
@@ -153,7 +158,7 @@ class ContactModelFilterSet(django_filters.FilterSet):
     )
     )
     contact_group = TreeNodeMultipleChoiceFilter(
     contact_group = TreeNodeMultipleChoiceFilter(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
-        field_name='contacts__contact__group',
+        field_name='contacts__contact__groups',
         lookup_expr='in',
         lookup_expr='in',
         label=_('Contact group'),
         label=_('Contact group'),
     )
     )

+ 14 - 5
netbox/tenancy/forms/bulk_edit.py

@@ -5,7 +5,7 @@ from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.choices import ContactPriorityChoices
 from tenancy.choices import ContactPriorityChoices
 from tenancy.models import *
 from tenancy.models import *
 from utilities.forms import add_blank_choice
 from utilities.forms import add_blank_choice
-from utilities.forms.fields import CommentField, DynamicModelChoiceField
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
 
 
 __all__ = (
 __all__ = (
@@ -90,8 +90,13 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm):
 
 
 
 
 class ContactBulkEditForm(NetBoxModelBulkEditForm):
 class ContactBulkEditForm(NetBoxModelBulkEditForm):
-    group = DynamicModelChoiceField(
-        label=_('Group'),
+    add_groups = DynamicModelMultipleChoiceField(
+        label=_('Add groups'),
+        queryset=ContactGroup.objects.all(),
+        required=False
+    )
+    remove_groups = DynamicModelMultipleChoiceField(
+        label=_('Remove groups'),
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False
         required=False
     )
     )
@@ -127,9 +132,13 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Contact
     model = Contact
     fieldsets = (
     fieldsets = (
-        FieldSet('group', 'title', 'phone', 'email', 'address', 'link', 'description'),
+        FieldSet('title', 'phone', 'email', 'address', 'link', 'description'),
+        FieldSet('add_groups', 'remove_groups', name=_('Groups')),
+    )
+
+    nullable_fields = (
+        'add_groups', 'remove_groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments'
     )
     )
-    nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments')
 
 
 
 
 class ContactAssignmentBulkEditForm(NetBoxModelBulkEditForm):
 class ContactAssignmentBulkEditForm(NetBoxModelBulkEditForm):

+ 4 - 5
netbox/tenancy/forms/bulk_import.py

@@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import *
 from tenancy.models import *
-from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, SlugField
+from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
 
 
 __all__ = (
 __all__ = (
     'ContactAssignmentImportForm',
     'ContactAssignmentImportForm',
@@ -77,17 +77,16 @@ class ContactRoleImportForm(NetBoxModelImportForm):
 
 
 
 
 class ContactImportForm(NetBoxModelImportForm):
 class ContactImportForm(NetBoxModelImportForm):
-    group = CSVModelChoiceField(
-        label=_('Group'),
+    groups = CSVModelMultipleChoiceField(
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text=_('Assigned group')
+        help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")')
     )
     )
 
 
     class Meta:
     class Meta:
         model = Contact
         model = Contact
-        fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags')
+        fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'groups', 'description', 'comments', 'tags')
 
 
 
 
 class ContactAssignmentImportForm(NetBoxModelImportForm):
 class ContactAssignmentImportForm(NetBoxModelImportForm):

+ 1 - 1
netbox/tenancy/forms/filtersets.py

@@ -75,7 +75,7 @@ class ContactFilterForm(NetBoxModelFilterSetForm):
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False,
         required=False,
         null_option='None',
         null_option='None',
-        label=_('Group')
+        label=_('Groups')
     )
     )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 

+ 6 - 6
netbox/tenancy/forms/model_forms.py

@@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.models import *
 from tenancy.models import *
-from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
 from utilities.forms.rendering import FieldSet, ObjectAttribute
 from utilities.forms.rendering import FieldSet, ObjectAttribute
 
 
 __all__ = (
 __all__ = (
@@ -93,8 +93,8 @@ class ContactRoleForm(NetBoxModelForm):
 
 
 
 
 class ContactForm(NetBoxModelForm):
 class ContactForm(NetBoxModelForm):
-    group = DynamicModelChoiceField(
-        label=_('Group'),
+    groups = DynamicModelMultipleChoiceField(
+        label=_('Groups'),
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False
         required=False
     )
     )
@@ -102,7 +102,7 @@ class ContactForm(NetBoxModelForm):
 
 
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
-            'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags',
+            'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags',
             name=_('Contact')
             name=_('Contact')
         ),
         ),
     )
     )
@@ -110,7 +110,7 @@ class ContactForm(NetBoxModelForm):
     class Meta:
     class Meta:
         model = Contact
         model = Contact
         fields = (
         fields = (
-            'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags',
+            'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags',
         )
         )
         widgets = {
         widgets = {
             'address': forms.Textarea(attrs={'rows': 3}),
             'address': forms.Textarea(attrs={'rows': 3}),
@@ -123,7 +123,7 @@ class ContactAssignmentForm(NetBoxModelForm):
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
         required=False,
         required=False,
         initial_params={
         initial_params={
-            'contacts': '$contact'
+            'contact': '$contact'
         }
         }
     )
     )
     contact = DynamicModelChoiceField(
     contact = DynamicModelChoiceField(

+ 1 - 1
netbox/tenancy/graphql/types.py

@@ -97,7 +97,7 @@ class TenantGroupType(OrganizationalObjectType):
 
 
 @strawberry_django.type(models.Contact, fields='__all__', filters=ContactFilter)
 @strawberry_django.type(models.Contact, fields='__all__', filters=ContactFilter)
 class ContactType(ContactAssignmentsMixin, NetBoxObjectType):
 class ContactType(ContactAssignmentsMixin, NetBoxObjectType):
-    group: Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')] | None
+    groups: List[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]]
 
 
 
 
 @strawberry_django.type(models.ContactRole, fields='__all__', filters=ContactRoleFilter)
 @strawberry_django.type(models.ContactRole, fields='__all__', filters=ContactRoleFilter)

+ 68 - 0
netbox/tenancy/migrations/0018_contact_groups.py

@@ -0,0 +1,68 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def migrate_contact_groups(apps, schema_editor):
+    Contacts = apps.get_model('tenancy', 'Contact')
+
+    qs = Contacts.objects.filter(group__isnull=False)
+    for contact in qs:
+        contact.groups.add(contact.group)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0017_natural_ordering'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ContactGroupMembership',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+            ],
+            options={
+                'verbose_name': 'contact group membership',
+                'verbose_name_plural': 'contact group memberships',
+            },
+        ),
+        migrations.RemoveConstraint(
+            model_name='contact',
+            name='tenancy_contact_unique_group_name',
+        ),
+        migrations.AddField(
+            model_name='contactgroupmembership',
+            name='contact',
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contact'
+            ),
+        ),
+        migrations.AddField(
+            model_name='contactgroupmembership',
+            name='group',
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contactgroup'
+            ),
+        ),
+        migrations.AddField(
+            model_name='contact',
+            name='groups',
+            field=models.ManyToManyField(
+                blank=True,
+                related_name='contacts',
+                related_query_name='contact',
+                through='tenancy.ContactGroupMembership',
+                to='tenancy.contactgroup',
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='contactgroupmembership',
+            constraint=models.UniqueConstraint(fields=('group', 'contact'), name='unique_group_name'),
+        ),
+        migrations.RunPython(code=migrate_contact_groups, reverse_code=migrations.RunPython.noop),
+        migrations.RemoveField(
+            model_name='contact',
+            name='group',
+        ),
+    ]

+ 18 - 11
netbox/tenancy/models/contacts.py

@@ -13,6 +13,7 @@ __all__ = (
     'ContactAssignment',
     'ContactAssignment',
     'Contact',
     'Contact',
     'ContactGroup',
     'ContactGroup',
+    'ContactGroupMembership',
     'ContactRole',
     'ContactRole',
 )
 )
 
 
@@ -47,12 +48,12 @@ class Contact(PrimaryModel):
     """
     """
     Contact information for a particular object(s) in NetBox.
     Contact information for a particular object(s) in NetBox.
     """
     """
-    group = models.ForeignKey(
+    groups = models.ManyToManyField(
         to='tenancy.ContactGroup',
         to='tenancy.ContactGroup',
-        on_delete=models.SET_NULL,
         related_name='contacts',
         related_name='contacts',
-        blank=True,
-        null=True
+        through='tenancy.ContactGroupMembership',
+        related_query_name='contact',
+        blank=True
     )
     )
     name = models.CharField(
     name = models.CharField(
         verbose_name=_('name'),
         verbose_name=_('name'),
@@ -84,17 +85,11 @@ class Contact(PrimaryModel):
     )
     )
 
 
     clone_fields = (
     clone_fields = (
-        'group', 'name', 'title', 'phone', 'email', 'address', 'link',
+        'groups', 'name', 'title', 'phone', 'email', 'address', 'link',
     )
     )
 
 
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
-        constraints = (
-            models.UniqueConstraint(
-                fields=('group', 'name'),
-                name='%(app_label)s_%(class)s_unique_group_name'
-            ),
-        )
         verbose_name = _('contact')
         verbose_name = _('contact')
         verbose_name_plural = _('contacts')
         verbose_name_plural = _('contacts')
 
 
@@ -102,6 +97,18 @@ class Contact(PrimaryModel):
         return self.name
         return self.name
 
 
 
 
+class ContactGroupMembership(models.Model):
+    group = models.ForeignKey(ContactGroup, related_name="+", on_delete=models.CASCADE)
+    contact = models.ForeignKey(Contact, related_name="+", on_delete=models.CASCADE)
+
+    class Meta:
+        constraints = [
+            models.UniqueConstraint(fields=['group', 'contact'], name='unique_group_name')
+        ]
+        verbose_name = _('contact group membership')
+        verbose_name_plural = _('contact group memberships')
+
+
 class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
 class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
     object_type = models.ForeignKey(
     object_type = models.ForeignKey(
         to='contenttypes.ContentType',
         to='contenttypes.ContentType',

+ 5 - 5
netbox/tenancy/tables/contacts.py

@@ -56,9 +56,9 @@ class ContactTable(NetBoxTable):
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
     )
     )
-    group = tables.Column(
-        verbose_name=_('Group'),
-        linkify=True
+    groups = columns.ManyToManyColumn(
+        verbose_name=_('Groups'),
+        linkify_item=('tenancy:contactgroup', {'pk': tables.A('pk')})
     )
     )
     phone = tables.Column(
     phone = tables.Column(
         verbose_name=_('Phone'),
         verbose_name=_('Phone'),
@@ -79,10 +79,10 @@ class ContactTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Contact
         model = Contact
         fields = (
         fields = (
-            'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments',
+            'pk', 'name', 'groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments',
             'assignment_count', 'tags', 'created', 'last_updated',
             'assignment_count', 'tags', 'created', 'last_updated',
         )
         )
-        default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email')
+        default_columns = ('pk', 'name', 'groups', 'assignment_count', 'title', 'phone', 'email')
 
 
 
 
 class ContactAssignmentTable(NetBoxTable):
 class ContactAssignmentTable(NetBoxTable):

+ 8 - 6
netbox/tenancy/tests/test_api.py

@@ -170,7 +170,7 @@ class ContactTest(APIViewTestCases.APIViewTestCase):
     model = Contact
     model = Contact
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
-        'group': None,
+        'groups': [],
         'comments': 'New comments',
         'comments': 'New comments',
     }
     }
 
 
@@ -183,20 +183,22 @@ class ContactTest(APIViewTestCases.APIViewTestCase):
         )
         )
 
 
         contacts = (
         contacts = (
-            Contact(name='Contact 1', group=contact_groups[0]),
-            Contact(name='Contact 2', group=contact_groups[0]),
-            Contact(name='Contact 3', group=contact_groups[0]),
+            Contact(name='Contact 1'),
+            Contact(name='Contact 2'),
+            Contact(name='Contact 3'),
         )
         )
         Contact.objects.bulk_create(contacts)
         Contact.objects.bulk_create(contacts)
+        contacts[0].groups.add(contact_groups[0])
+        contacts[1].groups.add(contact_groups[0])
+        contacts[2].groups.add(contact_groups[0])
 
 
         cls.create_data = [
         cls.create_data = [
             {
             {
                 'name': 'Contact 4',
                 'name': 'Contact 4',
-                'group': contact_groups[1].pk,
+                'groups': [contact_groups[1].pk],
             },
             },
             {
             {
                 'name': 'Contact 5',
                 'name': 'Contact 5',
-                'group': contact_groups[1].pk,
             },
             },
             {
             {
                 'name': 'Contact 6',
                 'name': 'Contact 6',

+ 13 - 6
netbox/tenancy/tests/test_filtersets.py

@@ -241,6 +241,7 @@ class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Contact.objects.all()
     queryset = Contact.objects.all()
     filterset = ContactFilterSet
     filterset = ContactFilterSet
+    ignore_fields = ('groups',)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -254,11 +255,14 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
             contactgroup.save()
             contactgroup.save()
 
 
         contacts = (
         contacts = (
-            Contact(name='Contact 1', group=contact_groups[0], description='foobar1'),
-            Contact(name='Contact 2', group=contact_groups[1], description='foobar2'),
-            Contact(name='Contact 3', group=contact_groups[2], description='foobar3'),
+            Contact(name='Contact 1', description='foobar1'),
+            Contact(name='Contact 2', description='foobar2'),
+            Contact(name='Contact 3', description='foobar3'),
         )
         )
         Contact.objects.bulk_create(contacts)
         Contact.objects.bulk_create(contacts)
+        contacts[0].groups.add(contact_groups[0])
+        contacts[1].groups.add(contact_groups[1])
+        contacts[2].groups.add(contact_groups[2])
 
 
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -311,11 +315,14 @@ class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
         ContactRole.objects.bulk_create(contact_roles)
         ContactRole.objects.bulk_create(contact_roles)
 
 
         contacts = (
         contacts = (
-            Contact(name='Contact 1', group=contact_groups[0]),
-            Contact(name='Contact 2', group=contact_groups[1]),
-            Contact(name='Contact 3', group=contact_groups[2]),
+            Contact(name='Contact 1'),
+            Contact(name='Contact 2'),
+            Contact(name='Contact 3'),
         )
         )
         Contact.objects.bulk_create(contacts)
         Contact.objects.bulk_create(contacts)
+        contacts[0].groups.add(contact_groups[0])
+        contacts[1].groups.add(contact_groups[1])
+        contacts[2].groups.add(contact_groups[2])
 
 
         assignments = (
         assignments = (
             ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]),
             ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]),

+ 16 - 13
netbox/tenancy/tests/test_views.py

@@ -196,37 +196,40 @@ class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             contactgroup.save()
             contactgroup.save()
 
 
         contacts = (
         contacts = (
-            Contact(name='Contact 1', group=contact_groups[0]),
-            Contact(name='Contact 2', group=contact_groups[0]),
-            Contact(name='Contact 3', group=contact_groups[0]),
+            Contact(name='Contact 1'),
+            Contact(name='Contact 2'),
+            Contact(name='Contact 3'),
         )
         )
         Contact.objects.bulk_create(contacts)
         Contact.objects.bulk_create(contacts)
+        contacts[0].groups.add(contact_groups[0])
+        contacts[1].groups.add(contact_groups[1])
 
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'Contact X',
             'name': 'Contact X',
-            'group': contact_groups[1].pk,
+            'groups': [contact_groups[1].pk],
             'comments': 'Some comments',
             'comments': 'Some comments',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            "group,name",
-            "Contact Group 1,Contact 4",
-            "Contact Group 1,Contact 5",
-            "Contact Group 1,Contact 6",
+            "name",
+            "groups",
+            "Contact 4",
+            "Contact 5",
+            "Contact 6",
         )
         )
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (
-            "id,name,comments",
-            f"{contacts[0].pk},Contact Group 7,New comments 7",
-            f"{contacts[1].pk},Contact Group 8,New comments 8",
-            f"{contacts[2].pk},Contact Group 9,New comments 9",
+            "id,name,groups,comments",
+            f'{contacts[0].pk},Contact 7,"Contact Group 1,Contact Group 2",New comments 7',
+            f'{contacts[1].pk},Contact 8,"Contact Group 1",New comments 8',
+            f'{contacts[2].pk},Contact 9,"Contact Group 1",New comments 9',
         )
         )
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
-            'group': contact_groups[1].pk,
+            'description':  "New description",
         }
         }
 
 
 
 

+ 12 - 3
netbox/tenancy/views.py

@@ -170,7 +170,7 @@ class ContactGroupListView(generic.ObjectListView):
     queryset = ContactGroup.objects.add_related_count(
     queryset = ContactGroup.objects.add_related_count(
         ContactGroup.objects.all(),
         ContactGroup.objects.all(),
         Contact,
         Contact,
-        'group',
+        'groups',
         'contact_count',
         'contact_count',
         cumulative=True
         cumulative=True
     )
     )
@@ -214,7 +214,7 @@ class ContactGroupBulkEditView(generic.BulkEditView):
     queryset = ContactGroup.objects.add_related_count(
     queryset = ContactGroup.objects.add_related_count(
         ContactGroup.objects.all(),
         ContactGroup.objects.all(),
         Contact,
         Contact,
-        'group',
+        'groups',
         'contact_count',
         'contact_count',
         cumulative=True
         cumulative=True
     )
     )
@@ -228,7 +228,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = ContactGroup.objects.add_related_count(
     queryset = ContactGroup.objects.add_related_count(
         ContactGroup.objects.all(),
         ContactGroup.objects.all(),
         Contact,
         Contact,
-        'group',
+        'groups',
         'contact_count',
         'contact_count',
         cumulative=True
         cumulative=True
     )
     )
@@ -337,6 +337,15 @@ class ContactBulkEditView(generic.BulkEditView):
     table = tables.ContactTable
     table = tables.ContactTable
     form = forms.ContactBulkEditForm
     form = forms.ContactBulkEditForm
 
 
+    def post_save_operations(self, form, obj):
+        super().post_save_operations(form, obj)
+
+        # Add/remove groups
+        if form.cleaned_data.get('add_groups', None):
+            obj.groups.add(*form.cleaned_data['add_groups'])
+        if form.cleaned_data.get('remove_groups', None):
+            obj.groups.remove(*form.cleaned_data['remove_groups'])
+
 
 
 @register_model_view(Contact, 'bulk_delete', path='delete', detail=False)
 @register_model_view(Contact, 'bulk_delete', path='delete', detail=False)
 class ContactBulkDeleteView(generic.BulkDeleteView):
 class ContactBulkDeleteView(generic.BulkDeleteView):

+ 2 - 2
netbox/utilities/testing/filtersets.py

@@ -144,8 +144,8 @@ class BaseFilterSetTests:
                 # Check that the filter class is correct
                 # Check that the filter class is correct
                 filter = filters[filter_name]
                 filter = filters[filter_name]
                 if filter_class is not None:
                 if filter_class is not None:
-                    self.assertIs(
-                        type(filter),
+                    self.assertIsInstance(
+                        filter,
                         filter_class,
                         filter_class,
                         f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!"
                         f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!"
                     )
                     )