Django Admin – One to Many – How to make sure only one children has a boolean field selected
Question:
In django, I have the following models:
class System(models.Model):
name = models.CharField(max_length=200)
""" ... many other fields, not useful for here ..."""
# Would it make more sense to have the primary instance here ?
class Instance(models.Model):
name = models.CharField(max_length=200)
url = models.UrlField(max_length=200)
system = models.ForeignKey(System, on_delete=models.PROTECT)
is_production = models.BooleanField()
This data is managed using the admin. What I want is that when an instance of the system is marked as is_production, all other instances, for that system have their is_production field updated to False.
Also, I am interested in how to best setup the admin for this case. I, will be using inlines for the edition/creation of instances.
However, I am not sure about how to make sure each system can only have one instance in production.
- Should I use a dropdown on the System to select the production instance and filter using
formfield_for_foreignkey
?
- Use an admin action, something like:
Mark as production
?
- Use signals after a save ?
- is there any other way I have not thought about ?
Answers:
You asked multiple questions but I’ll focus on what I interpreted as the main one:
What I want is that when an instance of the system is marked as is_production, all other instances, for that system have their is_production field updated to False.
How about overriding the Instance model’s save method?
class Instance(models.Model):
name = models.CharField(max_length=200)
url = models.URLField(max_length=200)
system = models.ForeignKey(System, on_delete=models.PROTECT)
is_production = models.BooleanField()
def save(self, *args, **kwargs):
if self.is_production:
self.system.instance_set.exclude(id=self.id).update(is_production=False)
super().save(*args, **kwargs)
This ensures that whenever an Instance instance with is_production=True is saved, all other Instance instances that are linked to the related System object will have their is_production values updated to False.
Depending on how you go about changing the Instance instances’ is_production values, this might or might not be suitable for what you want to do. See e. g. this thread discussing how using the .update() method doesn’t lead to the save() method being called: Django .update doesn't call override save? (also described in the Django docs, referred to in the linked thread)
You can make only one inline (child) object boolean field True
with save_formset() in one-to-many relationship in Django Admin.
For example, there are Person
model and Email
model which has the foreign key of Person
model as shown below:
# "models.py"
class Person(models.Model):
name = models.CharField(max_length=20)
def __str__(self):
return self.name
class Email(models.Model):
person = models.ForeignKey(Person, on_delete=models.CASCADE)
is_used = models.BooleanField()
email = models.EmailField()
def __str__(self):
return self.email
And, there is Person
admin which has Email
inline as shown below:
# "admin.py"
class EmailInline(admin.TabularInline):
model = Email
min_num = 1
extra = 0
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
inlines = (EmailInline,)
Now, I override save_formset()
as shown below. *Only the 1st submitted inline object which is is_used=True
is accepted as is_used=True
:
# "admin.py"
# ...
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
inlines = (EmailInline,)
def save_formset(self, request, form, formset, change):
for obj in formset.save(): # Saves and returns submitted inline objects
if obj.is_used:
# ↓ Makes all "is_used" of the specific person False except
# ↓ the 1st submitted inline object which is "is_used=True"
Email.objects.filter(person=obj.person_id)
.exclude(id=obj.id)
.update(is_used=False)
return # <- Don't forget to return
Then, add the main (parent) object with 3 inline (child) objects and the 2nd and 3rd inline objects are is_used=True
as shown below:
Then, only the 2nd inline object is is_used=True
as shown below:
Next, change is_used=False
to is_used=True
for the 3rd inline object as shown below:
Then, only the 3rd inline object is is_used=True
as shown below. *For inlines, only changed objects are passed to save_formset()
, then handled in it:
In addition, the code below can do more things:
# "admin.py"
g_counts = 0
class EmailForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
global g_counts
if g_counts < 1:
kwargs['initial'] = {'is_used': True}
g_counts += 1
super().__init__(*args, **kwargs)
class EmailInlineFormSet(forms.BaseInlineFormSet):
def save_existing_objects(self, commit=True):
self.counts = 0
return super().save_existing_objects(commit)
def delete_existing(self, obj, commit=True):
if len(self.initial_forms) == len(self.deleted_forms):
if self.counts < 1:
self.counts += 1
obj.is_used = True
obj.save()
return
if commit:
obj.delete()
self.counts += 1
if self.counts == len(self.deleted_forms):
email_counts = Email.objects.filter(person=obj.person_id, is_used=True).count()
if email_counts == 0:
email_obj = Email.objects.filter(person=obj.person_id).first()
email_obj.is_used = True
email_obj.save()
class EmailInline(admin.TabularInline):
model = Email
formset = EmailInlineFormSet
min_num = 1
extra = 0
def get_formset(self, request, obj=None, **kwargs):
if not obj:
self.form = EmailForm
global g_counts
g_counts = 0
return super().get_formset(request, obj, **kwargs)
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
inlines = (EmailInline,)
def save_formset(self, request, form, formset, change):
objs = formset.save()
if not objs:
return
true_ids = [obj.id for obj in objs if obj.is_used == True]
email_counts = Email.objects.filter(
person=objs[0].person_id, is_used=True
).count()
if email_counts == 1: # For "add" and "change" pages
return
if email_counts == 0: # For "add" and "change" pages
id = Email.objects.filter(person=objs[0].person_id).first().id
obj = Email.objects.get(person=objs[0].person_id, id=id)
obj.is_used = True
obj.save()
return
if email_counts > 1 and not change: # For "add" page
Email.objects.filter(
person=objs[0].person_id
).exclude(id=true_ids[0]).update(is_used=False)
return
if email_counts > 1 and change: # For "change" page
id = Email.objects.filter(
person=objs[0].person_id,
is_used=True
).first().id
Email.objects.filter(
person=objs[0].person_id,
).exclude(
id=id if id < true_ids[0] else true_ids[0]
).update(is_used=False)
return
For example, only the 1st inline object is is_used=True
by default only for add page as shown below:
And, if trying to add or change all inline objects which are is_used=False
as shown below:
Then, the 1st inline object is is_used=True
as shown below:
And, if trying to add or change more than 1 inline objects which are is_used=True
as shown below:
Then, only the 1st inline object which is is_used=True
is accepted as is_used=True
as shown below:
And, if trying to delete the inline object which is is_used=True
as shown below:
Then, the 1st inline object is is_used=True
as shown below:
And, if trying to delete all inline objects including the one which is is_used=True
as shown below:
Then, the 1st inline object is is_used=True
without deleted as shown below. *There must be at least one inline object:
In django, I have the following models:
class System(models.Model):
name = models.CharField(max_length=200)
""" ... many other fields, not useful for here ..."""
# Would it make more sense to have the primary instance here ?
class Instance(models.Model):
name = models.CharField(max_length=200)
url = models.UrlField(max_length=200)
system = models.ForeignKey(System, on_delete=models.PROTECT)
is_production = models.BooleanField()
This data is managed using the admin. What I want is that when an instance of the system is marked as is_production, all other instances, for that system have their is_production field updated to False.
Also, I am interested in how to best setup the admin for this case. I, will be using inlines for the edition/creation of instances.
However, I am not sure about how to make sure each system can only have one instance in production.
- Should I use a dropdown on the System to select the production instance and filter using
formfield_for_foreignkey
? - Use an admin action, something like:
Mark as production
? - Use signals after a save ?
- is there any other way I have not thought about ?
You asked multiple questions but I’ll focus on what I interpreted as the main one:
What I want is that when an instance of the system is marked as is_production, all other instances, for that system have their is_production field updated to False.
How about overriding the Instance model’s save method?
class Instance(models.Model):
name = models.CharField(max_length=200)
url = models.URLField(max_length=200)
system = models.ForeignKey(System, on_delete=models.PROTECT)
is_production = models.BooleanField()
def save(self, *args, **kwargs):
if self.is_production:
self.system.instance_set.exclude(id=self.id).update(is_production=False)
super().save(*args, **kwargs)
This ensures that whenever an Instance instance with is_production=True is saved, all other Instance instances that are linked to the related System object will have their is_production values updated to False.
Depending on how you go about changing the Instance instances’ is_production values, this might or might not be suitable for what you want to do. See e. g. this thread discussing how using the .update() method doesn’t lead to the save() method being called: Django .update doesn't call override save? (also described in the Django docs, referred to in the linked thread)
You can make only one inline (child) object boolean field True
with save_formset() in one-to-many relationship in Django Admin.
For example, there are Person
model and Email
model which has the foreign key of Person
model as shown below:
# "models.py"
class Person(models.Model):
name = models.CharField(max_length=20)
def __str__(self):
return self.name
class Email(models.Model):
person = models.ForeignKey(Person, on_delete=models.CASCADE)
is_used = models.BooleanField()
email = models.EmailField()
def __str__(self):
return self.email
And, there is Person
admin which has Email
inline as shown below:
# "admin.py"
class EmailInline(admin.TabularInline):
model = Email
min_num = 1
extra = 0
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
inlines = (EmailInline,)
Now, I override save_formset()
as shown below. *Only the 1st submitted inline object which is is_used=True
is accepted as is_used=True
:
# "admin.py"
# ...
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
inlines = (EmailInline,)
def save_formset(self, request, form, formset, change):
for obj in formset.save(): # Saves and returns submitted inline objects
if obj.is_used:
# ↓ Makes all "is_used" of the specific person False except
# ↓ the 1st submitted inline object which is "is_used=True"
Email.objects.filter(person=obj.person_id)
.exclude(id=obj.id)
.update(is_used=False)
return # <- Don't forget to return
Then, add the main (parent) object with 3 inline (child) objects and the 2nd and 3rd inline objects are is_used=True
as shown below:
Then, only the 2nd inline object is is_used=True
as shown below:
Next, change is_used=False
to is_used=True
for the 3rd inline object as shown below:
Then, only the 3rd inline object is is_used=True
as shown below. *For inlines, only changed objects are passed to save_formset()
, then handled in it:
In addition, the code below can do more things:
# "admin.py"
g_counts = 0
class EmailForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
global g_counts
if g_counts < 1:
kwargs['initial'] = {'is_used': True}
g_counts += 1
super().__init__(*args, **kwargs)
class EmailInlineFormSet(forms.BaseInlineFormSet):
def save_existing_objects(self, commit=True):
self.counts = 0
return super().save_existing_objects(commit)
def delete_existing(self, obj, commit=True):
if len(self.initial_forms) == len(self.deleted_forms):
if self.counts < 1:
self.counts += 1
obj.is_used = True
obj.save()
return
if commit:
obj.delete()
self.counts += 1
if self.counts == len(self.deleted_forms):
email_counts = Email.objects.filter(person=obj.person_id, is_used=True).count()
if email_counts == 0:
email_obj = Email.objects.filter(person=obj.person_id).first()
email_obj.is_used = True
email_obj.save()
class EmailInline(admin.TabularInline):
model = Email
formset = EmailInlineFormSet
min_num = 1
extra = 0
def get_formset(self, request, obj=None, **kwargs):
if not obj:
self.form = EmailForm
global g_counts
g_counts = 0
return super().get_formset(request, obj, **kwargs)
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
inlines = (EmailInline,)
def save_formset(self, request, form, formset, change):
objs = formset.save()
if not objs:
return
true_ids = [obj.id for obj in objs if obj.is_used == True]
email_counts = Email.objects.filter(
person=objs[0].person_id, is_used=True
).count()
if email_counts == 1: # For "add" and "change" pages
return
if email_counts == 0: # For "add" and "change" pages
id = Email.objects.filter(person=objs[0].person_id).first().id
obj = Email.objects.get(person=objs[0].person_id, id=id)
obj.is_used = True
obj.save()
return
if email_counts > 1 and not change: # For "add" page
Email.objects.filter(
person=objs[0].person_id
).exclude(id=true_ids[0]).update(is_used=False)
return
if email_counts > 1 and change: # For "change" page
id = Email.objects.filter(
person=objs[0].person_id,
is_used=True
).first().id
Email.objects.filter(
person=objs[0].person_id,
).exclude(
id=id if id < true_ids[0] else true_ids[0]
).update(is_used=False)
return
For example, only the 1st inline object is is_used=True
by default only for add page as shown below:
And, if trying to add or change all inline objects which are is_used=False
as shown below:
Then, the 1st inline object is is_used=True
as shown below:
And, if trying to add or change more than 1 inline objects which are is_used=True
as shown below:
Then, only the 1st inline object which is is_used=True
is accepted as is_used=True
as shown below:
And, if trying to delete the inline object which is is_used=True
as shown below:
Then, the 1st inline object is is_used=True
as shown below:
And, if trying to delete all inline objects including the one which is is_used=True
as shown below:
Then, the 1st inline object is is_used=True
without deleted as shown below. *There must be at least one inline object: