Richard
Richard

Reputation: 65560

Django: Show reverse ForeignKey lookup on admin page, as read-only list?

I'd like to show a reverse ForeignKey lookup on a Django admin page, and make it read-only, as a lightweight list of strings.

The standard way to do this seems to be with an inline admin field, but I don't want it to be editable, so I wonder if there's a lighter-weight way to do it.

These are my fields:

class Book: 
  title = models.TextField(blank=True)
  author = models.ForeignKey(Author, on_delete=models.PROTECT)

class Author: 
  name = models.TextField(blank=True)

On the admin change/delete page for an author, I'd like to show a read-only list of their books.

I can do this with an admin.StackedInline, but it's quite cumbersome to make it read-only:

class BooksInline(admin.StackedInline):
  model = Book
  fields = ('title',)
  readonly_fields = ('title',)

  def has_add_permission(request, obj):
    return False

  def has_delete_permission(request, obj, self):
    return False

And the resulting list takes up a lot of space on the page, because the design expects it all to be editable.

Is there a simpler way to make a read-only list?

Upvotes: 9

Views: 3883

Answers (3)

johnboiles
johnboiles

Reputation: 3524

I didn't like the idea of having admin-only logic in the model and I didn't like the idea of mucking with the templates if I didn't need to, so I instead implemented this in the admin.

from django.contrib import admin

class AuthorAdmin(admin.ModelAdmin):
    readonly_fields = ('books_list')
    fields = ('name', 'books_list')

    def books_list(self, obj):
        books = Book.objects.filter(author=obj)
        if books.count() == 0:
            return '(None)'
        output = ', '.join([unicode(book) for book in books])
        return output
    books_list.short_description = 'Book(s)'

For bonus points, we can make each book link to the change page for that book.

from django.contrib import admin
from django.core import urlresolvers
from django.utils.html import format_html

class AuthorAdmin(admin.ModelAdmin):
    readonly_fields = ('books_list')
    fields = ('name', 'books_list')

    def books_list(self, obj):
        books = Book.objects.filter(author=obj)
        if books.count() == 0:
            return '(None)'
        book_links = []
        for book in books:
            change_url = urlresolvers.reverse('admin:myapp_book_change', args=(book.id,))
            book_links.append('<a href="%s">%s</a>' % (change_url, unicode(book))
        return format_html(', '.join(book_links))
    books_list.allow_tags = True
    books_list.short_description = 'Book(s)'

Double bonus, you can encapsulate this into a function so that you don't have to rewrite this logic everytime you want to show a list of reverse foreignkey objects in Admin:

from django.contrib import admin
from django.core import urlresolvers
from django.utils.html import format_html

def reverse_foreignkey_change_links(model, get_instances, description=None, get_link_html=None, empty_text='(None)'):
    if not description:
        description = model.__name__ + '(s)'

    def model_change_link_function(_, obj):
        instances = get_instances(obj)
        if instances.count() == 0:
            return empty_text
        output = ''
        links = []
        for instance in instances:
            change_url = urlresolvers.reverse('admin:%s_change' % model._meta.db_table, args=(instance.id,))
            links.append('<a href="%s">%s</a>' % (change_url, unicode(instance))
        return format_html(', '.join(links))

    model_change_link_function.short_description = description
    model_change_link_function.allow_tags = True
    return model_change_link_function

class AuthorAdmin(admin.ModelAdmin):
    readonly_fields = ('books_list')
    fields = ('name', 'books_list')

    def books_list = reverse_foreignkey_change_links(Book, lambda obj: Book.objects.filter(author=obj))

Upvotes: 11

benjaoming
benjaoming

Reputation: 2215

I would normally do this by overriding the admin template. That's because this is mainly about presentation logic (but you may want to manipulate the queryset in the ModelAdmin to use prefetch_related()). Create a file myapp/templates/admin/myapp/mymodel/change_form.html. In the file, put:

{% extends "admin/change_form.html" %}
{% load i18n admin_materials %}

{% block content %}
{% if change %}
<div class="author-list">
    <h1>{% trans "Authors" %}</h1>
    <ul>
    {% for author in original.author_set.all %}
        <li>{{ author.name }}</li>
    {% endfor %}
    </ul>
</div>
{% endif %}

{{ block.super }}

{% endblock %}

This is compatible with standard django.contrib.admin and django-suit... I can't say if more exotic admin skins like django-grappelli modifies the admin template block names.

Upvotes: 0

Jason Cross
Jason Cross

Reputation: 11

I would use a class method as mentioned in the django docs and use the _set related reverse lookup described in the docs here.

The list result is read only, so there is no need to do checking in the Admin.

// models.py
class Book: 
  title = models.TextField(blank=True)
  author = models.ForeignKey(Author, on_delete=models.PROTECT)

class Author: 
  name = models.TextField(blank=True)

  def get_author_books(self):
      cur_books = self.book_set.all()
      books = [b.title for b in cur_books]
      return books

  get_author_books.short_description = "Author's Books"


// admin.py
class BooksInline(admin.StackedInline):
  model = Book
  fields = ('title','get_author_books')

Upvotes: 1

Related Questions