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')
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)
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')
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')
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', )
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')
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', )
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')
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')
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()
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()
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