Repository: akvo/akvo-rsr

Issue:
Show qualitative indicators in 'Results and Indicators Overview' PDF report



Discussion:

Resolving patch:
diff --git a/akvo/rsr/views/py_reports/results_indicators_with_map_pdf_reports.py b/akvo/rsr/views/py_reports/results_indicators_with_map_pdf_reports.py
--- a/akvo/rsr/views/py_reports/results_indicators_with_map_pdf_reports.py
+++ b/akvo/rsr/views/py_reports/results_indicators_with_map_pdf_reports.py
@@ -7,7 +7,7 @@
 see < http://www.gnu.org/licenses/agpl.html >.
 """
 
-from akvo.rsr.models import Project, Country, Organisation
+from akvo.rsr.models import Project, Country, Organisation, IndicatorPeriod
 from akvo.rsr.staticmap import get_staticmap_url, Coordinate, Size
 from datetime import datetime
 from django.contrib.auth.decorators import login_required
@@ -54,16 +54,7 @@ def render_organisation_projects_results_indicators_map_overview(request, org_id
         context={
             'title': 'Results and indicators overview for projects in {}'.format(country.name),
             'staticmap': get_staticmap_url(coordinates, Size(900, 600)),
-            'projects': [
-                {
-                    'id': p.id,
-                    'title': p.title,
-                    'subtitle': p.subtitle,
-                    'results': _transform_project_results(p, start_date, end_date)
-                }
-                for p
-                in projects
-            ],
+            'projects': [build_view_object(p, start_date, end_date) for p in projects],
             'show_comment': show_comment,
             'today': now.strftime('%d-%b-%Y'),
         }
@@ -89,6 +80,28 @@ def render_project_results_indicators_overview(request, project_id):
     return _render_project_report(request, project_id, with_disaggregation=True)
 
 
+def build_view_object(project, start_date=None, end_date=None):
+    results = {}
+    periods = IndicatorPeriod.objects\
+        .select_related('indicator', 'indicator__result')\
+        .prefetch_related('disaggregations')\
+        .filter(indicator__result__project=project)
+    if start_date and end_date:
+        periods = periods.filter(
+            Q(period_start__isnull=True) | Q(period_start__gte=start_date),
+            Q(period_end__isnull=True) | Q(period_end__lte=end_date)
+        )
+    for period in periods:
+        indicator = period.indicator
+        result = indicator.result
+        if result.id not in results:
+            results[result.id] = {'item': result, 'indicators': {}}
+        if indicator.id not in results[result.id]['indicators']:
+            results[result.id]['indicators'][indicator.id] = {'item': indicator, 'periods': []}
+        results[result.id]['indicators'][indicator.id]['periods'].append(period)
+    return utils.ProjectProxy(project, results)
+
+
 def _render_project_report(request, project_id, with_map=False, with_disaggregation=False):
     show_comment = True if request.GET.get('comment', '').strip() == 'true' else False
     start_date = utils.parse_date(request.GET.get('start_date', '').strip(), datetime(1900, 1, 1))
@@ -122,7 +135,7 @@ def _render_project_report(request, project_id, with_map=False, with_disaggregat
     html = render_to_string(
         'reports/project-results-indicators-map-overview.html',
         context={
-            'project': project,
+            'project': build_view_object(project, start_date, end_date),
             'location': ", ".join([
                 _f
                 for _f
@@ -130,8 +143,8 @@ def _render_project_report(request, project_id, with_map=False, with_disaggregat
                 if _f
             ]) if project_location else "",
             'staticmap': get_staticmap_url(coordinates, Size(900, 600)) if with_map else None,
-            'results': _transform_project_results(project, start_date, end_date, with_disaggregation),
             'show_comment': show_comment,
+            'show_disaggregations': with_disaggregation,
             'today': now.strftime('%d-%b-%Y'),
         }
     )
@@ -143,68 +156,3 @@ def _render_project_report(request, project_id, with_map=False, with_disaggregat
         now.strftime('%Y%b%d'), project.id, '-map' if with_map else '')
 
     return utils.make_pdf_response(html, filename)
-
-
-def _transform_project_results(project, start_date, end_date, with_disaggregation=False):
-    is_eutf_descendant = project.in_eutf_hierarchy
-    return [
-        {
-            'title': r.title.strip(),
-            'indicators': [
-                {
-                    'title': i.title,
-                    'baseline_value': '{}{}'.format(i.baseline_value, '%' if i.measure == '2' else ''),
-                    'baseline_year': '({})'.format(i.baseline_year) if i.baseline_year else '',
-                    'baseline_comment': i.baseline_comment,
-                    'periods': [
-                        _transform_period(p, project, is_eutf_descendant)
-                        for p
-                        in i.periods.all()
-                    ],
-                    'disaggregations': _transform_disaggregations(i) if with_disaggregation else {},
-                }
-                for i
-                in r.indicators.all()
-            ]
-        }
-        for r
-        in project.results.filter(
-            Q(indicators__periods__period_start__isnull=True) | Q(indicators__periods__period_start__gte=start_date),
-            Q(indicators__periods__period_end__isnull=True) | Q(indicators__periods__period_end__lte=end_date)
-        ).all()
-    ]
-
-
-def _transform_disaggregations(indicator):
-    disaggregations = {}
-
-    for period in indicator.periods.all():
-        for disaggregation in period.disaggregations.all():
-            category = disaggregation.dimension_value.name.name
-            type = disaggregation.dimension_value.value
-            if category not in disaggregations:
-                disaggregations[category] = {}
-            if type not in disaggregations[category]:
-                disaggregations[category][type] = 0
-
-            if disaggregation.value:
-                disaggregations[category][type] += disaggregation.value
-
-    return disaggregations
-
-
-def _transform_period(period, project, is_eutf_descendant):
-    actual_value = utils.force_decimal(period.actual_value)
-    target_value = utils.force_decimal(period.target_value)
-    total = utils.calculate_percentage(actual_value, target_value)
-    grade = 'low' if total <= 49 else 'high' if total >= 85 else 'medium'
-
-    return {
-        'period_start': utils.get_period_start(period, is_eutf_descendant),
-        'period_end': utils.get_period_end(period, is_eutf_descendant),
-        'actual_value': actual_value,
-        'target_value': target_value,
-        'actual_comment': "\n\n".join(period.actual_comment.split(' | ')),
-        'grade': grade,
-        'total': '{}%'.format(total),
-    }
diff --git a/akvo/rsr/views/py_reports/utils.py b/akvo/rsr/views/py_reports/utils.py
--- a/akvo/rsr/views/py_reports/utils.py
+++ b/akvo/rsr/views/py_reports/utils.py
@@ -13,6 +13,7 @@
 from django.http import HttpResponse
 from weasyprint import HTML
 from weasyprint.fonts import FontConfiguration
+from akvo.rsr.models.result.utils import QUALITATIVE, PERCENTAGE_MEASURE
 
 
 def make_pdf_response(html, filename='reports.pdf'):
@@ -76,3 +77,116 @@ def get_period_end(period, in_eutf_hierarchy):
         return project.date_end_actual
 
     return project.date_end_planned
+
+
+class Proxy(object):
+    def __init__(self, real):
+        self._real = real
+
+    def __getattr__(self, attr):
+        return getattr(self._real, attr)
+
+
+class ProjectProxy(Proxy):
+    def __init__(self, project, results={}):
+        super().__init__(project)
+        self._results = []
+        for r in sorted(results.values(), key=lambda it: it['item'].order or 0):
+            self._results.append(ResultProxy(r['item'], r['indicators']))
+
+    @property
+    def results(self):
+        return self._results
+
+
+class ResultProxy(Proxy):
+    def __init__(self, result, indicators={}):
+        super().__init__(result)
+        self._indicators = []
+        for i in sorted(indicators.values(), key=lambda it: it['item'].order or 0):
+            self._indicators.append(IndicatorProxy(i['item'], i['periods']))
+
+    @property
+    def indicators(self):
+        return self._indicators
+
+
+class IndicatorProxy(Proxy):
+    def __init__(self, indicator, periods=[]):
+        super().__init__(indicator)
+        self._periods = []
+        for p in periods:
+            self._periods.append(PeriodProxy(p))
+        self._disaggregations = None
+
+    @property
+    def is_qualitative(self):
+        return self.type == QUALITATIVE
+
+    @property
+    def is_percentage(self):
+        return self.measure == PERCENTAGE_MEASURE
+
+    @property
+    def periods(self):
+        return self._periods
+
+    @property
+    def disaggregations(self):
+        if self._disaggregations is None:
+            self._disaggregations = {}
+            for period in self.periods:
+                for d in period.disaggregations.all():
+                    category = d.dimension_value.name.name
+                    type = d.dimension_value.value
+                    if category not in self._disaggregations:
+                        self._disaggregations[category] = {}
+                    if type not in self._disaggregations[category]:
+                        self._disaggregations[category][type] = {'value': 0, 'numerator': 0, 'denominator': 0}
+                    self._disaggregations[category][type]['value'] += (d.value or 0)
+                    self._disaggregations[category][type]['numerator'] += (d.numerator or 0)
+                    self._disaggregations[category][type]['denominator'] += (d.denominator or 0)
+
+            if self.is_percentage:
+                for category, types in self._disaggregations.items():
+                    for type in types.keys():
+                        self._disaggregations[category][type]['value'] = calculate_percentage(
+                            self._disaggregations[category][type]['numerator'],
+                            self._disaggregations[category][type]['denominator']
+                        )
+
+        return self._disaggregations
+
+
+class PeriodProxy(Proxy):
+    def __init__(self, period):
+        super().__init__(period)
+        self._actual_value = None
+        self._target_value = None
+        self._progress = None
+
+    @property
+    def actual_value(self):
+        if self._actual_value is None:
+            self._actual_value = force_decimal(self._real.actual_value)
+        return self._actual_value
+
+    @property
+    def target_value(self):
+        if self._target_value is None:
+            self._target_value = force_decimal(self._real.target_value)
+        return self._target_value
+
+    @property
+    def progress(self):
+        if self._progress is None:
+            self._progress = calculate_percentage(self.actual_value, self.target_value)
+        return self._progress
+
+    @property
+    def progress_str(self):
+        return '{}%'.format(self.progress)
+
+    @property
+    def grade(self):
+        return 'low' if self.progress <= 49 else 'high' if self.progress >= 85 else 'medium'


Test patch:
diff --git a/akvo/rsr/tests/utils.py b/akvo/rsr/tests/utils.py
--- a/akvo/rsr/tests/utils.py
+++ b/akvo/rsr/tests/utils.py
@@ -5,9 +5,133 @@
 See more details in the license.txt file located at the root folder of the Akvo RSR module.
 For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
 """
