32

To deal with the lack of nested inlines in django-admin, I've put special cases into two of the templates to create links between the admin change pages and inline admins of two models.

My question is: how do I create a link from the admin change page or inline admin of one model to the admin change page or inline admin of a related model cleanly, without nasty hacks in the template?

I would like a general solution that I can apply to the admin change page or inline admin of any model.


I have one model, post (not its real name) that is both an inline on the blog admin page, and also has its own admin page. The reason it can't just be inline is that it has models with foreign keys to it that only make sense when edited with it, and it only makes sense when edited with blog.

For the post admin page, I changed part of "fieldset.html" from:

{% if field.is_readonly %}
    <p>{{ field.contents }}</p>
{% else %}
    {{ field.field }}
{% endif %}

to

{% if field.is_readonly %}
    <p>{{ field.contents }}</p>
{% else %}
    {% ifequal field.field.name "blog" %}
        <p>{{ field.field.form.instance.blog_link|safe }}</p>
    {% else %}
        {{ field.field }}
    {% endifequal %}
{% endif %}

to create a link to the blog admin page, where blog_link is a method on the model:

def blog_link(self):
      return '<a href="%s">%s</a>' % (reverse("admin:myblog_blog_change",  
                                        args=(self.blog.id,)), escape(self.blog))

I couldn't find the id of the blog instance anywhere outside field.field.form.instance.

On the blog admin page, where post is inline, I modified part of "stacked.html" from:

<h3><b>{{ inline_admin_formset.opts.verbose_name|title }}:</b>&nbsp;
<span class="inline_label">{% if inline_admin_form.original %}
    {{ inline_admin_form.original }}
{% else %}#{{ forloop.counter }}{% endif %}</span>

to

<h3><b>{{ inline_admin_formset.opts.verbose_name|title }}:</b>&nbsp;
<span class="inline_label">{% if inline_admin_form.original %}
    {% ifequal inline_admin_formset.opts.verbose_name "post" %}
    <a href="/admin/myblog/post/{{ inline_admin_form.pk_field.field.value }}/">
            {{ inline_admin_form.original }}</a>
{% else %}{{ inline_admin_form.original }}{% endifequal %}
{% else %}#{{ forloop.counter }}{% endif %}</span>

to create a link to the post admin page since here I was able to find the id stored in the foreign key field.


I'm sure there is a better, more general way to do add links to admin forms without repeating myself; what is it?

Community
  • 1
  • 1
