Repository: akvo/akvo-rsr

Issue:
IATI xml is broken w.r.t related_activity list
Created via Reamaze:

Link: https://rsrsupport.reamaze.com/admin/conversations/akvo-xml

In the IATI xml of EU Trust fund we see this:

<related-activity type="2" ref="XI-IATI-EC_DEVCO_T05-EUTF-SAH-ML-01-01”/> <related-activity type="2" ref="XI-IATI-EC_DEVCO_T05-EUTF-SAH-ML-01-02”/>

<related-activity type="2" ref="XI-IATI-EC_DEVCO_T05-EUTF-SAH-ML-01-03”/> <related-activity type="1" ref="XI-IATI-EC_DEVCO_T05-EUTF-SAH-ML-01-01”/>

<related-activity type="1" ref="XI-IATI-EC_DEVCO_T05-EUTF-SAH-ML-01-02”/> <related-activity type="1" ref="XI-IATI-EC_DEVCO_T05-EUTF-SAH-ML-01-03”/>

What we see:

urgent: 1) three project are repeated: both as type 1 and 2. Not realistic. A bug? I tested this in test.rsr.akvo.org: added a relationship and created xml file. It worked fine... Note that this issue occurs more often for EU Trust fund and even in our own IATI file (where it should not).


Discussion:

