Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Mar 18, 2025
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/models/tenancy/contact.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ 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).

### Name

Expand Down
8 changes: 6 additions & 2 deletions netbox/templates/tenancy/contact.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@
<h2 class="card-header">{% trans "Contact" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Group" %}</th>
<td>{{ object.group|linkify|placeholder }}</td>
<th scope="row">{% trans "Groups" %}</th>
<td>
{% for group in object.groups.all %}
{{ group|linkify|placeholder }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Name" %}</th>
Expand Down
11 changes: 8 additions & 3 deletions netbox/tenancy/api/serializers_/contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
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
Expand Down Expand Up @@ -43,12 +43,17 @@ class Meta:


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')
Expand Down
2 changes: 1 addition & 1 deletion netbox/tenancy/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(),
Contact,
'group',
'groups',
'contact_count',
cumulative=True
)
Expand Down
21 changes: 13 additions & 8 deletions netbox/tenancy/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,17 +65,17 @@ class Meta:


class ContactFilterSet(NetBoxModelFilterSet):
group_id = TreeNodeMultipleChoiceFilter(
contact_group_id = TreeNodeMultipleChoiceFilter(
queryset=ContactGroup.objects.all(),
field_name='group',
field_name='groups',
lookup_expr='in',
label=_('Contact group (ID)'),
)
group = TreeNodeMultipleChoiceFilter(
contact_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)'),
)

Expand Down Expand Up @@ -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)'),
Expand Down Expand Up @@ -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'),
)
Expand Down
10 changes: 5 additions & 5 deletions netbox/tenancy/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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__ = (
Expand Down Expand Up @@ -90,8 +90,8 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm):


class ContactBulkEditForm(NetBoxModelBulkEditForm):
group = DynamicModelChoiceField(
label=_('Group'),
groups = DynamicModelMultipleChoiceField(
label=_('Groups'),
queryset=ContactGroup.objects.all(),
required=False
)
Expand Down Expand Up @@ -127,9 +127,9 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm):

model = Contact
fieldsets = (
FieldSet('group', 'title', 'phone', 'email', 'address', 'link', 'description'),
FieldSet('groups', 'title', 'phone', 'email', 'address', 'link', 'description'),
)
nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments')
nullable_fields = ('groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments')


class ContactAssignmentBulkEditForm(NetBoxModelBulkEditForm):
Expand Down
9 changes: 4 additions & 5 deletions netbox/tenancy/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

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',
Expand Down Expand Up @@ -77,17 +77,16 @@ class Meta:


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=_('Groups')
)

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):
Expand Down
2 changes: 1 addition & 1 deletion netbox/tenancy/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class ContactRoleFilterForm(NetBoxModelFilterSetForm):

class ContactFilterForm(NetBoxModelFilterSetForm):
model = Contact
group_id = DynamicModelMultipleChoiceField(
contact_group_id = DynamicModelMultipleChoiceField(
queryset=ContactGroup.objects.all(),
required=False,
null_option='None',
Expand Down
12 changes: 6 additions & 6 deletions netbox/tenancy/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

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__ = (
Expand Down Expand Up @@ -93,24 +93,24 @@ class Meta:


class ContactForm(NetBoxModelForm):
group = DynamicModelChoiceField(
label=_('Group'),
groups = DynamicModelMultipleChoiceField(
label=_('Groups'),
queryset=ContactGroup.objects.all(),
required=False
)
comments = CommentField()

fieldsets = (
FieldSet(
'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags',
'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags',
name=_('Contact')
),
)

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}),
Expand All @@ -123,7 +123,7 @@ class ContactAssignmentForm(NetBoxModelForm):
queryset=ContactGroup.objects.all(),
required=False,
initial_params={
'contacts': '$contact'
'contact': '$contact'
}
)
contact = DynamicModelChoiceField(
Expand Down
2 changes: 1 addition & 1 deletion netbox/tenancy/graphql/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
94 changes: 94 additions & 0 deletions netbox/tenancy/migrations/0018_contact_groups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Generated by Django 5.1.5 on 2025-03-11 20:27

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)
contact.save()


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)),
],
),
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, to='tenancy.contact'),
),
migrations.AddField(
model_name='contactgroupmembership',
name='group',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tenancy.contactgroup'),
),
migrations.AddField(
model_name='contact',
name='groups',
field=models.ManyToManyField(
blank=True,
related_name='group_contacts',
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',
),
migrations.AlterField(
model_name='contact',
name='groups',
field=models.ManyToManyField(
blank=True, related_name='contacts', through='tenancy.ContactGroupMembership', to='tenancy.contactgroup'
),
),
migrations.AlterField(
model_name='contactgroupmembership',
name='contact',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contact'
),
),
migrations.AlterField(
model_name='contactgroupmembership',
name='group',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contactgroup'
),
),
migrations.AlterField(
model_name='contact',
name='groups',
field=models.ManyToManyField(
blank=True,
related_name='contacts',
related_query_name='contact',
through='tenancy.ContactGroupMembership',
to='tenancy.contactgroup',
),
),
]
26 changes: 15 additions & 11 deletions netbox/tenancy/models/contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,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'),
Expand Down Expand Up @@ -84,24 +84,28 @@ 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')

def __str__(self):
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')
]


class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
object_type = models.ForeignKey(
to='contenttypes.ContentType',
Expand Down
Loading
Loading