agf
  • 171,228
  • 44
  • 289
  • 238
  • 1
    Trying to solve the same issue, the following solutions did not help me on python 3/django 1.6. [This post](http://stackoverflow.com/a/21079750), however, did solve the problem neatly and I would like to share it. – raratiru Aug 18 '14 at 18:20
  • @rara_tiru Good link! Thanks. That solves pretty much the same problem, wish I'd found it before asking this question. – agf Aug 19 '14 at 16:14

7 Answers7

19

Use readonly_fields:

class MyInline(admin.TabularInline):
    model = MyModel
    readonly_fields = ['link']

    def link(self, obj):
        url = reverse(...)
        return mark_safe("<a href='%s'>edit</a>" % url)

    # the following is necessary if 'link' method is also used in list_display
    link.allow_tags = True
agf
  • 171,228
  • 44
  • 289
  • 238
Mikhail Korobov
  • 21,908
  • 8
  • 73
  • 65
  • The code above won't work as a mixin, it obviously should omit model=MyModel definition and inherit from object instead of TabularInline. – Mikhail Korobov Apr 02 '12 at 22:31
  • The key info here for me was that you can use a callable on your modelAdmin as a field. I somehow didn't realize that. While I didn't notice until now, the other answer actually added that info before you posted. I'm going to award him the bounty. If you want to give an implementation a try, I'll accept your answer if it's better than the factory-function-generating-a-mixin I've whipped up. – agf Apr 03 '12 at 01:13
  • I don't care about bounties or accepted answer count, but the idea of putting html link to a custom widget for a fake model field is a bit crazy, and I think suggesting it to future stackoverflowers is not a good idea :) For my taste mixin is also not necessary here and readonly_fields + a callable is just fine and thus I won't change my answer. So maybe it is better just to provide an answer with what you've came up with and mark it as accepted. – Mikhail Korobov Apr 03 '12 at 02:05
  • I just don't like the idea of having to add the method (and `link.allow_tags` and `link.short_description`) to every model that needs it just because a few parameters change from one model to the next. Don't repeat yourself is one of the principles of software development. Are you saying the amount of repetition isn't enough to justify factoring it out? What if I had 10 models in my admin that needed it? Or 20? – agf Apr 03 '12 at 02:10
  • @MikhailKorobov: I agree, this was an old snippet I had used with django 1.1, though your solution is better for the current versions. :) However its not crazy to whip up a custom widget or add a field to the modelform its one of the strengths of django. Honestly I did not understand the intention of the question until I had a look at the pastebin link. – Pannu Apr 04 '12 at 09:40
  • @agf: I agree that if there are 10 models that need it then mixin approach would be better than code repetition. But there are several ways to remove this code repetition (e.g. metaclass and or class decorator may be better than mixin because they can add to readonly_fields and not only override them) and I think it is more important to give a focused answer that addresses the root issue; the code organization is a separate issue. I think that for 1-2 models even mixin would be an overkill because it won't save much code (if any) but will make it more complicated. – Mikhail Korobov Apr 04 '12 at 22:26
  • @MikhailKorobov The `readonly_fields` point does recommend a different method. A mixin was just the first thing to occur to me. I've switched to a decorator since it changes the class the least. I'd appreciate it if you could add the suggestion of how to factor it out to your answer. – agf Apr 04 '12 at 23:09
  • I put the new version in my answer; comments are appreciated. – agf Apr 04 '12 at 23:16
  • The new version is fine for your task. A couple of notes: maybe 'site_name' and 'reverse_name' variables should be better named as 'app_name' and 'model_name'; the provided class decorator doesn't allow link text customization which may be an issue for non-English countries (link text can't always be generated from model name there); for just 2 models it is more code than the basic `readonly_fields` version :) – Mikhail Korobov Apr 04 '12 at 23:29
  • @MikhailKorobov I've eliminated `site_name` from the parameters and renamed it as you suggested. I'm not sure about calling it "model_name" since the model the admin is for and the model you want a link can be different, so "reverse" makes it clear it's the model you want to reverse. The link text is customizeable, it takes any callable on the model you're linking to, but it would be more general to take a callable that takes the model as an argument, so that you wouldn't have to create an extra callable on the model if you wanted to do some sort of customization, you could create it anywhere. – agf Apr 05 '12 at 11:53
  • It's not just for two models, it's for four, but they're duplicate circumstances so why show you both pairs? And there are still more models to be added to this app, and it's possible I'll need it two or four more times. – agf Apr 05 '12 at 12:03
17

New in Django 1.8 : show_change_link for inline admin.

Set show_change_link to True (False by default) in your inline model, so that inline objects have a link to their change form (where they can have their own inlines).

from django.contrib import admin

class PostInline(admin.StackedInline):
    model = Post
    show_change_link = True
    ...

class BlogAdmin(admin.ModelAdmin):
    inlines = [PostInline]
    ...

class ImageInline(admin.StackedInline):
    # Assume Image model has foreign key to Post
    model = Image
    show_change_link = True
    ...

class PostAdmin(admin.ModelAdmin):
    inlines = [ImageInline]
    ...

admin.site.register(Blog, BlogAdmin)
admin.site.register(Post, PostAdmin)
bitnik
  • 389
  • 2
  • 5
  • While this link may answer the question, it is better to include the essential parts of the answer here and provide the link for reference. Link-only answers can become invalid if the linked page changes. – ekad Jul 28 '15 at 10:57
  • Thanks for posting this! Glad this feature finally got added. – agf Jul 29 '15 at 17:30
12

This is my current solution, based on what was suggested by Pannu (in his edit) and Mikhail.

I have a couple of top-level admin change view I need to link to a top-level admin change view of a related object, and a couple of inline admin change views I need to link to the top-level admin change view of the same object. Because of that, I want to factor out the link method rather than repeating variations of it for every admin change view.

I use a class decorator to create the link callable, and add it to readonly_fields.

def add_link_field(target_model = None, field = '', link_text = unicode):
    def add_link(cls):
        reverse_name = target_model or cls.model.__name__.lower()
        def link(self, instance):
            app_name = instance._meta.app_label
            reverse_path = "admin:%s_%s_change" % (app_name, reverse_name)
            link_obj = getattr(instance, field, None) or instance
            url = reverse(reverse_path, args = (link_obj.id,))
            return mark_safe("<a href='%s'>%s</a>" % (url, link_text(link_obj)))
        link.allow_tags = True
        link.short_description = reverse_name + ' link'
        cls.link = link
        cls.readonly_fields = list(getattr(cls, 'readonly_fields', [])) + ['link']
        return cls
    return add_link

