class IndicatorPeriodTargetLocation(models.Model): period = models.ForeignKey(IndicatorPeriod, verbose_name=_(u'indicator period'), related_name='target_locations') location = ValidXMLCharField( _(u'location'), blank=True, max_length=25, help_text=_( u'A location of the target of this indicator period. The location must be the ' u'reference of an existing location of the current project.')) class Meta: app_label = 'rsr' verbose_name = _(u'indicator period target location') verbose_name_plural = _(u'indicator period target locations') def __unicode__(self): return self.location
class OrganisationBudget(OrganisationFinanceBasic): status = ValidXMLCharField( _(u'status'), max_length=1, blank=True, choices=codelist_choices(BUDGET_STATUS), help_text=_( u'The status explains whether the budget being reported is indicative or has ' u'been formally committed.')) class Meta: app_label = 'rsr' abstract = True def iati_status(self): return codelist_value(BudgetStatus, self, 'status') def iati_status_unicode(self): return str(self.iati_status())
class IndicatorPeriodTargetLocation(models.Model): project_relation = 'results__indicators__periods__target_locations__in' period = models.ForeignKey(IndicatorPeriod, on_delete=models.CASCADE, verbose_name=_('indicator period'), related_name='target_locations') location = ValidXMLCharField( _('location'), blank=True, max_length=25, help_text=_('A location of the target of this indicator period. The location must be the ' 'reference of an existing location of the current project.')) class Meta: app_label = 'rsr' verbose_name = _('indicator period target location') verbose_name_plural = _('indicator period target locations') ordering = ('pk',) def __str__(self): return self.location
class Result(models.Model): project = models.ForeignKey('Project', verbose_name=_('project'), related_name='results') title = ValidXMLCharField( _('result title'), blank=True, max_length=500, help_text=_('The aim of the project in one sentence. This doesn’t need to be something ' 'that can be directly counted, but it should describe an overall goal of the ' 'project. There can be multiple results for one project.') ) type = ValidXMLCharField( _('result type'), blank=True, max_length=1, choices=codelist_choices(RESULT_TYPE), help_text=_('Choose whether the result is an output, outcome or impact.<br/>' '1 - Output: Direct result of the project activities. E.g. number of booklets ' 'produced, workshops held, people trained, latrines build.<br/>' '2 - Outcome: The changes or benefits that result from the program activities ' 'and resulting outputs. E.g number of beneficiaries reached, knowledge ' 'increased, capacity build, monitored behaviour change.<br/>' '3 - Impact: Long-term results of program (on population) that can be ' 'attributed to the project outputs and outcomes. E.g improved health, ' 'increased political participation of women.<br/>' '9 - Other: Another type of result, not specified above.') ) aggregation_status = models.NullBooleanField( _('aggregation status'), blank=True, help_text=_('Indicate whether the data in the result set can be accumulated.') ) description = ValidXMLCharField( _('result description'), blank=True, max_length=2000, help_text=_('You can provide further information of the result here.') ) parent_result = models.ForeignKey('self', blank=True, null=True, default=None, help_text=_('The parent result of this result.'), related_name='child_results') order = models.PositiveSmallIntegerField(_('result order'), null=True, blank=True) def __str__(self): result_unicode = self.title if self.title else '%s' % _('No result title') if self.type: result_unicode += ' (' + self.iati_type().name + ')' if self.indicators.all(): result_unicode += _(' - %s indicators') % (str(self.indicators.count())) return result_unicode def save(self, *args, **kwargs): """Update the values of child results, if a parent result is updated.""" is_new_result = not self.pk for child_result in self.child_results.all(): # Always copy title, type and aggregation status. They should be the same as the parent. child_result.title = self.title child_result.type = self.type child_result.aggregation_status = self.aggregation_status # Only copy the description if the child has none (e.g. new) if not child_result.description and self.description: child_result.description = self.description child_result.save() if is_new_result and Result.objects.filter(project_id=self.project.id).exists(): prev_result = Result.objects.filter(project_id=self.project.id).reverse()[0] if prev_result.order: self.order = prev_result.order + 1 super(Result, self).save(*args, **kwargs) if is_new_result: self.project.copy_result_to_children(self) def clean(self): validation_errors = {} if self.pk and self.parent_result: orig_result = Result.objects.get(pk=self.pk) # Don't allow some values to be changed when it is a child result if self.project != orig_result.project: validation_errors['project'] = '%s' % \ _('It is not possible to update the project of this result, ' 'because it is linked to a parent result.') if self.title != orig_result.title: validation_errors['title'] = '%s' % \ _('It is not possible to update the title of this result, ' 'because it is linked to a parent result.') if self.type != orig_result.type: validation_errors['type'] = '%s' % \ _('It is not possible to update the type of this result, ' 'because it is linked to a parent result.') if self.aggregation_status != orig_result.aggregation_status: validation_errors['aggregation_status'] = '%s' % \ _('It is not possible to update the aggregation status of this result, ' 'because it is linked to a parent result.') if validation_errors: raise ValidationError(validation_errors) def delete(self, *args, **kwargs): """ Check if indicator is ordered manually, and cascade following indicators if needed """ if self.order: sibling_results = Result.objects.filter(project_id=self.project.id) if not self == sibling_results.reverse()[0]: for ind in range(self.order + 1, len(sibling_results)): sibling_results[ind].order -= 1 sibling_results[ind].save() super(Result, self).delete(*args, **kwargs) def iati_type(self): return codelist_value(ResultType, self, 'type') def iati_type_unicode(self): return str(self.iati_type()) def has_info(self): if self.title or self.type or self.aggregation_status or self.description: return True return False def is_calculated(self): return self.project.is_impact_project def parent_project(self): """ Return a dictionary of this result's parent project. """ if self.parent_result: return {self.parent_result.project.id: self.parent_result.project.title} return {} def child_projects(self): """ Return a dictionary of this result's child projects. """ projects = {} for result in Result.objects.filter(parent_result=self).select_related('project'): projects[result.project.id] = result.project.title return projects class Meta: app_label = 'rsr' ordering = ['order', 'id'] verbose_name = _('result') verbose_name_plural = _('results') unique_together = ('project', 'parent_result')
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 OrganisationFinanceBasic(models.Model): currency = ValidXMLCharField(_('currency'), max_length=3, blank=True, choices=codelist_choices(CURRENCY)) value = models.DecimalField( _('value'), max_digits=20, decimal_places=2, null=True, blank=True, help_text=_( 'Enter the amount of budget that is set aside for this specific budget. ' 'Use a period to denote decimals.')) value_date = models.DateField( _('value date'), null=True, blank=True, help_text=_( 'Enter the date (DD/MM/YYYY) to be used for determining the exchange rate for ' 'currency conversions.')) period_start = models.DateField( _('period start'), null=True, blank=True, help_text=_( 'Enter the start date (DD/MM/YYYY) for the budget period.')) period_end = models.DateField( _('period end'), null=True, blank=True, help_text=_('Enter the end date (DD/MM/YYYY) for the budget period.')) class Meta: app_label = 'rsr' abstract = True def __str__(self): if self.value and self.currency: return '%s %s' % (self.currency, '{:,}'.format(int(self.value))) else: return '%s' % _('No currency or value specified') def clean(self): # Don't allow a start date before an end date if self.period_start and self.period_end and (self.period_start > self.period_end): raise ValidationError({ 'period_start': '%s' % _('Period start cannot be at a later time than period ' 'end.'), 'period_end': '%s' % _('Period start cannot be at a later time than period ' 'end.') }) def iati_currency(self): return codelist_value(Currency, self, 'currency') def iati_currency_unicode(self): return str(self.iati_currency())
class HumanitarianScope(models.Model): project = models.ForeignKey('Project', verbose_name=_(u'project'), related_name='humanitarian_scopes') code = ValidXMLCharField( _(u'humanitarian scope code'), blank=True, max_length=25, help_text=_( u'A code for the event or action from the vocabulary specified. More ' u'information on the vocabularies can be found here: ' u'<a href="http://glidenumber.net/glide/public/search/search.jsp" ' u'target="_blank">Glide</a> and ' u'<a href="http://fts.unocha.org/docs/IATICodelist_HS2-1.csv" ' u'target="_blank">Humanitarian plan</a>.')) type = ValidXMLCharField( _(u'humanitarian scope type'), blank=True, max_length=1, choices=codelist_choices(HUMANITARIAN_SCOPE_TYPE), help_text=_( u'The type of event or action being classified. See the ' u'<a href="http://iatistandard.org/202/codelists/HumanitarianScopeType/" ' u'target="_blank">IATI codelist</a>.')) vocabulary = ValidXMLCharField( _(u'humanitarian scope vocabulary'), blank=True, max_length=3, choices=codelist_choices(HUMANITARIAN_SCOPE_VOCABULARY), help_text= _(u'A recognised vocabulary of terms classifying the event or action. See the ' u'<a href="http://iatistandard.org/202/codelists/HumanitarianScopeVocabulary/" ' u'target="_blank">IATI codelist</a>.')) vocabulary_uri = ValidXMLCharField( _(u'humanitarian scope vocabulary URI'), blank=True, max_length=1000, help_text=_( u'If the vocabulary is 99 (reporting organisation), the URI where this ' u'internal vocabulary is defined.')) text = ValidXMLCharField(_(u'humanitarian scope description'), blank=True, max_length=1000, help_text=_(u'Optionally enter a description.')) class Meta: app_label = 'rsr' verbose_name = _(u'humanitarian scope') verbose_name_plural = _(u'humanitarian scopes') def __unicode__(self): if self.text: return self.text elif self.code: return self.code else: return '' def iati_type(self): return codelist_value(HumanitarianScopeType, self, 'type') def iati_type_unicode(self): return str(self.iati_type()) def iati_vocabulary(self): return codelist_value(HumanitarianScopeVocabulary, self, 'vocabulary') def iati_vocabulary_unicode(self): return str(self.iati_vocabulary())
class IndicatorPeriodData(TimestampsMixin, IndicatorUpdateMixin, models.Model): """ Model for adding data to an indicator period. """ project_relation = 'results__indicators__periods__data__in' STATUS_DRAFT = str(_('draft')) STATUS_PENDING = str(_('pending approval')) STATUS_REVISION = str(_('return for revision')) STATUS_APPROVED = str(_('approved')) STATUS_DRAFT_CODE = 'D' STATUS_PENDING_CODE = 'P' STATUS_REVISION_CODE = 'R' STATUS_APPROVED_CODE = 'A' STATUS_CODES_LIST = [ STATUS_DRAFT_CODE, STATUS_PENDING_CODE, STATUS_REVISION_CODE, STATUS_APPROVED_CODE ] STATUSES_LABELS_LIST = [ STATUS_DRAFT, STATUS_PENDING, STATUS_REVISION, STATUS_APPROVED ] STATUSES = list(zip(STATUS_CODES_LIST, STATUSES_LABELS_LIST)) UPDATE_METHODS = ( ('W', _('web')), ('M', _('mobile')), ) period = models.ForeignKey('IndicatorPeriod', verbose_name=_('indicator period'), related_name='data', on_delete=models.PROTECT) # TODO: rename to created_by when old results framework page is no longer in use user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name=_('user'), db_index=True, related_name='created_period_updates') approved_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, verbose_name=_('approved by'), db_index=True, related_name='approved_period_updates', blank=True, null=True, ) narrative = ValidXMLTextField(_('qualitative indicator narrative'), blank=True) score_index = models.SmallIntegerField(_('score index'), null=True, blank=True) score_indices = ArrayField(models.SmallIntegerField(), default=list, blank=True) period_actual_value = ValidXMLCharField(_('period actual value'), max_length=50, default='') status = ValidXMLCharField(_('status'), max_length=1, choices=STATUSES, db_index=True, default=STATUS_DRAFT_CODE) text = ValidXMLTextField(_('text'), blank=True) review_note = ValidXMLTextField(_('text'), blank=True) photo = ImageField(_('photo'), blank=True, upload_to=image_path, max_length=255) file = models.FileField(_('file'), blank=True, upload_to=file_path, max_length=255) update_method = ValidXMLCharField(_('update method'), blank=True, max_length=1, choices=UPDATE_METHODS, db_index=True, default='W') class Meta: app_label = 'rsr' verbose_name = _('indicator period data') verbose_name_plural = _('indicator period data') ordering = ('-id', ) def save(self, recalculate=True, *args, **kwargs): # Allow only a single update for percentage measure indicators if not self.period.can_save_update(self.id): raise MultipleUpdateError( 'Cannot create multiple updates with percentages') if (self.period.indicator.measure == PERCENTAGE_MEASURE and self.numerator is not None and self.denominator not in {0, '0', None}): self.value = calculate_percentage(self.numerator, self.denominator) super(IndicatorPeriodData, self).save(*args, **kwargs) # In case the status is approved, recalculate the period if recalculate and self.status == self.STATUS_APPROVED_CODE: # FIXME: Should we call this even when status is not approved? self.period.recalculate_period() self.period.update_actual_comment() # Update score even when the update is not approved, yet. It handles the # case where an approved update is returned for revision, etc. self.period.update_score() def delete(self, *args, **kwargs): old_status = self.status super(IndicatorPeriodData, self).delete(*args, **kwargs) # In case the status was approved, recalculate the period if old_status == self.STATUS_APPROVED_CODE: self.period.recalculate_period() self.period.update_actual_comment() self.period.update_score() def clean(self): """ Perform several checks before we can actually save the update data. """ validation_errors = {} project = self.period.indicator.result.project # Don't allow a data update to an unpublished project if not project.is_published(): validation_errors['period'] = str( _('Indicator period must be part of a published ' 'project to add data to it')) raise ValidationError(validation_errors) # Don't allow a data update to a non-Impact project if not project.is_impact_project: validation_errors['period'] = str( _('Indicator period must be part of an RSR ' 'Impact project to add data to it')) raise ValidationError(validation_errors) # Don't allow a data update to a locked period if self.period.locked: validation_errors['period'] = str( _('Indicator period must be unlocked to add ' 'data to it')) raise ValidationError(validation_errors) # Don't allow a data update to an aggregated parent period with 'percentage' as measurement if self.period.indicator.children_aggregate_percentage: validation_errors['period'] = str( _('Indicator period has an average aggregate of the child projects. Disable ' 'aggregations to add data to it')) raise ValidationError(validation_errors) if self.pk: orig = IndicatorPeriodData.objects.get(pk=self.pk) # Don't allow for the indicator period to change if orig.period != self.period: validation_errors['period'] = str( _('Not allowed to change indicator period ' 'in a data update')) if self.period.indicator.type == QUANTITATIVE: if self.narrative is not None: validation_errors['period'] = str( _('Narrative field should be empty in quantitative indicators' )) if self.value is not None: try: self.value = Decimal(self.value) except Exception: validation_errors['period'] = str( _('Only numeric values are allowed in quantitative indicators' )) if self.period.indicator.type == QUALITATIVE: if self.value is not None: validation_errors['period'] = str( _('Value field should be empty in qualitative indicators')) if validation_errors: raise ValidationError(validation_errors) @property def status_display(self): """ Returns the display of the status. """ try: return dict(self.STATUSES)[self.status].capitalize() except KeyError: return '' @property def photo_url(self): """ Returns the full URL of the photo. """ return self.photo.url if self.photo else '' @property def file_url(self): """ Returns the full URL of the file. """ return self.file.url if self.file else '' def update_new_value(self): """Returns a string with the new value.""" try: add_up = Decimal(self.value) + Decimal(self.period_actual_value) relative = '+' + str(self.value) if self.value >= 0 else str( self.value) return "{} ({})".format(str(add_up), relative) except (InvalidOperation, TypeError): return self.value @classmethod def get_user_viewable_updates(cls, queryset, user): approved_updates = queryset.filter(status=cls.STATUS_APPROVED_CODE) if user.is_anonymous: f_queryset = approved_updates elif user.is_admin or user.is_superuser: f_queryset = queryset else: from akvo.rsr.models import Project projects = Project.objects\ .filter(results__indicators__periods__data__in=queryset)\ .distinct() project = projects.first() if projects.count() == 1 else None # Allow Nuffic users to see all updates, irrespective of what state they are in if project is not None and project.in_nuffic_hierarchy( ) and user.has_perm('rsr.view_project', project): f_queryset = queryset else: own_updates = queryset.filter(user=user) non_draft_updates = queryset.exclude( status=cls.STATUS_DRAFT_CODE) filter_ = user.get_permission_filter( 'rsr.view_indicatorperioddata', 'period__indicator__result__project__') others_updates = non_draft_updates.filter(filter_) f_queryset = (approved_updates | own_updates | others_updates) return f_queryset.distinct()
class IatiExport(TimestampsMixin, models.Model): STATUS_PENDING = 1 STATUS_IN_PROGRESS = 2 STATUS_COMPLETED = 3 STATUS_CANCELLED = 4 STATUS_CODE = { STATUS_PENDING: _('pending'), STATUS_IN_PROGRESS: _('in progress'), STATUS_COMPLETED: _('completed'), STATUS_CANCELLED: _('cancelled') } reporting_organisation = models.ForeignKey( 'Organisation', verbose_name=_('reporting organisation'), related_name='iati_exports' ) user = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_('user'), related_name='iati_exports' ) projects = models.ManyToManyField('Project', verbose_name=_('projects')) version = ValidXMLCharField(_('version'), max_length=4, default='2.03') status = models.PositiveSmallIntegerField(_('status'), default=STATUS_PENDING) iati_file = models.FileField(_('IATI file'), blank=True, upload_to=file_path) latest = models.BooleanField(_('latest'), default=False) class Meta: app_label = 'rsr' verbose_name = _('IATI export') verbose_name_plural = _('IATI exports') ordering = ('id',) def __str__(self): if self.reporting_organisation and self.reporting_organisation.name: return '%s %s' % (_('IATI export for'), self.reporting_organisation.name) else: return '%s' % _('IATI export for unknown organisation') @property def is_latest(self): return self.latest @is_latest.setter def is_latest(self, latest): if not latest: self.latest = False self.save(update_fields=['latest']) return with transaction.atomic(): # Set all other exports to not be latest latest_exports = IatiExport.objects.filter( latest=True, reporting_organisation=self.reporting_organisation) latest_exports.update(latest=False) # Set current export to be latest self.latest = True self.save(update_fields=['latest']) def show_status(self): if self.status not in self.STATUS_CODE: return _('unknown status') else: return self.STATUS_CODE[self.status].title() def update_status(self, status_code): """ Update the status of this IATI export. :param status_code; Integer in self.STATUS_CODE keys """ self.status = status_code self.save(update_fields=['status']) def update_iati_file(self, iati_file): """ Update the IATI file of this IATI export. :param iati_file; File object """ self.iati_file = iati_file self.save(update_fields=['iati_file']) def create_iati_file(self): """ Create an IATI XML file. """ self.update_status(self.STATUS_IN_PROGRESS) # Retrieve all projects projects = self.projects.all() if projects: try: # Generate and save the IATI file iati_xml = IatiXML(projects, self.version, self) self.update_iati_file(iati_xml.save_file( str(self.reporting_organisation.pk), datetime.utcnow().strftime("%Y%m%d-%H%M%S") + '.xml') ) self.update_status(self.STATUS_COMPLETED) self.is_latest = True except Exception: self.update_status(self.STATUS_CANCELLED) else: self.update_status(self.STATUS_CANCELLED) def processed_projects(self): """ Find the number of processed projects of this IATI export. Generally, for completed exports, this number will be the same as the number of total projects. """ return self.iati_activity_exports.filter(status=self.STATUS_IN_PROGRESS).count()
class Indicator(models.Model): project_relation = 'results__indicators__in' INDICATOR_TYPES = ( (QUANTITATIVE, _('Quantitative')), (QUALITATIVE, _('Qualitative')), ) result = models.ForeignKey('Result', verbose_name=_('result'), related_name='indicators') parent_indicator = models.ForeignKey( 'self', blank=True, null=True, default=None, verbose_name=_('parent indicator'), related_name='child_indicators' ) title = ValidXMLCharField( _('indicator title'), blank=True, max_length=500, help_text=_('Within each result indicators can be defined. Indicators should be items ' 'that can be counted and evaluated as the project continues and is completed.') ) # NOTE: type and measure should probably only be one field measure, wit the values Unit, # Percentage and Qualitative. However since the project editor design splits the choice we use # two fields, type and measure to simplify the interaction between front and back end. type = models.PositiveSmallIntegerField( _('indicator type'), choices=INDICATOR_TYPES, default=QUANTITATIVE ) measure = ValidXMLCharField( _('indicator measure'), blank=True, max_length=1, choices=codelist_choices(INDICATOR_MEASURE), help_text=_('Choose how the indicator will be measured (in percentage or units).') ) ascending = models.NullBooleanField( _('ascending'), blank=True, help_text=_('Choose ascending if the target value of the indicator is higher than the ' 'baseline value (eg. people with access to sanitation). Choose descending if ' 'the target value of the indicator is lower than the baseline value ' '(eg. people with diarrhea).')) description = ValidXMLCharField( _('indicator description'), blank=True, max_length=2000, help_text=_('You can provide further information of the indicator here.') ) baseline_year = models.PositiveIntegerField( _('baseline year'), blank=True, null=True, help_text=_('The year the baseline value was taken.') ) baseline_value = ValidXMLCharField( _('baseline value'), blank=True, max_length=200, help_text=_('The value of the baseline at the start of the project.') ) baseline_comment = ValidXMLCharField( _('baseline comment'), blank=True, max_length=2000, help_text=_('Here you can provide extra information on the baseline value, if needed.') ) target_value = models.DecimalField( _('target value'), max_digits=20, decimal_places=2, null=True, blank=True, help_text=_('The target value for all reporting periods in this indicator.') ) target_comment = ValidXMLCharField( _('target value comment'), blank=True, max_length=2000, help_text=_('Here you can provide extra information on the target value, if needed.') ) order = models.PositiveSmallIntegerField(_('indicator order'), null=True, blank=True) export_to_iati = models.BooleanField( _('Include indicator in IATI exports'), default=True, help_text=_('Choose whether this indicator will be included in IATI exports. ' 'If you are not exporting to IATI, you may ignore this option.') ) dimension_names = models.ManyToManyField('IndicatorDimensionName', related_name='indicators') scores = ArrayField(models.CharField(max_length=1000), default=[]) def __str__(self): indicator_unicode = self.title if self.title else '%s' % _('No indicator title') if self.periods.all(): indicator_unicode += ' - %s %s' % (str(self.periods.count()), _('period(s)')) indicator_unicode += ' - %s' % dict(self.INDICATOR_TYPES)[self.type] return indicator_unicode def save(self, *args, **kwargs): """Update the values of child indicators, if a parent indicator is updated.""" new_indicator = not self.pk if new_indicator and Indicator.objects.filter(result_id=self.result.id).exists(): prev_indicator = Indicator.objects.filter(result_id=self.result.id).reverse()[0] if prev_indicator.order: self.order = prev_indicator.order + 1 # HACK: Delete IndicatorLabels on non-qualitative indicators if new_indicator and self.type != QUALITATIVE: IndicatorLabel.objects.filter(indicator=self).delete() super(Indicator, self).save(*args, **kwargs) for child_result in self.result.child_results.all(): if new_indicator: child_result.project.copy_indicator(child_result, self, set_parent=True) else: child_result.project.update_indicator(child_result, self) def clean(self): validation_errors = {} if self.pk and self.is_child_indicator(): orig_indicator = Indicator.objects.get(pk=self.pk) # Don't allow some values to be changed when it is a child indicator if self.result != orig_indicator.result: validation_errors['result'] = '%s' % \ _('It is not possible to update the result of this indicator, ' 'because it is linked to a parent result.') if self.title != orig_indicator.title: validation_errors['title'] = '%s' % \ _('It is not possible to update the title of this indicator, ' 'because it is linked to a parent result.') if self.measure != orig_indicator.measure: validation_errors['measure'] = '%s' % \ _('It is not possible to update the measure of this indicator, ' 'because it is linked to a parent result.') if self.ascending != orig_indicator.ascending: validation_errors['ascending'] = '%s' % \ _('It is not possible to update the ascending value of this indicator, ' 'because it is linked to a parent result.') if validation_errors: raise ValidationError(validation_errors) def delete(self, *args, **kwargs): """ Check if indicator is ordered manually, and cascade following indicators if needed """ if self.order: sibling_indicators = Indicator.objects.filter(result_id=self.result.id) if not self == sibling_indicators.reverse()[0]: for ind in range(self.order + 1, len(sibling_indicators)): sibling_indicators[ind].order -= 1 sibling_indicators[ind].save() super(Indicator, self).delete(*args, **kwargs) def iati_measure(self): return codelist_value(IndicatorMeasure, self, 'measure') def iati_measure_unicode(self): return str(self.iati_measure()) def is_calculated(self): return self.result.project.is_impact_project def is_child_indicator(self): """ Indicates whether this indicator is linked to a parent indicator. """ return bool(self.parent_indicator) def is_parent_indicator(self): """ Indicates whether this indicator has children. """ return self.child_indicators.count() > 0 @property def children_aggregate_percentage(self): """ Returns True if this indicator has percentage as a measure and has children that aggregate to this indicator. """ if self.measure == PERCENTAGE_MEASURE and self.is_parent_indicator() and \ self.result.project.aggregate_children and \ any(self.child_indicators.values_list('result__project__aggregate_to_parent', flat=True)): return True return False class Meta: app_label = 'rsr' ordering = ['order', 'id'] verbose_name = _('indicator') verbose_name_plural = _('indicators') unique_together = ('result', 'parent_indicator')
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 IatiExport(TimestampsMixin, models.Model): reporting_organisation = models.ForeignKey( 'Organisation', verbose_name=_(u'reporting organisation'), related_name='iati_exports') user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_(u'user'), related_name='iati_exports') projects = models.ManyToManyField('Project', verbose_name=_(u'projects')) version = ValidXMLCharField(_(u'version'), max_length=4, default='2.02') status = models.PositiveSmallIntegerField(_(u'status'), default=1) iati_file = models.FileField(_(u'IATI file'), blank=True, upload_to=file_path) is_public = models.BooleanField(_(u'public'), default=True) class Meta: app_label = 'rsr' verbose_name = _(u'IATI export') verbose_name_plural = _(u'IATI exports') def __unicode__(self): if self.reporting_organisation and self.reporting_organisation.name: return u'%s %s' % (_(u'IATI export for'), self.reporting_organisation.name) else: return u'%s' % _(u'IATI export for unknown organisation') def show_status(self): if self.status not in STATUS_CODE.keys(): return _(u'unknown status') else: return STATUS_CODE[self.status].title() def update_status(self, status_code): """ Update the status of this IATI export. :param status_code; Integer in STATUS_CODE keys """ self.status = status_code self.save(update_fields=['status']) def update_iati_file(self, iati_file): """ Update the IATI file of this IATI export. :param iati_file; File object """ self.iati_file = iati_file self.save(update_fields=['iati_file']) def create_iati_file(self): """ Create an IATI XML file. """ # Set status to 'In progress' self.update_status(2) # Retrieve all projects projects = self.projects.all() if projects: try: # Generate and save the IATI file iati_xml = IatiXML(projects, self.version, self) self.update_iati_file( iati_xml.save_file( str(self.reporting_organisation.pk), datetime.utcnow().strftime("%Y%m%d-%H%M%S") + '.xml')) # All done, so update the status to 'Completed' self.update_status(3) except: # Something went wrong, so update the status to 'Cancelled' self.update_status(4) else: # No projects, so update the status to 'Cancelled' self.update_status(4) def processed_projects(self): """ Find the number of processed projects of this IATI export. Generally, for completed exports, this number will be the same as the number of total projects. """ return self.iati_activity_exports.filter(status=2).count()
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
class IndicatorPeriod(models.Model): indicator = models.ForeignKey(Indicator, verbose_name=_(u'indicator'), related_name='periods') parent_period = models.ForeignKey( 'self', blank=True, null=True, default=None, verbose_name=_(u'parent indicator period'), related_name='child_periods') locked = models.BooleanField(_(u'locked'), default=True, db_index=True) period_start = models.DateField( _(u'period start'), null=True, blank=True, help_text=_( u'The start date of the reporting period for this indicator.')) period_end = models.DateField( _(u'period end'), null=True, blank=True, help_text=_( u'The end date of the reporting period for this indicator.')) target_value = ValidXMLCharField( _(u'target value'), blank=True, max_length=50, help_text=_(u'The target value for the above period.')) target_comment = ValidXMLCharField( _(u'target value comment'), blank=True, max_length=2000, help_text= _(u'Here you can provide extra information on the target value, if needed.' )) actual_value = ValidXMLCharField( _(u'actual value'), blank=True, max_length=50, help_text=_(u'A record of the achieved result for this period.')) actual_comment = ValidXMLCharField( _(u'actual value comment'), blank=True, max_length=2000, help_text= _(u'Here you can provide extra information on the actual value, if needed ' u'(for instance, why the actual value differs from the target value).' )) def __unicode__(self): if self.period_start: period_unicode = unicode(self.period_start) else: period_unicode = u'%s' % _(u'No start date') if self.period_end: period_unicode += u' - %s' % unicode(self.period_end) else: period_unicode += u' - %s' % _(u'No end date') if self.actual_value or self.target_value: period_unicode += u' (' if self.actual_value and self.target_value: period_unicode += u'actual: %s / target: %s)' % (unicode( self.actual_value), unicode(self.target_value)) elif self.actual_value: period_unicode += u'actual: %s)' % unicode(self.actual_value) else: period_unicode += u'target: %s)' % unicode(self.target_value) return period_unicode def save(self, *args, **kwargs): actual_value_changed = False # When the general information of a parent period is updated, this information should also # be reflected in the child periods. if self.pk: for child_period in self.child_periods.all(): # Always copy period start and end. They should be the same as the parent. child_period.period_start = self.period_start child_period.period_end = self.period_end # Only copy the target value and comments if the child has no values (in case the # child period is new). Afterwards, it is possible to adjust these values (update # the target for the child, for instance) and then these values should not be # overwritten. if not child_period.target_value and self.target_value: child_period.target_value = self.target_value if not child_period.target_comment and self.target_comment: child_period.target_comment = self.target_comment child_period.save() # Check if the actual value has changed orig_period = IndicatorPeriod.objects.get(pk=self.pk) if orig_period.actual_value != self.actual_value: actual_value_changed = True # In case the period is new and the period's indicator does have child indicators, the (new) # period should also be copied to the child indicator. else: for child_indicator in self.indicator.child_indicators.all(): child_indicator.result.project.add_period( child_indicator, self) super(IndicatorPeriod, self).save(*args, **kwargs) # If the actual value has changed, the period has a parent period and aggregations are on, # then the the parent should be updated as well if actual_value_changed and self.is_child_period() and \ self.parent_period.indicator.result.project.aggregate_children and \ self.indicator.result.project.aggregate_to_parent: self.parent_period.recalculate_period() def clean(self): validation_errors = {} if self.pk: orig_period = IndicatorPeriod.objects.get(pk=self.pk) # Don't allow an actual value to be changed when the indicator period is calculated if self.is_calculated( ) and self.actual_value != orig_period.actual_value: validation_errors['actual_value'] = u'%s' % \ _(u'It is not possible to update the actual value of this indicator period, ' u'because it is a calculated value. Please update the actual value through ' u'a new update.') # Don't allow some values to be changed when it is a child period if self.is_child_period(): if self.indicator != orig_period.indicator: validation_errors['indicator'] = u'%s' % \ _(u'It is not possible to update the indicator of this indicator period, ' u'because it is linked to a parent result.') if self.period_start != orig_period.period_start: validation_errors['period_start'] = u'%s' % \ _(u'It is not possible to update the start period of this indicator, ' u'because it is linked to a parent result.') if self.period_end != orig_period.period_end: validation_errors['period_end'] = u'%s' % \ _(u'It is not possible to update the end period of this indicator, ' u'because it is linked to a parent result.') # Don't allow a start date before an end date if self.period_start and self.period_end and (self.period_start > self.period_end): validation_errors['period_start'] = u'%s' % _( u'Period start cannot be at a later time ' u'than period end.') validation_errors['period_end'] = u'%s' % _( u'Period start cannot be at a later time ' u'than period end.') # TODO: add validation that prevents creating a period for a child indicator if validation_errors: raise ValidationError(validation_errors) def recalculate_period(self, save=True, only_self=False): """ Re-calculate the values of all updates from the start. This will prevent strange values, for example when an update is deleted or edited after it has been approved. :param save; Boolean, saves actual value to period if True :param only_self; Boolean, to take into account if this is a parent or just re-calculate this period only :return Actual value of period """ # If this period is a parent period, the sum or average of the children should be # re-calculated if not only_self and self.is_parent_period() and \ self.indicator.result.project.aggregate_children: return self.recalculate_children(save) prev_val = '0' # For every approved update, add up the new value (if possible) for update in self.data.filter(status='A').order_by('created_at'): update.period_actual_value = prev_val update.save(recalculate=False) if update.relative_data: try: # Try to add up the update to the previous actual value prev_val = str(Decimal(prev_val) + Decimal(update.data)) except InvalidOperation: # If not possible, the update data or previous value is a normal string prev_val = update.data else: prev_val = update.data # For every non-approved update, set the data to the current data for update in self.data.exclude(status='A'): update.period_actual_value = prev_val update.save(recalculate=False) # Special case: only_self and no data should give an empty string instead of '0' if only_self and not self.data.exists(): prev_val = '' # Finally, update the actual value of the period itself if save: self.actual_value = prev_val self.save() # Return the actual value of the period itself return prev_val def recalculate_children(self, save=True): """ Re-calculate the actual value of this period based on the actual values of the child periods. In case the measurement is 'Percentage', it should be an average of all child periods. Otherwise, the child period values can just be added up. :param save; Boolean, saves to period if True :return Actual value of period """ if self.indicator.measure == '2': new_value = self.child_periods_average() else: new_value = self.child_periods_sum(include_self=True) if save: self.actual_value = new_value self.save() return new_value def update_actual_comment(self, save=True): """ Set the actual comment to the text of the latest approved update. :param save; Boolean, save period if True :return Actual comment of period """ approved_updates = self.data.filter( status=IndicatorPeriodData.STATUS_APPROVED_CODE) update_texts = [ u'{}: {}'.format(update.last_modified_at.strftime('%d-%m-%Y'), update.text) for update in approved_updates.order_by('-created_at') ] actual_comment = u' | '.join(update_texts) if len(actual_comment) >= 2000: # max_size actual_comment = u'{} ...'.format(actual_comment[:1995]) self.actual_comment = actual_comment if save: self.save() return self.actual_comment def is_calculated(self): """ When a period has got indicator updates, we consider the actual value to be a 'calculated' value, meaning that it's not possible to update the actual value directly. Only through indicator updates. """ return self.data.exists() def actual_value_is_decimal(self): try: Decimal(self.actual_value) return True except (InvalidOperation, TypeError): return not self.actual_value def is_child_period(self): """ Indicates whether this period is linked to a parent period """ return bool(self.parent_period) def is_parent_period(self): """ Indicates whether this result has child periods linked to it. """ return self.child_periods.count() > 0 def child_periods_with_data(self): """ Returns the child indicator periods with numeric data """ children_with_data = [] for child in self.child_periods.all(): try: Decimal(child.actual_value) children_with_data += [child.pk] except (InvalidOperation, TypeError): pass return self.child_periods.filter(pk__in=children_with_data) # TODO: refactor child_periods_sum() and child_periods_average() and child_periods_with_data(), # they use each other in very inefficient ways I think def child_periods_sum(self, include_self=False): """ Returns the sum of child indicator periods. :param include_self; Boolean to include the updates on the period itself, as well as its' children :return String of the sum """ period_sum = 0 # Loop through the child periods and sum up all the values for period in self.child_periods.all(): if period.indicator.result.project.aggregate_to_parent and period.actual_value: try: period_sum += Decimal(period.actual_value) except (InvalidOperation, TypeError): pass if include_self: try: period_sum += Decimal( self.recalculate_period(save=False, only_self=True)) except (InvalidOperation, TypeError): pass return str(period_sum) def child_periods_average(self): """ Returns the average of child indicator periods. :return String of the average """ if self.indicator.result.project.aggregate_children: child_periods = self.child_periods_with_data() for child in child_periods: if not (child.indicator.result.project.aggregate_to_parent and child.actual_value): child_periods = child_periods.exclude(pk=child.pk) number_of_child_periods = child_periods.count() if number_of_child_periods > 0: return str( Decimal(self.child_periods_sum()) / number_of_child_periods) return '0' def adjacent_period(self, next_period=True): """ Returns the next or previous indicator period, if we can find one with a start date, and we have a start date ourselves. :param next_period; Boolean indicating either the next (True) or previous (False) period. """ if not self.period_start: return None elif next_period: return self.indicator.periods.exclude(period_start=None).filter( period_start__gt=self.period_start).order_by( 'period_start').first() else: return self.indicator.periods.exclude(period_start=None).filter( period_start__lt=self.period_start).order_by( '-period_start').first() @property def percent_accomplishment(self): """ Return the percentage completed for this indicator period. If not possible to convert the values to numbers, return None. """ try: return round( Decimal(self.actual_value) / Decimal(self.target_value) * 100, 1) except (InvalidOperation, TypeError, DivisionByZero): return None @property def percent_accomplishment_100(self): """ Similar to the percent_accomplishment property. However, it won't return any number bigger than 100. """ return max(self.percent_accomplishment, 100) if self.percent_accomplishment else None @property def actual(self): """ Returns the actual value of the indicator period, if it can be converted to a number. Otherwise it'll return the baseline value, which is a calculated value. """ try: return Decimal(self.actual_value) except (InvalidOperation, TypeError): return self.actual_value if self.actual_value else self.baseline @property def target(self): """ Returns the target value of the indicator period, if it can be converted to a number. Otherwise it'll return just the target value. """ try: return Decimal(self.target_value) except (InvalidOperation, TypeError): return self.target_value @property def baseline(self): """ Returns the baseline value of the indicator. The baseline is a calculated value: - If the period has no previous periods, then it's the baseline value of the indicator - If the period has a previous period, then it's the actual value of that period When this baseline value is empty, it returns 0. Otherwise (e.g. 'Available') it just returns the baseline value. """ previous_period = self.adjacent_period(False) baseline = self.indicator.baseline_value if not previous_period else previous_period.actual if not baseline: return Decimal(0) else: try: return Decimal(baseline) except (InvalidOperation, TypeError): return baseline class Meta: app_label = 'rsr' verbose_name = _(u'indicator period') verbose_name_plural = _(u'indicator periods') ordering = ['period_start']
class Indicator(models.Model): result = models.ForeignKey('Result', verbose_name=_(u'result'), related_name='indicators') parent_indicator = models.ForeignKey('self', blank=True, null=True, default=None, verbose_name=_(u'parent indicator'), related_name='child_indicators') title = ValidXMLCharField( _(u'indicator title'), blank=True, max_length=500, help_text= _(u'Within each result indicators can be defined. Indicators should be items ' u'that can be counted and evaluated as the project continues and is completed.' )) measure = ValidXMLCharField( _(u'indicator measure'), blank=True, max_length=1, choices=codelist_choices(INDICATOR_MEASURE), help_text= _(u'Choose how the indicator will be measured (in percentage or units).' )) ascending = models.NullBooleanField( _(u'ascending'), blank=True, help_text= _(u'Choose ascending if the target value of the indicator is higher than the ' u'baseline value (eg. people with access to sanitation). Choose descending if ' u'the target value of the indicator is lower than the baseline value ' u'(eg. people with diarrhea).')) description = ValidXMLCharField( _(u'indicator description'), blank=True, max_length=2000, help_text=_( u'You can provide further information of the indicator here.')) baseline_year = models.PositiveIntegerField( _(u'baseline year'), blank=True, null=True, max_length=4, help_text=_(u'The year the baseline value was taken.')) baseline_value = ValidXMLCharField( _(u'baseline value'), blank=True, max_length=50, help_text=_(u'The value of the baseline at the start of the project.')) baseline_comment = ValidXMLCharField( _(u'baseline comment'), blank=True, max_length=2000, help_text= _(u'Here you can provide extra information on the baseline value, if needed.' )) order = models.PositiveSmallIntegerField(_(u'indicator order'), null=True, blank=True) default_periods = models.NullBooleanField( _(u'default indicator periods'), default=False, blank=True, help_text=_( u'Determines whether periods of indicator are used by default.')) def __unicode__(self): indicator_unicode = self.title if self.title else u'%s' % _( u'No indicator title') if self.periods.all(): indicator_unicode += u' - %s %s' % (unicode( self.periods.count()), _(u'period(s)')) return indicator_unicode def save(self, *args, **kwargs): """Update the values of child indicators, if a parent indicator is updated.""" # Update the values for an existing indicator if self.pk: for child_indicator in self.child_indicators.all(): # Always copy title, measure and ascending. They should be the same as the parent. child_indicator.title = self.title child_indicator.measure = self.measure child_indicator.ascending = self.ascending # Only copy the description and baseline if the child has none (e.g. new) fields = [ 'description', 'baseline_year', 'baseline_value', 'baseline_comment' ] for field in fields: parent_field_value = getattr(self, field) if not getattr(child_indicator, field) and parent_field_value: setattr(child_indicator, field, parent_field_value) child_indicator.save() # Create a new indicator when it's added else: for child_result in self.result.child_results.all(): child_result.project.add_indicator(child_result, self) if Indicator.objects.filter(result_id=self.result.id).exists(): prev_indicator = Indicator.objects.filter( result_id=self.result.id).reverse()[0] if prev_indicator.order: self.order = prev_indicator.order + 1 super(Indicator, self).save(*args, **kwargs) def clean(self): validation_errors = {} if self.pk and self.is_child_indicator(): orig_indicator = Indicator.objects.get(pk=self.pk) # Don't allow some values to be changed when it is a child indicator if self.result != orig_indicator.result: validation_errors['result'] = u'%s' % \ _(u'It is not possible to update the result of this indicator, ' u'because it is linked to a parent result.') if self.title != orig_indicator.title: validation_errors['title'] = u'%s' % \ _(u'It is not possible to update the title of this indicator, ' u'because it is linked to a parent result.') if self.measure != orig_indicator.measure: validation_errors['measure'] = u'%s' % \ _(u'It is not possible to update the measure of this indicator, ' u'because it is linked to a parent result.') if self.ascending != orig_indicator.ascending: validation_errors['ascending'] = u'%s' % \ _(u'It is not possible to update the ascending value of this indicator, ' u'because it is linked to a parent result.') if validation_errors: raise ValidationError(validation_errors) def delete(self, *args, **kwargs): """ Check if indicator is ordered manually, and cascade following indicators if needed """ if self.order: sibling_indicators = Indicator.objects.filter( result_id=self.result.id) if not self == sibling_indicators.reverse()[0]: for ind in range(self.order + 1, len(sibling_indicators)): sibling_indicators[ind].order -= 1 sibling_indicators[ind].save() super(Indicator, self).delete(*args, **kwargs) def iati_measure(self): return codelist_value(IndicatorMeasure, self, 'measure') def iati_measure_unicode(self): return str(self.iati_measure()) def is_calculated(self): return self.result.project.is_impact_project def is_child_indicator(self): """ Indicates whether this indicator is linked to a parent indicator. """ return bool(self.parent_indicator) def is_parent_indicator(self): """ Indicates whether this indicator has children. """ return self.child_indicators.count() > 0 @property def last_updated(self): from akvo.rsr.models import ProjectUpdate period_updates = ProjectUpdate.objects.filter( indicator_period__indicator=self) return period_updates.order_by( '-created_at')[0].time_gmt if period_updates else None @property def baseline(self): """ Returns the baseline value of the indicator, if it can be converted to a number. Otherwise it'll return None. """ try: return Decimal(self.baseline_value) except (InvalidOperation, TypeError): return None @property def children_aggregate_percentage(self): """ Returns True if this indicator has percentage as a measure and has children that aggregate to this indicator. """ if self.measure == '2' and self.is_parent_indicator() and \ self.result.project.aggregate_children and \ any([ind.result.project.aggregate_to_parent for ind in self.child_indicators.all()]): return True return False class Meta: app_label = 'rsr' ordering = ['order', 'id'] verbose_name = _(u'indicator') verbose_name_plural = _(u'indicators')