77

I have a standard many-to-one relationship set up. There are a bunch of fields, but for our purposes here, the relevant model is:

class Class(models.Model):
    name = models.CharField(max_length=128)

class Student(models.Model):
    class = models.ForeignKey(Class)
    name = models.CharField(max_length=128)
    address = models.CharField(max_length=128)
    # ...etc

I created an admin, and it works great. it even automatically has the ability for me to set the Class when I am editing a Student. However, when I go to create/edit a Class, all I get is an input box for the name.

Is there a way to add a box/field where Students can be added as members of Class from the Class admin page? I can make a form inline, but that is to create new Students. I already have all my Students created and am just looking for a quick method to add multiple existing Students to different Class'.

Serjik
  • 10,543
  • 8
  • 61
  • 70
MrGlass
  • 9,094
  • 17
  • 64
  • 89

7 Answers7

54

There is! You want InlineModelAdmin (see InlineModelAdmin documentation here)

Sample code in brief:

class StudentAdminInline(admin.TabularInline):
    model = Student

class ClassAdmin(admin.ModelAdmin):
    inlines = (StudentAdminInline, )
admin.site.register(Class, ClassAdmin)
Thomas W
  • 14,757
  • 6
  • 48
  • 67
Luke Sneeringer
  • 9,270
  • 2
  • 35
  • 32
  • 5
    By the way, be pretty careful with naming your model class "Class". It does work, but it will bite you later (for instance, in the first line of `Student`). And, `class = models.ForeignKey(Class)` *won't* work, because "class" is a reserved word. – Luke Sneeringer May 17 '11 at 16:36
  • yeah. Actually, those are fake names :P security, blah blah blah, etc. Realized the bad name later. Actually, i expected to get called out as a college student looking for homework help - this is like the standard scenario for a hw assignment. – MrGlass May 17 '11 at 17:03
  • 23
    Tried implementing and this does not do what I need. As I said in the question, I know how to add an inline form, but TabularInline is designed to create a new Object. I want to simply allow the user to select from the existing list of students I already have. – MrGlass May 18 '11 at 21:11
  • It does that, too. If there are already students related to the class, they should show up. – Luke Sneeringer May 18 '11 at 21:21
  • 1
    Ones that I have defined as related show up in the inline. I want an inline to set existing students as related. – MrGlass May 18 '11 at 21:46
  • 1
    Ah, I understand the issue now. You could define a custom `Form` class and assign it to the `form` attribute of your `ModelAdmin` class. – Luke Sneeringer May 19 '11 at 04:13
  • Thanks a lots. I was find for this for months – Guilherme Soares Oct 23 '15 at 20:43
  • Much appreciated! – Joe Cheng Apr 07 '17 at 03:43
  • 3
    This clearly does not answer the question! It is looking for a quick method to add multiple existing Students to different Class... again, existing not to create new ones... – Slipstream Jun 21 '18 at 23:38
53

Here is "custom form" solution as Luke Sneeringer suggested. Anyway, I'm suprised by absence of out-of-the-box Django solution to this (rather natural and probably common) problem. Am I missing something?

from django import forms
from django.db import models
from django.contrib import admin

class Foo(models.Model):
    pass

class Bar(models.Model):
    foo = models.ForeignKey(Foo)

class FooForm(forms.ModelForm):
    class Meta:
        model = Foo

    bars = forms.ModelMultipleChoiceField(queryset=Bar.objects.all())

    def __init__(self, *args, **kwargs):
        super(FooForm, self).__init__(*args, **kwargs)
        if self.instance:
            self.fields['bars'].initial = self.instance.bar_set.all()

    def save(self, *args, **kwargs):
        # FIXME: 'commit' argument is not handled
        # TODO: Wrap reassignments into transaction
        # NOTE: Previously assigned Foos are silently reset
        instance = super(FooForm, self).save(commit=False)
        self.fields['bars'].initial.update(foo=None)
        self.cleaned_data['bars'].update(foo=instance)
        return instance

class FooAdmin(admin.ModelAdmin):
    form = FooForm
zag
  • 3,379
  • 1
  • 21
  • 19
  • 1
    I honestly don't know what I ended up doing to solve this in the end. Pretty sure it was something similar to this though. And yeah, its really odd they don't have something premade/simpler. – MrGlass Jan 11 '12 at 12:53
  • 1
    I like this solution, but wanted to make sure what you mean by "FIXME: 'commit' argument is hot handled". What is the issue here? – B Robster Oct 12 '12 at 20:35
  • 2
    To be consistent with ModelForm API, foo_form.save(commit=False) should return an object that hasn't yet been saved to the database (see https://docs.djangoproject.com/en/dev/topics/forms/modelforms/#the-save-method); hovewer provided implementation ignores 'commit' argument. Most likely you are not going to use commit=False; if so, there is no issue. – zag Oct 26 '12 at 22:56
  • Is this supposed to allow you to add a new Bar? For some reason it just lists the bars but won't give a button to create new bar. – LondonAppDev Dec 06 '13 at 14:12
  • @MarkWinterbottom No, this is just a solution for adding previously existing Bars to a Foo. –  Aug 09 '14 at 01:29
  • 6
    Two years on and this is still the only solution I can find - unless you know of any new developments? Could I please ask: what is the purpose of the line self.fields['bars'].initial.update(foo=None)? – Gabriel Sep 06 '14 at 10:38
  • 2
    @zag there is one thing I cannot wrap my head around -- where the model gets saved in this code? `instance = super(FooForm, self).save(commit=False)` doesn't commit instance to the db. In the rest of the function `instance.save()` is not called... I see empirically (in my project) that this works but I can't see how :) could you help me understand this? – tadek Feb 15 '18 at 10:17
  • 2
    @zag Hello! It is just 2018 and this is the most relevant and actually only partly functional piece of code that I've found, that is sad. However, when i try to save the Foo object. It gives me error: Unsaved model instance cannot be used in an ORM query, any ideas? – Josef Korbel Apr 11 '18 at 13:14
  • @zag Hello. Is it working with Django 3.x.x+? Because there is error `Improperly configured` – Jasurbek Nabijonov Feb 01 '21 at 11:12
  • @Gabriel it's been a minute since you asked this but I have been playing around w this in the year of our lord 2021 as there is still no adequate built-in solution. `self.fields['bars'].initial.update(foo=None)` removes all initial associations and `self.cleaned_data['bars'].update(foo=instance)` adds an association for all items selected via the admin. This is important to do because without that first line, if any associations are REMOVED, the form wouldn't handle it. So we remove all and then add back only the ones that still should exist (plus more if appropriate). – Ashley H. Mar 05 '21 at 23:30
  • as an alternative to @AshleyH. suggestion, `instance.bars.set(self.cleaned_data['bars'])` also works... I have the same question as @tadek about the `commit=False` bit – Marcio Cruz Feb 16 '23 at 18:42