You can also pass a custom callable if you need to get your link text in some way than just calling unicode on the object you're linking to.

I use it like this:

# the first 'blog' is the name of the model who's change page you want to link to
# the second is the name of the field on the model you're linking from
# so here, Post.blog is a foreign key to a Blog object. 
@add_link_field('blog', 'blog')
class PostAdmin(admin.ModelAdmin):
    inlines = [SubPostInline, DefinitionInline]
    fieldsets = ((None, {'fields': (('link', 'enabled'),)}),)
    list_display = ('__unicode__', 'enabled', 'link')

# can call without arguments when you want to link to the model change page
# for the model of an inline model admin.
@add_link_field()
class PostInline(admin.StackedInline):
    model = Post
    fieldsets = ((None, {'fields': (('link', 'enabled'),)}),)
    extra = 0

Of course none of this would be necessary if I could nest the admin change views for SubPost and Definition inside the inline admin of Post on the Blog admin change page without patching Django.

joshcartme
  • 2,717
  • 1
  • 22
  • 34
agf
  • 171,228
  • 44
  • 289
  • 238
  • you can the `app_name` from `cls._meta.app_label` and the model name from `cls._meta__str__()` this is good try I'll using it. :) – Pannu Apr 05 '12 at 07:24
  • It looks like I can get the site name from `instance._meta.app_label`, which is good -- I don't need that outside the callable, but I still need the name of the model (for the inline version) outside of the callable to set the `short_description`. – agf Apr 05 '12 at 11:45
  • Thanks for the idea, I think I found a way. For the case when you're using it to link from an inline admin to the admin change page for the same model, I get the name from `cls.model.__name__`. It still needs a model name manually in other circumstances because the target of the link can be any related object. – agf Apr 05 '12 at 12:55
  • I tried using this, didn't understand it much as I'm new to django, and got this error: Caught AttributeError while rendering: 'XXXXXXXXXXXAdmin' object has no attribute '__name__', – ultrajohn Jun 03 '12 at 13:19
  • @ultrajohn You need to specify a target model if you're not using it on an `InlineAdmin` subclass. In the example, it's `@add_link_field('blog', 'blog')` on the `PostAdmin` (which is a `ModelAdmin` subclass). – agf Jun 03 '12 at 22:54
  • 1
    I found this quite helpful. I made an edit, I believe that field in add_link_field should default to the empty string, not None. getattr expects a string so the getattr or instance doesn't work unless the default is an empty string. – joshcartme Jun 05 '12 at 22:39
  • @joshcartme Thanks. I'm out of town but I'll check it when I get back, and fix it when I confirm. – agf Jun 06 '12 at 00:25
11

I think that agf's solution is pretty awesome -- lots of kudos to him. But I needed a couple more features:

  • to be able to have multiple links for one admin
  • to be able to link to model in different app

Solution:

def add_link_field(target_model = None, field = '', app='', field_name='link',
                   link_text=unicode):
    def add_link(cls):
        reverse_name = target_model or cls.model.__name__.lower()
        def link(self, instance):
            app_name = app or instance._meta.app_label
            reverse_path = "admin:%s_%s_change" % (app_name, reverse_name)
            link_obj = getattr(instance, field, None) or instance
            url = reverse(reverse_path, args = (link_obj.id,))
            return mark_safe("<a href='%s'>%s</a>" % (url, link_text(link_obj)))
        link.allow_tags = True
        link.short_description = reverse_name + ' link'
        setattr(cls, field_name, link)
        cls.readonly_fields = list(getattr(cls, 'readonly_fields', [])) + \
            [field_name]
        return cls
    return add_link

Usage:

# 'apple' is name of model to link to
# 'fruit_food' is field name in `instance`, so instance.fruit_food = Apple()
# 'link2' will be name of this field
@add_link_field('apple','fruit_food',field_name='link2')
# 'cheese' is name of model to link to
# 'milk_food' is field name in `instance`, so instance.milk_food = Cheese()
# 'milk' is the name of the app where Cheese lives
@add_link_field('cheese','milk_food', 'milk')
class FoodAdmin(admin.ModelAdmin):
    list_display = ("id", "...", 'link', 'link2')