Resolving patch:
diff --git a/akvo/iati/exports/elements/related_activity.py b/akvo/iati/exports/elements/related_activity.py
--- a/akvo/iati/exports/elements/related_activity.py
+++ b/akvo/iati/exports/elements/related_activity.py
@@ -14,30 +14,32 @@ def related_activity(project):
     :param project: Project object
     :return: A list of Etree elements
     """
-    related_activity_elements = []
+    related_activities = set()
 
     for related_project in project.related_projects.all():
         if related_project.related_project and related_project.relation:
             if related_project.related_project.iati_activity_id:
-                element = etree.Element("related-activity")
-                element.attrib['ref'] = related_project.related_project.iati_activity_id
-                element.attrib['type'] = related_project.relation
-
-                related_activity_elements.append(element)
+                related_activity = (
+                    related_project.related_project.iati_activity_id,
+                    related_project.relation
+                )
+                related_activities.add(related_activity)
 
         elif related_project.related_iati_id and related_project.relation:
-            element = etree.Element("related-activity")
-            element.attrib['ref'] = related_project.related_iati_id
-            element.attrib['type'] = related_project.relation
-
-            related_activity_elements.append(element)
+            related_activity = (related_project.related_iati_id, related_project.relation)
+            related_activities.add(related_activity)
 
     for related_to_project in project.related_to_projects.all():
         if related_to_project.project.iati_activity_id and related_to_project.relation:
-            element = etree.Element("related-activity")
-            element.attrib['ref'] = related_to_project.project.iati_activity_id
-            element.attrib['type'] = related_to_project.relation
-
-            related_activity_elements.append(element)
+            related_activity = (
+                related_to_project.project.iati_activity_id,
+                related_to_project.reciprocal_relation
+            )
+            related_activities.add(related_activity)
+
+    related_activity_elements = [
+        etree.Element('related-activity', attrib={'ref': ref, 'type': type_})
+        for (ref, type_) in related_activities
+    ]
 
     return related_activity_elements
diff --git a/akvo/rsr/models/project.py b/akvo/rsr/models/project.py
--- a/akvo/rsr/models/project.py
+++ b/akvo/rsr/models/project.py
@@ -49,6 +49,7 @@
 from .project_update import ProjectUpdate
 from .project_editor_validation import ProjectEditorValidationSet
 from .publishing_status import PublishingStatus
+from .related_project import RelatedProject
 from .budget_item import BudgetItem
 
 
@@ -1194,68 +1195,44 @@ def has_relations(self):
         return self.parents() or self.children() or self.siblings()
 
     def parents(self):
-        return (
-            Project.objects.filter(
-                related_projects__related_project=self,
-                related_projects__relation=2
-            ) | Project.objects.filter(
-                related_to_projects__project=self,
-                related_to_projects__relation=1
-            )
-        ).distinct().published().public()
+        return self.parents_all().published().public()
 
     def parents_all(self):
         return (
             Project.objects.filter(
                 related_projects__related_project=self,
-                related_projects__relation=2
+                related_projects__relation=RelatedProject.PROJECT_RELATION_CHILD
             ) | Project.objects.filter(
                 related_to_projects__project=self,
-                related_to_projects__relation=1
+                related_to_projects__relation=RelatedProject.PROJECT_RELATION_PARENT
             )
         ).distinct()
 
     def children(self):
-        return (
-            Project.objects.filter(
-                related_projects__related_project=self,
-                related_projects__relation=1
-            ) | Project.objects.filter(
-                related_to_projects__project=self,
-                related_to_projects__relation=2
-            )
-        ).distinct().published().public()
+        return self.children_all().published().public()
 
     def children_all(self):
         return (
             Project.objects.filter(
                 related_projects__related_project=self,
-                related_projects__relation=1
+                related_projects__relation=RelatedProject.PROJECT_RELATION_PARENT
             ) | Project.objects.filter(
                 related_to_projects__project=self,
-                related_to_projects__relation=2
+                related_to_projects__relation=RelatedProject.PROJECT_RELATION_CHILD
             )
         ).distinct()
 
     def siblings(self):
-        return (
-            Project.objects.filter(
-                related_projects__related_project=self,
-                related_projects__relation=3
-            ) | Project.objects.filter(
-                related_to_projects__project=self,
-                related_to_projects__relation=3
-            )
-        ).distinct().published().public()
+        return self.siblings_all().published().public()
 
     def siblings_all(self):
         return (
             Project.objects.filter(
                 related_projects__related_project=self,
-                related_projects__relation=3
+                related_projects__relation=RelatedProject.PROJECT_RELATION_SIBLING
             ) | Project.objects.filter(
                 related_to_projects__project=self,
-                related_to_projects__relation=3
+                related_to_projects__relation=RelatedProject.PROJECT_RELATION_SIBLING
             )
         ).distinct()
 
diff --git a/akvo/rsr/models/related_project.py b/akvo/rsr/models/related_project.py
--- a/akvo/rsr/models/related_project.py
+++ b/akvo/rsr/models/related_project.py
@@ -15,6 +15,13 @@
 
 
 class RelatedProject(models.Model):
+
+    PROJECT_RELATION_PARENT = u'1'
+    PROJECT_RELATION_CHILD = u'2'
+    PROJECT_RELATION_SIBLING = u'3'
+    PROJECT_RELATION_CO_FUNDED = u'4'
+    PROJECT_RELATION_THIRD_PARTY = u'5'
+
     project = models.ForeignKey('Project', related_name='related_projects')
     related_project = models.ForeignKey(
         'Project', related_name='related_to_projects', null=True, blank=True,
@@ -41,6 +48,24 @@ class RelatedProject(models.Model):
                     u'that you are reporting on.')
     )
 
+    @property
+    def reciprocal_relation(self):
+        """Return the relation between related_project and project.
+
+        `relation` specifies the relationship between project and
+        related_project.  This returns the reciprocal relationship.
+
+        """
+
+        if self.relation == RelatedProject.PROJECT_RELATION_PARENT:
+            return RelatedProject.PROJECT_RELATION_CHILD
+
+        elif self.relation == RelatedProject.PROJECT_RELATION_CHILD:
+            return RelatedProject.PROJECT_RELATION_PARENT
+
+        else:
+            return self.relation
+
     def iati_relation(self):
         return codelist_value(RelatedActivityType, self, 'relation')
 


Test patch:
diff --git a/akvo/rsr/tests/iati_checks/test_iati_checks.py b/akvo/rsr/tests/iati_checks/test_iati_checks.py
--- a/akvo/rsr/tests/iati_checks/test_iati_checks.py
+++ b/akvo/rsr/tests/iati_checks/test_iati_checks.py
@@ -170,17 +170,17 @@ def test_iati_checks_correct_project(self):
         RelatedProject.objects.create(
             project=project,
             related_project=related_project,
-            relation='1'
+            relation=RelatedProject.PROJECT_RELATION_PARENT
         )
         RelatedProject.objects.create(
             project=project,
             related_iati_id="NL-KVK-related",
-            relation='1'
+            relation=RelatedProject.PROJECT_RELATION_PARENT
         )
         RelatedProject.objects.create(
             project=related_project,
             related_project=project,
-            relation='1'
+            relation=RelatedProject.PROJECT_RELATION_PARENT
         )
 
         # Add sector
diff --git a/akvo/rsr/tests/iati_export/test_iati_export.py b/akvo/rsr/tests/iati_export/test_iati_export.py
--- a/akvo/rsr/tests/iati_export/test_iati_export.py
+++ b/akvo/rsr/tests/iati_export/test_iati_export.py
@@ -153,17 +153,17 @@ def test_complete_project_export(self):
         RelatedProject.objects.create(
             project=project,
             related_project=related_project,
-            relation='1'
+            relation=RelatedProject.PROJECT_RELATION_PARENT
         )
         RelatedProject.objects.create(
             project=project,
             related_iati_id="NL-KVK-related",
-            relation='1'
+            relation=RelatedProject.PROJECT_RELATION_PARENT
         )
         RelatedProject.objects.create(
             project=related_project,
             related_project=project,
-            relation='1'
+            relation=RelatedProject.PROJECT_RELATION_CHILD
         )
 
         # Add sector
@@ -480,6 +480,15 @@ def test_complete_project_export(self):
                                            './iati-activity/reporting-org',
                                            './iati-activity/title'))
 
+        # Test related activities are listed only once
+        related_activity_id = related_project.iati_activity_id
+        attributes = {'ref': related_activity_id, 'type': RelatedProject.PROJECT_RELATION_PARENT}
+        related_activities = root_test.xpath(
+            './iati-activity/related-activity[@ref="{}"]'.format(related_activity_id)
+        )
+        self.assertEqual(1, len(related_activities))
+        self.assertEqual(attributes, related_activities[0].attrib)
+
     def test_different_complete_project_export(self):
         """
         Test the export of a fully filled project with different settings.
diff --git a/akvo/rsr/tests/results_framework/test_results_framework.py b/akvo/rsr/tests/results_framework/test_results_framework.py
--- a/akvo/rsr/tests/results_framework/test_results_framework.py
+++ b/akvo/rsr/tests/results_framework/test_results_framework.py
@@ -61,7 +61,7 @@ def setUp(self):
         RelatedProject.objects.create(
             project=self.parent_project,
             related_project=self.child_project,
-            relation='2',
+            relation=RelatedProject.PROJECT_RELATION_CHILD,
         )
 
         # Create results framework
@@ -247,7 +247,7 @@ def test_updates_with_percentages(self):
         RelatedProject.objects.create(
             project=self.parent_project,
             related_project=child_project_2,
-            relation='2',
+            relation=RelatedProject.PROJECT_RELATION_CHILD,
         )
 
         # Import results framework into child 2