Repository: akvo/akvo-rsr

Issue:
[#5280] Fix IATI budget checker and sector export
# TODO / Done

Summarize what has been changed / what has to be done in order to finalize the PR.

 - [x] Modify IATI sector export
 - [x] Modify IATI budget checker

# Test plan

What tests are necessary to ensure this works or doesn't break anything working

 - [x] Unit test
 - [x] Manual test
 



Discussion:

[![Coverage Status](https://coveralls.io/builds/60180247/badge)](https://coveralls.io/builds/60180247)

Coverage: 68.329% (-0.005%) from 68.335% when pulling **60c62441fa4d7cee6770608e960bf4615d46f74e on 5280-fix-sector-and-budget-iati-checker** into **af70bfbac28c9eeb2033d55f55a02dd188b8769c on master**.


Resolving patch:
diff --git a/akvo/iati/checks/fields/countries_and_regions.py b/akvo/iati/checks/fields/countries_and_regions.py
--- a/akvo/iati/checks/fields/countries_and_regions.py
+++ b/akvo/iati/checks/fields/countries_and_regions.py
@@ -82,6 +82,12 @@ def countries_and_regions(project):
                 message = f'{message}. {extra_message}'
             checks.append(('error', message))
 
+    if no_recipient_countries == 1 and no_recipient_regions == 0:
+        country = project.recipient_countries.first()
+        if country.percentage and 0 < country.percentage < 100:
+            all_checks_passed = False
+            checks.append(('error', 'when a single recipient country is declared, the percentage must either be omitted or set to 100'))
+
     for country in project.recipient_countries.all():
         if not country.country:
             all_checks_passed = False
diff --git a/akvo/iati/checks/fields/sectors.py b/akvo/iati/checks/fields/sectors.py
--- a/akvo/iati/checks/fields/sectors.py
+++ b/akvo/iati/checks/fields/sectors.py
@@ -5,6 +5,23 @@
 # For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
 
 from decimal import Decimal
+from akvo.utils import codelist_has_value
+from akvo.codelists import models
+
+VOCABULARY_CODE_MODEL_MAP = {
+    '1': models.Sector,
+    '2': models.SectorCategory,
+    '7': models.UNSDGGoals,
+    '8': models.UNSDGTargets,
+}
+
+
+def has_invalid_vocabulary_code(sector):
+    return (
+        sector.sector_code
+        and sector.vocabulary in VOCABULARY_CODE_MODEL_MAP.keys()
+        and not codelist_has_value(VOCABULARY_CODE_MODEL_MAP[sector.vocabulary], sector.sector_code)
+    )
 
 
 def sectors(project):
@@ -55,17 +72,34 @@ def sectors(project):
                         checks.append(('error', 'sector percentages for vocabulary \'%s\' '
                                        'do not add up to 100' % str(voc_key)))
 
+        sectors_by_vocabulary = dict()
+
         for sector in project.sectors.all():
+            sectors_by_vocabulary.setdefault(sector.vocabulary, []).append(sector)
             if not sector.sector_code:
                 all_checks_passed = False
                 checks.append(('error', 'sector (id: %s) is missing sector code' %
                                str(sector.pk)))
 
+            if has_invalid_vocabulary_code(sector):
+                all_checks_passed = False
+                checks.append(('error', 'sector (id: %s) has invalid sector code' % str(sector.pk)))
+
             if sector.vocabulary in ['98', '99'] and not sector.vocabulary_uri:
                 checks.append(('warning', 'sector (id: %s) with vocabulary 98 or 99 (reporting '
                                'organisation) has no vocabulary URI specified' %
                                str(sector.pk)))
 
+        for grouped_sectors in sectors_by_vocabulary.values():
+            if len(grouped_sectors) > 1:
+                continue
+            single_sector_in_vocabulary = grouped_sectors[0]
+            if not single_sector_in_vocabulary.percentage or single_sector_in_vocabulary.percentage == 100:
+                continue
+            all_checks_passed = False
+            checks.append(('error', 'sector "%s" declared only once, the percentage must either be omitted or set to 100'
+                           % single_sector_in_vocabulary.iati_vocabulary().name))
+
     elif not project.transactions.all():
         all_checks_passed = False
         checks.append(('error', 'no sectors present on project or transaction level'))
diff --git a/akvo/utils/__init__.py b/akvo/utils/__init__.py
--- a/akvo/utils/__init__.py
+++ b/akvo/utils/__init__.py
@@ -347,6 +347,14 @@ def codelist_value(model, instance, field, version=settings.IATI_VERSION):
         return result
 
 
+def codelist_has_value(model, value, version=settings.IATI_VERSION):
+    """
+    Check if value exists
+    """
+    objects = getattr(model, 'objects')
+    return objects.filter(code=value, version__code=version).exists()
+
+
 def codelist_name(model, instance, field, version=settings.IATI_VERSION):
     """
     Looks up the name of a codelist, returns the field value if the lookup fails


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
@@ -181,7 +181,7 @@ def test_iati_checks_correct_project(self):
         Sector.objects.create(
             project=project,
             sector_code="140",
-            vocabulary="1",
+            vocabulary="3",
             vocabulary_uri="http://akvo.org",
             percentage=50,
             text="WASH",
@@ -189,7 +189,7 @@ def test_iati_checks_correct_project(self):
         Sector.objects.create(
             project=project,
             sector_code="150",
-            vocabulary="1",
+            vocabulary="3",
             vocabulary_uri="http://akvo.org",
             percentage=50,
             text="WASH",
diff --git a/akvo/rsr/tests/iati_checks/test_iati_checks_fields_countries_and_regions.py b/akvo/rsr/tests/iati_checks/test_iati_checks_fields_countries_and_regions.py
--- a/akvo/rsr/tests/iati_checks/test_iati_checks_fields_countries_and_regions.py
+++ b/akvo/rsr/tests/iati_checks/test_iati_checks_fields_countries_and_regions.py
@@ -43,3 +43,27 @@ def test_transcation_with_recipient_regions(self):
         RecipientRegion.objects.create(project=self.project, region="89", percentage=100)
         all_checks_passed, _ = geo_checks(self.project)
         self.assertTrue(all_checks_passed)
+
+
+class SingleRecipientCountryPercentage(BaseTestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.project = self.create_project("Test project")
+
+    def test_invalid(self):
+        RecipientCountry.objects.create(project=self.project, country="NL", percentage=50)
+        all_checks_passed, checks = geo_checks(self.project)
+        self.assertFalse(all_checks_passed)
+        self.assertEqual('error', checks[0][0])
+        self.assertIn('when a single recipient country is declared, the percentage must either be omitted or set to 100', checks[0][1])
+
+    def test_percentage_zero(self):
+        RecipientCountry.objects.create(project=self.project, country="NL", percentage=0)
+        all_checks_passed, _ = geo_checks(self.project)
+        self.assertTrue(all_checks_passed)
+
+    def test_percentage_100(self):
+        RecipientCountry.objects.create(project=self.project, country="NL", percentage=100)
+        all_checks_passed, _ = geo_checks(self.project)
+        self.assertTrue(all_checks_passed)
diff --git a/akvo/rsr/tests/iati_checks/test_iati_checks_fields_sectors.py b/akvo/rsr/tests/iati_checks/test_iati_checks_fields_sectors.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/tests/iati_checks/test_iati_checks_fields_sectors.py
@@ -0,0 +1,130 @@
+from parameterized import parameterized
+from django.conf import settings
+from akvo.iati.checks.fields.sectors import sectors as sectors_checks, VOCABULARY_CODE_MODEL_MAP
+from akvo.rsr.models import Sector
+from akvo.rsr.tests.base import BaseTestCase
+from akvo.rsr.tests.utils import random_string
+
+
+class IatiCheckSectorVocabularyCodeTestCase(BaseTestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.project = self.create_project("Test project")
+
+    def get_first_vocabulary_code(self, vocabulary, version=settings.IATI_VERSION):
+        model = VOCABULARY_CODE_MODEL_MAP[vocabulary]
+        objects = getattr(model, 'objects')
+        return objects.filter(version__code=version).first().code
+
+    @parameterized.expand([('1'), ('2'), ('7'), ('8')])
+    def test_vocabulary_code_invalid(self, vocabulary):
+        Sector.objects.create(
+            project=self.project,
+            vocabulary=vocabulary,
+            sector_code=random_string(),
+        )
+
+        all_checks_passed, checks = sectors_checks(self.project)
+
+        self.assertFalse(all_checks_passed)
+        self.assertEqual('error', checks[0][0])
+        self.assertIn('has invalid sector code', checks[0][1])
+
+    @parameterized.expand([('1'), ('2'), ('7'), ('8')])
+    def test_vocabulary_code_valid(self, vocabulary):
+        code = self.get_first_vocabulary_code(vocabulary)
+        Sector.objects.create(
+            project=self.project,
+            vocabulary=vocabulary,
+            sector_code=code,
+        )
+
+        all_checks_passed, _ = sectors_checks(self.project)
+
+        self.assertTrue(all_checks_passed)
+
+    @parameterized.expand([('3'), ('4'), ('5'), ('6'), ('9'), ('10'), ('11'), ('98'), ('99')])
+    def test_other_vocabulary(self, vocabulary):
+        Sector.objects.create(
+            project=self.project,
+            vocabulary=vocabulary,
+            sector_code=random_string(),
+        )
+        all_checks_passed, _ = sectors_checks(self.project)
+
+        self.assertTrue(all_checks_passed)
+
+
+class SingleSectorPercentageTestCase(BaseTestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.project = self.create_project("Test project")
+        Sector.objects.create(
+            project=self.project,
+            vocabulary='3',
+            sector_code=random_string(),
+            percentage=50
+        )
+
+    def test_single_sector(self):
+        all_checks_passed, checks = sectors_checks(self.project)
+
+        self.assertFalse(all_checks_passed)
+        self.assertEqual(1, len(checks))
+        self.assertEqual('error', checks[0][0])
+        self.assertIn('the percentage must either be omitted or set to 100', checks[0][1])
+
+    def test_same_vocabulary_sectors(self):
+        Sector.objects.create(
+            project=self.project,
+            vocabulary='3',
+            sector_code=random_string(),
+            percentage=50
+        )
+
+        all_checks_passed, _ = sectors_checks(self.project)
+
+        self.assertTrue(all_checks_passed)
+
+    def test_different_vocabulary_sectors(self):
+        Sector.objects.create(
+            project=self.project,
+            vocabulary='4',
+            sector_code=random_string(),
+            percentage=50
+        )
+
+        all_checks_passed, checks = sectors_checks(self.project)
+
+        self.assertFalse(all_checks_passed)
+        self.assertEqual(2, len(checks))
+        self.assertEqual('error', checks[0][0])
+        self.assertIn('the percentage must either be omitted or set to 100', checks[0][1])
+        self.assertEqual('error', checks[1][0])
+        self.assertIn('the percentage must either be omitted or set to 100', checks[1][1])
+
+    def test_valid_case(self):
+        Sector.objects.create(
+            project=self.project,
+            vocabulary='3',
+            sector_code=random_string(),
+            percentage=50
+        )
+        Sector.objects.create(
+            project=self.project,
+            vocabulary='4',
+            sector_code=random_string(),
+            percentage=100
+        )
+        Sector.objects.create(
+            project=self.project,
+            vocabulary='5',
+            sector_code=random_string(),
+            percentage=0
+        )
+
+        all_checks_passed, _ = sectors_checks(self.project)
+
+        self.assertTrue(all_checks_passed)