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:
- Task 1 - currently, score is set to 7
- 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:
- Define upfront what the score is for a DepartmentGoal.
- Any new task with a given score will decrement the pre-defined score of the DepartmentGoal.
- Once the pre-defined score has been exhausted, no additional tasks for that DepartmentGoal should be allowed.
- In addition, any modification of a task's score should not contribute to a total task score that exceeds the pre-defined score.
Solution
- In your DepartmentGoal model, define a field called
score
. This is the field where you define the score upfront, and is a required field.
- In your Task model, add a
clean
method to validate the score. The clean
method will automatically be called by your ModelForm
.
- 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"})