Beispiel #1
0
class IndicatorPeriodTargetLocation(models.Model):
    period = models.ForeignKey(IndicatorPeriod,
                               verbose_name=_(u'indicator period'),
                               related_name='target_locations')
    location = ValidXMLCharField(
        _(u'location'),
        blank=True,
        max_length=25,
        help_text=_(
            u'A location of the target of this indicator period. The location must be the '
            u'reference of an existing location of the current project.'))

    class Meta:
        app_label = 'rsr'
        verbose_name = _(u'indicator period target location')
        verbose_name_plural = _(u'indicator period target locations')

    def __unicode__(self):
        return self.location
Beispiel #2
0
class OrganisationBudget(OrganisationFinanceBasic):
    status = ValidXMLCharField(
        _(u'status'),
        max_length=1,
        blank=True,
        choices=codelist_choices(BUDGET_STATUS),
        help_text=_(
            u'The status explains whether the budget being reported is indicative or has '
            u'been formally committed.'))

    class Meta:
        app_label = 'rsr'
        abstract = True

    def iati_status(self):
        return codelist_value(BudgetStatus, self, 'status')

    def iati_status_unicode(self):
        return str(self.iati_status())
class IndicatorPeriodTargetLocation(models.Model):
    project_relation = 'results__indicators__periods__target_locations__in'

    period = models.ForeignKey(IndicatorPeriod,
                               on_delete=models.CASCADE,
                               verbose_name=_('indicator period'),
                               related_name='target_locations')
    location = ValidXMLCharField(
        _('location'), blank=True, max_length=25,
        help_text=_('A location of the target of this indicator period. The location must be the '
                    'reference of an existing location of the current project.'))

    class Meta:
        app_label = 'rsr'
        verbose_name = _('indicator period target location')
        verbose_name_plural = _('indicator period target locations')
        ordering = ('pk',)

    def __str__(self):
        return self.location
Beispiel #4
0
class Result(models.Model):
    project = models.ForeignKey('Project', verbose_name=_('project'), related_name='results')
    title = ValidXMLCharField(
        _('result title'), blank=True, max_length=500,
        help_text=_('The aim of the project in one sentence. This doesn’t need to be something '
                    'that can be directly counted, but it should describe an overall goal of the '
                    'project. There can be multiple results for one project.')
    )
    type = ValidXMLCharField(
        _('result type'), blank=True, max_length=1, choices=codelist_choices(RESULT_TYPE),
        help_text=_('Choose whether the result is an output, outcome or impact.<br/>'
                    '1 - Output: Direct result of the project activities. E.g. number of booklets '
                    'produced, workshops held, people trained, latrines build.<br/>'
                    '2 - Outcome: The changes or benefits that result from the program activities '
                    'and resulting outputs. E.g number of beneficiaries reached, knowledge '
                    'increased, capacity build, monitored behaviour change.<br/>'
                    '3 - Impact: Long-term results of program (on population) that can be '
                    'attributed to the project outputs and outcomes. E.g improved health, '
                    'increased political participation of women.<br/>'
                    '9 - Other: Another type of result, not specified above.')
    )
    aggregation_status = models.NullBooleanField(
        _('aggregation status'), blank=True,
        help_text=_('Indicate whether the data in the result set can be accumulated.')
    )
    description = ValidXMLCharField(
        _('result description'), blank=True, max_length=2000,
        help_text=_('You can provide further information of the result here.')
    )
    parent_result = models.ForeignKey('self', blank=True, null=True, default=None,
                                      help_text=_('The parent result of this result.'),
                                      related_name='child_results')
    order = models.PositiveSmallIntegerField(_('result order'), null=True, blank=True)

    def __str__(self):
        result_unicode = self.title if self.title else '%s' % _('No result title')

        if self.type:
            result_unicode += ' (' + self.iati_type().name + ')'

        if self.indicators.all():
            result_unicode += _(' - %s indicators') % (str(self.indicators.count()))

        return result_unicode

    def save(self, *args, **kwargs):
        """Update the values of child results, if a parent result is updated."""

        is_new_result = not self.pk

        for child_result in self.child_results.all():
            # Always copy title, type and aggregation status. They should be the same as the parent.
            child_result.title = self.title
            child_result.type = self.type
            child_result.aggregation_status = self.aggregation_status

            # Only copy the description if the child has none (e.g. new)
            if not child_result.description and self.description:
                child_result.description = self.description

            child_result.save()

        if is_new_result and Result.objects.filter(project_id=self.project.id).exists():
            prev_result = Result.objects.filter(project_id=self.project.id).reverse()[0]
            if prev_result.order:
                self.order = prev_result.order + 1

        super(Result, self).save(*args, **kwargs)

        if is_new_result:
            self.project.copy_result_to_children(self)

    def clean(self):
        validation_errors = {}

        if self.pk and self.parent_result:
            orig_result = Result.objects.get(pk=self.pk)

            # Don't allow some values to be changed when it is a child result
            if self.project != orig_result.project:
                validation_errors['project'] = '%s' % \
                    _('It is not possible to update the project of this result, '
                      'because it is linked to a parent result.')
            if self.title != orig_result.title:
                validation_errors['title'] = '%s' % \
                    _('It is not possible to update the title of this result, '
                      'because it is linked to a parent result.')
            if self.type != orig_result.type:
                validation_errors['type'] = '%s' % \
                    _('It is not possible to update the type of this result, '
                      'because it is linked to a parent result.')
            if self.aggregation_status != orig_result.aggregation_status:
                validation_errors['aggregation_status'] = '%s' % \
                    _('It is not possible to update the aggregation status of this result, '
                      'because it is linked to a parent result.')

        if validation_errors:
            raise ValidationError(validation_errors)

    def delete(self, *args, **kwargs):
        """
        Check if indicator is ordered manually, and cascade following indicators if needed
        """
        if self.order:
            sibling_results = Result.objects.filter(project_id=self.project.id)

            if not self == sibling_results.reverse()[0]:
                for ind in range(self.order + 1, len(sibling_results)):
                    sibling_results[ind].order -= 1
                    sibling_results[ind].save()

        super(Result, self).delete(*args, **kwargs)

    def iati_type(self):
        return codelist_value(ResultType, self, 'type')

    def iati_type_unicode(self):
        return str(self.iati_type())

    def has_info(self):
        if self.title or self.type or self.aggregation_status or self.description:
            return True
        return False

    def is_calculated(self):
        return self.project.is_impact_project

    def parent_project(self):
        """
        Return a dictionary of this result's parent project.
        """
        if self.parent_result:
            return {self.parent_result.project.id: self.parent_result.project.title}
        return {}

    def child_projects(self):
        """
        Return a dictionary of this result's child projects.
        """
        projects = {}
        for result in Result.objects.filter(parent_result=self).select_related('project'):
            projects[result.project.id] = result.project.title
        return projects

    class Meta:
        app_label = 'rsr'
        ordering = ['order', 'id']
        verbose_name = _('result')
        verbose_name_plural = _('results')
        unique_together = ('project', 'parent_result')