I am sorry that the example is so illogical, but I didn't want to use my data.

Tomas Tomecek
  • 6,226
  • 3
  • 30
  • 26
4

I agree that its hard to do template editing so, I create a custom widget to show an anchor on the admin change view page(can be used on both forms and inline forms).

So, I used the anchor widget, along with form overriding to get the link on the page.

forms.py:

class AnchorWidget(forms.Widget):

    def _format_value(self,value):
        if self.is_localized:
            return formats.localize_input(value)
        return value

    def render(self, name, value, attrs=None):
        if not value:
            value = u''

        text = unicode("")
        if self.attrs.has_key('text'):
            text = self.attrs.pop('text')

        final_attrs = self.build_attrs(attrs,name=name)

        return mark_safe(u"<a %s>%s</a>" %(flatatt(final_attrs),unicode(text)))

class PostAdminForm(forms.ModelForm):
    .......

    def __init__(self,*args,**kwargs):
        super(PostAdminForm, self).__init__(*args, **kwargs)
        instance = kwargs.get('instance',None)
        if instance.blog:
            href = reverse("admin:appname_Blog_change",args=(instance.blog))  
            self.fields["link"] = forms.CharField(label="View Blog",required=False,widget=AnchorWidget(attrs={'text':'go to blog','href':href}))


 class BlogAdminForm(forms.ModelForm):
    .......
    link = forms..CharField(label="View Post",required=False,widget=AnchorWidget(attrs={'text':'go to post'}))

    def __init__(self,*args,**kwargs):
        super(BlogAdminForm, self).__init__(*args, **kwargs)
        instance = kwargs.get('instance',None)
        href = ""
        if instance:
            posts = Post.objects.filter(blog=instance.pk)
            for idx,post in enumerate(posts):
                href = reverse("admin:appname_Post_change",args=(post["id"]))  
                self.fields["link_%s" % idx] = forms..CharField(label=Post["name"],required=False,widget=AnchorWidget(attrs={'text':post["desc"],'href':href}))

now in your ModelAdmin override the form attribute and you should get the desired result. I assumed you have a OneToOne relationship between these tables, If you have one to many then the BlogAdmin side will not work.

update: I've made some changes to dynamically add links and that also solves the OneToMany issue with the Blog to Post hope this solves the issue. :)

After Pastebin: In Your PostAdmin I noticed blog_link, that means your trying to show the blog link on changelist_view which lists all the posts. If I'm correct then you should add a method to show the link on the page.

class PostAdmin(admin.ModelAdmin):
    model = Post
    inlines = [SubPostInline, DefinitionInline]
    list_display = ('__unicode__', 'enabled', 'blog_on_site')

    def blog_on_site(self, obj):
        href = reverse("admin:appname_Blog_change",args=(obj.blog))
        return mark_safe(u"<a href='%s'>%s</a>" %(href,obj.desc))
    blog_on_site.allow_tags = True
    blog_on_site.short_description = 'Blog'

As far as the showing post links on BlogAdmin changelist_view you can do the same as above. My earlier solution will show you the link one level lower at the change_view page where you can edit each instance.

If you want the BlogAdmin page to show the links to the post in the change_view page then you will have to include each in the fieldsets dynamically by overriding the get_form method for class BlogAdmin and adding the link's dynamically, in get_form set the self.fieldsets, but first don't use tuples to for fieldsets instead use a list.

