Repository: Nitrate/Nitrate

Issue:
Allow add duplicate components to a case
**Steps**

- Go to a case and switch to `Components` tab
- Click `Add Component` and add a component
- Click `Add Component` again and select some other components and also select the one just added

**Actual result**

Duplicate components are listed, and the component was actually added to case twice.

**Expected result**

The component must be added only once.


Discussion:

Resolving patch:
diff --git a/tcms/testcases/migrations/0006_unique_case_component.py b/tcms/testcases/migrations/0006_unique_case_component.py
new file mode 100644
--- /dev/null
+++ b/tcms/testcases/migrations/0006_unique_case_component.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.11 on 2018-04-09 14:19
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('management', '0003_TestBuild_is_active_use_custom_bool_field'),
+        ('testcases', '0005_allow_null_to_testcaseattachment_case_run'),
+    ]
+
+    operations = [
+        migrations.AlterUniqueTogether(
+            name='testcasecomponent',
+            unique_together=set([('case', 'component')]),
+        ),
+    ]
diff --git a/tcms/testcases/models.py b/tcms/testcases/models.py
--- a/tcms/testcases/models.py
+++ b/tcms/testcases/models.py
@@ -347,7 +347,23 @@ def add_bug(self, bug_id, bug_system_id, summary=None, description=None,
             raise ValueError('Bug %s already exist.' % bug_id)
 
     def add_component(self, component):
-        return TestCaseComponent.objects.create(case=self, component=component)
+        """Add a component
+
+        Relationship between case and component is unique. A same component
+        with same pk is not added.
+
+        :param component: component to be added.
+        :type component: :class:`Component`
+        :return: the object representing relationship between this case and the
+            component. If component is already added to this case, nothing will
+            happen and ``None`` will be returned.
+        :rtype: :class:`TestCaseComponent`.
+        """
+        manager = TestCaseComponent.objects
+        if manager.filter(case=self, component=component).exists():
+            return
+        else:
+            return manager.create(case=self, component=component)
 
     def add_tag(self, tag):
         return TestCaseTag.objects.get_or_create(case=self, tag=tag)
@@ -599,11 +615,12 @@ class Meta:
 
 
 class TestCaseComponent(models.Model):
-    case = models.ForeignKey(TestCase)  # case_id
-    component = models.ForeignKey('management.Component')  # component_id
+    case = models.ForeignKey(TestCase)
+    component = models.ForeignKey('management.Component')
 
     class Meta:
         db_table = u'test_case_components'
+        unique_together = ('case', 'component')
 
 
 class TestCaseTag(models.Model):
diff --git a/tcms/testcases/views.py b/tcms/testcases/views.py
--- a/tcms/testcases/views.py
+++ b/tcms/testcases/views.py
@@ -39,7 +39,7 @@
 from tcms.testcases.models import TestCase, TestCaseStatus, \
     TestCaseAttachment, TestCasePlan
 from tcms.management.models import Priority, TestTag
-from tcms.testcases.models import TestCaseBug
+from tcms.testcases.models import TestCaseBug, TestCaseComponent
 from tcms.testplans.models import TestPlan
 from tcms.testruns.models import TestCaseRun
 from tcms.testruns.models import TestCaseRunStatus
@@ -1640,16 +1640,25 @@ def post(self, request):
             })
 
         case_ids = [int(case_id) for case_id in request.POST.getlist('case')]
-        cases = TestCase.objects.filter(pk__in=case_ids)
+
+        # Remove duplicate pair of case and component
+        existings = set(TestCaseComponent.objects.filter(
+            case__in=case_ids).values_list('case', 'component'))
+        cases = TestCase.objects.filter(pk__in=case_ids).only('pk')
+        components_to_add = (
+            (case, comp)
+            for case in cases
+            for comp in form.cleaned_data['o_component']
+            if (case.pk, comp.pk) not in existings)
+
         errors = []
-        for case in cases:
-            for component in form.cleaned_data['o_component']:
-                try:
-                    case.add_component(component=component)
-                except Exception as e:
-                    logger.exception('Failed to add component %r from case %r',
-                                     component, case)
-                    errors.append(str(e))
+        for case, component in components_to_add:
+            try:
+                case.add_component(component=component)
+            except Exception as e:
+                logger.exception('Failed to add component %r to case %r',
+                                 component, case)
+                errors.append(str(e))
 
         if errors:
             return JsonResponse({


Test patch:
diff --git a/tcms/testcases/tests/test_views.py b/tcms/testcases/tests/test_views.py
--- a/tcms/testcases/tests/test_views.py
+++ b/tcms/testcases/tests/test_views.py
@@ -975,3 +975,64 @@ def test_return_sorted_cases_by_name_desc(self):
         ]
         expected_id_links.sort()
         self.assertEqual(expected_id_links, id_links)
+
+
+class TestAddComponent(BasePlanCase):
+    """Test AddComponentView"""
+
+    @classmethod
+    def setUpTestData(cls):
+        super(TestAddComponent, cls).setUpTestData()
+        cls.component_db = ComponentFactory(name='db', product=cls.product)
+        cls.component_doc = ComponentFactory(name='doc', product=cls.product)
+        cls.component_cli = ComponentFactory(name='cli', product=cls.product)
+
+    def setUp(self):
+        user_should_have_perm(self.tester, 'testcases.add_testcasecomponent')
+        self.add_component_url = reverse('cases-add-component')
+
+        self.login_tester()
+
+    def test_add_one_component(self):
+        resp = self.client.post(self.add_component_url, {
+            'product': self.product.pk,
+            'case': self.case_1.pk,
+            'o_component': [self.component_db.pk],
+        })
+
+        self.assertEqual(200, resp.status_code)
+
+        components = self.case_1.component.all()
+        self.assertEqual(1, len(components))
+        self.assertEqual(self.component_db, components[0])
+
+    def test_add_multiple_components(self):
+        resp = self.client.post(self.add_component_url, {
+            'product': self.product.pk,
+            'case': self.case_1.pk,
+            'o_component': [self.component_db.pk, self.component_cli.pk],
+        })
+
+        self.assertEqual(200, resp.status_code)
+
+        components = self.case_1.component.order_by('name')
+        self.assertEqual(2, len(components))
+        self.assertEqual(self.component_cli, components[0])
+        self.assertEqual(self.component_db, components[1])
+
+    def test_avoid_duplicate_components(self):
+        TestCaseComponent.objects.create(case=self.case_1,
+                                         component=self.component_doc)
+
+        resp = self.client.post(self.add_component_url, {
+            'product': self.product.pk,
+            'case': self.case_1.pk,
+            'o_component': [self.component_doc.pk, self.component_cli.pk],
+        })
+
+        self.assertEqual(200, resp.status_code)
+
+        components = self.case_1.component.order_by('name')
+        self.assertEqual(2, len(components))
+        self.assertEqual(self.component_cli, components[0])
+        self.assertEqual(self.component_doc, components[1])