Beispiel #1
0
class IndicatorCustomField(models.Model):

    TYPES = (
        ('text', _('Text')),
        ('boolean', _('Checkbox')),
        ('dropdown', _('Dropdown')),
    )

    project = models.ForeignKey('Project',
                                on_delete=models.CASCADE,
                                verbose_name=_('project'),
                                related_name='indicator_custom_fields')
    name = ValidXMLTextField(_('name'))
    order = models.PositiveSmallIntegerField(
        _('order'),
        help_text=_(
            'The order of the fields as they will be displayed in the '
            'project editor. Must be a positive number, and the lowest '
            'number will be shown on top.'),
        default=1,
    )
    mandatory = models.BooleanField(
        _('mandatory'),
        default=False,
        help_text=_('Indicate whether this field is mandatory or not'))
    help_text = ValidXMLTextField(
        _('help text'),
        max_length=1000,
        blank=True,
        help_text=_(
            'The help text to be displayed with the field in the admin. Leave empty if '
            'there is no need for a help text. (max 1000 characters)'))
    type = ValidXMLCharField(
        _('type'),
        max_length=20,
        choices=TYPES,
        default='text',
        help_text=_(
            'Select the type of custom field. Text will show a text area in the project '
            'editor, and checkbox will show a checkbox.'))
    dropdown_options = JSONField(_('dropdown options'), null=True, blank=True)

    class Meta:
        app_label = 'rsr'
        verbose_name = _('indicator custom field')
        verbose_name_plural = _('indicator custom fields')
Beispiel #2
0
class Disaggregation(TimestampsMixin, IndicatorUpdateMixin, models.Model):
    """Model for storing a disaggregated value along one axis of a dimension."""

    # TODO: rename to dimension_axis of simply axis?
    dimension_value = models.ForeignKey('IndicatorDimensionValue',
                                        null=True,
                                        related_name='disaggregations')

    update = models.ForeignKey(IndicatorPeriodData,
                               verbose_name=_('indicator period update'),
                               related_name='disaggregations')

    # FIXME: Add a type to allow disaggregated values for target/baseline
    # type = models.CharField

    narrative = ValidXMLTextField(_('qualitative narrative'), blank=True)
    incomplete_data = models.BooleanField(
        _('disaggregation data is incomplete'), default=False)

    class Meta:
        app_label = 'rsr'
        verbose_name = _('disaggregated value')
        verbose_name_plural = _('disaggregated values')
        ordering = ('id', )

    def siblings(self):
        return Disaggregation.objects.filter(
            update=self.update,
            dimension_value__name=self.dimension_value.name)

    def disaggregation_total(self):
        if self.update.period.indicator.type == QUALITATIVE:
            raise NotImplementedError

        if self.update.period.indicator.measure == PERCENTAGE_MEASURE:
            values = self.siblings().values_list('numerator', 'denominator')
            numerator_sum = sum(numerator for (numerator, _) in values
                                if numerator is not None)
            denominator_sum = sum(denominator for (_, denominator) in values
                                  if denominator is not None)
            return True, (numerator_sum, denominator_sum)
        else:
            return False, sum([
                _f for _f in self.siblings().values_list('value', flat=True)
                if _f
            ])

    def update_incomplete_data(self):
        percentage_measure, disaggregation_total = self.disaggregation_total()
        if not percentage_measure:
            incomplete_data = disaggregation_total != self.update.value
            self.siblings().update(incomplete_data=incomplete_data)

        else:
            numerator, denominator = disaggregation_total
            incomplete_data = (numerator != self.update.numerator
                               or denominator != self.update.denominator)
            self.siblings().update(incomplete_data=incomplete_data)
Beispiel #3
0
class NarrativeReport(models.Model):

    project = models.ForeignKey('Project',
                                verbose_name=_(u'project'),
                                related_name='narrative_reports')

    category = models.ForeignKey('OrganisationIndicatorLabel',
                                 verbose_name=_(u'category'),
                                 related_name='narrative_reports',
                                 on_delete=models.PROTECT)

    text = ValidXMLTextField(_(u'narrative report text'),
                             blank=True,
                             help_text=_(u'The text of the narrative report.'))

    published = models.BooleanField(_(u'published'), default=False)

    period_start = models.DateField(
        _(u'period start'),
        help_text=_(
            u'The start date of the reporting period for this narrative report.'
        ))
    period_end = models.DateField(
        _(u'period end'),
        help_text=_(
            u'The end date of the reporting period for this narrative report.')
    )

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

    class Meta:
        app_label = 'rsr'
        verbose_name = _(u'narrative report')
        verbose_name_plural = _(u'narrative reports')
        unique_together = ('project', 'category', 'period_start', 'period_end')
