0

I have two models. DepartmentGoal with a field 'scores' and Task also with a field 'scores' and a foreign departmentgoal.

What I want is; If I allocate scores to DepartmentGoal, scores allocated to Task should subtract from scores of DepartmentGoal which means Any number of instances of scores of Task when summed up should be equal to score of a single instance of DepartmentGoal.

I just need a clue on how to implement this.

This are the models

class DepartmentGoal(models.Model):
   name = models.TextField(max_length=150, null=True)
   score = models.IntegerField(null=True, blank=True)
   created_at = models.DateTimeField(auto_now_add=True, null=True)
   updated_at = models.DateTimeField(auto_now=True, null=True)

   def __str__(self):
       return self.name



class Task(models.Model):
   name = models.CharField(max_length=300, null=True)
   departmentgoal = models.ForeignKey(DepartmentGoal, on_delete=models.CASCADE, related_name='departgoal', null=True, blank=True)
   score = models.IntegerField(null=True, blank=True)
   created_at = models.DateTimeField(auto_now_add=True, null=True)
   updated_at = models.DateTimeField(auto_now=True, null=True)

   def __str__(self):
       return self.name

Here the the forms

class DepartmentGoalForm(ModelForm):
    class Meta:
        model = DepartmentGoal
        fields = (         
            ('name'),                      
            ('score'),                      
        )


class TaskForm(ModelForm):
    class Meta:
        model = Task
        fields = [ 
            'departmentgoal', 
            'name', 
            'score',
            ]

This is my implementation

class Task(models.Model):
   name = models.CharField(max_length=300, null=True)
   departmentgoal = models.ForeignKey(DepartmentGoal, on_delete=models.CASCADE, related_name='departgoal', null=True, blank=True)
   score = models.IntegerField(null=True, blank=True)
   created_at = models.DateTimeField(auto_now_add=True, null=True)
   updated_at = models.DateTimeField(auto_now=True, null=True)


    def save(self, *args, **kwargs):
        goal = DepartmentGoal.objects.get(id=self.departmentgoal.id)
        goal.scores -= self.scores
        goal.save()
        super(Task, self).save(*args, **kwargs)

My problem right now is, if departmentgoals scores is exhausted, i.e. becomes 0, users are still able to add new task scores to task and this updates the value of departmentgoal scores to negative score. This is the behavior I want to prevent. If the value of departmentgoal scores reaches zero, Users should be unable to add more task and task scores

  • So if there will be multiple instances of Task... then from which Task are you planning to subtract the score...?? – Shivendra Pratap Kushwaha Jul 24 '21 at 03:49
  • If I'm understanding correctly, you want the `score` of `DepartmentGoal` to be a calculated field that is the sum of `score`(s) of the associated `Task`(s)? – Scratch'N'Purr Jul 24 '21 at 05:56
  • @Scratch’N’purr. Yes. – Thaddeaus Iorbee Jul 24 '21 at 13:35
  • @Pratab multiple instances of a task scores should sum up to a single department goal. If a goal is set e.g Build a house and I set a score 10point for achieving this. Sub goals which are also know as tasks e.g Draw Building plan = 5. Build the house = 3. Roof the house=2 will be set. These scored will sum up to the points of the goal which is 10. This is what I have been trying to achieve for the past 3 days of restless nights. – Thaddeaus Iorbee Jul 24 '21 at 13:44
  • I want to be able to set the goal scores and task scores using forms. – Thaddeaus Iorbee Jul 24 '21 at 13:50

1 Answers1

2

Former response

The way I would go about this is only expose task scores as editable and upon saving or updating a Task instance, update the score of the associated DepartmentGoal. The reason I would not allow editing of DepartmentGoal scores is because propagating the changes down to the associated tasks would be difficult.

Imagine if you have a DepartmentGoal score of 10 and it has two associated tasks:

  1. Task 1 - currently, score is set to 7
  2. Task 2 - currently, score is set to 3

