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
This commit is contained in:
Arthur Hanson 2025-03-18 11:05:02 -07:00 committed by GitHub
parent d4f8cb72aa
commit af5ec19430
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 204 additions and 78 deletions

View File

@ -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

View File

@ -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> <th scope="row">{% trans "Groups" %}</th>
<td>{{ object.group|linkify|placeholder }}</td> <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>

View File

@ -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')

View File

@ -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
) )

View File

@ -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', field_name='groups',
lookup_expr='in',
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'),
) )

View File

@ -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( add_groups = DynamicModelMultipleChoiceField(
label=_('Group'), 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):

View File

@ -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( groups = CSVModelMultipleChoiceField(
label=_('Group'),
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):

View File

@ -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)

View File

@ -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( groups = DynamicModelMultipleChoiceField(
label=_('Group'), 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(

View File

@ -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)

View File

@ -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',
),
]

View File

@ -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, through='tenancy.ContactGroupMembership',
null=True 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',

View File

@ -56,9 +56,9 @@ class ContactTable(NetBoxTable):
verbose_name=_('Name'), verbose_name=_('Name'),
linkify=True linkify=True
) )
group = tables.Column( groups = columns.ManyToManyColumn(
verbose_name=_('Group'), verbose_name=_('Groups'),
linkify=True 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):

View File

@ -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 1'),
Contact(name='Contact 2', group=contact_groups[0]), Contact(name='Contact 2'),
Contact(name='Contact 3', group=contact_groups[0]), 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',

View File

@ -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 1', description='foobar1'),
Contact(name='Contact 2', group=contact_groups[1], description='foobar2'), Contact(name='Contact 2', description='foobar2'),
Contact(name='Contact 3', group=contact_groups[2], description='foobar3'), 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 1'),
Contact(name='Contact 2', group=contact_groups[1]), Contact(name='Contact 2'),
Contact(name='Contact 3', group=contact_groups[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]),

View File

@ -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 1'),
Contact(name='Contact 2', group=contact_groups[0]), Contact(name='Contact 2'),
Contact(name='Contact 3', group=contact_groups[0]), 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", "name",
"Contact Group 1,Contact 4", "groups",
"Contact Group 1,Contact 5", "Contact 4",
"Contact Group 1,Contact 6", "Contact 5",
"Contact 6",
) )
cls.csv_update_data = ( cls.csv_update_data = (
"id,name,comments", "id,name,groups,comments",
f"{contacts[0].pk},Contact Group 7,New comments 7", f'{contacts[0].pk},Contact 7,"Contact Group 1,Contact Group 2",New comments 7',
f"{contacts[1].pk},Contact Group 8,New comments 8", f'{contacts[1].pk},Contact 8,"Contact Group 1",New comments 8',
f"{contacts[2].pk},Contact Group 9,New comments 9", 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",
} }

View File

@ -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):

View File

@ -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( self.assertIsInstance(
type(filter), 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})!"
) )