class Scenario(models.Model): plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name='scenarios', verbose_name=_('plan')) name = models.CharField(max_length=100, verbose_name=_('name')) identifier = IdentifierField() description = models.TextField(null=True, blank=True, verbose_name=_('description')) public_fields = [ 'id', 'plan', 'name', 'identifier', 'description', ] class Meta: unique_together = (('plan', 'identifier'), ) verbose_name = _('scenario') verbose_name_plural = _('scenarios') def __str__(self): return self.name
class MonitoringQualityPoint(OrderedModel, TranslatableModel): plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name='monitoring_quality_points', verbose_name=_('plan')) identifier = IdentifierField() translations = TranslatedFields( name=models.CharField( max_length=100, verbose_name=_('name'), ), description_yes=models.CharField( max_length=200, verbose_name=_("description when action fulfills criteria")), description_no=models.CharField( max_length=200, verbose_name=_( "description when action doesn\'t fulfill criteria")), ) class Meta: verbose_name = _('monitoring quality point') verbose_name_plural = _('monitoring quality points') unique_together = (('plan', 'order'), ) ordering = ('plan', 'order') def __str__(self): return self.name
class Plan(ModelWithImage, models.Model): name = models.CharField(max_length=100, verbose_name=_('name')) identifier = IdentifierField(unique=True) created_at = models.DateTimeField(auto_now_add=True, editable=False) site_url = models.URLField(blank=True, null=True, verbose_name=_('site URL')) actions_locked = models.BooleanField( default=False, verbose_name=_('actions locked'), help_text=_('Can actions be added and the official metadata edited?'), ) allow_images_for_actions = models.BooleanField( default=True, verbose_name=_('allow images for actions'), help_text=_('Should custom images for individual actions be allowed')) general_admins = models.ManyToManyField( User, blank=True, related_name='general_admin_plans', verbose_name=_('general administrators'), help_text=_( 'Users that can modify everything related to the action plan')) public_fields = [ 'id', 'name', 'identifier', 'image_url', 'action_schedules', 'actions', 'category_types', 'action_statuses', 'indicator_levels', 'action_impacts', 'blog_posts', 'static_pages', 'general_content', 'impact_groups', 'monitoring_quality_points', ] class Meta: verbose_name = _('plan') verbose_name_plural = _('plans') get_latest_by = 'created_at' ordering = ('created_at', ) def __str__(self): return self.name def get_last_action_identifier(self): return self.actions.order_by('order').values_list('identifier', flat=True).last()
class ActionDecisionLevel(models.Model): plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name='action_decision_levels', verbose_name=_('plan')) name = models.CharField(max_length=200, verbose_name=_('name')) identifier = IdentifierField() class Meta: unique_together = (('plan', 'identifier'), ) def __str__(self): return self.name
class ActionImpact(OrderedModel): plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name='action_impacts', verbose_name=_('plan')) name = models.CharField(max_length=200, verbose_name=_('name')) identifier = IdentifierField() class Meta: unique_together = (('plan', 'identifier'), ) ordering = ('plan', 'order') verbose_name = _('action impact class') verbose_name_plural = _('action impact classes') def __str__(self): return '%s (%s)' % (self.name, self.identifier)
class CategoryType(models.Model): plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name='category_types') name = models.CharField(max_length=50) identifier = IdentifierField() class Meta: unique_together = (('plan', 'identifier'), ) ordering = ('plan', 'name') verbose_name = _('category type') verbose_name_plural = _('category types') def __str__(self): return "%s (%s:%s)" % (self.name, self.plan.identifier, self.identifier)
class ActionStatus(models.Model): plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name='action_statuses', verbose_name=_('plan')) name = models.CharField(max_length=50, verbose_name=_('name')) identifier = IdentifierField(max_length=20) is_completed = models.BooleanField(default=False, verbose_name=_('is completed')) class Meta: unique_together = (('plan', 'identifier'), ) verbose_name = _('action status') verbose_name_plural = _('action statuses') def __str__(self): return self.name
class Category(OrderedModel, ModelWithImage): type = models.ForeignKey(CategoryType, on_delete=models.PROTECT, related_name='categories', verbose_name=_('type')) identifier = IdentifierField() name = models.CharField(max_length=100, verbose_name=_('name')) parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.SET_NULL, related_name='children', verbose_name=_('parent category')) short_description = models.CharField(max_length=200, blank=True, verbose_name=_('short description')) class Meta: unique_together = (('type', 'identifier'), ) verbose_name = _('category') verbose_name_plural = _('categories') ordering = ('type', 'identifier') def clean(self): if self.parent_id is not None: seen_categories = {self.id} obj = self.parent while obj is not None: if obj.id in seen_categories: raise ValidationError({ 'parent': _('Parent forms a loop. Leave empty if top-level category.' ) }) seen_categories.add(obj.id) obj = obj.parent def __str__(self): if self.identifier and self.identifier[0].isnumeric(): return "%s %s" % (self.identifier, self.name) else: return self.name
class ImpactGroup(TranslatableModel): plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name='impact_groups', verbose_name=_('plan')) identifier = IdentifierField() parent = models.ForeignKey('self', on_delete=models.SET_NULL, related_name='children', null=True, blank=True, verbose_name=_('parent')) weight = models.FloatField(verbose_name=_('weight'), null=True, blank=True) color = models.CharField(max_length=16, verbose_name=_('color'), null=True, blank=True, validators=[validate_hex_color]) translations = TranslatedFields(name=models.CharField( verbose_name=_('name'), max_length=200), ) public_fields = [ 'id', 'plan', 'identifier', 'parent', 'weight', 'name', 'color', 'actions', ] class Meta: unique_together = (('plan', 'identifier'), ) verbose_name = _('impact group') verbose_name_plural = _('impact groups') ordering = ('plan', '-weight') def __str__(self): return self.name
class Category(OrderedModel, ModelWithImage): type = models.ForeignKey(CategoryType, on_delete=models.CASCADE, related_name='categories', verbose_name=_('type')) identifier = IdentifierField() name = models.CharField(max_length=100, verbose_name=_('name')) parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.SET_NULL, related_name='children', verbose_name=_('parent category')) class Meta: unique_together = (('type', 'identifier'), ) verbose_name = _('category') verbose_name_plural = _('categories') ordering = ('type', 'identifier') def __str__(self): return "%s %s" % (self.identifier, self.name)
class CategoryType(models.Model): plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name='category_types') name = models.CharField(max_length=50, verbose_name=_('name')) identifier = IdentifierField() editable_for_actions = models.BooleanField( default=False, verbose_name=_('editable for actions'), ) editable_for_indicators = models.BooleanField( default=False, verbose_name=_('editable for indicators'), ) class Meta: unique_together = (('plan', 'identifier'), ) ordering = ('plan', 'name') verbose_name = _('category type') verbose_name_plural = _('category types') def __str__(self): return "%s (%s:%s)" % (self.name, self.plan.identifier, self.identifier)
class Indicator(models.Model): TIME_RESOLUTIONS = (('year', _('year')), ('month', _('month')), ('week', _('week')), ('day', _('day'))) LEVELS = ( ('strategic', _('strategic')), ('tactical', _('tactical')), ('operational', _('operational')), ) plans = models.ManyToManyField('actions.Plan', through='indicators.IndicatorLevel', blank=True, verbose_name=_('plans')) identifier = IdentifierField(null=True, blank=True) name = models.CharField(max_length=100, verbose_name=_('name')) quantity = models.ForeignKey(Quantity, related_name='indicators', on_delete=models.PROTECT, verbose_name=pgettext_lazy( 'physical', 'quantity'), null=True, blank=True) unit = models.ForeignKey(Unit, related_name='indicators', on_delete=models.PROTECT, verbose_name=_('unit')) description = models.TextField(null=True, blank=True, verbose_name=_('description')) categories = models.ManyToManyField('actions.Category', blank=True) time_resolution = models.CharField(max_length=50, choices=TIME_RESOLUTIONS, default=TIME_RESOLUTIONS[0][0], verbose_name=_('time resolution')) latest_graph = models.ForeignKey('IndicatorGraph', null=True, blank=True, related_name='+', on_delete=models.SET_NULL, editable=False) latest_value = models.ForeignKey('IndicatorValue', null=True, blank=True, related_name='+', on_delete=models.SET_NULL, editable=False) datasets = models.ManyToManyField(Dataset, blank=True, verbose_name=_('datasets')) # summaries = models.JSONField(null=True) # E.g.: # { # "day_when_target_reached": "2079-01-22", # "yearly_ghg_emission_reductions_left": "1963000", # } updated_at = models.DateTimeField(auto_now=True, editable=False, verbose_name=_('updated at')) created_at = models.DateTimeField(auto_now_add=True, editable=False, verbose_name=_('created at')) class Meta: verbose_name = _('indicator') verbose_name_plural = _('indicators') ordering = ('-updated_at', ) def get_latest_graph(self): return self.graphs.latest() def set_latest_value(self): try: latest_value = self.values.latest() except IndicatorValue.DoesNotExist: latest_value = None if self.latest_value == latest_value: return self.latest_value = latest_value self.save(update_fields=['latest_value']) def has_current_data(self): return self.latest_value_id is not None def has_current_goals(self): now = timezone.now() return self.goals.filter(date__gte=now).exists() def has_datasets(self): return self.datasets.exists() has_datasets.short_description = _('Has datasets') has_datasets.boolean = True def has_data(self): return self.latest_value_id is not None has_data.short_description = _('Has data') has_data.boolean = True def has_graph(self): return self.latest_graph_id is not None has_graph.short_description = _('Has a graph') has_graph.boolean = True def __str__(self): return self.name
class Plan(ModelWithImage, models.Model): name = models.CharField(max_length=100, verbose_name=_('name')) identifier = IdentifierField(unique=True) created_at = models.DateTimeField(auto_now_add=True, editable=False) site_url = models.URLField(blank=True, null=True, verbose_name=_('site URL')) actions_locked = models.BooleanField( default=False, verbose_name=_('actions locked'), help_text=_('Can actions be added and the official metadata edited?'), ) allow_images_for_actions = models.BooleanField( default=True, verbose_name=_('allow images for actions'), help_text=_('Should custom images for individual actions be allowed')) general_admins = models.ManyToManyField( User, blank=True, related_name='general_admin_plans', verbose_name=_('general administrators'), help_text=_( 'Users that can modify everything related to the action plan')) root_collection = models.OneToOneField( Collection, null=True, on_delete=models.PROTECT, editable=False, related_name='plan', ) admin_group = models.OneToOneField( Group, null=True, on_delete=models.PROTECT, editable=False, related_name='admin_for_plan', ) contact_person_group = models.OneToOneField( Group, null=True, on_delete=models.PROTECT, editable=False, related_name='contact_person_for_plan', ) i18n = TranslationField(fields=['name']) public_fields = [ 'id', 'name', 'identifier', 'image_url', 'action_schedules', 'actions', 'category_types', 'action_statuses', 'indicator_levels', 'action_impacts', 'blog_posts', 'static_pages', 'general_content', 'impact_groups', 'monitoring_quality_points', 'scenarios', ] class Meta: verbose_name = _('plan') verbose_name_plural = _('plans') get_latest_by = 'created_at' ordering = ('created_at', ) def __str__(self): return self.name def get_last_action_identifier(self): return self.actions.order_by('order').values_list('identifier', flat=True).last() def save(self, *args, **kwargs): ret = super().save(*args, **kwargs) update_fields = [] if self.root_collection is None: obj = Collection(name=self.name) Collection.add_root(instance=obj) self.root_collection = obj update_fields.append('root_collection') if self.admin_group is None: obj = Group.objects.create(name='%s admins' % self.name) self.admin_group = obj update_fields.append('admin_group') if self.contact_person_group is None: obj = Group.objects.create(name='%s contact persons' % self.name) self.contact_person_group = obj update_fields.append('contact_person_group') if update_fields: super().save(update_fields=update_fields) return ret
class Action(ModelWithImage, OrderedModel, ClusterableModel): plan = models.ForeignKey(Plan, on_delete=models.CASCADE, default=latest_plan, related_name='actions', verbose_name=_('plan')) name = models.CharField(max_length=1000, verbose_name=_('name')) official_name = models.CharField( null=True, blank=True, max_length=1000, verbose_name=_('official name'), help_text=_('The name as approved by an official party')) identifier = IdentifierField( help_text=_('The identifier for this action (e.g. number)')) description = RichTextField( null=True, blank=True, verbose_name=_('description'), help_text=_('What does this action involve in more detail?')) impact = models.ForeignKey( 'ActionImpact', blank=True, null=True, related_name='actions', on_delete=models.SET_NULL, verbose_name=_('impact'), help_text=_('The impact of this action'), ) internal_priority = models.PositiveIntegerField( blank=True, null=True, verbose_name=_('internal priority')) internal_priority_comment = models.TextField( blank=True, null=True, verbose_name=_('internal priority comment')) status = models.ForeignKey( 'ActionStatus', blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_('status'), ) merged_with = models.ForeignKey( 'Action', blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_('merged with action'), help_text=_('Set if this action is merged with another action'), related_name='merged_actions') completion = models.PositiveIntegerField( null=True, blank=True, verbose_name=_('completion'), editable=False, help_text=_('The completion percentage for this action')) schedule = models.ManyToManyField('ActionSchedule', blank=True, verbose_name=_('schedule')) decision_level = models.ForeignKey('ActionDecisionLevel', blank=True, null=True, related_name='actions', on_delete=models.SET_NULL, verbose_name=_('decision-making level')) categories = models.ManyToManyField('Category', blank=True, verbose_name=_('categories')) indicators = models.ManyToManyField('indicators.Indicator', blank=True, verbose_name=_('indicators'), through='indicators.ActionIndicator', related_name='actions') contact_persons_unordered = models.ManyToManyField( 'people.Person', through='ActionContactPerson', blank=True, related_name='contact_for_actions', verbose_name=_('contact persons')) monitoring_quality_points = models.ManyToManyField( 'MonitoringQualityPoint', blank=True, related_name='actions', editable=False, ) updated_at = models.DateTimeField(editable=False, verbose_name=_('updated at'), default=timezone.now) sent_notifications = GenericRelation('notifications.SentNotification', related_query_name='action') objects = ActionQuerySet.as_manager() # Used by GraphQL + REST API code public_fields = [ 'id', 'plan', 'name', 'official_name', 'identifier', 'description', 'status', 'completion', 'schedule', 'decision_level', 'responsible_parties', 'categories', 'indicators', 'contact_persons', 'updated_at', 'tasks', 'related_indicators', 'impact', 'status_updates', 'merged_with', 'merged_actions', 'impact_groups', 'monitoring_quality_points', ] class Meta: verbose_name = _('action') verbose_name_plural = _('actions') ordering = ('plan', 'order') index_together = (('plan', 'order'), ) permissions = (('admin_action', _("Can administrate all actions")), ) def __str__(self): return "%s. %s" % (self.identifier, self.name) def clean(self): if self.merged_with is not None: other = self.merged_with if other.merged_with == self: raise ValidationError( {'merged_with': _('Other action is merged with this one')}) # FIXME: Make sure FKs and M2Ms point to objects that are within the # same action plan. def is_merged(self): return self.merged_with_id is not None def is_active(self): return not self.is_merged() and (self.status is None or not self.status.is_completed) def get_next_action(self): return Action.objects.filter(plan=self.plan_id, order__gt=self.order).unmerged().first() def get_previous_action(self): return Action.objects.filter( plan=self.plan_id, order__lt=self.order).unmerged().order_by('-order').first() def _calculate_status_from_indicators(self): progress_indicators = self.related_indicators.filter( indicates_action_progress=True) total_completion = 0 total_indicators = 0 is_late = False for action_ind in progress_indicators: ind = action_ind.indicator try: latest_value = ind.values.latest() except ind.values.model.DoesNotExist: continue start_value = ind.values.first() try: last_goal = ind.goals.filter(plan=self.plan).latest() except ind.goals.model.DoesNotExist: continue diff = last_goal.value - start_value.value if not diff: # Avoid divide by zero continue completion = (latest_value.value - start_value.value) / diff total_completion += completion total_indicators += 1 # Figure out if the action is late or not by comparing # the latest measured value to the closest goal closest_goal = ind.goals.filter( plan=self.plan, date__lte=latest_value.date).last() if closest_goal is None: continue # Are we supposed to up or down? if diff > 0: # Up! if closest_goal.value - latest_value.value > 0: is_late = True else: # Down if closest_goal.value - latest_value.value < 0: is_late = True if not total_indicators: return None # Return average completion completion = int((total_completion / total_indicators) * 100) return dict(completion=completion, is_late=is_late) def _calculate_completion_from_tasks(self, tasks): if not tasks: return None n_completed = len( list(filter(lambda x: x.completed_at is not None, tasks))) return dict(completion=int(n_completed * 100 / len(tasks))) def _determine_status(self, tasks, indicator_status): statuses = self.plan.action_statuses.all() if not statuses: return None by_id = {x.identifier: x for x in statuses} KNOWN_IDS = {'not_started', 'on_time', 'late'} # If the status set is not something we can handle, bail out. if not KNOWN_IDS.issubset(set(by_id.keys())): logger.error('Unknown action status IDs: %s' % set(by_id.keys())) return None if indicator_status is not None and indicator_status.get('is_late'): return by_id['late'] today = date.today() def is_late(task): if task.due_at is None or task.completed_at is not None: return False return today > task.due_at late_tasks = list(filter(is_late, tasks)) if not late_tasks: completed_tasks = list( filter(lambda x: x.completed_at is not None, tasks)) if not completed_tasks: return by_id['not_started'] else: return by_id['on_time'] return by_id['late'] def recalculate_status(self): if self.merged_with is not None: return if self.status is not None and self.status.is_completed: if self.completion != 100: self.completion = 100 self.save(update_fields=['completion']) return determine_monitoring_quality(self, self.plan.monitoring_quality_points.all()) tasks = self.tasks.exclude(state=ActionTask.CANCELLED).only( 'due_at', 'completed_at') update_fields = [] indicator_status = self._calculate_status_from_indicators() if indicator_status: new_completion = indicator_status['completion'] else: new_completion = None if self.completion != new_completion: update_fields.append('completion') self.completion = new_completion self.updated_at = timezone.now() update_fields.append('updated_at') status = self._determine_status(tasks, indicator_status) if status is not None and status.id != self.status_id: self.status = status update_fields.append('status') if not update_fields: return self.save(update_fields=update_fields) def set_categories(self, type, categories): if isinstance(type, str): type = self.plan.category_types.get(identifier=type) all_cats = {x.identifier: x for x in type.categories.all()} existing_cats = set(self.categories.filter(type=type)) new_cats = set() for cat in categories: if isinstance(cat, str): cat = all_cats[cat] new_cats.add(cat) for cat in existing_cats - new_cats: self.categories.remove(cat) for cat in new_cats - existing_cats: self.categories.add(cat) def get_notification_context(self): change_url = reverse('admin:actions_action_change', args=(self.id, )) return { 'id': self.id, 'identifier': self.identifier, 'name': self.name, 'change_url': change_url, 'updated_at': self.updated_at } def has_contact_persons(self): return self.contact_persons.exists() has_contact_persons.short_description = _('Has contact persons') has_contact_persons.boolean = True def active_task_count(self): def task_active(task): return task.state != ActionTask.CANCELLED and not task.completed_at active_tasks = [task for task in self.tasks.all() if task_active(task)] return len(active_tasks) active_task_count.short_description = _('Active tasks')