Now if you update the DepartmentGoal score to 13, how do you propagate the changes down to the tasks? Does task 1's score increase by 2 and task 2's score increase by 1? Do each task's score increase by an equal amount (which in this case would mean +1.5 for each task)?

So by only allowing the editing of the task scores and propagating the changes back up to the DepartmentGoal, you can at least be confident that the DepartmentGoal score will accurately reflect the sum of the associated Task scores. Afterall, based on your comment, you agreed that the DepartmentGoal score is a calculated field.

So the solution is really simple. You can override your Task model's save method, or use a post-save signal. I'll go with the former approach for demonstration but if you choose to use post-save signals, it would be similar.

class Task(models.Model):
    name = models.CharField(max_length=300, null=True)
    departmentgoal = models.ForeignKey(
        DepartmentGoal,
        on_delete=models.CASCADE,
        related_name='departgoal',
        null=True,
        blank=True)
    score = models.IntegerField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True, null=True)
    updated_at = models.DateTimeField(auto_now=True, null=True)

    def __str__(self):
        return self.name
    
    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)

        # post-save, update the associated score of the `DepartmentGoal`
        # 1. grab associated `DepartmentGoal`
        goal = DepartmentGoal.objects.get(id=self.departmentgoal.id)
        # 2. sum all task scores of the `DeparmentGoal`
        # Note: I'm using `departgoal` which is the `related_name` you set on
        # the foreign key. I would rename this to `tasks` since the `related_name`
        # is the reference back to the model from the foreign key.
        sum = goal.departgoal.values("departmentgoal") \
            .annotate(total=models.Sum("score")) \
            .values_list("total", flat=True)[0]
        # 3. update score of `DepartmentGoal` with the calculated `sum`
        goal.score = sum
        goal.save(update_fields=["score"])

This is just a minimum viable example. Obviously, there can be some optimizations for the post-save hook such as checking whether the score of the task had changed, but this would require utilizing a field tracker such as the one provided by django-model-utils.

Additional note:

You can also utilize the property approach, where you don't need to run any post-save hooks, but have python calculate the sum of the scores when you call the property attribute. This has the benefit where you don't need to do any calculations after saving a task (hence a performance optimization). However, the disadvantage is that you would not be able to use properties in a django queryset because querysets use fields, not properties.

class DepartmentGoal(models.Model):
    name = models.TextField(max_length=150, null=True)
    created_at = models.DateTimeField(auto_now_add=True, null=True)
    updated_at = models.DateTimeField(auto_now=True, null=True)
 
    def __str__(self):
        return self.name

    @property
    def score(self):
        if self.departgoal.count() > 0:
            return (
                self.departgoal.values("departmentgoal")
                .annotate(total=models.Sum("score"))
                .values_list("total", flat=True)[0]
            )
        return 0

Updated response

Here's what your requirements are:

  1. Define upfront what the score is for a DepartmentGoal.
  2. Any new task with a given score will decrement the pre-defined score of the DepartmentGoal.
  3. Once the pre-defined score has been exhausted, no additional tasks for that DepartmentGoal should be allowed.
  4. In addition, any modification of a task's score should not contribute to a total task score that exceeds the pre-defined score.

Solution

  1. In your DepartmentGoal model, define a field called score. This is the field where you define the score upfront, and is a required field.
  2. In your Task model, add a clean method to validate the score. The clean method will automatically be called by your ModelForm.
  3. Back in your DepartmentGoal model, add a clean method as well to validate the score, in case a user plans to revise the score for the goal. This ensures that the score isn't set below the sum of the tasks if the goal already has associated tasks.
from django.core.exceptions import ValidationError


class DepartmentGoal(models.Model):
    name = models.TextField(max_length=150, null=True)
    score = models.IntegerField()  # 1
    created_at = models.DateTimeField(auto_now_add=True, null=True)
    updated_at = models.DateTimeField(auto_now=True, null=True)
 
    def __str__(self):
        return self.name

    # 3
    def clean(self):
        # calculate all contributed scores from tasks
        if self.departgoal.count() > 0:
            task_scores = self.departgoal.values("departmentgoal") \
                .annotate(total=models.Sum("score")) \
                .values_list("total", flat=True)[0]
        else:
            task_scores = 0
        
        # is new score lower than `task_scores`
        if self.score < task_scores:
            raise ValidationError({"score": "Score not enough"})