Beispiel #5
0
class IndicatorPeriod(models.Model):

    project_relation = 'results__indicators__periods__in'

    indicator = models.ForeignKey('Indicator',
                                  verbose_name=_('indicator'),
                                  related_name='periods')
    parent_period = models.ForeignKey(
        'self',
        blank=True,
        null=True,
        default=None,
        verbose_name=_('parent indicator period'),
        related_name='child_periods')
    locked = models.BooleanField(_('locked'), default=True, db_index=True)
    period_start = models.DateField(
        _('period start'),
        null=True,
        blank=True,
        help_text=_(
            'The start date of the reporting period for this indicator.'))
    period_end = models.DateField(
        _('period end'),
        null=True,
        blank=True,
        help_text=_(
            'The end date of the reporting period for this indicator.'))
    target_value = ValidXMLCharField(
        _('target value'),
        blank=True,
        max_length=50,
        help_text=_('The target value for the above period.'))
    target_comment = ValidXMLCharField(
        _('target value comment'),
        blank=True,
        max_length=2000,
        help_text=
        _('Here you can provide extra information on the target value, if needed.'
          ))
    actual_value = ValidXMLCharField(
        _('actual value'),
        blank=True,
        max_length=50,
        help_text=_('A record of the achieved result for this period.'))
    actual_comment = ValidXMLCharField(
        _('actual value comment'),
        blank=True,
        max_length=2000,
        help_text=
        _('Here you can provide extra information on the actual value, if needed '
          '(for instance, why the actual value differs from the target value).'
          ))
    numerator = models.DecimalField(
        _('numerator for indicator'),
        max_digits=20,
        decimal_places=2,
        null=True,
        blank=True,
        help_text=_('The numerator for a calculated percentage'))
    denominator = models.DecimalField(
        _('denominator for indicator'),
        max_digits=20,
        decimal_places=2,
        null=True,
        blank=True,
        help_text=_('The denominator for a calculated percentage'))
    narrative = ValidXMLTextField(_('qualitative indicator narrative'),
                                  blank=True)
    score_index = models.SmallIntegerField(_('score index'),
                                           null=True,
                                           blank=True)

    def __str__(self):
        if self.period_start:
            period_unicode = str(self.period_start)
        else:
            period_unicode = '%s' % _('No start date')

        if self.period_end:
            period_unicode += ' - %s' % str(self.period_end)
        else:
            period_unicode += ' - %s' % _('No end date')

        if self.actual_value or self.target_value:
            period_unicode += ' ('

            if self.actual_value and self.target_value:
                period_unicode += 'actual: %s / target: %s)' % (str(
                    self.actual_value), str(self.target_value))
            elif self.actual_value:
                period_unicode += 'actual: %s)' % str(self.actual_value)
            else:
                period_unicode += 'target: %s)' % str(self.target_value)

        return period_unicode

    def save(self, *args, **kwargs):
        actual_value_changed = False
        new_period = not self.pk

        if (self.indicator.measure == PERCENTAGE_MEASURE
                and self.numerator is not None
                and self.denominator not in {0, '0', None}):
            percentage = calculate_percentage(self.numerator, self.denominator)
            self.actual_value = str(percentage)

        if not new_period:
            # Check if the actual value has changed
            orig_period = IndicatorPeriod.objects.get(pk=self.pk)
            if orig_period.actual_value != self.actual_value:
                actual_value_changed = True

        super(IndicatorPeriod, self).save(*args, **kwargs)

        child_indicators = self.indicator.child_indicators.select_related(
            'result',
            'result__project',
        )

        for child_indicator in child_indicators.all():
            if new_period:
                child_indicator.result.project.copy_period(child_indicator,
                                                           self,
                                                           set_parent=True)
            else:
                child_indicator.result.project.update_period(
                    child_indicator, self)

        # If the actual value has changed, the period has a parent period and aggregations are on,
        # then the the parent should be updated as well
        if actual_value_changed and self.is_child_period() and \
                self.parent_period.indicator.result.project.aggregate_children and \
                self.indicator.result.project.aggregate_to_parent:
            self.parent_period.recalculate_period()

    def clean(self):
        validation_errors = {}

        if self.pk:
            orig_period = IndicatorPeriod.objects.get(pk=self.pk)

            # Don't allow an actual value to be changed when the indicator period is calculated
            if self.is_calculated(
            ) and self.actual_value != orig_period.actual_value:
                validation_errors['actual_value'] = '%s' % \
                    _('It is not possible to update the actual value of this indicator period, '
                      'because it is a calculated value. Please update the actual value through '
                      'a new update.')

            # Don't allow some values to be changed when it is a child period
            if self.is_child_period():
                if self.indicator != orig_period.indicator:
                    validation_errors['indicator'] = '%s' % \
                        _('It is not possible to update the indicator of this indicator period, '
                          'because it is linked to a parent result.')
                if self.period_start != orig_period.period_start:
                    validation_errors['period_start'] = '%s' % \
                        _('It is not possible to update the start period of this indicator, '
                          'because it is linked to a parent result.')
                if self.period_end != orig_period.period_end:
                    validation_errors['period_end'] = '%s' % \
                        _('It is not possible to update the end period of this indicator, '
                          'because it is linked to a parent result.')

        # Don't allow a start date before an end date
        if self.period_start and self.period_end and (self.period_start >
                                                      self.period_end):
            validation_errors['period_start'] = '%s' % _(
                'Period start cannot be at a later time '
                'than period end.')
            validation_errors['period_end'] = '%s' % _(
                'Period start cannot be at a later time '
                'than period end.')

        # TODO: add validation that prevents creating a period for a child indicator
        if validation_errors:
            raise ValidationError(validation_errors)

    def recalculate_period(self, save=True, only_self=False):
        """
        Re-calculate the values of all updates from the start. This will prevent strange values,
        for example when an update is deleted or edited after it has been approved.

        :param save; Boolean, saves actual value to period if True
        :param only_self; Boolean, to take into account if this is a parent or just re-calculate
        this period only
        :return Actual value of period
        """

        # If this period is a parent period, the sum or average of the children
        # should be re-calculated
        if not only_self and self.is_parent_period() and \
                self.indicator.result.project.aggregate_children:
            return self.recalculate_children(save)

        prev_val = '0'
        if self.indicator.measure == PERCENTAGE_MEASURE:
            prev_num = '0'
            prev_den = '0'

        # For every approved update, add up the new value (if possible)
        for update in self.data.filter(status='A').order_by('created_at'):
            if self.indicator.measure == PERCENTAGE_MEASURE:
                update.period_numerator = prev_num
                update.period_denominator = prev_den
            update.period_actual_value = prev_val
            update.save(recalculate=False)

            if update.value is None:
                continue

            try:
                # Try to add up the update to the previous actual value
                if self.indicator.measure == PERCENTAGE_MEASURE:
                    prev_num = str(
                        Decimal(prev_num) + Decimal(update.numerator))
                    prev_den = str(
                        Decimal(prev_den) + Decimal(update.denominator))
                    prev_val = str(
                        calculate_percentage(float(prev_num), float(prev_den)))
                else:
                    prev_val = str(Decimal(prev_val) + Decimal(update.value))
            except (InvalidOperation, TypeError):
                # If not possible, the update data or previous value is a normal string
                if self.indicator.measure == PERCENTAGE_MEASURE:
                    prev_num = update.numerator
                    prev_den = update.denominator
                prev_val = update.value

        # For every non-approved update, set the value to the current value
        for update in self.data.exclude(status='A'):
            update.period_actual_value = prev_val
            if self.indicator.measure == PERCENTAGE_MEASURE:
                update.period_numerator = prev_num
                update.period_denominator = prev_den
            update.save(recalculate=False)

        # Special case: only_self and no data should give an empty string instead of '0'
        if only_self and not self.data.exists():
            prev_val = ''
            # FIXME: Do we need a special case here with numerator and denominator???

        # Finally, update the actual value of the period itself
        if save:
            self.actual_value = prev_val
            if self.indicator.measure == PERCENTAGE_MEASURE:
                self.numerator = prev_num
                self.denominator = prev_den
            self.save()

        # Return the actual value of the period itself
        return prev_val

    def recalculate_children(self, save=True):
        """
        Re-calculate the actual value of this period based on the actual values of the child
        periods.

        In case the measurement is 'Percentage', it should be an average of all child periods.
        Otherwise, the child period values can just be added up.

        :param save; Boolean, saves to period if True
        :return Actual value of period
        """
        if self.indicator.measure == PERCENTAGE_MEASURE:
            numerator, denominator = self.child_periods_percentage()
            new_value = calculate_percentage(numerator, denominator)
        else:
            new_value = self.child_periods_sum(include_self=True)

        if save:
            self.actual_value = new_value
            if self.indicator.measure == PERCENTAGE_MEASURE:
                self.numerator = numerator
                self.denominator = denominator
            self.save()

        return new_value

    def update_actual_comment(self, save=True):
        """
        Set the actual comment to the text of the latest approved update.

        :param save; Boolean, save period if True
        :return Actual comment of period
        """
        update_texts = [
            '{}: {}'.format(update.last_modified_at.strftime('%d-%m-%Y'),
                            update.text)
            for update in self.approved_updates.order_by('-created_at')
            if update.text.strip()
        ]
        actual_comment = ' | '.join(update_texts)
        if len(actual_comment) >= 2000:  # max_size
            actual_comment = '{} ...'.format(actual_comment[:1995])

        self.actual_comment = actual_comment
        if save:
            self.save()

        return self.actual_comment

    def update_score(self, save=True):
        """Set the score of the period to the score of the latest approved update."""

        if self.indicator.type != QUALITATIVE or not self.indicator.scores:
            return

        latest_update = self.approved_updates.order_by('-created_at').first()
        score_index = latest_update.score_index if latest_update is not None else None
        score_changed = self.score_index != score_index
        self.score_index = score_index

        if score_changed and save:
            self.save(update_fields=['score_index'])

    def is_calculated(self):
        """
        When a period has got indicator updates, we consider the actual value to be a
        'calculated' value, meaning that it's not possible to update the actual value directly.
        Only through indicator updates.
        """
        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 period is linked to a parent period
        """
        return bool(self.parent_period)

    def is_parent_period(self):
        """
        Indicates whether this result has child periods linked to it.
        """
        return self.child_periods.count() > 0

    def can_save_update(self, update_id=None):
        """Return True if an update can be created/updated on the indicator period.

        If no update_id is passed, we check if a new update can be created. If
        an update_id is passed, we verify that the update can be modified.

        Non percentage indicators can have multiple updates. If the indicator
        is a percentage indicator, we check that no other update is present,
        other than the one currently being created or changed.

        """
        return (self.indicator.measure != PERCENTAGE_MEASURE
                or self.data.exclude(id=update_id).count() == 0)

    def child_periods_with_data(self, only_aggregated=False):
        """
        Returns the child indicator periods with numeric values
        """
        children_with_data = []
        for child in self.child_periods.all():
            try:
                Decimal(child.actual_value)
                children_with_data += [child.pk]
            except (InvalidOperation, TypeError):
                pass
        child_periods = self.child_periods.filter(pk__in=children_with_data)
        if only_aggregated:
            child_periods = child_periods.filter(
                indicator__result__project__aggregate_to_parent=True)
        return child_periods

    # TODO: refactor child_periods_sum() and child_periods_with_data(),
    # they use each other in very inefficient ways I think
    def child_periods_sum(self, include_self=False):
        """
        Returns the sum of child indicator periods.

        :param include_self; Boolean to include the updates on the period itself, as well as its'
        children
        :return String of the sum
        """
        period_sum = 0

        # Loop through the child periods and sum up all the values
        for period in self.child_periods_with_data(only_aggregated=True):
            try:
                period_sum += Decimal(period.actual_value)
            except (InvalidOperation, TypeError):
                pass

        if include_self:
            try:
                period_sum += Decimal(
                    self.recalculate_period(save=False, only_self=True))
            except (InvalidOperation, TypeError):
                pass

        return str(period_sum)

    def child_periods_percentage(self):
        """Returns percentage calculated from the child periods.

        :return String of numerator and denominator

        """
        period_numerator = 0
        period_denominator = 0
        for period in self.child_periods_with_data(only_aggregated=True):
            try:
                period_numerator += Decimal(period.numerator)
                period_denominator += Decimal(period.denominator)
            except (InvalidOperation, TypeError):
                pass
        return str(period_numerator), str(period_denominator)

    def adjacent_period(self, next_period=True):
        """
        Returns the next or previous indicator period, if we can find one with a start date,
        and we have a start date ourselves.

        :param next_period; Boolean indicating either the next (True) or previous (False) period.
        """
        if not self.period_start:
            return None
        elif next_period:
            return self.indicator.periods.exclude(period_start=None).filter(
                period_start__gt=self.period_start).order_by(
                    'period_start').first()
        else:
            return self.indicator.periods.exclude(period_start=None).filter(
                period_start__lt=self.period_start).order_by(
                    '-period_start').first()

    @property
    def percent_accomplishment(self):
        """
        Return the percentage completed for this indicator period. If not possible to convert the
        values to numbers, return None.
        """
        try:
            return round(
                Decimal(self.actual_value) / Decimal(self.target_value) * 100,
                1)
        except (InvalidOperation, TypeError, DivisionByZero):
            return None

    @property
    def percent_accomplishment_100(self):
        """
        Similar to the percent_accomplishment property. However, it won't return any number bigger
        than 100.
        """
        return max(self.percent_accomplishment,
                   100) if self.percent_accomplishment else None

    @property
    def approved_updates(self):
        return self.data.filter(
            status=IndicatorPeriodData.STATUS_APPROVED_CODE)

    class Meta:
        app_label = 'rsr'
        verbose_name = _('indicator period')
        verbose_name_plural = _('indicator periods')
        ordering = ['period_start', 'period_end']
        unique_together = ('indicator', 'parent_period')
Beispiel #6
0
class OrganisationFinanceBasic(models.Model):
    currency = ValidXMLCharField(_('currency'),
                                 max_length=3,
                                 blank=True,
                                 choices=codelist_choices(CURRENCY))
    value = models.DecimalField(
        _('value'),
        max_digits=20,
        decimal_places=2,
        null=True,
        blank=True,
        help_text=_(
            'Enter the amount of budget that is set aside for this specific budget. '
            'Use a period to denote decimals.'))
    value_date = models.DateField(
        _('value date'),
        null=True,
        blank=True,
        help_text=_(
            'Enter the date (DD/MM/YYYY) to be used for determining the exchange rate for '
            'currency conversions.'))
    period_start = models.DateField(
        _('period start'),
        null=True,
        blank=True,
        help_text=_(
            'Enter the start date (DD/MM/YYYY) for the budget period.'))
    period_end = models.DateField(
        _('period end'),
        null=True,
        blank=True,
        help_text=_('Enter the end date (DD/MM/YYYY) for the budget period.'))

    class Meta:
        app_label = 'rsr'
        abstract = True

    def __str__(self):
        if self.value and self.currency:
            return '%s %s' % (self.currency, '{:,}'.format(int(self.value)))
        else:
            return '%s' % _('No currency or value specified')

    def clean(self):
        # Don't allow a start date before an end date
        if self.period_start and self.period_end and (self.period_start >
                                                      self.period_end):
            raise ValidationError({
                'period_start':
                '%s' % _('Period start cannot be at a later time than period '
                         'end.'),
                'period_end':
                '%s' % _('Period start cannot be at a later time than period '
                         'end.')
            })

    def iati_currency(self):
        return codelist_value(Currency, self, 'currency')

    def iati_currency_unicode(self):
        return str(self.iati_currency())
Beispiel #7
0
class HumanitarianScope(models.Model):
    project = models.ForeignKey('Project',
                                verbose_name=_(u'project'),
                                related_name='humanitarian_scopes')
    code = ValidXMLCharField(
        _(u'humanitarian scope code'),
        blank=True,
        max_length=25,
        help_text=_(
            u'A code for the event or action from the vocabulary specified. More '
            u'information on the vocabularies can be found here: '
            u'<a href="http://glidenumber.net/glide/public/search/search.jsp" '
            u'target="_blank">Glide</a> and '
            u'<a href="http://fts.unocha.org/docs/IATICodelist_HS2-1.csv" '
            u'target="_blank">Humanitarian plan</a>.'))
    type = ValidXMLCharField(
        _(u'humanitarian scope type'),
        blank=True,
        max_length=1,
        choices=codelist_choices(HUMANITARIAN_SCOPE_TYPE),
        help_text=_(
            u'The type of event or action being classified. See the '
            u'<a href="http://iatistandard.org/202/codelists/HumanitarianScopeType/" '
            u'target="_blank">IATI codelist</a>.'))
    vocabulary = ValidXMLCharField(
        _(u'humanitarian scope vocabulary'),
        blank=True,
        max_length=3,
        choices=codelist_choices(HUMANITARIAN_SCOPE_VOCABULARY),
        help_text=
        _(u'A recognised vocabulary of terms classifying the event or action. See the '
          u'<a href="http://iatistandard.org/202/codelists/HumanitarianScopeVocabulary/" '
          u'target="_blank">IATI codelist</a>.'))
    vocabulary_uri = ValidXMLCharField(
        _(u'humanitarian scope vocabulary URI'),
        blank=True,
        max_length=1000,
        help_text=_(
            u'If the vocabulary is 99 (reporting organisation), the URI where this '
            u'internal vocabulary is defined.'))
    text = ValidXMLCharField(_(u'humanitarian scope description'),
                             blank=True,
                             max_length=1000,
                             help_text=_(u'Optionally enter a description.'))

    class Meta:
        app_label = 'rsr'
        verbose_name = _(u'humanitarian scope')
        verbose_name_plural = _(u'humanitarian scopes')

    def __unicode__(self):
        if self.text:
            return self.text
        elif self.code:
            return self.code
        else:
            return ''

    def iati_type(self):
        return codelist_value(HumanitarianScopeType, self, 'type')

    def iati_type_unicode(self):
        return str(self.iati_type())

    def iati_vocabulary(self):
        return codelist_value(HumanitarianScopeVocabulary, self, 'vocabulary')

    def iati_vocabulary_unicode(self):
        return str(self.iati_vocabulary())
class IndicatorPeriodData(TimestampsMixin, IndicatorUpdateMixin, models.Model):
    """
    Model for adding data to an indicator period.
    """

    project_relation = 'results__indicators__periods__data__in'

    STATUS_DRAFT = str(_('draft'))
    STATUS_PENDING = str(_('pending approval'))
    STATUS_REVISION = str(_('return for revision'))
    STATUS_APPROVED = str(_('approved'))

    STATUS_DRAFT_CODE = 'D'
    STATUS_PENDING_CODE = 'P'
    STATUS_REVISION_CODE = 'R'
    STATUS_APPROVED_CODE = 'A'

    STATUS_CODES_LIST = [
        STATUS_DRAFT_CODE, STATUS_PENDING_CODE, STATUS_REVISION_CODE,
        STATUS_APPROVED_CODE
    ]
    STATUSES_LABELS_LIST = [
        STATUS_DRAFT, STATUS_PENDING, STATUS_REVISION, STATUS_APPROVED
    ]
    STATUSES = list(zip(STATUS_CODES_LIST, STATUSES_LABELS_LIST))

    UPDATE_METHODS = (
        ('W', _('web')),
        ('M', _('mobile')),
    )

    period = models.ForeignKey('IndicatorPeriod',
                               verbose_name=_('indicator period'),
                               related_name='data',
                               on_delete=models.PROTECT)
    # TODO: rename to created_by when old results framework page is no longer in use
    user = models.ForeignKey(settings.AUTH_USER_MODEL,
                             on_delete=models.CASCADE,
                             verbose_name=_('user'),
                             db_index=True,
                             related_name='created_period_updates')
    approved_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        verbose_name=_('approved by'),
        db_index=True,
        related_name='approved_period_updates',
        blank=True,
        null=True,
    )
    narrative = ValidXMLTextField(_('qualitative indicator narrative'),
                                  blank=True)
    score_index = models.SmallIntegerField(_('score index'),
                                           null=True,
                                           blank=True)
    score_indices = ArrayField(models.SmallIntegerField(),
                               default=list,
                               blank=True)
    period_actual_value = ValidXMLCharField(_('period actual value'),
                                            max_length=50,
                                            default='')
    status = ValidXMLCharField(_('status'),
                               max_length=1,
                               choices=STATUSES,
                               db_index=True,
                               default=STATUS_DRAFT_CODE)
    text = ValidXMLTextField(_('text'), blank=True)
    review_note = ValidXMLTextField(_('text'), blank=True)
    photo = ImageField(_('photo'),
                       blank=True,
                       upload_to=image_path,
                       max_length=255)
    file = models.FileField(_('file'),
                            blank=True,
                            upload_to=file_path,
                            max_length=255)
    update_method = ValidXMLCharField(_('update method'),
                                      blank=True,
                                      max_length=1,
                                      choices=UPDATE_METHODS,
                                      db_index=True,
                                      default='W')

    class Meta:
        app_label = 'rsr'
        verbose_name = _('indicator period data')
        verbose_name_plural = _('indicator period data')
        ordering = ('-id', )

    def save(self, recalculate=True, *args, **kwargs):
        # Allow only a single update for percentage measure indicators
        if not self.period.can_save_update(self.id):
            raise MultipleUpdateError(
                'Cannot create multiple updates with percentages')

        if (self.period.indicator.measure == PERCENTAGE_MEASURE
                and self.numerator is not None
                and self.denominator not in {0, '0', None}):
            self.value = calculate_percentage(self.numerator, self.denominator)

        super(IndicatorPeriodData, self).save(*args, **kwargs)

        # In case the status is approved, recalculate the period
        if recalculate and self.status == self.STATUS_APPROVED_CODE:
            # FIXME: Should we call this even when status is not approved?
            self.period.recalculate_period()
            self.period.update_actual_comment()
        # Update score even when the update is not approved, yet. It handles the
        # case where an approved update is returned for revision, etc.
        self.period.update_score()

    def delete(self, *args, **kwargs):
        old_status = self.status

        super(IndicatorPeriodData, self).delete(*args, **kwargs)

        # In case the status was approved, recalculate the period
        if old_status == self.STATUS_APPROVED_CODE:
            self.period.recalculate_period()
            self.period.update_actual_comment()
            self.period.update_score()

    def clean(self):
        """
        Perform several checks before we can actually save the update data.
        """
        validation_errors = {}

        project = self.period.indicator.result.project

        # Don't allow a data update to an unpublished project
        if not project.is_published():
            validation_errors['period'] = str(
                _('Indicator period must be part of a published '
                  'project to add data to it'))
            raise ValidationError(validation_errors)

        # Don't allow a data update to a non-Impact project
        if not project.is_impact_project:
            validation_errors['period'] = str(
                _('Indicator period must be part of an RSR '
                  'Impact project to add data to it'))
            raise ValidationError(validation_errors)

        # Don't allow a data update to a locked period
        if self.period.locked:
            validation_errors['period'] = str(
                _('Indicator period must be unlocked to add '
                  'data to it'))
            raise ValidationError(validation_errors)

        # Don't allow a data update to an aggregated parent period with 'percentage' as measurement
        if self.period.indicator.children_aggregate_percentage:
            validation_errors['period'] = str(
                _('Indicator period has an average aggregate of the child projects. Disable '
                  'aggregations to add data to it'))
            raise ValidationError(validation_errors)

        if self.pk:
            orig = IndicatorPeriodData.objects.get(pk=self.pk)

            # Don't allow for the indicator period to change
            if orig.period != self.period:
                validation_errors['period'] = str(
                    _('Not allowed to change indicator period '
                      'in a data update'))

        if self.period.indicator.type == QUANTITATIVE:
            if self.narrative is not None:
                validation_errors['period'] = str(
                    _('Narrative field should be empty in quantitative indicators'
                      ))
            if self.value is not None:
                try:
                    self.value = Decimal(self.value)
                except Exception:
                    validation_errors['period'] = str(
                        _('Only numeric values are allowed in quantitative indicators'
                          ))

        if self.period.indicator.type == QUALITATIVE:
            if self.value is not None:
                validation_errors['period'] = str(
                    _('Value field should be empty in qualitative indicators'))

        if validation_errors:
            raise ValidationError(validation_errors)

    @property
    def status_display(self):
        """
        Returns the display of the status.
        """
        try:
            return dict(self.STATUSES)[self.status].capitalize()
        except KeyError:
            return ''

    @property
    def photo_url(self):
        """
        Returns the full URL of the photo.
        """
        return self.photo.url if self.photo else ''

    @property
    def file_url(self):
        """
        Returns the full URL of the file.
        """
        return self.file.url if self.file else ''

    def update_new_value(self):
        """Returns a string with the new value."""
        try:
            add_up = Decimal(self.value) + Decimal(self.period_actual_value)
            relative = '+' + str(self.value) if self.value >= 0 else str(
                self.value)
            return "{} ({})".format(str(add_up), relative)
        except (InvalidOperation, TypeError):
            return self.value

    @classmethod
    def get_user_viewable_updates(cls, queryset, user):
        approved_updates = queryset.filter(status=cls.STATUS_APPROVED_CODE)

        if user.is_anonymous:
            f_queryset = approved_updates

        elif user.is_admin or user.is_superuser:
            f_queryset = queryset

        else:
            from akvo.rsr.models import Project
            projects = Project.objects\
                              .filter(results__indicators__periods__data__in=queryset)\
                              .distinct()
            project = projects.first() if projects.count() == 1 else None

            # Allow Nuffic users to see all updates, irrespective of what state they are in
            if project is not None and project.in_nuffic_hierarchy(
            ) and user.has_perm('rsr.view_project', project):
                f_queryset = queryset

            else:
                own_updates = queryset.filter(user=user)
                non_draft_updates = queryset.exclude(
                    status=cls.STATUS_DRAFT_CODE)
                filter_ = user.get_permission_filter(
                    'rsr.view_indicatorperioddata',
                    'period__indicator__result__project__')
                others_updates = non_draft_updates.filter(filter_)
                f_queryset = (approved_updates | own_updates | others_updates)

        return f_queryset.distinct()
Beispiel #9
0
class IatiExport(TimestampsMixin, models.Model):
    STATUS_PENDING = 1
    STATUS_IN_PROGRESS = 2
    STATUS_COMPLETED = 3
    STATUS_CANCELLED = 4
    STATUS_CODE = {
        STATUS_PENDING: _('pending'),
        STATUS_IN_PROGRESS: _('in progress'),
        STATUS_COMPLETED: _('completed'),
        STATUS_CANCELLED: _('cancelled')
    }

    reporting_organisation = models.ForeignKey(
        'Organisation', verbose_name=_('reporting organisation'), related_name='iati_exports'
    )
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL, verbose_name=_('user'), related_name='iati_exports'
    )
    projects = models.ManyToManyField('Project', verbose_name=_('projects'))
    version = ValidXMLCharField(_('version'), max_length=4, default='2.03')
    status = models.PositiveSmallIntegerField(_('status'), default=STATUS_PENDING)
    iati_file = models.FileField(_('IATI file'), blank=True, upload_to=file_path)
    latest = models.BooleanField(_('latest'), default=False)

    class Meta:
        app_label = 'rsr'
        verbose_name = _('IATI export')
        verbose_name_plural = _('IATI exports')
        ordering = ('id',)

    def __str__(self):
        if self.reporting_organisation and self.reporting_organisation.name:
            return '%s %s' % (_('IATI export for'), self.reporting_organisation.name)
        else:
            return '%s' % _('IATI export for unknown organisation')

    @property
    def is_latest(self):
        return self.latest

    @is_latest.setter
    def is_latest(self, latest):
        if not latest:
            self.latest = False
            self.save(update_fields=['latest'])
            return

        with transaction.atomic():
            # Set all other exports to not be latest
            latest_exports = IatiExport.objects.filter(
                latest=True, reporting_organisation=self.reporting_organisation)
            latest_exports.update(latest=False)
            # Set current export to be latest
            self.latest = True
            self.save(update_fields=['latest'])

    def show_status(self):
        if self.status not in self.STATUS_CODE:
            return _('unknown status')
        else:
            return self.STATUS_CODE[self.status].title()

    def update_status(self, status_code):
        """
        Update the status of this IATI export.

        :param status_code; Integer in self.STATUS_CODE keys
        """
        self.status = status_code
        self.save(update_fields=['status'])

    def update_iati_file(self, iati_file):
        """
        Update the IATI file of this IATI export.

        :param iati_file; File object
        """
        self.iati_file = iati_file
        self.save(update_fields=['iati_file'])

    def create_iati_file(self):
        """
        Create an IATI XML file.
        """
        self.update_status(self.STATUS_IN_PROGRESS)

        # Retrieve all projects
        projects = self.projects.all()
        if projects:
            try:
                # Generate and save the IATI file
                iati_xml = IatiXML(projects, self.version, self)
                self.update_iati_file(iati_xml.save_file(
                    str(self.reporting_organisation.pk),
                    datetime.utcnow().strftime("%Y%m%d-%H%M%S") + '.xml')
                )

                self.update_status(self.STATUS_COMPLETED)
                self.is_latest = True
            except Exception:
                self.update_status(self.STATUS_CANCELLED)
        else:
            self.update_status(self.STATUS_CANCELLED)

    def processed_projects(self):
        """
        Find the number of processed projects of this IATI export. Generally, for completed
        exports, this number will be the same as the number of total projects.
        """
        return self.iati_activity_exports.filter(status=self.STATUS_IN_PROGRESS).count()
Beispiel #10
0
class Indicator(models.Model):
    project_relation = 'results__indicators__in'

    INDICATOR_TYPES = (
        (QUANTITATIVE, _('Quantitative')),
        (QUALITATIVE, _('Qualitative')),
    )

    result = models.ForeignKey('Result', verbose_name=_('result'), related_name='indicators')
    parent_indicator = models.ForeignKey(
        'self', blank=True, null=True, default=None,
        verbose_name=_('parent indicator'), related_name='child_indicators'
    )
    title = ValidXMLCharField(
        _('indicator title'), blank=True, max_length=500,
        help_text=_('Within each result indicators can be defined. Indicators should be items '
                    'that can be counted and evaluated as the project continues and is completed.')
    )
    # NOTE: type and measure should probably only be one field measure, wit the values Unit,
    # Percentage and Qualitative. However since the project editor design splits the choice we use
    # two fields, type and measure to simplify the interaction between front and back end.
    type = models.PositiveSmallIntegerField(
        _('indicator type'), choices=INDICATOR_TYPES, default=QUANTITATIVE
    )
    measure = ValidXMLCharField(
        _('indicator measure'), blank=True, max_length=1,
        choices=codelist_choices(INDICATOR_MEASURE),
        help_text=_('Choose how the indicator will be measured (in percentage or units).')
    )
    ascending = models.NullBooleanField(
        _('ascending'), blank=True,
        help_text=_('Choose ascending if the target value of the indicator is higher than the '
                    'baseline value (eg. people with access to sanitation). Choose descending if '
                    'the target value of the indicator is lower than the baseline value '
                    '(eg. people with diarrhea).'))
    description = ValidXMLCharField(
        _('indicator description'), blank=True, max_length=2000,
        help_text=_('You can provide further information of the indicator here.')
    )
    baseline_year = models.PositiveIntegerField(
        _('baseline year'), blank=True, null=True,
        help_text=_('The year the baseline value was taken.')
    )
    baseline_value = ValidXMLCharField(
        _('baseline value'), blank=True, max_length=200,
        help_text=_('The value of the baseline at the start of the project.')
    )
    baseline_comment = ValidXMLCharField(
        _('baseline comment'), blank=True, max_length=2000,
        help_text=_('Here you can provide extra information on the baseline value, if needed.')
    )
    target_value = models.DecimalField(
        _('target value'), max_digits=20, decimal_places=2, null=True, blank=True,
        help_text=_('The target value for all reporting periods in this indicator.')
    )
    target_comment = ValidXMLCharField(
        _('target value comment'), blank=True, max_length=2000,
        help_text=_('Here you can provide extra information on the target value, if needed.')
    )
    order = models.PositiveSmallIntegerField(_('indicator order'), null=True, blank=True)
    export_to_iati = models.BooleanField(
        _('Include indicator in IATI exports'), default=True,
        help_text=_('Choose whether this indicator will be included in IATI exports. '
                    'If you are not exporting to IATI, you may ignore this option.')
    )
    dimension_names = models.ManyToManyField('IndicatorDimensionName', related_name='indicators')
    scores = ArrayField(models.CharField(max_length=1000), default=[])

    def __str__(self):
        indicator_unicode = self.title if self.title else '%s' % _('No indicator title')

        if self.periods.all():
            indicator_unicode += ' - %s %s' % (str(self.periods.count()),
                                               _('period(s)'))

        indicator_unicode += ' - %s' % dict(self.INDICATOR_TYPES)[self.type]

        return indicator_unicode

    def save(self, *args, **kwargs):
        """Update the values of child indicators, if a parent indicator is updated."""

        new_indicator = not self.pk

        if new_indicator and Indicator.objects.filter(result_id=self.result.id).exists():
            prev_indicator = Indicator.objects.filter(result_id=self.result.id).reverse()[0]
            if prev_indicator.order:
                self.order = prev_indicator.order + 1

        # HACK: Delete IndicatorLabels on non-qualitative indicators
        if new_indicator and self.type != QUALITATIVE:
            IndicatorLabel.objects.filter(indicator=self).delete()

        super(Indicator, self).save(*args, **kwargs)

        for child_result in self.result.child_results.all():
            if new_indicator:
                child_result.project.copy_indicator(child_result, self, set_parent=True)
            else:
                child_result.project.update_indicator(child_result, self)

    def clean(self):
        validation_errors = {}

        if self.pk and self.is_child_indicator():
            orig_indicator = Indicator.objects.get(pk=self.pk)

            # Don't allow some values to be changed when it is a child indicator
            if self.result != orig_indicator.result:
                validation_errors['result'] = '%s' % \
                    _('It is not possible to update the result of this indicator, '
                      'because it is linked to a parent result.')
            if self.title != orig_indicator.title:
                validation_errors['title'] = '%s' % \
                    _('It is not possible to update the title of this indicator, '
                      'because it is linked to a parent result.')
            if self.measure != orig_indicator.measure:
                validation_errors['measure'] = '%s' % \
                    _('It is not possible to update the measure of this indicator, '
                      'because it is linked to a parent result.')
            if self.ascending != orig_indicator.ascending:
                validation_errors['ascending'] = '%s' % \
                    _('It is not possible to update the ascending value of this indicator, '
                      'because it is linked to a parent result.')

        if validation_errors:
            raise ValidationError(validation_errors)

    def delete(self, *args, **kwargs):
        """
        Check if indicator is ordered manually, and cascade following indicators if needed
        """
        if self.order:
            sibling_indicators = Indicator.objects.filter(result_id=self.result.id)

            if not self == sibling_indicators.reverse()[0]:
                for ind in range(self.order + 1, len(sibling_indicators)):
                    sibling_indicators[ind].order -= 1
                    sibling_indicators[ind].save()

        super(Indicator, self).delete(*args, **kwargs)

    def iati_measure(self):
        return codelist_value(IndicatorMeasure, self, 'measure')

    def iati_measure_unicode(self):
        return str(self.iati_measure())

    def is_calculated(self):
        return self.result.project.is_impact_project

    def is_child_indicator(self):
        """
        Indicates whether this indicator is linked to a parent indicator.
        """
        return bool(self.parent_indicator)

    def is_parent_indicator(self):
        """
        Indicates whether this indicator has children.
        """
        return self.child_indicators.count() > 0

    @property
    def children_aggregate_percentage(self):
        """
        Returns True if this indicator has percentage as a measure and has children that aggregate
        to this indicator.
        """
        if self.measure == PERCENTAGE_MEASURE and self.is_parent_indicator() and \
                self.result.project.aggregate_children and \
                any(self.child_indicators.values_list('result__project__aggregate_to_parent', flat=True)):
            return True
        return False

    class Meta:
        app_label = 'rsr'
        ordering = ['order', 'id']
        verbose_name = _('indicator')
        verbose_name_plural = _('indicators')
        unique_together = ('result', 'parent_indicator')
Beispiel #11
0
class IatiImportLog(models.Model):
    """
    IatiImportLog log the progress of an import.

    Fields:
    iati_import_job: FK to the job we are logging for
    project: the project the log is for, if applicable
    iati_activity_import: FK to the IatiActivityImport, if applicable
    message_type: logging is used both for "high" and "low" levels of logging, this is indicated by
                  the MESSAGE_TYPE_CODES
    tag: the IATI XML tag the log entry refers to, if applicable
    model: the model the log entry refers to, if applicable
    field: the model field the log entry refers to, if applicable
    text: log entry free text
    created_at: timestamp field
    """
    MESSAGE_TYPE_CODES = list(zip(LOG_ENTRY_TYPE, MESSAGE_TYPE_LABELS))

    iati_import_job = models.ForeignKey('IatiImportJob',
                                        on_delete=models.CASCADE,
                                        verbose_name=_('iati import'),
                                        related_name='iati_import_logs')
    project = models.ForeignKey('Project',
                                verbose_name=_('project'),
                                related_name='iati_project_import_logs',
                                blank=True,
                                null=True,
                                on_delete=models.SET_NULL)
    iati_activity_import = models.ForeignKey(
        'IatiActivityImport',
        on_delete=models.SET_NULL,
        verbose_name=_('activity'),
        blank=True,
        null=True,
    )
    message_type = models.PositiveSmallIntegerField(
        verbose_name=_('type of message'),
        choices=MESSAGE_TYPE_CODES,
        default=LOG_ENTRY_TYPE.CRITICAL_ERROR)
    tag = ValidXMLCharField(
        _('xml tag'),
        max_length=100,
        default='',
    )
    model = ValidXMLCharField(
        _('model'),
        max_length=255,
        default='',
    )
    field = ValidXMLCharField(
        _('field'),
        max_length=100,
        default='',
    )
    text = ValidXMLTextField(_('text'))
    created_at = models.DateTimeField(db_index=True, editable=False)

    def __str__(self):
        return '{} (ID: {}): {}'.format(self.iati_import_job.iati_import.label,
                                        self.iati_import_job.iati_import.pk,
                                        self.text)
        # return u'Iati Import Log ID: {}'.format(self.pk)

    class Meta:
        app_label = 'rsr'
        verbose_name = _('IATI import log')
        verbose_name_plural = _('IATI import logs')
        ordering = ('created_at', )

    def model_field(self):
        "Concatenate name of the model and the field. Used in the admin list display"

        def get_model_name(model_string):
            name = model_string.split('.')[-1][:-2]
            if not name:
                return model_string
            return name

        if self.model:
            model_name = get_model_name(self.model)
            if self.field:
                return '{}.{}'.format(model_name, self.field)
            else:
                return model_name
        else:
            return self.field

    def show_message_type(self):
        return dict([x for x in self.MESSAGE_TYPE_CODES])[self.message_type]

    def activity_admin_url(self):
        """ Returns a link to the admin change view of the IatiActivityImport object associated with
            this log entry
        """
        url = reverse('admin:rsr_iatiactivityimport_change',
                      args=(self.iati_activity_import.pk, ))
        return '<a href="{}">{}</a>'.format(url, self.iati_activity_import)

    activity_admin_url.allow_tags = True
    activity_admin_url.short_description = "IATI activity import"

    def iati_import_job_admin_url(self):
        """ Returns a link to the admin change view of the IatiImportJob object associated with this
            log entry
        """
        url = reverse('admin:rsr_iatiimportjob_change',
                      args=(self.iati_import_job.pk, ))
        return '<a href="{}">{}</a>'.format(url, self.iati_import_job)

    iati_import_job_admin_url.allow_tags = True
    iati_import_job_admin_url.short_description = "IATI import job"
class IndicatorPeriodData(TimestampsMixin, models.Model):
    """
    Model for adding data to an indicator period.
    """
    STATUS_DRAFT = unicode(_(u'draft'))
    STATUS_PENDING = unicode(_(u'pending approval'))
    STATUS_REVISION = unicode(_(u'return for revision'))
    STATUS_APPROVED = unicode(_(u'approved'))

    STATUS_DRAFT_CODE = u'D'
    STATUS_PENDING_CODE = u'P'
    STATUS_REVISION_CODE = u'R'
    STATUS_APPROVED_CODE = u'A'

    STATUS_CODES_LIST = [
        STATUS_DRAFT_CODE, STATUS_PENDING_CODE, STATUS_REVISION_CODE,
        STATUS_APPROVED_CODE
    ]
    STATUSES_LABELS_LIST = [
        STATUS_DRAFT, STATUS_PENDING, STATUS_REVISION, STATUS_APPROVED
    ]
    STATUSES = zip(STATUS_CODES_LIST, STATUSES_LABELS_LIST)

    UPDATE_METHODS = (
        ('W', _(u'web')),
        ('M', _(u'mobile')),
    )

    period = models.ForeignKey('IndicatorPeriod',
                               verbose_name=_(u'indicator period'),
                               related_name='data')
    # TODO: rename to created_by when old results framework page is no longer in use
    user = models.ForeignKey(settings.AUTH_USER_MODEL,
                             verbose_name=_(u'user'),
                             db_index=True,
                             related_name='created_period_updates')
    approved_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_(u'approved by'),
        db_index=True,
        related_name='approved_period_updates',
        blank=True,
        null=True,
    )
    # TODO: migrate value field to DecimalField
    # value = ValidXMLCharField(_(u'quantitative indicator value'), max_length=300, blank=True, null=True)
    value = models.DecimalField(
        _('quantitative indicator value'),
        max_digits=20,
        decimal_places=2,
        null=True,
        blank=True,
    )
    narrative = ValidXMLTextField(_(u'qualitative indicator narrative'),
                                  blank=True)
    period_actual_value = ValidXMLCharField(_(u'period actual value'),
                                            max_length=50,
                                            default='')
    status = ValidXMLCharField(_(u'status'),
                               max_length=1,
                               choices=STATUSES,
                               db_index=True,
                               default=STATUS_DRAFT_CODE)
    text = ValidXMLTextField(_(u'text'), blank=True)
    photo = ImageField(_(u'photo'),
                       blank=True,
                       upload_to=image_path,
                       max_length=255)
    file = models.FileField(_(u'file'),
                            blank=True,
                            upload_to=file_path,
                            max_length=255)
    update_method = ValidXMLCharField(_(u'update method'),
                                      blank=True,
                                      max_length=1,
                                      choices=UPDATE_METHODS,
                                      db_index=True,
                                      default='W')

    numerator = models.DecimalField(
        _(u'numerator for indicator'),
        max_digits=20,
        decimal_places=2,
        null=True,
        blank=True,
        help_text=_(u'The numerator for a calculated percentage'))
    denominator = models.DecimalField(
        _(u'denominator for indicator'),
        max_digits=20,
        decimal_places=2,
        null=True,
        blank=True,
        help_text=_(u'The denominator for a calculated percentage'))

    class Meta:
        app_label = 'rsr'
        verbose_name = _(u'indicator period data')
        verbose_name_plural = _(u'indicator period data')
        ordering = ('-id', )

    def save(self, recalculate=True, *args, **kwargs):
        # Allow only a single update for percentage measure indicators
        if (self.period.indicator.measure == PERCENTAGE_MEASURE
                and self.period.data.exclude(id=self.id).count() > 0):
            raise MultipleUpdateError(
                'Cannot create multiple updates with percentages')

        if (self.period.indicator.measure == PERCENTAGE_MEASURE
                and self.numerator is not None
                and self.denominator not in {0, '0', None}):
            self.value = calculate_percentage(self.numerator, self.denominator)

        super(IndicatorPeriodData, self).save(*args, **kwargs)

        # In case the status is approved, recalculate the period
        if recalculate and self.status == self.STATUS_APPROVED_CODE:
            self.period.recalculate_period()
            self.period.update_actual_comment()

    def delete(self, *args, **kwargs):
        old_status = self.status

        super(IndicatorPeriodData, self).delete(*args, **kwargs)

        # In case the status was approved, recalculate the period
        if old_status == self.STATUS_APPROVED_CODE:
            self.period.recalculate_period()
            self.period.update_actual_comment()

    def clean(self):
        """
        Perform several checks before we can actually save the update data.
        """
        validation_errors = {}

        project = self.period.indicator.result.project

        # Don't allow a data update to an unpublished project
        if not project.is_published():
            validation_errors['period'] = unicode(
                _(u'Indicator period must be part of a published '
                  u'project to add data to it'))
            raise ValidationError(validation_errors)

        # Don't allow a data update to a non-Impact project
        if not project.is_impact_project:
            validation_errors['period'] = unicode(
                _(u'Indicator period must be part of an RSR '
                  u'Impact project to add data to it'))
            raise ValidationError(validation_errors)

        # Don't allow a data update to a locked period
        if self.period.locked:
            validation_errors['period'] = unicode(
                _(u'Indicator period must be unlocked to add '
                  u'data to it'))
            raise ValidationError(validation_errors)

        # Don't allow a data update to an aggregated parent period with 'percentage' as measurement
        if self.period.indicator.children_aggregate_percentage:
            validation_errors['period'] = unicode(
                _(u'Indicator period has an average aggregate of the child projects. Disable '
                  u'aggregations to add data to it'))
            raise ValidationError(validation_errors)

        if self.pk:
            orig = IndicatorPeriodData.objects.get(pk=self.pk)

            # Don't allow for the indicator period to change
            if orig.period != self.period:
                validation_errors['period'] = unicode(
                    _(u'Not allowed to change indicator period '
                      u'in a data update'))

        if self.period.indicator.type == QUANTITATIVE:
            if self.narrative is not None:
                validation_errors['period'] = unicode(
                    _(u'Narrative field should be empty in quantitative indicators'
                      ))
            if self.value is not None:
                try:
                    self.value = Decimal(self.value)
                except:
                    validation_errors['period'] = unicode(
                        _(u'Only numeric values are allowed in quantitative indicators'
                          ))

        if self.period.indicator.type == QUALITATIVE:
            if self.value is not None:
                validation_errors['period'] = unicode(
                    _(u'Value field should be empty in qualitative indicators')
                )

        if validation_errors:
            raise ValidationError(validation_errors)

    @property
    def status_display(self):
        """
        Returns the display of the status.
        """
        try:
            return dict(self.STATUSES)[self.status].capitalize()
        except KeyError:
            return u''

    @property
    def photo_url(self):
        """
        Returns the full URL of the photo.
        """
        return self.photo.url if self.photo else u''

    @property
    def file_url(self):
        """
        Returns the full URL of the file.
        """
        return self.file.url if self.file else u''

    def update_new_value(self):
        """Returns a string with the new value."""
        try:
            add_up = Decimal(self.value) + Decimal(self.period_actual_value)
            relative = '+' + str(self.value) if self.value >= 0 else str(
                self.value)
            return "{} ({})".format(str(add_up), relative)
        except (InvalidOperation, TypeError):
            return self.value

    @classmethod
    def get_user_viewable_updates(cls, queryset, user):
        approved_updates = queryset.filter(status=cls.STATUS_APPROVED_CODE)

        if user.is_anonymous():
            f_queryset = approved_updates

        elif user.is_admin or user.is_superuser:
            f_queryset = queryset

        else:
            own_updates = queryset.filter(user=user)
            non_draft_updates = queryset.exclude(status=cls.STATUS_DRAFT_CODE)
            filter_ = user.get_permission_filter(
                'rsr.view_indicatorperioddata',
                'period__indicator__result__project__')
            f_queryset = (approved_updates | own_updates
                          | non_draft_updates.filter(filter_))

        return f_queryset.distinct()
Beispiel #13
0
class IatiExport(TimestampsMixin, models.Model):
    reporting_organisation = models.ForeignKey(
        'Organisation',
        verbose_name=_(u'reporting organisation'),
        related_name='iati_exports')
    user = models.ForeignKey(settings.AUTH_USER_MODEL,
                             verbose_name=_(u'user'),
                             related_name='iati_exports')
    projects = models.ManyToManyField('Project', verbose_name=_(u'projects'))
    version = ValidXMLCharField(_(u'version'), max_length=4, default='2.02')
    status = models.PositiveSmallIntegerField(_(u'status'), default=1)
    iati_file = models.FileField(_(u'IATI file'),
                                 blank=True,
                                 upload_to=file_path)
    is_public = models.BooleanField(_(u'public'), default=True)

    class Meta:
        app_label = 'rsr'
        verbose_name = _(u'IATI export')
        verbose_name_plural = _(u'IATI exports')

    def __unicode__(self):
        if self.reporting_organisation and self.reporting_organisation.name:
            return u'%s %s' % (_(u'IATI export for'),
                               self.reporting_organisation.name)
        else:
            return u'%s' % _(u'IATI export for unknown organisation')

    def show_status(self):
        if self.status not in STATUS_CODE.keys():
            return _(u'unknown status')
        else:
            return STATUS_CODE[self.status].title()

    def update_status(self, status_code):
        """
        Update the status of this IATI export.

        :param status_code; Integer in STATUS_CODE keys
        """
        self.status = status_code
        self.save(update_fields=['status'])

    def update_iati_file(self, iati_file):
        """
        Update the IATI file of this IATI export.

        :param iati_file; File object
        """
        self.iati_file = iati_file
        self.save(update_fields=['iati_file'])

    def create_iati_file(self):
        """
        Create an IATI XML file.
        """
        # Set status to 'In progress'
        self.update_status(2)

        # Retrieve all projects
        projects = self.projects.all()
        if projects:
            try:
                # Generate and save the IATI file
                iati_xml = IatiXML(projects, self.version, self)
                self.update_iati_file(
                    iati_xml.save_file(
                        str(self.reporting_organisation.pk),
                        datetime.utcnow().strftime("%Y%m%d-%H%M%S") + '.xml'))

                # All done, so update the status to 'Completed'
                self.update_status(3)
            except:
                # Something went wrong, so update the status to 'Cancelled'
                self.update_status(4)
        else:
            # No projects, so update the status to 'Cancelled'
            self.update_status(4)

    def processed_projects(self):
        """
        Find the number of processed projects of this IATI export. Generally, for completed
        exports, this number will be the same as the number of total projects.
        """
        return self.iati_activity_exports.filter(status=2).count()
Beispiel #14
0
class IndicatorPeriodData(TimestampsMixin, models.Model):
    """
    Model for adding data to an indicator period.
    """
    STATUS_NEW = unicode(_(u'new'))
    STATUS_DRAFT = unicode(_(u'draft'))
    STATUS_PENDING = unicode(_(u'pending approval'))
    STATUS_REVISION = unicode(_(u'return for revision'))
    STATUS_APPROVED = unicode(_(u'approved'))

    STATUS_NEW_CODE = u'N'
    STATUS_DRAFT_CODE = u'D'
    STATUS_PENDING_CODE = u'P'
    STATUS_REVISION_CODE = u'R'
    STATUS_APPROVED_CODE = u'A'

    STATUS_CODES_LIST = [
        STATUS_NEW_CODE, STATUS_DRAFT_CODE, STATUS_PENDING_CODE,
        STATUS_REVISION_CODE, STATUS_APPROVED_CODE
    ]
    STATUSES_LABELS_LIST = [
        STATUS_NEW, STATUS_DRAFT, STATUS_PENDING, STATUS_REVISION,
        STATUS_APPROVED
    ]
    STATUSES = zip(STATUS_CODES_LIST, STATUSES_LABELS_LIST)

    UPDATE_METHODS = (
        ('W', _(u'web')),
        ('M', _(u'mobile')),
    )

    period = models.ForeignKey(IndicatorPeriod,
                               verbose_name=_(u'indicator period'),
                               related_name='data')
    user = models.ForeignKey(settings.AUTH_USER_MODEL,
                             verbose_name=_(u'user'),
                             db_index=True)
    relative_data = models.BooleanField(_(u'relative data'), default=True)
    # TODO: rename to update of period_update; we're using the term Indicator update in the UI
    data = ValidXMLCharField(_(u'data'), max_length=300)
    period_actual_value = ValidXMLCharField(_(u'period actual value'),
                                            max_length=50,
                                            default='')
    status = ValidXMLCharField(_(u'status'),
                               max_length=1,
                               choices=STATUSES,
                               db_index=True,
                               default=STATUS_NEW_CODE)
    text = ValidXMLTextField(_(u'text'), blank=True)
    photo = ImageField(_(u'photo'), blank=True, upload_to=image_path)
    file = models.FileField(_(u'file'), blank=True, upload_to=file_path)
    update_method = ValidXMLCharField(_(u'update method'),
                                      blank=True,
                                      max_length=1,
                                      choices=UPDATE_METHODS,
                                      db_index=True,
                                      default='W')

    class Meta:
        app_label = 'rsr'
        verbose_name = _(u'indicator period data')
        verbose_name_plural = _(u'indicator period data')

    def save(self, recalculate=True, *args, **kwargs):
        super(IndicatorPeriodData, self).save(*args, **kwargs)

        # In case the status is approved, recalculate the period
        if recalculate and self.status == self.STATUS_APPROVED_CODE:
            self.period.recalculate_period()
            self.period.update_actual_comment()

    def delete(self, *args, **kwargs):
        old_status = self.status

        super(IndicatorPeriodData, self).delete(*args, **kwargs)

        # In case the status was approved, recalculate the period
        if old_status == self.STATUS_APPROVED_CODE:
            self.period.recalculate_period()

    def clean(self):
        """
        Perform several checks before we can actually save the update data.
        """
        validation_errors = {}

        project = self.period.indicator.result.project

        # Don't allow a data update to an unpublished project
        if not project.is_published():
            validation_errors['period'] = unicode(
                _(u'Indicator period must be part of a published '
                  u'project to add data to it'))
            raise ValidationError(validation_errors)

        # Don't allow a data update to a non-Impact project
        if not project.is_impact_project:
            validation_errors['period'] = unicode(
                _(u'Indicator period must be part of an RSR '
                  u'Impact project to add data to it'))
            raise ValidationError(validation_errors)

        # Don't allow a data update to a locked period
        if self.period.locked:
            validation_errors['period'] = unicode(
                _(u'Indicator period must be unlocked to add '
                  u'data to it'))
            raise ValidationError(validation_errors)

        # Don't allow a data update to an aggregated parent period with 'percentage' as measurement
        if self.period.indicator.children_aggregate_percentage:
            validation_errors['period'] = unicode(
                _(u'Indicator period has an average aggregate of the child projects. Disable '
                  u'aggregations to add data to it'))
            raise ValidationError(validation_errors)

        if self.pk:
            orig = IndicatorPeriodData.objects.get(pk=self.pk)

            # Don't allow for the indicator period to change
            if orig.period != self.period:
                validation_errors['period'] = unicode(
                    _(u'Not allowed to change indicator period '
                      u'in a data update'))
        if validation_errors:
            raise ValidationError(validation_errors)

    @property
    def status_display(self):
        """
        Returns the display of the status.
        """
        try:
            return dict(self.STATUSES)[self.status].capitalize()
        except KeyError:
            return u''

    @property
    def photo_url(self):
        """
        Returns the full URL of the photo.
        """
        return self.photo.url if self.photo else u''

    @property
    def file_url(self):
        """
        Returns the full URL of the file.
        """
        return self.file.url if self.file else u''

    def update_new_value(self):
        """
        Returns a string with the new value, taking into account a relative update.
        """
        if self.relative_data:
            try:
                add_up = Decimal(self.data) + Decimal(self.period_actual_value)
                relative = '+' + str(self.data) if self.data >= 0 else str(
                    self.data)
                return "{} ({})".format(str(add_up), relative)
            except (InvalidOperation, TypeError):
                return self.data
        else:
            try:
                substract = Decimal(self.data) - Decimal(
                    self.period_actual_value)
                relative = '+' + str(substract) if substract >= 0 else str(
                    substract)
                return "{} ({})".format(self.data, relative)
            except (InvalidOperation, TypeError):
                return self.data
Beispiel #15
0
class IndicatorPeriod(models.Model):
    indicator = models.ForeignKey(Indicator,
                                  verbose_name=_(u'indicator'),
                                  related_name='periods')
    parent_period = models.ForeignKey(
        'self',
        blank=True,
        null=True,
        default=None,
        verbose_name=_(u'parent indicator period'),
        related_name='child_periods')
    locked = models.BooleanField(_(u'locked'), default=True, db_index=True)
    period_start = models.DateField(
        _(u'period start'),
        null=True,
        blank=True,
        help_text=_(
            u'The start date of the reporting period for this indicator.'))
    period_end = models.DateField(
        _(u'period end'),
        null=True,
        blank=True,
        help_text=_(
            u'The end date of the reporting period for this indicator.'))
    target_value = ValidXMLCharField(
        _(u'target value'),
        blank=True,
        max_length=50,
        help_text=_(u'The target value for the above period.'))
    target_comment = ValidXMLCharField(
        _(u'target value comment'),
        blank=True,
        max_length=2000,
        help_text=
        _(u'Here you can provide extra information on the target value, if needed.'
          ))
    actual_value = ValidXMLCharField(
        _(u'actual value'),
        blank=True,
        max_length=50,
        help_text=_(u'A record of the achieved result for this period.'))
    actual_comment = ValidXMLCharField(
        _(u'actual value comment'),
        blank=True,
        max_length=2000,
        help_text=
        _(u'Here you can provide extra information on the actual value, if needed '
          u'(for instance, why the actual value differs from the target value).'
          ))

    def __unicode__(self):
        if self.period_start:
            period_unicode = unicode(self.period_start)
        else:
            period_unicode = u'%s' % _(u'No start date')

        if self.period_end:
            period_unicode += u' - %s' % unicode(self.period_end)
        else:
            period_unicode += u' - %s' % _(u'No end date')

        if self.actual_value or self.target_value:
            period_unicode += u' ('

            if self.actual_value and self.target_value:
                period_unicode += u'actual: %s / target: %s)' % (unicode(
                    self.actual_value), unicode(self.target_value))
            elif self.actual_value:
                period_unicode += u'actual: %s)' % unicode(self.actual_value)
            else:
                period_unicode += u'target: %s)' % unicode(self.target_value)

        return period_unicode

    def save(self, *args, **kwargs):
        actual_value_changed = False

        # When the general information of a parent period is updated, this information should also
        # be reflected in the child periods.
        if self.pk:
            for child_period in self.child_periods.all():
                # Always copy period start and end. They should be the same as the parent.
                child_period.period_start = self.period_start
                child_period.period_end = self.period_end

                # Only copy the target value and comments if the child has no values (in case the
                # child period is new). Afterwards, it is possible to adjust these values (update
                # the target for the child, for instance) and then these values should not be
                # overwritten.
                if not child_period.target_value and self.target_value:
                    child_period.target_value = self.target_value
                if not child_period.target_comment and self.target_comment:
                    child_period.target_comment = self.target_comment

                child_period.save()

            # Check if the actual value has changed
            orig_period = IndicatorPeriod.objects.get(pk=self.pk)
            if orig_period.actual_value != self.actual_value:
                actual_value_changed = True

        # In case the period is new and the period's indicator does have child indicators, the (new)
        # period should also be copied to the child indicator.
        else:
            for child_indicator in self.indicator.child_indicators.all():
                child_indicator.result.project.add_period(
                    child_indicator, self)

        super(IndicatorPeriod, self).save(*args, **kwargs)

        # If the actual value has changed, the period has a parent period and aggregations are on,
        # then the the parent should be updated as well
        if actual_value_changed and self.is_child_period() and \
                self.parent_period.indicator.result.project.aggregate_children and \
                self.indicator.result.project.aggregate_to_parent:
            self.parent_period.recalculate_period()

    def clean(self):
        validation_errors = {}

        if self.pk:
            orig_period = IndicatorPeriod.objects.get(pk=self.pk)

            # Don't allow an actual value to be changed when the indicator period is calculated
            if self.is_calculated(
            ) and self.actual_value != orig_period.actual_value:
                validation_errors['actual_value'] = u'%s' % \
                    _(u'It is not possible to update the actual value of this indicator period, '
                      u'because it is a calculated value. Please update the actual value through '
                      u'a new update.')

            # Don't allow some values to be changed when it is a child period
            if self.is_child_period():
                if self.indicator != orig_period.indicator:
                    validation_errors['indicator'] = u'%s' % \
                        _(u'It is not possible to update the indicator of this indicator period, '
                          u'because it is linked to a parent result.')
                if self.period_start != orig_period.period_start:
                    validation_errors['period_start'] = u'%s' % \
                        _(u'It is not possible to update the start period of this indicator, '
                          u'because it is linked to a parent result.')
                if self.period_end != orig_period.period_end:
                    validation_errors['period_end'] = u'%s' % \
                        _(u'It is not possible to update the end period of this indicator, '
                          u'because it is linked to a parent result.')

        # Don't allow a start date before an end date
        if self.period_start and self.period_end and (self.period_start >
                                                      self.period_end):
            validation_errors['period_start'] = u'%s' % _(
                u'Period start cannot be at a later time '
                u'than period end.')
            validation_errors['period_end'] = u'%s' % _(
                u'Period start cannot be at a later time '
                u'than period end.')

        # TODO: add validation that prevents creating a period for a child indicator
        if validation_errors:
            raise ValidationError(validation_errors)

    def recalculate_period(self, save=True, only_self=False):
        """
        Re-calculate the values of all updates from the start. This will prevent strange values,
        for example when an update is deleted or edited after it has been approved.

        :param save; Boolean, saves actual value to period if True
        :param only_self; Boolean, to take into account if this is a parent or just re-calculate
        this period only
        :return Actual value of period
        """

        # If this period is a parent period, the sum or average of the children should be
        # re-calculated
        if not only_self and self.is_parent_period() and \
                self.indicator.result.project.aggregate_children:
            return self.recalculate_children(save)

        prev_val = '0'

        # For every approved update, add up the new value (if possible)
        for update in self.data.filter(status='A').order_by('created_at'):
            update.period_actual_value = prev_val
            update.save(recalculate=False)

            if update.relative_data:
                try:
                    # Try to add up the update to the previous actual value
                    prev_val = str(Decimal(prev_val) + Decimal(update.data))
                except InvalidOperation:
                    # If not possible, the update data or previous value is a normal string
                    prev_val = update.data
            else:
                prev_val = update.data

        # For every non-approved update, set the data to the current data
        for update in self.data.exclude(status='A'):
            update.period_actual_value = prev_val
            update.save(recalculate=False)

        # Special case: only_self and no data should give an empty string instead of '0'
        if only_self and not self.data.exists():
            prev_val = ''

        # Finally, update the actual value of the period itself
        if save:
            self.actual_value = prev_val
            self.save()

        # Return the actual value of the period itself
        return prev_val

    def recalculate_children(self, save=True):
        """
        Re-calculate the actual value of this period based on the actual values of the child
        periods.

        In case the measurement is 'Percentage', it should be an average of all child periods.
        Otherwise, the child period values can just be added up.

        :param save; Boolean, saves to period if True
        :return Actual value of period
        """
        if self.indicator.measure == '2':
            new_value = self.child_periods_average()
        else:
            new_value = self.child_periods_sum(include_self=True)

        if save:
            self.actual_value = new_value
            self.save()

        return new_value

    def update_actual_comment(self, save=True):
        """
        Set the actual comment to the text of the latest approved update.

        :param save; Boolean, save period if True
        :return Actual comment of period
        """

        approved_updates = self.data.filter(
            status=IndicatorPeriodData.STATUS_APPROVED_CODE)
        update_texts = [
            u'{}: {}'.format(update.last_modified_at.strftime('%d-%m-%Y'),
                             update.text)
            for update in approved_updates.order_by('-created_at')
        ]
        actual_comment = u' | '.join(update_texts)
        if len(actual_comment) >= 2000:  # max_size
            actual_comment = u'{} ...'.format(actual_comment[:1995])

        self.actual_comment = actual_comment
        if save:
            self.save()

        return self.actual_comment

    def is_calculated(self):
        """
        When a period has got indicator updates, we consider the actual value to be a
        'calculated' value, meaning that it's not possible to update the actual value directly.
        Only through indicator updates.
        """
        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 period is linked to a parent period
        """
        return bool(self.parent_period)

    def is_parent_period(self):
        """
        Indicates whether this result has child periods linked to it.
        """
        return self.child_periods.count() > 0

    def child_periods_with_data(self):
        """
        Returns the child indicator periods with numeric data
        """
        children_with_data = []
        for child in self.child_periods.all():
            try:
                Decimal(child.actual_value)
                children_with_data += [child.pk]
            except (InvalidOperation, TypeError):
                pass
        return self.child_periods.filter(pk__in=children_with_data)

    # TODO: refactor child_periods_sum() and child_periods_average() and child_periods_with_data(),
    # they use each other in very inefficient ways I think
    def child_periods_sum(self, include_self=False):
        """
        Returns the sum of child indicator periods.

        :param include_self; Boolean to include the updates on the period itself, as well as its'
        children
        :return String of the sum
        """
        period_sum = 0

        # Loop through the child periods and sum up all the values
        for period in self.child_periods.all():
            if period.indicator.result.project.aggregate_to_parent and period.actual_value:
                try:
                    period_sum += Decimal(period.actual_value)
                except (InvalidOperation, TypeError):
                    pass

        if include_self:
            try:
                period_sum += Decimal(
                    self.recalculate_period(save=False, only_self=True))
            except (InvalidOperation, TypeError):
                pass

        return str(period_sum)

    def child_periods_average(self):
        """
        Returns the average of child indicator periods.

        :return String of the average
        """
        if self.indicator.result.project.aggregate_children:
            child_periods = self.child_periods_with_data()
            for child in child_periods:
                if not (child.indicator.result.project.aggregate_to_parent
                        and child.actual_value):
                    child_periods = child_periods.exclude(pk=child.pk)

            number_of_child_periods = child_periods.count()
            if number_of_child_periods > 0:
                return str(
                    Decimal(self.child_periods_sum()) /
                    number_of_child_periods)
        return '0'

    def adjacent_period(self, next_period=True):
        """
        Returns the next or previous indicator period, if we can find one with a start date,
        and we have a start date ourselves.

        :param next_period; Boolean indicating either the next (True) or previous (False) period.
        """
        if not self.period_start:
            return None
        elif next_period:
            return self.indicator.periods.exclude(period_start=None).filter(
                period_start__gt=self.period_start).order_by(
                    'period_start').first()
        else:
            return self.indicator.periods.exclude(period_start=None).filter(
                period_start__lt=self.period_start).order_by(
                    '-period_start').first()

    @property
    def percent_accomplishment(self):
        """
        Return the percentage completed for this indicator period. If not possible to convert the
        values to numbers, return None.
        """
        try:
            return round(
                Decimal(self.actual_value) / Decimal(self.target_value) * 100,
                1)
        except (InvalidOperation, TypeError, DivisionByZero):
            return None

    @property
    def percent_accomplishment_100(self):
        """
        Similar to the percent_accomplishment property. However, it won't return any number bigger
        than 100.
        """
        return max(self.percent_accomplishment,
                   100) if self.percent_accomplishment else None

    @property
    def actual(self):
        """
        Returns the actual value of the indicator period, if it can be converted to a number.
        Otherwise it'll return the baseline value, which is a calculated value.
        """
        try:
            return Decimal(self.actual_value)
        except (InvalidOperation, TypeError):
            return self.actual_value if self.actual_value else self.baseline

    @property
    def target(self):
        """
        Returns the target value of the indicator period, if it can be converted to a number.
        Otherwise it'll return just the target value.
        """
        try:
            return Decimal(self.target_value)
        except (InvalidOperation, TypeError):
            return self.target_value

    @property
    def baseline(self):
        """
        Returns the baseline value of the indicator. The baseline is a calculated value:

        - If the period has no previous periods, then it's the baseline value of the indicator
        - If the period has a previous period, then it's the actual value of that period

        When this baseline value is empty, it returns 0. Otherwise (e.g. 'Available') it just
        returns the baseline value.
        """
        previous_period = self.adjacent_period(False)
        baseline = self.indicator.baseline_value if not previous_period else previous_period.actual

        if not baseline:
            return Decimal(0)
        else:
            try:
                return Decimal(baseline)
            except (InvalidOperation, TypeError):
                return baseline

    class Meta:
        app_label = 'rsr'
        verbose_name = _(u'indicator period')
        verbose_name_plural = _(u'indicator periods')
        ordering = ['period_start']