4

Probably, this will help: I used the described approach, but changed methods save and save_m2m in the following way:

from django import forms
from django.db import models
from django.contrib import admin

class Foo(models.Model):
     pass

class Bar(models.Model):
     foo = models.ForeignKey(Foo)

class FooForm(forms.ModelForm):
    class Meta:
        model = Foo

    bars = forms.ModelMultipleChoiceField(queryset=Bar.objects.all())

    def __init__(self, *args, **kwargs):
        super(FooForm, self).__init__(*args, **kwargs)
        if self.instance:
            self.fields['bars'].initial = self.instance.bar_set.all()

    def save_m2m(self):
        pass

    def save(self, *args, **kwargs):
        self.fields['bars'].initial.update(foo=None)
        foo_instance = Foo()
        foo_instance.pk = self.instance.pk
        # Copy all other fields.
        # ... #
        foo_instance.save()
        self.cleaned_data['bars'].update(foo=instance)
        return instance

class FooAdmin(admin.ModelAdmin):
    form = FooForm
Vladimir
  • 103
  • 2
  • 8
  • In Django 3, this is the only solution I've found, so thanks. It's annoying that we have to override `save_m2m` and manually create an instance, but hey. – Archimaredes Jul 01 '20 at 10:17
1

In 2021, there is no straight solution to this problem.

What I did is using ManyToMany with symmetrical=False. Read more one Django official doc about symmetrical. Only in that case, you can select multiple entities in django-admin

parent_questions = models.ManyToManyField('self', related_name='parent_questions',
                                         blank=True, symmetrical=False)
Jasurbek Nabijonov
  • 1,607
  • 3
  • 24
  • 37
1

Something that works for me in Django 3.0.9

In models.py

from django import forms
from django.db import models

class Student(models.Model):
    name = models.CharField(max_length=100)
    course = models.ForeignKey(Course, on_delete=models.CASCADE, null=True, blank=True)

    def __str__(self):
        return self.name


class CourseForm(forms.ModelForm):

    students = forms.ModelMultipleChoiceField(
        queryset=Student.objects.all(), required=False
    )
    name = forms.CharField(max_length=100)

    class Meta:
        model = Student
        fields = ["students", "name"]

    def __init__(self, *args, **kwargs):
        super(CourseForm, self).__init__(*args, **kwargs)
        if self.instance:
            self.fields["students"].initial = self.instance.student_set.all()

    def save_m2m(self):
        pass

    def save(self, *args, **kwargs):
        self.fields["students"].initial.update(course=None)
        course_instance = Course()
        course_instance.pk = self.instance.pk
        course_instance.name = self.instance.name
        course_instance.save()
        self.cleaned_data["students"].update(course=course_instance)
        return course_instance

In admin.py

from django.contrib import admin
from .models import Student, Course

class StudentAdmin(admin.ModelAdmin):
    pass

class CourseAdmin(admin.ModelAdmin):
    form = CourseForm

admin.site.register(Student, StudentAdmin)
admin.site.register(Course, CourseAdmin)
Oliver Ye
  • 23
  • 3
0

You could also run the student names trough a second model so that they are a ForeignKey. For example after the original code post add:

class AssignedStudents(models.Model):
    assigned_to = models.ForeignKey(Class, on_delete=models.CASCADE)
    student = models.ForeignKey(Student, on_delete=models.CASCADE)

Then add the inline to the admin like Luke Sneeringer said. You end up with a drop down list that you can select the student names from. Although, there is still the option to create new students.

Olivier Pons
  • 15,363
  • 26
  • 117
  • 213
Kris O
  • 71
  • 1
  • 3
  • This effectively creates a many-to-many relationship with `AssignedStudents` being the association table. It may achieve the desired effect in the admin interface but at the expense of adding an extra table. I suppose that it in most cases that would be an unacceptable approach. – Peter Bašista Oct 28 '20 at 02:35
-3

If the intention is to have students exist independently from a class, and then be able to add or remove them from a class, then this sets up a different relationship:

  • Many students can be a part of one class.
  • One student can be a part of many classes

In reality this is describing a Many-To-Many relationship. Simply exchanging

class Student(models.Model):
    class = models.ForeignKey(Class) ...

for

class Student(models.Model):
    class = models.ManyToManyField(Class)... 

will give the desired effect in Django admin immediately

Django Many-to-many

Zac Butko
  • 19
  • 7
  • 1
    Yes, what you just described WOULD be a many-to-many relationship. My question was not about that, but instead a one-to-many. – MrGlass May 26 '20 at 22:03