Repository: django-import-export/django-import-export

Issue:
Enable ManyToMany field to add new elements without clearing old elements (call 'add' instead of 'set' for ManyToMany)
This is a request for a new feature to support having a resource field for a ManyToMany field which during import appends new values to the ManyToMany field.  This allows for creating a resource which supports appending values to a ManyToMany field without clearing other entries in the ManyToMany field.

Here's an example resource that instead of setting all values in a ManyToMany field it just appends missing values from a ManyToMany field.

```
class Category(models.Model):
    name = models.CharField(max_length=100, unique=True)

class Book(models.Model):
    name = models.CharField('Book name', max_length=100)
    categories = models.ManyToManyField(Category, blank=True)

class BookM2MResource(resources.ModelResource):
    categories = fields.Field(
        attribute='categories',
        m2m_add=True,   # === This is a new parameter recommended by this issue === 
        widget=widgets.ManyToManyWidget(Category, field='name',
                                        separator='|')
    )

    class Meta:
        model = Book
```
Using this resource in an import would then add any new categories to a Book but wouldn't remove existing values.





Discussion:
Pull request coming shortly.

Resolving patch:
diff --git a/import_export/fields.py b/import_export/fields.py
--- a/import_export/fields.py
+++ b/import_export/fields.py
@@ -30,11 +30,15 @@ class Field:
     :param saves_null_values: Controls whether null values are saved on the object
     :param dehydrate_method: Lets you choose your own method for dehydration rather
         than using `dehydrate_{field_name}` syntax.
+    :param m2m_add: changes save of this field to add the values, if they do not exist,
+        to a ManyToMany field instead of setting all values.  Only useful if field is
+        a ManyToMany field.
     """
     empty_values = [None, '']
 
     def __init__(self, attribute=None, column_name=None, widget=None,
-                 default=NOT_PROVIDED, readonly=False, saves_null_values=True, dehydrate_method=None):
+                 default=NOT_PROVIDED, readonly=False, saves_null_values=True,
+                 dehydrate_method=None, m2m_add=False):
         self.attribute = attribute
         self.default = default
         self.column_name = column_name
@@ -44,6 +48,7 @@ def __init__(self, attribute=None, column_name=None, widget=None,
         self.readonly = readonly
         self.saves_null_values = saves_null_values
         self.dehydrate_method = dehydrate_method
+        self.m2m_add = m2m_add
 
     def __repr__(self):
         """
@@ -116,7 +121,10 @@ def save(self, obj, data, is_m2m=False, **kwargs):
                 if not is_m2m:
                     setattr(obj, attrs[-1], cleaned)
                 else:
-                    getattr(obj, attrs[-1]).set(cleaned)
+                    if self.m2m_add:
+                        getattr(obj, attrs[-1]).add(*cleaned)
+                    else:
+                        getattr(obj, attrs[-1]).set(cleaned)
 
     def export(self, obj):
         """


Test patch:
diff --git a/tests/core/tests/test_fields.py b/tests/core/tests/test_fields.py
--- a/tests/core/tests/test_fields.py
+++ b/tests/core/tests/test_fields.py
@@ -1,4 +1,5 @@
 from datetime import date
+from unittest import mock
 
 from django.test import TestCase
 
@@ -108,3 +109,38 @@ def testget_dehydrate_method_without_params_raises_attribute_error(self):
             FieldError,
             field.get_dehydrate_method
         )
+
+    def test_m2m_add_true(self):
+        m2m_related_manager = mock.Mock(spec=["add", "set", "all"])
+        m2m_related_manager.all.return_value = []
+        self.obj.aliases = m2m_related_manager
+        field = fields.Field(column_name='aliases', attribute='aliases', m2m_add=True)
+        row = {
+            'aliases': ["Foo", "Bar"],
+        }
+        field.save(self.obj, row, is_m2m=True)
+
+        self.assertEqual(m2m_related_manager.add.call_count, 1)
+        self.assertEqual(m2m_related_manager.set.call_count, 0)
+        m2m_related_manager.add.assert_called_once_with('Foo', 'Bar')
+
+        row = {
+            'aliases': ["apple"],
+        }
+        field.save(self.obj, row, is_m2m=True)
+        m2m_related_manager.add.assert_called_with('apple')
+
+    def test_m2m_add_False(self):
+        m2m_related_manager = mock.Mock(spec=["add", "set", "all"])
+        self.obj.aliases = m2m_related_manager
+        field = fields.Field(column_name='aliases', attribute='aliases')
+        row = {
+            'aliases': ["Foo", "Bar"],
+        }
+        field.save(self.obj, row, is_m2m=True)
+
+        self.assertEqual(m2m_related_manager.add.call_count, 0)
+        self.assertEqual(m2m_related_manager.set.call_count, 1)
+        m2m_related_manager.set.assert_called_once_with(['Foo', 'Bar'])
+
+
diff --git a/tests/core/tests/test_resources.py b/tests/core/tests/test_resources.py
--- a/tests/core/tests/test_resources.py
+++ b/tests/core/tests/test_resources.py
@@ -1053,6 +1053,46 @@ class Meta:
         self.assertIn(cat1, book.categories.all())
         self.assertIn(cat2, book.categories.all())
 
+    def test_m2m_add(self):
+        cat1 = Category.objects.create(name='Cat 1')
+        cat2 = Category.objects.create(name='Cat 2')
+        cat3 = Category.objects.create(name='Cat 3')
+        cat4 = Category.objects.create(name='Cat 4')
+        headers = ['id', 'name', 'categories']
+        row = [None, 'FooBook', "Cat 1|Cat 2"]
+        dataset = tablib.Dataset(row, headers=headers)
+
+        class BookM2MResource(resources.ModelResource):
+            categories = fields.Field(
+                attribute='categories',
+                m2m_add=True,
+                widget=widgets.ManyToManyWidget(Category, field='name',
+                                                separator='|')
+            )
+
+            class Meta:
+                model = Book
+
+        resource = BookM2MResource()
+        resource.import_data(dataset, raise_errors=True)
+        book = Book.objects.get(name='FooBook')
+        self.assertIn(cat1, book.categories.all())
+        self.assertIn(cat2, book.categories.all())
+        self.assertNotIn(cat3, book.categories.all())
+        self.assertNotIn(cat4, book.categories.all())
+
+        row1 = [book.id, 'FooBook', "Cat 1|Cat 2"]  # This should have no effect, since Cat 1 and Cat 2 already exist
+        row2 = [book.id, 'FooBook', "Cat 3|Cat 4"]
+        dataset = tablib.Dataset(row1, row2, headers=headers)
+        resource.import_data(dataset, raise_errors=True)
+        book2 = Book.objects.get(name='FooBook')
+        self.assertEqual(book.id, book2.id)
+        self.assertEqual(book.categories.count(), 4)
+        self.assertIn(cat1, book2.categories.all())
+        self.assertIn(cat2, book2.categories.all())
+        self.assertIn(cat3, book2.categories.all())
+        self.assertIn(cat4, book2.categories.all())
+
     def test_related_one_to_one(self):
         # issue #17 - Exception when attempting access something on the
         # related_name