Beispiel #4
0
class IndicatorPeriodDataComment(TimestampsMixin, models.Model):
    """
    Model for adding comments to data of an indicator period.
    """
    data = models.ForeignKey(IndicatorPeriodData,
                             verbose_name=_(u'indicator period data'),
                             related_name='comments')
    user = models.ForeignKey(settings.AUTH_USER_MODEL,
                             verbose_name=_(u'user'),
                             db_index=True)
    comment = ValidXMLTextField(_(u'comment'), blank=True)

    class Meta:
        app_label = 'rsr'
        verbose_name = _(u'indicator period data comment')
        verbose_name_plural = _(u'indicator period data comments')
Beispiel #5
0
class IndicatorPeriodLabel(models.Model):
    """ Model for adding a label on an indicator period."""

    project_relation = ''
    project = models.ForeignKey('Project',
                                on_delete=models.CASCADE,
                                verbose_name=_('indicator period data'),
                                related_name='period_labels')
    label = ValidXMLTextField(_('label'), blank=True)

    def __str__(self):
        return self.label

    class Meta:
        app_label = 'rsr'
        verbose_name = _('indicator period label')
        verbose_name_plural = _('indicator period labels')
        ordering = ('-id', )
Beispiel #6
0
class IndicatorCustomValue(models.Model):
    project_relation = 'results__indicators__custom_values__in'
    indicator = models.ForeignKey('Indicator',
                                  on_delete=models.CASCADE,
                                  verbose_name=_('indicator'),
                                  related_name='custom_values')
    custom_field = models.ForeignKey('IndicatorCustomField',
                                     on_delete=models.CASCADE,
                                     verbose_name=_('custom_field'),
                                     related_name='values')
    text_value = ValidXMLTextField(_('text_value'), blank=True)
    boolean_value = models.BooleanField(_('boolean_value'), default=False)
    dropdown_selection = JSONField(_('dropdown selection'),
                                   null=True,
                                   blank=True)

    class Meta:
        app_label = 'rsr'
        verbose_name = _('indicator custom value')
        verbose_name_plural = _('indicator custom values')
Beispiel #7
0
class IatiCheck(models.Model):
    project = models.ForeignKey('Project', verbose_name=_(u'project'), related_name='iati_checks')
    status = models.PositiveSmallIntegerField(_(u'status'))
    description = ValidXMLTextField(_(u'description'))

    class Meta:
        app_label = 'rsr'
        verbose_name = _(u'IATI check')
        verbose_name_plural = _(u'IATI checks')

    def __unicode__(self):
        if self.project and self.project.title:
            return u'%s %s' % (_(u'IATI check for'), self.project.title)
        else:
            return u'%s' % _(u'IATI check for unknown project')

    def show_status(self):
        if self.status not in STATUS_CODE.keys():
            return _(u'unknown status')
        else:
            return STATUS_CODE[int(self.status)].title()
class IndicatorPeriodDataComment(TimestampsMixin):
    """
    Model for adding comments to data of an indicator period.
    """
    project_relation = 'results__indicators__periods__data__comments__in'

    data = models.ForeignKey(IndicatorPeriodData,
                             on_delete=models.CASCADE,
                             verbose_name=_('indicator period data'),
                             related_name='comments')
    user = models.ForeignKey(settings.AUTH_USER_MODEL,
                             on_delete=models.CASCADE,
                             verbose_name=_('user'),
                             db_index=True)
    comment = ValidXMLTextField(_('comment'), blank=True)

    class Meta:
        app_label = 'rsr'
        verbose_name = _('indicator period data comment')
        verbose_name_plural = _('indicator period data comments')
        ordering = ('-id', )
Beispiel #9
0
class Disaggregation(TimestampsMixin, models.Model):
    """Model for storing a disaggregated value along one axis of a dimension."""

    dimension = models.ForeignKey(IndicatorDimension)
    # FIXME: Should be able to associate with period/indicator too?
    update = models.ForeignKey(IndicatorPeriodData,
                               verbose_name=_(u'indicator period update'),
                               related_name='disaggregations')

    # FIXME: Add a type to allow disaggregated values for target/baseline
    # type = models.CharField

    # NOTE: corresponding value field on Update is still a CharField
    value = models.DecimalField(
        _(u'quantitative disaggregated value'),
        max_digits=20,
        decimal_places=2,
        blank=True,
        null=True
    )
    narrative = ValidXMLTextField(_(u'qualitative narrative'), blank=True)
    numerator = models.DecimalField(
        _(u'numerator for indicator'),
        max_digits=20, decimal_places=2,
        null=True, blank=True,
        help_text=_(u'The numerator for a percentage value')
    )
    denominator = models.DecimalField(
        _(u'denominator for indicator'),
        max_digits=20, decimal_places=2,
        null=True, blank=True,
        help_text=_(u'The denominator for a percentage value')
    )

    class Meta:
        app_label = 'rsr'
        verbose_name = _(u'disaggregated value')
        verbose_name_plural = _(u'disaggregated values')
Beispiel #10
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 #11
0
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 #12
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 #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