Search code examples
djangoformsforeign-keysdjango-formsinline-formset

Django: user interface for changing objects foreign key


Let's say I have a simple set of Django models related by a ForeignKey:

class Author(models.Model):
    name = models.CharField('Name', max_length=50)

class Book(models.Model):
    author = models.ForeignKey(Author)
    title = models.CharField('Title', max_length=50)
    rank = models.IntegerField('Rank')

Now in my template I want to create a user interface with two <ol> lists side by side, each list represents the list of Books for a given Author ordered by their rank, and has a form for the Book in the <li> node. Thus the page as a whole consists of an Author formset, each of which consists of an inline Book formset. Hope that is clear...

Now with some fancy javascript/jQuery I allow the user to do two things: 1)she can drag-and-drop a Book form within its <ol> to set its rank, and 2)she can drag-and-drop a Book from one <ol> to the other <ol> with the purpose of changing its Author.

From the Django point of view, there is absolutely no trouble form me to accomplish task 1. The formsets are submitted, the data saved, and Django doesn't know the difference between this method and if the user had simply entered different book ranks in the input text field of course.

However, accomplishing task 2 seems to me a little trickier. If I drag-and-drop a Book form from one Author list to the other, and then use some more fancy javascript to update its input class ids and names(id_1-book-0-title --> id_2-book-0-title for example), along with updating the TOTAL_FORMS and INITIAL_FORMS of the Book formset Management forms to reflect the new number of Book forms in each row, then things don't quite work.

Django treats the dragged form in the list as a new form of course, and creates a genuinely new Book in the database(problematic if you have unique fields since this Book is already in the database). As for the absence of this form in the oldlist, the forms DELETE is not sent and so Django doesn't end up deleting the old object of course. Without any unique fields the result is you get two copies of the Book in the database, one in each Author's list now. With unique fields of some kind of the Book model (e.g. a serial number say) you just hit validation errors.

Does anyone know what the right way to set this up would be?

EDIT: here is the rough view for what I have so far:

def managerView(request):

if request.method == "POST":
    author_formset = AuthorFormSet(request.POST, prefix='author')
    pairs =[]
    for authorform in author_formset:
        author=authorform.instance
        tempDict={}
        tempDict['authorform'] =authorform
        tempDict['bookformset'] = BookFormSet(request.POST, prefix=str(author.pk)+'-book', instance=author)
        pairs.append(tempDict)

    if author_formset.is_valid() and all([pair['bookformset'].is_valid() for pair in pairs]):
        author_formset.save()
        for pair in pairs:
            author=pair['authorform'].instance
            #For this author, delete all current books, then repopulate
            #from forms in book list that came from request.
            old_book_pks = set([book.pk for book in author.books.all()])
            new_book_pks = set([bform.instance.pk for bform in pair['bookformset'].forms])
            pks_for_trash = old_book_pks - new_book_pks
            if len(pair['bookformset'].forms): pair['bookformset'].save()
        return HttpResponseRedirect(reverse('admin:index'))

else:
    author_formset = AuthorFormSet(prefix='author', queryset=Author.objects.order_by('rank'))
    pairs=[]
    for f in author_formset:
        author=f.instance
        #prefix the different book formsets like '1-book' etc
        pairs.append({'authorform':f, 'bookformset': BookFormSet(prefix=str(author.pk)+'-book',instance=author)})

myContext= {'authorformset': author_formset, 'pairs':pairs, 'request': request}
return myContext

Now the formsets:

AuthorFormSet = modelformset_factory(Author, extra=0)   
BookFormSet = inlineformset_factory(Author, Book, form=BookForm, extra=0, formset=BaseBookFormSet)       

Not much going on in the BookForm and BaseBookFormSet except some custom cleaning, so I won't include them just yet, unless anyone thinks they would be useful.


Solution

  • I realised I was being quite silly. If instead of insisting on using inline formsets for the Books, I just send back a formset of all Books (regardless of Author) and another of all Authors, then every Book has an Author drop down which is trivial to update with javascript upon dragging and dropping it into a new list (this field could made hidden for presentation purposes). Everything then just works upon save as it should.

    For the problem of organising the right Books into the right Author <ol> in such a setup, a small template tag filter does the job:

    @register.filter(name='forms_for_author')
    def forms_for_author(book_formset, authorid):
    
    forms = []
    for form in book_formset:
        if str(form.instance.tap_id) == str(tapid):
            forms.append(form)
    return forms
    

    Used in template as

    {% for authorform in authorformset %}
    <ol class="author">
        {% for bookform in bookformset|forms_for_author:authorform.id.value %}
         <li>..</li>
         {% endfor %}
    </ol>
    {% endfor %}