Display foreign key columns as link to detail object in Django admin
Asked Answered
S

5

18

As explained in link-in-django-admin-to-foreign-key-object, one can display a ForeignKey field as a link to the admin detail page.

To summarize,

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

class FooAdmin(ModelAdmin):
    list_display = ('link_to_bar',)
    def link_to_bar(self, obj):
        link = urlresolvers.reverse('admin:app_bar_change', args=[obj.bar_id])
        return u'<a href="%s">%s</a>' % (link, obj.bar) if obj.bar else None
    link_to_bar.allow_tags = True

The question is: can we do it more automatically? For instance, provide to the FooAdmin definition a list of foreign key to display as links to detail page:

class FooAdmin(ModelAdmin):
    ...
    list_foreign_key_links = ('bar',)
    ...

I know that these ModelAdmin classes are generated with metaclass programming. Then, it should be possible. What would be a good start to do so?

Sybaris answered 31/5, 2016 at 7:12 Comment(0)
V
49

The solution below uses this answer but makes it reusable by all models, avoiding the need to add methods to each admin class.

Example Models

# models.py
from django.db import models

class Country(models.Model):
    name = models.CharField(max_length=200)
    population = models.IntegerField()

class Career(models.Model):
    name = models.CharField(max_length=200)
    average_salary = models.IntegerField()

class Person(models.Model):
    name = models.CharField(max_length=200)
    age = models.IntegerField()
    country = models.ForeignKey(Country, on_delete=models.CASCADE)
    career = models.ForeignKey(Career, on_delete=models.CASCADE)

Example Admin

# admin.py
from django.utils.html import format_html
from django.urls import reverse

from .models import Person


def linkify(field_name):
    """
    Converts a foreign key value into clickable links.
    
    If field_name is 'parent', link text will be str(obj.parent)
    Link will be admin url for the admin url for obj.parent.id:change
    """
    def _linkify(obj):
        linked_obj = getattr(obj, field_name)
        if linked_obj is None:
            return '-'
        app_label = linked_obj._meta.app_label
        model_name = linked_obj._meta.model_name
        view_name = f'admin:{app_label}_{model_name}_change'
        link_url = reverse(view_name, args=[linked_obj.pk])
        return format_html('<a href="{}">{}</a>', link_url, linked_obj)

    _linkify.short_description = field_name  # Sets column name
    return _linkify



@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
    list_display = [
        "name",
        "age",
        linkify(field_name="country"),
        linkify(field_name="career"),
    ]

Results

Given an App named app, and a Person instance Person(name='Adam' age=20) with country and carreer foreign key values with ids 123 and 456, the list result will be:

| Name | Age |                          Country                          |...|
|------|-----|-----------------------------------------------------------|...|
| Adam |  20 | <a href="/admin/app/country/123">Country object(123)</a>  |...|

(Continues)

|...|                          Career                         |
|---|---------------------------------------------------------|
|...| <a href="/admin/app/career/456">Career object(456)</a>  |
Vlissingen answered 31/10, 2018 at 22:41 Comment(6)
Great function! I'd change linked_obj.id by linked_obj.pk to deal with custom pks.Oaxaca
Super helpful! I'd use c = ContentType.objects.get_for_model(obj) from django.contrib.contenttypes.models instead of the private member _meta. Then you can get the app/model name from c.app_label and c.model.Twinscrew
raises attribute error if linked_obj is null - to handle nullable fields add if not linked_obj: return '-'Vlissingen
readonly_fields = (linkify("example"),); exclude = ("example",) makes it possible to make a read-only field within the admin view of the database object. However, for some reason, I can't do similar by overriding def get_readonly_fields(self): ...Women
You can add _linkify.admin_order_field = field_name # Enables ordering on the field next to _linkify.short_description = field_name # Sets column name to enable ordering on that field again.Denominate
Is there a way to always linkify foreign keys?Perihelion
W
1

A good start would be looking at the source of BaseModelAdmin and ModelAdmin. Try to find out how the ModelAdmin generates the default links. Extend ModelAdmin, add a method to generate links to arbitrary foreign keys and look at how ChangeList generates the change list.

I would also suggest you use format_html to render the links, which makes link_to_bar.allow_tags = True unnecessary:

from django.utils.html import format_html

class FooAdmin(ModelAdmin):
    list_display = ('link_to_bar', )
    def link_to_bar(self, obj):
        link = urlresolvers.reverse('admin:app_bar_change', args=[obj.bar_id])
        return format_html('<a href="{}">{}</a>', link, obj.bar) if obj.bar else None
Widner answered 31/5, 2016 at 7:53 Comment(0)
W
1

A slight respin on the accepted answer. It is not necessarily better, but implements some of the advice in the comments:

from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.html import format_html


