Kaynağa Gözat

review fixes

Arthur 9 saat önce
ebeveyn
işleme
6c37360fb7

+ 1 - 1
docs/features/contacts.md

@@ -47,6 +47,6 @@ The following models support the assignment of contacts:
 
 
 ### Bulk Assignment
 ### Bulk Assignment
 
 
-Contacts can be assigned to or removed from many objects at once using the bulk edit form of any supported object type. When adding contacts, a role (and optionally a priority) must be selected to apply to each new assignment. Removing a contact deletes all of its assignments to the selected objects, regardless of role. Removals are processed before additions, so a contact's role can be replaced in a single operation.
+Contacts can be assigned to or removed from many objects at once using the bulk edit form of any supported object type. When adding contacts, a role (and optionally a priority) must be selected to apply to each new assignment. If a contact is already assigned to an object with the selected role, no duplicate is created; instead, the selected priority (if any) is applied to the existing assignment. Removing a contact deletes all of its assignments to the selected objects, regardless of role. Removals are processed before additions, so a contact's role can be replaced in a single operation.
 
 
 When importing contact assignments in bulk, contacts are matched by name by default. Where contact names are not unique, use the `contact.id` column header to reference contacts by their numeric ID instead.
 When importing contact assignments in bulk, contacts are matched by name by default. Where contact names are not unique, use the `contact.id` column header to reference contacts by their numeric ID instead.

+ 2 - 1
netbox/netbox/forms/bulk_edit.py

