djangoforeign-keystaskdjango-model-field

How can i update the field of a model from another model field in django?


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


Solution

  • 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"})