class Task(models.Model):
    name = models.CharField(max_length=300, null=True)
    departmentgoal = models.ForeignKey(
        DepartmentGoal,
        on_delete=models.CASCADE,
        related_name='departgoal',
        null=True,
        blank=True)
    score = models.IntegerField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True, null=True)
    updated_at = models.DateTimeField(auto_now=True, null=True)

    def __str__(self):
        return self.name

    # 2
    def clean(self):
        # calculate contributed scores from other tasks
        other_tasks = Task.objects.exclude(pk=self.pk) \
            .filter(departmentgoal=self.departmentgoal)

        if other_tasks.count() > 0:
            contributed = (
                other_tasks.values("departmentgoal")
                .annotate(total=models.Sum("score"))
                .values_list("total", flat=True)[0]
            )
        else:
            contributed = 0

        # is the sum of this task's score and `contributed`
        # greater than DeparmentGoal's `score`
        if self.score and self.score + contributed > self.departmentgoal.score:
            raise ValidationError({"score": "Score is too much"})
Scratch'N'Purr
  • 9,959
  • 2
  • 35
  • 51
  • Thanks for your prompt response @Scratch’N’purr. Your solution is very close. Its summing up tasks but updating the DepartmentGoal. I dont want DepartmentGoal to be updated. If DepartmentGoal Score is 10. if you add tasks scores together, it should be equal to 10. e.g departmentgoal = 10, task1 = 7, task2 = 3. so, task1 + task2 = departmentgoal. which is 10 and should should remain 10. Can we have a conditional statement that will ensure the summation of several Tasks score must be equal to the fixed DepartmentGoal scores? – Thaddeaus Iorbee Jul 25 '21 at 13:09
  • Hi @Scratch’N’purr, I have edited the main question. Kindly check it again. Thanks. – Thaddeaus Iorbee Jul 25 '21 at 16:10
  • @ThaddeausIorbee edited with my new response – Scratch'N'Purr Jul 26 '21 at 01:39
  • Hi @Scratch’N’purr, I just implemented your code but its showing this error 'Exception Value:list index out of range'. Do you know why its showing it? – Thaddeaus Iorbee Jul 26 '21 at 02:07
  • @ThaddeausIorbee that means the queryset returned no other tasks other than the task itself. I'll update my answer. – Scratch'N'Purr Jul 26 '21 at 02:28
  • I am still having difficulty solving this problem. – Thaddeaus Iorbee Jul 27 '21 at 02:53
  • I updated my answer yesterday. What additional difficulties are you experiencing? – Scratch'N'Purr Jul 27 '21 at 02:54
  • Thank you @Scratch'N'Purr. The issue now is unsupported operand type(s) for +: 'NoneType' and 'int' and i think the problem is from this line if self.scores + contributed > self.departmentgoal.scores. What do you think? – Thaddeaus Iorbee Jul 27 '21 at 23:49
  • @ThaddeausIorbee ah yes, that's the line since your `score` can be null. You can enforce the field to be required or modify the code to be `if self.score and self.score + contributed > self.departmentgoal.score:` – Scratch'N'Purr Jul 27 '21 at 23:58
  • It worked like magic. Thanks @Scratch'N'Purr. I will accept this answer right away. I will read about annotate and aggregate. – Thaddeaus Iorbee Jul 28 '21 at 00:43
  • Thank you. I just thought of another required validation check on the `DepartmentGoal` model in case the user plans to revise the score for a goal. I will edit my answer now. – Scratch'N'Purr Jul 28 '21 at 10:47
  • ’N’Purr. Thank you so much. I will test it. – Thaddeaus Iorbee Jul 31 '21 at 17:31