def linkify(field_name):
    def _linkify(obj):
        content_type = ContentType.objects.get_for_model(obj)
        app_label = content_type.app_label
        linked_obj = getattr(obj, field_name)
        linked_content_type = ContentType.objects.get_for_model(linked_obj)
        model_name = linked_content_type.model
        view_name = f"admin:{app_label}_{model_name}_change"
        link_url = reverse(view_name, args=[linked_obj.pk])
        return format_html('<a href="{}">{}</a>', link_url, linked_obj)

    _linkify.short_description = field_name.replace("_", " ").capitalize()
    return _linkify
Women answered 17/6, 2020 at 17:14 Comment(1)
I get: 'In template /var/work/django/oscar/lib/python3.8/site-packages/grappelli/templates/admin/base.html, error at line 0 'NoneType' object has no attribute '_meta''Sudoriferous
O
0

This is an improvement using this answer, allowing you to supply an optional alternative short description instead of the supplied field_name being used for the column header. And also an optional label_prop value to display an alternative property value of the model if so desired

EDIT: I've also now updated it to handle ManyToManyFields

from typing import Optional

from django.contrib import admin
from django.contrib.contenttypes.models import ContentType
from django.db.models import ManyToManyField, ForeignKey, Model
from django.urls import reverse
from django.utils.html import format_html

def create_link(linked_obj, app_label: str, label_prop: Optional[str] = None) -> str:
    linked_content_type = ContentType.objects.get_for_model(linked_obj)
    model_name = linked_content_type.model
    view_name = f"admin:{app_label}_{model_name}_change"
    link_url = reverse(view_name, args=[linked_obj.pk])
    return "<a href='%s'>%s</a>" % (link_url, getattr(linked_obj, label_prop) if label_prop else linked_obj)


def linkify(field_name: str, label_prop: Optional[str] = None, short_description: Optional[str] = None, as_html_list: bool = False):
    def _linkify(obj: Model):
        content_type = ContentType.objects.get_for_model(obj)
        app_label = content_type.app_label
        field_type = obj._meta.get_field(field_name)
        items = None

        if isinstance(field_type, ManyToManyField):
            items = list(getattr(obj, field_name).all())
        elif isinstance(field_type, ForeignKey):
            items = [getattr(obj, field_name)]
        else:
            print(f'field_name {field_name} is not ManyToManyField or ForeignKey')

        links = [create_link(itm, app_label, label_prop) for itm in items if itm is not None]

        if len(links) > 1:
            if as_html_list:
                html = "<ul>"
                for link in links:
                    html += f'<li>{link}</li>'
                html += "</ul>"
            else:
                html = ", ".join(links)
        else:
            html = links[0]

        return format_html(html)

    _linkify.short_description = [short_description, field_name.replace("_", " ").capitalize()][short_description is None]

    return _linkify

Example usage:

Models

# models.py
from django.db import models

class Country(models.Model):
    name = models.CharField(max_length=200)
    population = models.IntegerField()

class Career(models.Model):
    name = models.CharField(max_length=200)
    average_salary = models.IntegerField()

class Person(models.Model):
    name = models.CharField(max_length=200)
    age = models.IntegerField()
    country = models.ForeignKey(Country, on_delete=models.CASCADE)
    career = models.ForeignKey(Career, on_delete=models.CASCADE)

Admin

@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
    list_display = [
        "name",
        "age",
        linkify(
          field_name="country",
          label_prop="name",
          short_description="Country Name"
        ),
        linkify(field_name="career"),
    ]

Result

Given the name of Adam's country is France.

| Name | Age |                     Country Name                         |...|
|------|-----|----------------------------------------------------------|...|
| Adam |  20 | <a href="/admin/app/country/123">France</a>              |...|

It's also worth noting...

...the linkify function can also be used along side the display decorator

@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
    list_display = [
        "name",
        "age",
        "sortable_country_link",
        linkify(field_name="career"),
    ]

    @admin.display(description='Country Name', ordering='country__name')
    def sortable_country_link(self, obj):
        return linkify(field_name='country', label_prop='name')(obj)
Overawe answered 15/9, 2023 at 16:10 Comment(0)
P
0

I wanted it even more automatic, so I can easily list all fields, possibly excluding some, while displaying foreign keys as links. So, building on the linkify function in the reply of @gtalarico, I defined:

def linkified_list_display(model, exclude = []):
    return [linkify(field_name=f.name) if f.many_to_one else f.name 
            for f in model._meta.fields if f.name not in exclude]

Now I can simply do:

class MyModelAdmin(admin.ModelAdmin):
    list_display = linkified_list_display(
        MyModel, exclude = ['not_to_list_1', 'not_to_list_2'])
Perihelion answered 12/12, 2023 at 13:6 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.