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
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:
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
Here's what your requirements are:
score
. This is the field where you define the score upfront, and is a required field.clean
method to validate the score. The clean
method will automatically be called by your ModelForm
.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"})