@@ -48,7 +48,8 @@ class NetBoxModelBulkEditForm(ChangelogMessageMixin, CustomFieldsMixin, BulkEdit
     add_contacts = DynamicModelMultipleChoiceField(
     add_contacts = DynamicModelMultipleChoiceField(
         label=_('Add contacts'),
         label=_('Add contacts'),
         queryset=Contact.objects.all(),
         queryset=Contact.objects.all(),
-        required=False
+        required=False,
+        help_text=_('Existing assignments with the selected role are updated to the selected priority, if any')
     )
     )
     contact_role = DynamicModelChoiceField(
     contact_role = DynamicModelChoiceField(
         label=_('Role'),
         label=_('Role'),

+ 55 - 14
netbox/netbox/views/generic/bulk_views.py

@@ -922,11 +922,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
 
 
         # Removal precedes addition so that a contact's role can be replaced in a single operation
         # Removal precedes addition so that a contact's role can be replaced in a single operation
         if remove_contacts := form.cleaned_data.get('remove_contacts'):
         if remove_contacts := form.cleaned_data.get('remove_contacts'):
-            to_delete = assignments.filter(contact__in=remove_contacts)
-            deletable = to_delete.restrict(self.request.user, 'delete')
-            if deletable.count() != to_delete.count():
-                raise PermissionsViolation
-            for assignment in to_delete:
+            for assignment in assignments.filter(contact__in=remove_contacts):
                 assignment.snapshot()
                 assignment.snapshot()
                 assignment._changelog_message = changelog_message
                 assignment._changelog_message = changelog_message
                 assignment.delete()
                 assignment.delete()
@@ -934,20 +930,59 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
         if add_contacts := form.cleaned_data.get('add_contacts'):
         if add_contacts := form.cleaned_data.get('add_contacts'):
             role = form.cleaned_data.get('contact_role')
             role = form.cleaned_data.get('contact_role')
             priority = form.cleaned_data.get('contact_priority') or None
             priority = form.cleaned_data.get('contact_priority') or None
-            existing = set(assignments.filter(role=role).values_list('contact_id', flat=True))
-            created_pks = []
+            existing = {
+                a.contact_id: a for a in assignments.filter(role=role, contact__in=add_contacts)
+            }
+            cf_defaults = CustomField.objects.get_defaults_for_model(ContactAssignment)
             for contact in add_contacts:
             for contact in add_contacts:
-                if contact.pk in existing:
-                    continue
-                assignment = ContactAssignment(object=obj, contact=contact, role=role, priority=priority)
+                # Apply a specified priority to an existing assignment rather than duplicating it
+                if assignment := existing.get(contact.pk):
+                    if priority is None or assignment.priority == priority:
+                        continue
+                    assignment.snapshot()
+                    assignment.priority = priority
+                    pk_list = self._contact_assignments['change']
+                else:
+                    assignment = ContactAssignment(
+                        object=obj, contact=contact, role=role, priority=priority,
+                        custom_field_data=dict(cf_defaults),
+                    )
+                    pk_list = self._contact_assignments['add']
                 assignment._changelog_message = changelog_message
                 assignment._changelog_message = changelog_message
-                assignment.full_clean()
+                try:
+                    assignment.full_clean()
+                except ValidationError as e:
+                    raise ValidationError(
+                        _("Unable to assign contact {contact} to {object}: {error}").format(
+                            contact=contact, object=obj, error='; '.join(e.messages)
+                        )
+                    )
                 assignment.save()
                 assignment.save()
-                created_pks.append(assignment.pk)
-            permitted = ContactAssignment.objects.restrict(self.request.user, 'add').filter(pk__in=created_pks)
-            if permitted.count() != len(created_pks):
+                pk_list.append(assignment.pk)
+
+    def _check_contact_removal_permission(self, form):
+        """
+        Verify that the user may delete every contact assignment targeted for removal.
+        """
+        if remove_contacts := form.cleaned_data.get('remove_contacts'):
+            to_delete = ContactAssignment.objects.filter(
+                object_type=ObjectType.objects.get_for_model(self.queryset.model),
+                object_id__in=self.queryset.filter(pk__in=form.cleaned_data['pk']).values('pk'),
+                contact__in=remove_contacts,
+            )
+            if to_delete.restrict(self.request.user, 'delete').count() != to_delete.count():
                 raise PermissionsViolation
                 raise PermissionsViolation
 
 
+    def _check_contact_assignment_permissions(self):
+        """
+        Verify that the user was permitted to create or modify every affected contact assignment.
+        """
+        for action, pk_list in self._contact_assignments.items():
+            if pk_list:
+                permitted = ContactAssignment.objects.restrict(self.request.user, action).filter(pk__in=pk_list)
+                if permitted.count() != len(pk_list):
+                    raise PermissionsViolation
+
     def _update_objects(self, form, request):
     def _update_objects(self, form, request):
         custom_fields = getattr(form, 'custom_fields', {})
         custom_fields = getattr(form, 'custom_fields', {})
         standard_fields = [
         standard_fields = [
@@ -973,6 +1008,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
                 # This form field is used to modify a field rather than set its value directly
                 # This form field is used to modify a field rather than set its value directly
                 model_fields[name] = None
                 model_fields[name] = None
 
 
+        # Contact assignment permissions are checked once for all objects rather than per object
+        self._contact_assignments = {'add': [], 'change': []}
+        self._check_contact_removal_permission(form)
+
         for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
         for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
 
 
             # Take a snapshot of change-logged models
             # Take a snapshot of change-logged models
@@ -1034,6 +1073,8 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
             if is_background_request(request):
             if is_background_request(request):
                 request.job.logger.info(f"Updated {obj}")
                 request.job.logger.info(f"Updated {obj}")
 
 
+        self._check_contact_assignment_permissions()
+
         # Rebuild the tree for MPTT plugin models (no-op for ltree; its triggers keep
         # Rebuild the tree for MPTT plugin models (no-op for ltree; its triggers keep
         # the tree current). TODO: Remove in v5.0 (see _rebuild_mptt_tree).
         # the tree current). TODO: Remove in v5.0 (see _rebuild_mptt_tree).
         _rebuild_mptt_tree(self.queryset.model)
         _rebuild_mptt_tree(self.queryset.model)

+ 93 - 2
netbox/tenancy/tests/test_views.py

@@ -1,7 +1,10 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 
 
+from core.models import ObjectType
 from dcim.models import Site
 from dcim.models import Site
+from extras.choices import CustomFieldTypeChoices
+from extras.models import CustomField
 from tenancy.choices import ContactPriorityChoices
 from tenancy.choices import ContactPriorityChoices
 from tenancy.forms import ContactBulkEditForm
 from tenancy.forms import ContactBulkEditForm
 from tenancy.models import *
 from tenancy.models import *
@@ -136,7 +139,10 @@ class TenantBulkEditContactsTestCase(TestCase):
             'tenancy.add_contactassignment',
             'tenancy.add_contactassignment',
         )
         )
         # An existing assignment should not be duplicated
         # An existing assignment should not be duplicated
-        ContactAssignment.objects.create(object=self.tenants[0], contact=self.contacts[0], role=self.roles[0])
+        ContactAssignment.objects.create(
+            object=self.tenants[0], contact=self.contacts[0], role=self.roles[0],
+            priority=ContactPriorityChoices.PRIORITY_PRIMARY,
+        )
 
 
         response = self._post(
         response = self._post(
             add_contacts=[c.pk for c in self.contacts],
             add_contacts=[c.pk for c in self.contacts],
@@ -151,7 +157,92 @@ class TenantBulkEditContactsTestCase(TestCase):
             self.assertEqual(
             self.assertEqual(
                 set(assignments.values_list('contact', flat=True)), {c.pk for c in self.contacts}
                 set(assignments.values_list('contact', flat=True)), {c.pk for c in self.contacts}
             )
             )
-        self.assertEqual(ContactAssignment.objects.filter(priority=ContactPriorityChoices.PRIORITY_PRIMARY).count(), 3)
+        self.assertEqual(ContactAssignment.objects.filter(priority=ContactPriorityChoices.PRIORITY_PRIMARY).count(), 4)
+
+    def test_add_contacts_without_priority(self):
+        self.add_permissions(
+            'tenancy.view_tenant', 'tenancy.change_tenant', 'tenancy.view_contact', 'tenancy.view_contactrole',
+            'tenancy.add_contactassignment',
+        )
+        response = self._post(add_contacts=[self.contacts[0].pk], contact_role=self.roles[0].pk)
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(ContactAssignment.objects.filter(contact=self.contacts[0], priority__isnull=True).count(), 2)
+
+    def test_add_contacts_updates_existing_priority(self):
+        self.add_permissions(
+            'tenancy.view_tenant', 'tenancy.change_tenant', 'tenancy.view_contact', 'tenancy.view_contactrole',
+            'tenancy.add_contactassignment', 'tenancy.change_contactassignment',
+        )
+        assignment = ContactAssignment.objects.create(
+            object=self.tenants[0], contact=self.contacts[0], role=self.roles[0]
+        )
+
+        response = self._post(
+            add_contacts=[self.contacts[0].pk],
+            contact_role=self.roles[0].pk,
+            contact_priority=ContactPriorityChoices.PRIORITY_PRIMARY,
+        )
+        self.assertHttpStatus(response, 302)
+        assignment.refresh_from_db()
+        self.assertEqual(assignment.priority, ContactPriorityChoices.PRIORITY_PRIMARY)
+        self.assertEqual(ContactAssignment.objects.count(), 2)
+
+    def test_add_contacts_updates_existing_priority_without_permission(self):
+        self.add_permissions(
+            'tenancy.view_tenant', 'tenancy.change_tenant', 'tenancy.view_contact', 'tenancy.view_contactrole',
+            'tenancy.add_contactassignment',
+        )
+        assignment = ContactAssignment.objects.create(
+            object=self.tenants[0], contact=self.contacts[0], role=self.roles[0]
+        )
+
+        response = self._post(
+            add_contacts=[self.contacts[0].pk],
+            contact_role=self.roles[0].pk,
+            contact_priority=ContactPriorityChoices.PRIORITY_PRIMARY,
+        )
+        self.assertHttpStatus(response, 200)
+        assignment.refresh_from_db()
+        self.assertIsNone(assignment.priority)
+        self.assertEqual(ContactAssignment.objects.count(), 1)
+
+    def test_add_contacts_required_custom_field(self):
+        self.add_permissions(
+            'tenancy.view_tenant', 'tenancy.change_tenant', 'tenancy.view_contact', 'tenancy.view_contactrole',
+            'tenancy.add_contactassignment',
+        )
+        cf = CustomField.objects.create(name='cf1', type=CustomFieldTypeChoices.TYPE_TEXT, required=True)
+        cf.object_types.set([ObjectType.objects.get_for_model(ContactAssignment)])
+
+        # A required custom field without a default cannot be satisfied
+        response = self._post(add_contacts=[self.contacts[0].pk], contact_role=self.roles[0].pk)
+        self.assertHttpStatus(response, 200)
+        self.assertFalse(ContactAssignment.objects.exists())
+
+        # The default value is applied to new assignments
+        cf.default = 'foo'
+        cf.save()
+        response = self._post(add_contacts=[self.contacts[0].pk], contact_role=self.roles[0].pk)
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(ContactAssignment.objects.filter(custom_field_data__cf1='foo').count(), 2)
+
+    def test_replace_contact_role(self):
+        self.add_permissions(
+            'tenancy.view_tenant', 'tenancy.change_tenant', 'tenancy.view_contact', 'tenancy.view_contactrole',
+            'tenancy.add_contactassignment', 'tenancy.delete_contactassignment',
+        )
+        for tenant in self.tenants:
+            ContactAssignment.objects.create(object=tenant, contact=self.contacts[0], role=self.roles[0])
+
+        # Removing and re-adding a contact in one edit replaces its role
+        response = self._post(
+            add_contacts=[self.contacts[0].pk],
+            contact_role=self.roles[1].pk,
+            remove_contacts=[self.contacts[0].pk],
+        )
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(ContactAssignment.objects.filter(contact=self.contacts[0], role=self.roles[1]).count(), 2)
+        self.assertFalse(ContactAssignment.objects.filter(role=self.roles[0]).exists())
 
 
     def test_add_contacts_requires_role(self):
     def test_add_contacts_requires_role(self):
         self.add_permissions(
         self.add_permissions(