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..8291cad56 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -62,13 +62,13 @@ 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', + field_name='groups__slug', lookup_expr='in', to_field_name='slug', label=_('Contact group (slug)'), @@ -105,13 +105,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__slug', lookup_expr='in', to_field_name='slug', label=_('Contact group (slug)'), @@ -153,7 +153,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/model_forms.py b/netbox/tenancy/forms/model_forms.py index bc18deed6..bd86c7953 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}), diff --git a/netbox/tenancy/migrations/0018_contact_groups.py b/netbox/tenancy/migrations/0018_contact_groups.py new file mode 100644 index 000000000..38be33d09 --- /dev/null +++ b/netbox/tenancy/migrations/0018_contact_groups.py @@ -0,0 +1,69 @@ +# 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' + ), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 3969c8317..28f7dc5d4 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -47,12 +47,11 @@ 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', + blank=True ) name = models.CharField( verbose_name=_('name'), @@ -84,17 +83,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 +95,16 @@ class Contact(PrimaryModel): return self.name +class ContactGroupMembership(models.Model): + group = models.ForeignKey(ContactGroup, on_delete=models.CASCADE) + contact = models.ForeignKey(Contact, 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', 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/views.py b/netbox/tenancy/views.py index 3b5029bd7..4ddee9c7a 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 ) @@ -183,12 +183,14 @@ class ContactGroupListView(generic.ObjectListView): class ContactGroupView(GetRelatedModelsMixin, generic.ObjectView): queryset = ContactGroup.objects.all() + """ def get_extra_context(self, request, instance): groups = instance.get_descendants(include_self=True) return { 'related_models': self.get_related_models(request, groups), } + """ @register_model_view(ContactGroup, 'add', detail=False) @@ -214,7 +216,7 @@ class ContactGroupBulkEditView(generic.BulkEditView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -228,7 +230,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True )