Pannu
  • 2,547
  • 2
  • 23
  • 29
  • It's a foreign key relationship, one blog to many posts. But this looks like a good start, I'll try it out in the next couple of days. – agf Mar 29 '12 at 09:58
  • I've added some changes to dynamically add those links, that should solve the issue. :) – Pannu Mar 29 '12 at 11:49
  • Ok, I just got a chance to try this out. After changing `args=(instance.blog)` to `args=(instance.blog,)` and `args=(post["id"])` to `args=(post.id,)` I get the same error for both: `NoReverseMatch at ...: Reverse for 'Site_(Blog or Post)_change' with arguments '(the arg here,)' and keyword arguments '{}' not found`. I also tried putting the actual name of my site in instead of `Site` but still got the same error. Any thoughts? I'm on Django 1.3 in case that is relevant. – agf Mar 30 '12 at 12:36
  • oh I'm sorry, the `Site` is actually your app name. Please replace the `Site` with your app name and it should work. please have a look at this as well https://docs.djangoproject.com/en/dev/ref/contrib/admin/#reversing-admin-urls – Pannu Mar 30 '12 at 15:35
  • I lowercased everything, and changed all your references from `post['key']` to `post.key`, and it removed the error. We're making progress. However, it hasn't changed either admin page at all. Do I need to do anything else, besides add `form = PostAdminForm` to my model? – agf Mar 30 '12 at 15:47
  • may be if your using `fieldsets` for `ModelAdmin`, then you will have to include the links in there as well. You can add those dynamically by overriding `get_form` and add `self.fieldsets`. This solution works for me, so if you share your **admin.py** and **forms.py** then may be i can find why its not working. You can share through pastebin or somethin. :) – Pannu Mar 31 '12 at 06:08
  • I added a bounty. Any chance to take a look at my code on [pastebin](http://pastebin.com/iPgWbMNq)? I've checked, the proper instances are found, but the fields never get displayed. If I add the field manually to `fields` on the appropriate `modelAdmin`, I get an error like `'PostAdmin.fields' refers to field 'link' that is missing from the form`. – agf Apr 02 '12 at 18:53
  • I just noticed your edit now. I actually didn't realize you could use callables on the model admin as fields -- that was the key info I was missing. After seeing the other post, I implemented a factory function that would create a mixin for each of my model admins. Now I see your edit would have given me the same idea! All you're missing is the need to use `readonly_fields`. As things stand now, I'll award you the bounty, though I may post my own factory function as an answer and accept that. – agf Apr 03 '12 at 01:10
  • oh it doesn't really matter, I just want something good coming out of this. Your solution is DRY:) – Pannu Apr 05 '12 at 06:49
2

Based on agfs and SummerBreeze's suggestions, I've improved the decorator to handle unicode better and to be able to link to backwards-foreignkey fields (ManyRelatedManager with one result). Also you can now add a short_description as a list header:

from django.core.urlresolvers import reverse
from django.core.exceptions import MultipleObjectsReturned
from django.utils.safestring import mark_safe

def add_link_field(target_model=None, field='', app='', field_name='link',
                   link_text=unicode, short_description=None):
    """
    decorator that automatically links to a model instance in the admin;
    inspired by http://stackoverflow.com/questions/9919780/how-do-i-add-a-link-from-the-django-admin-page-of-one-object-
    to-the-admin-page-o
    :param target_model: modelname.lower or model
    :param field: fieldname
    :param app: appname
    :param field_name: resulting field name
    :param link_text: callback to link text function
    :param short_description: list header
    :return:
    """
    def add_link(cls):
        reverse_name = target_model or cls.model.__name__.lower()

        def link(self, instance):
            app_name = app or instance._meta.app_label
            reverse_path = "admin:%s_%s_change" % (app_name, reverse_name)
            link_obj = getattr(instance, field, None) or instance

            # manyrelatedmanager with one result?
            if link_obj.__class__.__name__ == "RelatedManager":
                try:
                    link_obj = link_obj.get()
                except MultipleObjectsReturned:
                    return u"multiple, can't link"
                except link_obj.model.DoesNotExist:
                    return u""

            url = reverse(reverse_path, args = (link_obj.id,))
            return mark_safe(u"<a href='%s'>%s</a>" % (url, link_text(link_obj)))
        link.allow_tags = True
        link.short_description = short_description or (reverse_name + ' link')
        setattr(cls, field_name, link)
        cls.readonly_fields = list(getattr(cls, 'readonly_fields', [])) + \
            [field_name]
        return cls
    return add_link

Edit: updated due to link being gone.

panni
  • 46
  • 3
0

Looking through the source of the admin classes is enlightening: it shows that there is an object in context available to an admin view called "original".

Here is a similar situation, where I needed some info added to a change list view: Adding data to admin templates (on my blog).

agf
  • 171,228
  • 44
  • 289
  • 238
Matthew Schinckel
  • 35,041
  • 6
  • 86
  • 121
  • I don't think this is better for me than just adding a callable to the view. I can easily factor that code out, but the places I want the links to end up are in three different templates -- `fieldset.html` for a link to a related object from the object who's admin page it is, and `stacked.html` and `tabular.html` for a link to the top-level admin page for an object in an inline admin. While using `original` would have simplified my paths somewhat, it wouldn't have reduced the number of changes I needed to make to the templates. – agf Apr 04 '12 at 12:20