+from akvo.rsr.models import (
+    Result, Indicator, IndicatorPeriod, IndicatorPeriodData, Disaggregation,
+    IndicatorDimensionName, IndicatorDimensionValue)
+from akvo.rsr.tests.base import BaseTestCase
+from datetime import date, timedelta
+import random
+import string
 
 
 def contains_template_errors(markup):
     """Itterate over the markup looking for common errors."""
     common_errors = ['{%', '%}', '{{', '}}']
     return any(x in markup for x in common_errors)
+
+
+def random_string(length=5):
+    return ''.join(random.choice(string.ascii_lowercase) for i in range(length))
+
+
+class ProjectFixtureBuilder(object):
+    def __init__(self):
+        self.title = random_string()
+        self.results = []
+        self.disaggregations = {}
+
+    def with_title(self, title):
+        self.title = title
+        return self
+
+    def with_disaggregations(self, disaggregations):
+        self.disaggregations = disaggregations
+        return self
+
+    def with_results(self, results):
+        self.results = results
+        return self
+
+    def build(self):
+        project = BaseTestCase.create_project(self.title)
+        for params in self.results:
+            self._build_result(project, params)
+        self._build_disaggregations(project)
+
+        return ProjectFacade(project)
+
+    def _build_result(self, project, params):
+        indicators = params.get('indicators', [{'title': random_string()}])
+        kwargs = params.copy()
+        if 'indicators' in kwargs:
+            del kwargs['indicators']
+        kwargs['project'] = project
+        result = Result.objects.create(**kwargs)
+        for params in indicators:
+            self._build_indicator(result, params)
+
+    def _build_indicator(self, result, params):
+        periods = params.get('periods', [{'period_start': date.today(), 'period_end': date.today() + timedelta(days=30)}])
+        kwargs = params.copy()
+        if 'periods' in kwargs:
+            del kwargs['periods']
+        kwargs['result'] = result
+        indicator = Indicator.objects.create(**kwargs)
+        for params in periods:
+            self._build_period(indicator, params)
+
+    def _build_period(self, indicator, params):
+        kwargs = params.copy()
+        kwargs['indicator'] = indicator
+        IndicatorPeriod.objects.create(**kwargs)
+
+    def _build_disaggregations(self, project):
+        for key, types in self.disaggregations.items():
+            category = IndicatorDimensionName.objects.create(project=project, name=key)
+            for t in types:
+                IndicatorDimensionValue.objects.create(name=category, value=t)
+
+
+class ProjectFacade(object):
+    def __init__(self, project):
+        self.project = project
+
+    @property
+    def object(self):
+        return self.project
+
+    @property
+    def results(self):
+        return self.object.results
+
+    @property
+    def indicators(self):
+        return Indicator.objects.filter(result__project=self.project)
+
+    @property
+    def periods(self):
+        return IndicatorPeriod.objects.filter(indicator__result__project=self.project)
+
+    def get_disaggregation(self, category, type):
+        return IndicatorDimensionValue.objects.get(
+            name__project=self.project, name__name=category, value=type)
+
+    def get_period(self, **kwargs):
+        return PeriodFacade(self, self.periods.get(**kwargs))
+
+
+class PeriodFacade(object):
+    def __init__(self, project, period):
+        self.project = project
+        self.period = period
+
+    def add_update(self, user, value=None, numerator=None, denominator=None,
+                   disaggregations={}, status=IndicatorPeriodData.STATUS_APPROVED_CODE):
+        data = IndicatorPeriodData.objects.create(
+            period=self.period,
+            user=user,
+            value=value,
+            numerator=numerator,
+            denominator=denominator,
+            status=status
+        )
+        for category, types in disaggregations.items():
+            for type, vals in types.items():
+                dv = self.project.get_disaggregation(category, type)
+                Disaggregation.objects.create(
+                    update=data,
+                    dimension_value=dv,
+                    value=vals.get('value', None),
+                    numerator=vals.get('numerator', None),
+                    denominator=vals.get('denominator', None)
+                )
diff --git a/akvo/rsr/tests/views/py_reports/test_results_indicators.py b/akvo/rsr/tests/views/py_reports/test_results_indicators.py
new file mode 100644
--- /dev/null
+++ b/akvo/rsr/tests/views/py_reports/test_results_indicators.py
@@ -0,0 +1,273 @@
+# -*- coding: utf-8 -*-
+
+# Akvo RSR is covered by the GNU Affero General Public License.
+
+# See more details in the license.txt file located at the root folder of the Akvo RSR module.
+# For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
+
+from akvo.rsr.models.result.utils import QUANTITATIVE, PERCENTAGE_MEASURE
+from akvo.rsr.tests.base import BaseTestCase
+from akvo.rsr.tests.utils import ProjectFixtureBuilder
+from akvo.rsr.views.py_reports.results_indicators_with_map_pdf_reports import build_view_object
+from datetime import date
+
+
+class ResultsIndicatorsViewModelTestCase(BaseTestCase):
+    def test_view_object_structure(self):
+        project = ProjectFixtureBuilder()\
+            .with_title('Project #1')\
+            .with_results([
+                {
+                    'title': 'Result #1',
+                    'indicators': [
+                        {
+                            'title': 'Indicator #1',
+                            'periods': [
+                                {
+                                    'period_start': date(2010, 1, 1),
+                                    'period_end': date(2010, 12, 31)
+                                },
+                                {
+                                    'period_start': date(2011, 1, 1),
+                                    'period_end': date(2011, 12, 31)
+                                },
+                            ]
+                        }
+                    ]
+                },
+            ])\
+            .build()
+
+        vm = build_view_object(project.object)
+
+        self.assertEqual(vm.title, 'Project #1')
+        self.assertEqual(len(vm.results), 1)
+        result = vm.results[0]
+        self.assertEqual(result.title, 'Result #1')
+        self.assertEqual(len(result.indicators), 1)
+        indicator = result.indicators[0]
+        self.assertEqual(indicator.title, 'Indicator #1')
+        self.assertEqual(len(indicator.periods), 2)
+        period = indicator.periods[0]
+        self.assertEqual(period.period_start, date(2010, 1, 1))
+
+    def test_result_ordering(self):
+        project = ProjectFixtureBuilder()\
+            .with_results([
+                {'title': 'Result #2'},
+                {'title': 'Result #3'},
+                {'title': 'Result #1'},
+            ])\
+            .build()
+
+        project.results.filter(title='Result #1').update(order=1)
+        project.results.filter(title='Result #2').update(order=2)
+        project.results.filter(title='Result #3').update(order=3)
+
+        vm = build_view_object(project.object)
+
+        self.assertEqual(vm.results[0].title, 'Result #1')
+        self.assertEqual(vm.results[1].title, 'Result #2')
+        self.assertEqual(vm.results[2].title, 'Result #3')
+
+    def test_indicator_ordering(self):
+        project = ProjectFixtureBuilder()\
+            .with_results([
+                {
+                    'title': 'Result',
+                    'indicators': [
+                        {'title': 'Indicator #3'},
+                        {'title': 'Indicator #1'},
+                        {'title': 'Indicator #2'},
+                    ]
+                }
+            ])\
+            .build()
+
+        project.indicators.filter(title='Indicator #1').update(order=1)
+        project.indicators.filter(title='Indicator #2').update(order=2)
+        project.indicators.filter(title='Indicator #3').update(order=3)
+
+        vm = build_view_object(project.object)
+
+        self.assertEqual(vm.results[0].indicators[0].title, 'Indicator #1')
+        self.assertEqual(vm.results[0].indicators[1].title, 'Indicator #2')
+        self.assertEqual(vm.results[0].indicators[2].title, 'Indicator #3')
+
+    def test_indicator_disaggregations(self):
+        user = self.create_user('test@akvo.org', 'password', is_admin=True)
+        project = ProjectFixtureBuilder()\
+            .with_disaggregations({
+                'Gender': ['Male', 'Female'],
+                'Age': ['Children', 'Adults']
+            })\
+            .with_results([
+                {
+                    'title': 'Result #1',
+                    'indicators': [
+                        {
+                            'title': 'Indicator #1',
+                            'periods': [
+                                {
+                                    'period_start': date(2010, 1, 1),
+                                    'period_end': date(2010, 12, 31)
+                                },
+                                {
+                                    'period_start': date(2011, 1, 1),
+                                    'period_end': date(2011, 12, 31)
+                                },
+                            ]
+                        }
+                    ]
+                }
+            ])\
+            .build()
+        project.get_period(period_start=date(2010, 1, 1)).add_update(
+            user, value=10,
+            disaggregations={
+                'Gender': {
+                    'Male': {'value': 3},
+                    'Female': {'value': 7},
+                }
+            }
+        )
+        project.get_period(period_start=date(2011, 1, 1)).add_update(
+            user, value=5,
+            disaggregations={
+                'Gender': {
+                    'Male': {'value': 3},
+                    'Female': {'value': 2},
+                }
+            }
+        )
+
+        vm = build_view_object(project.object)
+        disaggregations = vm.results[0].indicators[0].disaggregations
+
+        self.assertEqual(disaggregations, {
+            'Gender': {
+                'Male': {'value': 6, 'numerator': 0, 'denominator': 0},
+                'Female': {'value': 9, 'numerator': 0, 'denominator': 0},
+            }
+        })
+
+    def test_percentage_indicator(self):
+        user = self.create_user('test@akvo.org', 'password', is_admin=True)
+        project = ProjectFixtureBuilder()\
+            .with_disaggregations({
+                'Gender': ['Male', 'Female'],
+                'Age': ['Children', 'Adults']
+            })\
+            .with_results([
+                {
+                    'title': 'Result #1',
+                    'indicators': [
+                        {
+                            'title': 'Indicator #1',
+                            'type': QUANTITATIVE,
+                            'measure': PERCENTAGE_MEASURE,
+                            'periods': [
+                                {
+                                    'period_start': date(2010, 1, 1),
+                                    'period_end': date(2010, 12, 31)
+                                },
+                                {
+                                    'period_start': date(2011, 1, 1),
+                                    'period_end': date(2011, 12, 31)
+                                },
+                            ]
+                        }
+                    ]
+                }
+            ])\
+            .build()
+        project.get_period(period_start=date(2010, 1, 1)).add_update(
+            user, numerator=10, denominator=10,
+            disaggregations={
+                'Gender': {
+                    'Male': {'numerator': 3, 'denominator': 10},
+                    'Female': {'numerator': 7, 'denominator': 10},
+                }
+            }
+        )
+        project.get_period(period_start=date(2011, 1, 1)).add_update(
+            user, numerator=5, denominator=10,
+            disaggregations={
+                'Gender': {
+                    'Male': {'numerator': 3, 'denominator': 10},
+                    'Female': {'numerator': 2, 'denominator': 10},
+                }
+            }
+        )
+
+        vm = build_view_object(project.object)
+        disaggregations = vm.results[0].indicators[0].disaggregations
+
+        self.assertEqual(disaggregations, {
+            'Gender': {
+                'Male': {'value': 30, 'numerator': 6, 'denominator': 20},
+                'Female': {'value': 45, 'numerator': 9, 'denominator': 20},
+            }
+        })
+
+    def test_date_filter(self):
+        project = ProjectFixtureBuilder()\
+            .with_title('Project #1')\
+            .with_results([
+                {
+                    'title': 'Result #1',
+                    'indicators': [
+                        {
+                            'title': 'Indicator #1',
+                            'periods': [
+                                {
+                                    'period_start': date(2010, 1, 1),
+                                    'period_end': date(2010, 12, 31)
+                                },
+                            ]
+                        }
+                    ]
+                },
+                {
+                    'title': 'Result #2',
+                    'indicators': [
+                        {
+                            'title': 'Indicator #2',
+                            'periods': [
+                                {
+                                    'period_start': date(2011, 1, 1),
+                                    'period_end': date(2011, 12, 31)
+                                },
+                                {
+                                    'period_start': date(2012, 1, 1),
+                                    'period_end': date(2012, 12, 31)
+                                },
+                            ]
+                        }
+                    ]
+                },
+                {
+                    'title': 'Result #3',
+                    'indicators': [
+                        {
+                            'title': 'Indicator #3',
+                            'periods': [
+                                {
+                                    'period_start': date(2012, 1, 1),
+                                    'period_end': date(2012, 12, 31)
+                                },
+                            ]
+                        }
+                    ]
+                },
+            ])\
+            .build()
+
+        vm = build_view_object(project.object, date(2010, 6, 1), date(2012, 6, 30))
+
+        self.assertEqual(len(vm.results), 1)
+        result = vm.results[0]
+        self.assertEqual(result.title, 'Result #2')
+        periods = result.indicators[0].periods
+        self.assertEqual(len(periods), 1)
+        self.assertEqual(periods[0].period_start, date(2011, 1, 1))