From af5ec19430f3103b79ce414ee203398d46f4c643 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Tue, 18 Mar 2025 11:05:02 -0700 Subject: [PATCH] 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 --- docs/models/tenancy/contact.md | 6 +- netbox/templates/tenancy/contact.html | 14 +++- netbox/tenancy/api/serializers_/contacts.py | 11 ++- netbox/tenancy/api/views.py | 2 +- netbox/tenancy/filtersets.py | 17 +++-- netbox/tenancy/forms/bulk_edit.py | 19 ++++-- netbox/tenancy/forms/bulk_import.py | 9 ++- netbox/tenancy/forms/filtersets.py | 2 +- netbox/tenancy/forms/model_forms.py | 12 ++-- netbox/tenancy/graphql/types.py | 2 +- .../tenancy/migrations/0018_contact_groups.py | 68 +++++++++++++++++++ netbox/tenancy/models/contacts.py | 29 +++++--- netbox/tenancy/tables/contacts.py | 10 +-- netbox/tenancy/tests/test_api.py | 14 ++-- netbox/tenancy/tests/test_filtersets.py | 19 ++++-- netbox/tenancy/tests/test_views.py | 29 ++++---- netbox/tenancy/views.py | 15 +++- netbox/utilities/testing/filtersets.py | 4 +- 18 files changed, 204 insertions(+), 78 deletions(-) create mode 100644 netbox/tenancy/migrations/0018_contact_groups.py diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md index eac630180..f277ab499 100644 --- a/docs/models/tenancy/contact.md +++ b/docs/models/tenancy/contact.md @@ -4,9 +4,11 @@ A contact represents an individual or group that has been associated with an obj ## 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 diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index b2d1a4078..f34a3573f 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -18,8 +18,18 @@

{% trans "Contact" %}

- - + + diff --git a/netbox/tenancy/api/serializers_/contacts.py b/netbox/tenancy/api/serializers_/contacts.py index 8c24df734..a5c4dd741 100644 --- a/netbox/tenancy/api/serializers_/contacts.py +++ b/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 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 tenancy.choices import ContactPriorityChoices from tenancy.models import ContactAssignment, Contact, ContactGroup, ContactRole @@ -43,12 +43,17 @@ class ContactRoleSerializer(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: model = Contact 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', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 70330ddb8..371b8ec24 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -44,7 +44,7 @@ class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index e2de18231..0042ca427 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -46,6 +46,11 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label=_('Contact group (slug)'), ) + contact_id = django_filters.ModelMultipleChoiceFilter( + field_name='contact', + queryset=Contact.objects.all(), + label=_('Contact (ID)'), + ) class Meta: model = ContactGroup @@ -62,15 +67,15 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet): class ContactFilterSet(NetBoxModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='group', + field_name='groups', lookup_expr='in', label=_('Contact group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='group', - lookup_expr='in', + field_name='groups', to_field_name='slug', + lookup_expr='in', label=_('Contact group (slug)'), ) @@ -105,13 +110,13 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet): ) group_id = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='contact__group', + field_name='contact__groups', lookup_expr='in', label=_('Contact group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='contact__group', + field_name='contact__groups', lookup_expr='in', to_field_name='slug', label=_('Contact group (slug)'), @@ -153,7 +158,7 @@ class ContactModelFilterSet(django_filters.FilterSet): ) contact_group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='contacts__contact__group', + field_name='contacts__contact__groups', lookup_expr='in', label=_('Contact group'), ) diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 5af3f22ac..63eaaad9d 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -5,7 +5,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.choices import ContactPriorityChoices from tenancy.models import * 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 __all__ = ( @@ -90,8 +90,13 @@ class ContactRoleBulkEditForm(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(), required=False ) @@ -127,9 +132,13 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): model = Contact 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): diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index f37317549..c4f48347a 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelImportForm from tenancy.models import * -from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField __all__ = ( 'ContactAssignmentImportForm', @@ -77,17 +77,16 @@ class ContactRoleImportForm(NetBoxModelImportForm): class ContactImportForm(NetBoxModelImportForm): - group = CSVModelChoiceField( - label=_('Group'), + groups = CSVModelMultipleChoiceField( queryset=ContactGroup.objects.all(), required=False, 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: 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): diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 960ca45b1..6541d9693 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -75,7 +75,7 @@ class ContactFilterForm(NetBoxModelFilterSetForm): queryset=ContactGroup.objects.all(), required=False, null_option='None', - label=_('Group') + label=_('Groups') ) tag = TagFilterField(model) diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index bc18deed6..65beebca9 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelForm 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 __all__ = ( @@ -93,8 +93,8 @@ class ContactRoleForm(NetBoxModelForm): class ContactForm(NetBoxModelForm): - group = DynamicModelChoiceField( - label=_('Group'), + groups = DynamicModelMultipleChoiceField( + label=_('Groups'), queryset=ContactGroup.objects.all(), required=False ) @@ -102,7 +102,7 @@ class ContactForm(NetBoxModelForm): fieldsets = ( FieldSet( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags', name=_('Contact') ), ) @@ -110,7 +110,7 @@ class ContactForm(NetBoxModelForm): class Meta: model = Contact fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', ) widgets = { 'address': forms.Textarea(attrs={'rows': 3}), @@ -123,7 +123,7 @@ class ContactAssignmentForm(NetBoxModelForm): queryset=ContactGroup.objects.all(), required=False, initial_params={ - 'contacts': '$contact' + 'contact': '$contact' } ) contact = DynamicModelChoiceField( diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index c340cdf7c..b47ac2da3 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -97,7 +97,7 @@ class TenantGroupType(OrganizationalObjectType): @strawberry_django.type(models.Contact, fields='__all__', filters=ContactFilter) 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) diff --git a/netbox/tenancy/migrations/0018_contact_groups.py b/netbox/tenancy/migrations/0018_contact_groups.py new file mode 100644 index 000000000..11030eb49 --- /dev/null +++ b/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', + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 3969c8317..5f39fe0db 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -13,6 +13,7 @@ __all__ = ( 'ContactAssignment', 'Contact', 'ContactGroup', + 'ContactGroupMembership', 'ContactRole', ) @@ -47,12 +48,12 @@ class Contact(PrimaryModel): """ Contact information for a particular object(s) in NetBox. """ - group = models.ForeignKey( + groups = models.ManyToManyField( to='tenancy.ContactGroup', - on_delete=models.SET_NULL, related_name='contacts', - blank=True, - null=True + through='tenancy.ContactGroupMembership', + related_query_name='contact', + blank=True ) name = models.CharField( verbose_name=_('name'), @@ -84,17 +85,11 @@ class Contact(PrimaryModel): ) clone_fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', ) class Meta: ordering = ['name'] - constraints = ( - models.UniqueConstraint( - fields=('group', 'name'), - name='%(app_label)s_%(class)s_unique_group_name' - ), - ) verbose_name = _('contact') verbose_name_plural = _('contacts') @@ -102,6 +97,18 @@ class Contact(PrimaryModel): 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): object_type = models.ForeignKey( to='contenttypes.ContentType', diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index c4e35ab1b..07af8382e 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -56,9 +56,9 @@ class ContactTable(NetBoxTable): verbose_name=_('Name'), 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( verbose_name=_('Phone'), @@ -79,10 +79,10 @@ class ContactTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Contact 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', ) - default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') + default_columns = ('pk', 'name', 'groups', 'assignment_count', 'title', 'phone', 'email') class ContactAssignmentTable(NetBoxTable): diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index c32ad3826..3bacb8fea 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -170,7 +170,7 @@ class ContactTest(APIViewTestCases.APIViewTestCase): model = Contact brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { - 'group': None, + 'groups': [], 'comments': 'New comments', } @@ -183,20 +183,22 @@ class ContactTest(APIViewTestCases.APIViewTestCase): ) 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) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[0]) + contacts[2].groups.add(contact_groups[0]) cls.create_data = [ { 'name': 'Contact 4', - 'group': contact_groups[1].pk, + 'groups': [contact_groups[1].pk], }, { 'name': 'Contact 5', - 'group': contact_groups[1].pk, }, { 'name': 'Contact 6', diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index f6890a3d4..d44d78ec4 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -241,6 +241,7 @@ class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests): class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Contact.objects.all() filterset = ContactFilterSet + ignore_fields = ('groups',) @classmethod def setUpTestData(cls): @@ -254,11 +255,14 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): contactgroup.save() 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) + 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): params = {'q': 'foobar1'} @@ -311,11 +315,14 @@ class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests): ContactRole.objects.bulk_create(contact_roles) 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) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[1]) + contacts[2].groups.add(contact_groups[2]) assignments = ( ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]), diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index cbdecc0d0..ec962e6e6 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -196,37 +196,40 @@ class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase): contactgroup.save() 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) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[1]) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'Contact X', - 'group': contact_groups[1].pk, + 'groups': [contact_groups[1].pk], 'comments': 'Some comments', 'tags': [t.pk for t in tags], } 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 = ( - "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 = { - 'group': contact_groups[1].pk, + 'description': "New description", } diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 3b5029bd7..d0c80b76f 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -170,7 +170,7 @@ class ContactGroupListView(generic.ObjectListView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -214,7 +214,7 @@ class ContactGroupBulkEditView(generic.BulkEditView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -228,7 +228,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -337,6 +337,15 @@ class ContactBulkEditView(generic.BulkEditView): table = tables.ContactTable 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) class ContactBulkDeleteView(generic.BulkDeleteView): diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index e58123f03..0b3d4b198 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -144,8 +144,8 @@ class BaseFilterSetTests: # Check that the filter class is correct filter = filters[filter_name] if filter_class is not None: - self.assertIs( - type(filter), + self.assertIsInstance( + filter, filter_class, f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!" )
{% trans "Group" %}{{ object.group|linkify|placeholder }}{% trans "Groups" %} + {% if object.groups.all|length > 0 %} +
    + {% for group in object.groups.all %} +
  1. {{ group|linkify|placeholder }}
  2. + {% endfor %} +
+ {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Name" %}