Beispiel #16
0
class Indicator(models.Model):
    result = models.ForeignKey('Result',
                               verbose_name=_(u'result'),
                               related_name='indicators')
    parent_indicator = models.ForeignKey('self',
                                         blank=True,
                                         null=True,
                                         default=None,
                                         verbose_name=_(u'parent indicator'),
                                         related_name='child_indicators')
    title = ValidXMLCharField(
        _(u'indicator title'),
        blank=True,
        max_length=500,
        help_text=
        _(u'Within each result indicators can be defined. Indicators should be items '
          u'that can be counted and evaluated as the project continues and is completed.'
          ))
    measure = ValidXMLCharField(
        _(u'indicator measure'),
        blank=True,
        max_length=1,
        choices=codelist_choices(INDICATOR_MEASURE),
        help_text=
        _(u'Choose how the indicator will be measured (in percentage or units).'
          ))
    ascending = models.NullBooleanField(
        _(u'ascending'),
        blank=True,
        help_text=
        _(u'Choose ascending if the target value of the indicator is higher than the '
          u'baseline value (eg. people with access to sanitation). Choose descending if '
          u'the target value of the indicator is lower than the baseline value '
          u'(eg. people with diarrhea).'))
    description = ValidXMLCharField(
        _(u'indicator description'),
        blank=True,
        max_length=2000,
        help_text=_(
            u'You can provide further information of the indicator here.'))
    baseline_year = models.PositiveIntegerField(
        _(u'baseline year'),
        blank=True,
        null=True,
        max_length=4,
        help_text=_(u'The year the baseline value was taken.'))
    baseline_value = ValidXMLCharField(
        _(u'baseline value'),
        blank=True,
        max_length=50,
        help_text=_(u'The value of the baseline at the start of the project.'))
    baseline_comment = ValidXMLCharField(
        _(u'baseline comment'),
        blank=True,
        max_length=2000,
        help_text=
        _(u'Here you can provide extra information on the baseline value, if needed.'
          ))
    order = models.PositiveSmallIntegerField(_(u'indicator order'),
                                             null=True,
                                             blank=True)
    default_periods = models.NullBooleanField(
        _(u'default indicator periods'),
        default=False,
        blank=True,
        help_text=_(
            u'Determines whether periods of indicator are used by default.'))

    def __unicode__(self):
        indicator_unicode = self.title if self.title else u'%s' % _(
            u'No indicator title')

        if self.periods.all():
            indicator_unicode += u' - %s %s' % (unicode(
                self.periods.count()), _(u'period(s)'))

        return indicator_unicode

    def save(self, *args, **kwargs):
        """Update the values of child indicators, if a parent indicator is updated."""
        # Update the values for an existing indicator
        if self.pk:
            for child_indicator in self.child_indicators.all():
                # Always copy title, measure and ascending. They should be the same as the parent.
                child_indicator.title = self.title
                child_indicator.measure = self.measure
                child_indicator.ascending = self.ascending

                # Only copy the description and baseline if the child has none (e.g. new)
                fields = [
                    'description', 'baseline_year', 'baseline_value',
                    'baseline_comment'
                ]
                for field in fields:
                    parent_field_value = getattr(self, field)
                    if not getattr(child_indicator,
                                   field) and parent_field_value:
                        setattr(child_indicator, field, parent_field_value)

                child_indicator.save()

        # Create a new indicator when it's added
        else:
            for child_result in self.result.child_results.all():
                child_result.project.add_indicator(child_result, self)

            if Indicator.objects.filter(result_id=self.result.id).exists():
                prev_indicator = Indicator.objects.filter(
                    result_id=self.result.id).reverse()[0]
                if prev_indicator.order:
                    self.order = prev_indicator.order + 1

        super(Indicator, self).save(*args, **kwargs)

    def clean(self):
        validation_errors = {}

        if self.pk and self.is_child_indicator():
            orig_indicator = Indicator.objects.get(pk=self.pk)

            # Don't allow some values to be changed when it is a child indicator
            if self.result != orig_indicator.result:
                validation_errors['result'] = u'%s' % \
                    _(u'It is not possible to update the result of this indicator, '
                      u'because it is linked to a parent result.')
            if self.title != orig_indicator.title:
                validation_errors['title'] = u'%s' % \
                    _(u'It is not possible to update the title of this indicator, '
                      u'because it is linked to a parent result.')
            if self.measure != orig_indicator.measure:
                validation_errors['measure'] = u'%s' % \
                    _(u'It is not possible to update the measure of this indicator, '
                      u'because it is linked to a parent result.')
            if self.ascending != orig_indicator.ascending:
                validation_errors['ascending'] = u'%s' % \
                    _(u'It is not possible to update the ascending value of this indicator, '
                      u'because it is linked to a parent result.')

        if validation_errors:
            raise ValidationError(validation_errors)

    def delete(self, *args, **kwargs):
        """
        Check if indicator is ordered manually, and cascade following indicators if needed
        """
        if self.order:
            sibling_indicators = Indicator.objects.filter(
                result_id=self.result.id)

            if not self == sibling_indicators.reverse()[0]:
                for ind in range(self.order + 1, len(sibling_indicators)):
                    sibling_indicators[ind].order -= 1
                    sibling_indicators[ind].save()

        super(Indicator, self).delete(*args, **kwargs)

    def iati_measure(self):
        return codelist_value(IndicatorMeasure, self, 'measure')

    def iati_measure_unicode(self):
        return str(self.iati_measure())

    def is_calculated(self):
        return self.result.project.is_impact_project

    def is_child_indicator(self):
        """
        Indicates whether this indicator is linked to a parent indicator.
        """
        return bool(self.parent_indicator)

    def is_parent_indicator(self):
        """
        Indicates whether this indicator has children.
        """
        return self.child_indicators.count() > 0

    @property
    def last_updated(self):
        from akvo.rsr.models import ProjectUpdate
        period_updates = ProjectUpdate.objects.filter(
            indicator_period__indicator=self)
        return period_updates.order_by(
            '-created_at')[0].time_gmt if period_updates else None

    @property
    def baseline(self):
        """
        Returns the baseline value of the indicator, if it can be converted to a number. Otherwise
        it'll return None.
        """
        try:
            return Decimal(self.baseline_value)
        except (InvalidOperation, TypeError):
            return None

    @property
    def children_aggregate_percentage(self):
        """
        Returns True if this indicator has percentage as a measure and has children that aggregate
        to this indicator.
        """
        if self.measure == '2' and self.is_parent_indicator() and \
                self.result.project.aggregate_children and \
                any([ind.result.project.aggregate_to_parent for ind in self.child_indicators.all()]):
            return True
        return False

    class Meta:
        app_label = 'rsr'
        ordering = ['order', 'id']
        verbose_name = _(u'indicator')
        verbose_name_plural = _(u'indicators')