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 ?
Asked By: Martin

||

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)

Answered By: datalowe

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:

enter image description here

Then, only the 2nd inline object is is_used=True as shown below:

enter image description here

Next, change is_used=False to is_used=True for the 3rd inline object as shown below:

enter image description here

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:

enter image description here

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:

enter image description here

And, if trying to add or change all inline objects which are is_used=False as shown below:

enter image description here

enter image description here

Then, the 1st inline object is is_used=True as shown below:

enter image description here

And, if trying to add or change more than 1 inline objects which are is_used=True as shown below:

enter image description here

enter image description here

Then, only the 1st inline object which is is_used=True is accepted as is_used=True as shown below:

enter image description here

And, if trying to delete the inline object which is is_used=True as shown below:

enter image description here

Then, the 1st inline object is is_used=True as shown below:

enter image description here

And, if trying to delete all inline objects including the one which is is_used=True as shown below:

enter image description here

Then, the 1st inline object is is_used=True without deleted as shown below. *There must be at least one inline object:

enter image description here

Answered By: Kai – Kazuya Ito