Repository: akvo/akvo-rsr

Issue:
Disable aggregation of results
Currently, we always aggregate the results to a parent project. However, it would be nice to have a setting to disallow this.



Discussion:

Resolving patch:
diff --git a/akvo/rsr/migrations/0080_auto_20160707_1204.py b/akvo/rsr/migrations/0080_auto_20160707_1204.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/migrations/0080_auto_20160707_1204.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import akvo.rsr.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('rsr', '0079_auto_20160620_1418'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='project',
+            name='aggregate_children',
+            field=models.BooleanField(default=True, help_text='By selecting this option, the results data of child projects will be aggregated to this project. In the child project(s), this can be turned off per project as well.', verbose_name='Aggregate results data from child projects'),
+            preserve_default=True,
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='aggregate_to_parent',
+            field=models.BooleanField(default=True, help_text='By selecting this option, the results data of this project will be aggregated to the parent project.', verbose_name='Aggregate results data to parent project'),
+            preserve_default=True,
+        ),
+    ]
diff --git a/akvo/rsr/models/indicator.py b/akvo/rsr/models/indicator.py
--- a/akvo/rsr/models/indicator.py
+++ b/akvo/rsr/models/indicator.py
@@ -407,6 +407,14 @@ def is_calculated(self):
         """
         return self.data.exists()
 
+    def actual_value_is_decimal(self):
+
+        try:
+            Decimal(self.actual_value)
+            return True
+        except (InvalidOperation, TypeError):
+            return not self.actual_value
+
     def is_child_period(self):
         """
         Indicates whether this result is linked to a parent result.
@@ -446,6 +454,20 @@ def child_periods(self):
             period_end=self.period_end
         )
 
+    def child_periods_sum(self):
+        """
+            Returns a sum of child indicator periods.
+            """
+        period_sum = 0
+        for period in self.child_periods():
+            if period.indicator.result.project.aggregate_to_parent and period.actual_value:
+                try:
+                    period_sum += Decimal(period.actual_value)
+                except (InvalidOperation, TypeError):
+                    pass
+
+        return str(period_sum)
+
     def adjacent_period(self, next_period=True):
         """
         Returns the next or previous indicator period, if we can find one with a start date,
@@ -471,21 +493,52 @@ def update_actual_value(self, data, relative_data, comment=''):
         relative value of the current actual value (True) or overwrite the actual value (False)
         :param comment; String that represents the new actual comment data of the period (Optional)
         """
-        updated_actual_value = False
+        old_value_is_decimal = False
+        new_value_is_decimal = False
+
         try:
             old_actual = Decimal(self.actual_value or '0')
-            self.actual_value = str(old_actual + Decimal(data)) if relative_data else str(data)
+            old_value_is_decimal = True
+        except (InvalidOperation, TypeError):
+            old_actual = self.actual_value
+
+        try:
+            new_actual = Decimal(data)
+            new_value_is_decimal = True
+        except (InvalidOperation, TypeError):
+            new_actual = data
+
+        parent = self.parent_period()
+        if old_value_is_decimal and new_value_is_decimal:
+            self.actual_value = str(old_actual + new_actual) if relative_data else str(new_actual)
             self.save(update_fields=['actual_value'])
-            updated_actual_value = True
 
             # Update parent period (if not percentages)
-            parent = self.parent_period()
-            if parent and self.indicator.measure != '2':
-                parent.update_actual_value(str(Decimal(self.actual_value) - old_actual), True)
-        except (InvalidOperation, TypeError):
-            if data and not updated_actual_value:
-                self.actual_value = data
-                self.save(update_fields=['actual_value'])
+            if parent:
+                if self.indicator.result.project.aggregate_to_parent and parent.actual_value_is_decimal() and \
+                        parent.indicator.result.project.aggregate_children and self.indicator.measure != '2':
+                    parent.update_actual_value(str(new_actual), True)
+
+        elif not old_value_is_decimal and new_value_is_decimal:
+            self.actual_value = str(new_actual + Decimal(self.child_periods_sum()))
+            self.save(update_fields=['actual_value'])
+
+            # Update parent period (if not percentages)
+            if parent:
+                if self.indicator.result.project.aggregate_to_parent and parent.actual_value_is_decimal() and \
+                        parent.indicator.result.project.aggregate_children and self.indicator.measure != '2':
+                    parent.update_actual_value(str(new_actual + Decimal(self.child_periods_sum())), True)
+
+        else:
+            self.actual_value = str(new_actual)
+            self.save(update_fields=['actual_value'])
+
+            # Update parent period (if not percentages)
+            if parent and old_value_is_decimal:
+                if self.indicator.result.project.aggregate_to_parent and parent.actual_value_is_decimal() and \
+                        parent.indicator.result.project.aggregate_children and self.indicator.measure != '2':
+                    parent.update_actual_value(str(-old_actual), True)
+
 
         if comment:
             self.actual_comment = comment
@@ -687,7 +740,11 @@ def save(self, *args, **kwargs):
                 # An approved indicator update has been edited. Update to the new values and
                 # recalculate
                 elif orig.status == self.STATUS_APPROVED_CODE:
-                    self.period.update_actual_value(Decimal(self.data) - Decimal(orig.data), True)
+                    try:
+                        self.period.update_actual_value(Decimal(self.data) - Decimal(orig.data), True)
+                    except (InvalidOperation, TypeError):
+                        self.period.update_actual_value(self.data, False)
+
                     do_recalculation = True
 
         super(IndicatorPeriodData, self).save(*args, **kwargs)
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
@@ -7,7 +7,7 @@
 
 import math
 
-from decimal import Decimal
+from decimal import Decimal, InvalidOperation
 
 from django.conf import settings
 from django.core.exceptions import ValidationError, ObjectDoesNotExist, MultipleObjectsReturned
@@ -228,6 +228,18 @@ class Project(TimestampsMixin, models.Model):
                     u'target="_blank">Markdown</a> is supported.')
     )
 
+    # Result aggregation
+    aggregate_children = models.BooleanField(
+        _(u'Aggregate results data from child projects'), default=True,
+        help_text=_(u'By selecting this option, the results data of child projects will be aggregated to this project. '
+                    u'In the child project(s), this can be turned off per project as well.')
+    )
+    aggregate_to_parent = models.BooleanField(
+        _(u'Aggregate results data to parent project'), default=True,
+        help_text=_(u'By selecting this option, the results data of this project will be aggregated '
+                    u'to the parent project.')
+    )
+
     # Results framework (always on)
     is_impact_project = models.BooleanField(
         _(u'is rsr impact project'), default=True,
@@ -433,6 +445,12 @@ def save(self, last_updated=False, *args, **kwargs):
                 self.iati_status = self.STATUS_TO_CODE[self.status]
                 super(Project, self).save(update_fields=['iati_status'])
 
+            if self.aggregate_children != orig.aggregate_children:
+                self.toggle_aggregate_children(self.aggregate_children)
+
+            if self.aggregate_to_parent != orig.aggregate_to_parent:
+                self.toggle_aggregate_to_parent(self.aggregate_to_parent)
+
         super(Project, self).save(*args, **kwargs)
 
     def clean(self):
@@ -1182,6 +1200,17 @@ def parents(self):
             )
         ).distinct().published().public()
 
+    def parents_all(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()
+
     def children(self):
         return (
             Project.objects.filter(
@@ -1193,6 +1222,17 @@ def children(self):
             )
         ).distinct().published().public()
 
+    def children_all(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()
+
     def siblings(self):
         return (
             Project.objects.filter(
@@ -1204,6 +1244,17 @@ def siblings(self):
             )
         ).distinct().published().public()
 
+    def siblings_all(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()
+
     def check_mandatory_fields(self):
         iati_checks = IatiChecks(self)
         return iati_checks.perform_checks()
@@ -1327,6 +1378,40 @@ def has_indicators(self):
                 return True
         return False
 
+    def toggle_aggregate_children(self, aggregate):
+        """ Add/subtract all child indicator period updates if aggregation is toggled """
+        for result in self.results.all():
+            for indicator in result.indicators.all():
+                if indicator.is_parent_indicator() and not indicator.measure == '2':
+                    for period in indicator.periods.all():
+                        sign = 1 if aggregate else -1
+                        self.update_parents(period, period.child_periods_sum(), sign)
+
+    def toggle_aggregate_to_parent(self, aggregate):
+        """ Add/subtract child indicator period values from parent if aggregation is toggled """
+        for result in self.results.all():
+            for indicator in result.indicators.all():
+                if indicator.is_child_indicator() and not indicator.measure == '2':
+                    for period in indicator.periods.all():
+                        parent = period.parent_period()
+                        if parent and period.actual_value:
+                            sign = 1 if aggregate else -1
+                            self.update_parents(parent, period.actual_value, sign)
+
+    def update_parents(self, update_period, difference, sign):
+        """ Update parent indicator periods if they exist and allow aggregation """
+        try:
+            update_period.actual_value = str(Decimal(update_period.actual_value) + sign * Decimal(difference))
+            update_period.save()
+
+            parent_period = update_period.parent_period()
+            if parent_period:
+                if parent_period.indicator.result.project.aggregate_children:
+                    self.update_parents(parent_period, difference, sign)
+
+        except (InvalidOperation, TypeError):
+            pass
+
 
 @receiver(post_save, sender=Project)
 def default_validation_set(sender, **kwargs):


Test patch: