Index: django/contrib/admin/options.py
===================================================================
--- django/contrib/admin/options.py	(revision 13290)
+++ django/contrib/admin/options.py	(working copy)
@@ -145,7 +145,7 @@
         """
         db = kwargs.get('using')
         if db_field.name in self.raw_id_fields:
-            kwargs['widget'] = widgets.ForeignKeyRawIdWidget(db_field.rel, using=db)
+            kwargs['widget'] = widgets.ForeignKeyRawIdWidget(db_field.rel, using=db, admin_site=self.admin_site)
         elif db_field.name in self.radio_fields:
             kwargs['widget'] = widgets.AdminRadioSelect(attrs={
                 'class': get_ul_class(self.radio_fields[db_field.name]),
Index: django/contrib/admin/util.py
===================================================================
--- django/contrib/admin/util.py	(revision 13290)
+++ django/contrib/admin/util.py	(working copy)
@@ -333,3 +333,42 @@
         return formats.number_format(value)
     else:
         return smart_unicode(value)
+
+
+def get_related_object(rel, value):
+    try:
+        return rel.to.objects.get(pk=value)
+    except:
+        return None
+
+def get_changelink_url(obj, admin_site):
+    """ If obj is registered in admin_site, return URL for change view. """
+    if admin_site is None:
+        # probably a ManyToManyRawIdWidget
+        return None
+    if obj.__class__ in admin_site._registry:
+        try:
+            view_name = '%s:%s_%s_change' % (
+                admin_site.name,
+                obj._meta.app_label,
+                obj._meta.object_name.lower())
+            return reverse(view_name, None, (quote(obj._get_pk_val()),))
+        except NoReverseMatch:
+            # should not happen, but fail silently just in case
+            pass
+    return None
+
+def get_changelink_html(obj, admin_site, show_name=False):
+    """ Return HTML link to change view for obj if registered in admin_site.
+
+    Optionally includes representation of object (%s) in link.
+    """
+    url = get_changelink_url(obj, admin_site)
+    if url is None:
+        return ''
+    if show_name:
+        text = _('edit %s' % obj)
+    else:
+        text = _('edit')
+    return (u' <a href="%(url)s" class="related_field_changelink changelink">'
+            '%(text)s</a>' % {'url': url, 'text': text})
Index: django/contrib/admin/helpers.py
===================================================================
--- django/contrib/admin/helpers.py	(revision 13290)
+++ django/contrib/admin/helpers.py	(working copy)
@@ -2,6 +2,7 @@
 from django.conf import settings
 from django.contrib.admin.util import flatten_fieldsets, lookup_field
 from django.contrib.admin.util import display_for_field, label_for_field
+from django.contrib.admin.util import get_related_object, get_changelink_url
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.db.models.fields import FieldDoesNotExist
@@ -186,6 +187,17 @@
                     result_repr = display_for_field(value, f)
         return conditional_escape(result_repr)
 
+    def change_url(self):
+        """ Return admin change URL for field if applicable. """
+        field, original, model_admin = \
+            self.field['field'], self.form.instance, self.model_admin
+        try:
+            f, attr, value = lookup_field(field, original, model_admin)
+        except (AttributeError, ValueError, ObjectDoesNotExist):
+            return None
+        return get_changelink_url(value, self.model_admin.admin_site)
+
+    
 class InlineAdminFormSet(object):
     """
     A wrapper around an inline formset for use in the admin system.
@@ -247,6 +259,11 @@
         if original is not None:
             self.original_content_type_id = ContentType.objects.get_for_model(original).pk
         self.show_url = original and hasattr(original, 'get_absolute_url')
+        if original is not None and model_admin is not None:
+            self.admin_url = get_changelink_url(original,
+                                                model_admin.admin_site)
+        else:
+            self.admin_url = None
         super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields,
             readonly_fields, model_admin)
 
Index: django/contrib/admin/widgets.py
===================================================================
--- django/contrib/admin/widgets.py	(revision 13290)
+++ django/contrib/admin/widgets.py	(working copy)
@@ -14,6 +14,7 @@
 from django.utils.encoding import force_unicode
 from django.conf import settings
 from django.core.urlresolvers import reverse, NoReverseMatch
+from django.contrib.admin.util import get_related_object, get_changelink_html
 
 class FilteredSelectMultiple(forms.SelectMultiple):
     """
@@ -105,9 +106,10 @@
     A Widget for displaying ForeignKeys in the "raw_id" interface rather than
     in a <select> box.
     """
-    def __init__(self, rel, attrs=None, using=None):
+    def __init__(self, rel, attrs=None, using=None, admin_site=None):
         self.rel = rel
         self.db = using
+        self.admin_site = admin_site
         super(ForeignKeyRawIdWidget, self).__init__(attrs)
 
     def render(self, name, value, attrs=None):
@@ -129,6 +131,9 @@
         output.append('<img src="%simg/admin/selector-search.gif" width="16" height="16" alt="%s" /></a>' % (settings.ADMIN_MEDIA_PREFIX, _('Lookup')))
         if value:
             output.append(self.label_for_value(value))
+            output.append(get_changelink_html(
+                    get_related_object(self.rel, value),
+                    self.admin_site, show_name=False))
         return mark_safe(u''.join(output))
 
     def base_url_parameters(self):
@@ -242,6 +247,10 @@
             output.append(u'<a href="%s" class="add-another" id="add_id_%s" onclick="return showAddAnotherPopup(this);"> ' % \
                 (related_url, name))
             output.append(u'<img src="%simg/admin/icon_addlink.gif" width="10" height="10" alt="%s"/></a>' % (settings.ADMIN_MEDIA_PREFIX, _('Add Another')))
+
+            output.append(get_changelink_html(
+                    get_related_object(self.rel, value),
+                    self.admin_site, show_name=True))
         return mark_safe(u''.join(output))
 
     def build_attrs(self, extra_attrs=None, **kwargs):
Index: django/contrib/admin/templates/admin/edit_inline/stacked.html
===================================================================
--- django/contrib/admin/templates/admin/edit_inline/stacked.html	(revision 13290)
+++ django/contrib/admin/templates/admin/edit_inline/stacked.html	(working copy)
@@ -6,6 +6,7 @@
 
 {% for inline_admin_form in inline_admin_formset %}<div class="inline-related{% if forloop.last %} empty-form last-related{% endif %}" id="{{ inline_admin_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
   <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>
+    {% if inline_admin_form.admin_url %}<a href="{{ inline_admin_form.admin_url }}" class="inline_changelink changelink">{% trans "edit separately" %}</a>&nbsp;&nbsp;{% endif %}
     {% if inline_admin_form.show_url %}<a href="../../../r/{{ inline_admin_form.original_content_type_id }}/{{ inline_admin_form.original.id }}/">{% trans "View on site" %}</a>{% endif %}
     {% if inline_admin_formset.formset.can_delete and inline_admin_form.original %}<span class="delete">{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}</span>{% endif %}
   </h3>
Index: django/contrib/admin/templates/admin/edit_inline/tabular.html
===================================================================
--- django/contrib/admin/templates/admin/edit_inline/tabular.html	(revision 13290)
+++ django/contrib/admin/templates/admin/edit_inline/tabular.html	(working copy)
@@ -25,6 +25,7 @@
         <td class="original">
           {% if inline_admin_form.original or inline_admin_form.show_url %}<p>
           {% if inline_admin_form.original %} {{ inline_admin_form.original }}{% endif %}
+          {% if inline_admin_form.admin_url %}<a href="{{ inline_admin_form.admin_url }}" class="inline_changelink changelink">{% trans "edit separately" %}</a>&nbsp;&nbsp;{% endif %}
           {% if inline_admin_form.show_url %}<a href="../../../r/{{ inline_admin_form.original_content_type_id }}/{{ inline_admin_form.original.id }}/">{% trans "View on site" %}</a>{% endif %}
             </p>{% endif %}
           {% if inline_admin_form.has_auto_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
@@ -44,7 +45,7 @@
             {% for field in line %}
               <td class="{{ field.field.name }}">
               {% if field.is_readonly %}
-                  <p>{{ field.contents }}</p>
+                  <p>{{ field.contents }}{% if field.change_url %} <a href="{{ field.change_url }}" class="readonly_changelink changelink">{% trans "edit" %}</a>{% endif %}</p>
               {% else %}
                   {{ field.field.errors.as_ul }}
                   {{ field.field }}
Index: django/contrib/admin/templates/admin/includes/fieldset.html
===================================================================
--- django/contrib/admin/templates/admin/includes/fieldset.html	(revision 13290)
+++ django/contrib/admin/templates/admin/includes/fieldset.html	(working copy)
@@ -1,3 +1,4 @@
+{% load i18n adminmedia %}
 <fieldset class="module aligned {{ fieldset.classes }}">
     {% if fieldset.name %}<h2>{{ fieldset.name }}</h2>{% endif %}
     {% if fieldset.description %}
@@ -13,7 +14,7 @@
                     {% else %}
                         {{ field.label_tag }}
                         {% if field.is_readonly %}
-                            <p>{{ field.contents }}</p>
+                            <p>{{ field.contents }}{% if field.change_url %} <a href="{{ field.change_url }}" class="readonly_changelink changelink">edit</a>{% endif %}</p>
                         {% else %}
                             {{ field.field }}
                         {% endif %}
Index: tests/regressiontests/admin_views/tests.py
===================================================================
--- tests/regressiontests/admin_views/tests.py	(revision 13290)
+++ tests/regressiontests/admin_views/tests.py	(working copy)
@@ -24,7 +24,8 @@
     FooAccount, Gallery, ModelWithStringPrimaryKey, \
     Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast, \
     Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit, \
-    Category, Post, Plot, FunkyTag
+    Category, Post, Plot, PlotDetails, FunkyTag, Villain, SecretHideout, \
+    Ambush, Pizza, PizzaOrder, Topping, Donut, DonutOrder
 
 
 class AdminViewBasicTest(TestCase):
@@ -2149,3 +2150,110 @@
         self.assert_('password' not in adminform.form.errors)
         self.assertEquals(adminform.form.errors['password2'],
                           [u"The two password fields didn't match."])
+
+
+class RelatedLinksTest(TestCase):
+    fixtures = ['admin-views-users.xml']
+    
+    def setUp(self):
+        self.client.login(username='super', password='secret')
+        self.RELATED_LINK_CSS_CLASS = 'related_field_changelink'
+        self.READONLY_LINK_CSS_CLASS = 'readonly_changelink'
+        self.black_knight = Villain.objects.create(name='Black Knight')
+        self.plot = Plot.objects.create(name='None shall pass',
+                                        team_leader=self.black_knight,
+                                        contact=self.black_knight)
+        self.plot_details = PlotDetails.objects.create(
+            plot=self.plot, details="I'll bite your legs off!")
+        self.hideout = SecretHideout.objects.create(villain=self.black_knight,
+                                                    location='forest')
+        self.ambush = Ambush.objects.create(hideout=self.hideout,
+                                            when=datetime.datetime.now())
+
+        self.pizza = Pizza.objects.create(name='Wafer-thin pizza')
+        self.topping = Topping.objects.create(name='Mint')
+        self.pizza.toppings.add(self.topping)
+        self.donut = Donut.objects.create(name='Wafer-thin donut')
+        self.donut.toppings.add(self.topping)
+        self.donut_order = DonutOrder.objects.create(donut=self.donut,
+                                                     quantity=1000000)
+        self.pizza_order = PizzaOrder.objects.create(pizza=self.pizza,
+                                                     quantity=50)
+        
+    def tearDown(self):
+        self.client.logout()
+
+    def test_foreignkey(self):
+        """ Confirm changelink appears for populated ForeignKey fields """
+        response = self.client.get('/test_admin/admin/admin_views/plot/add/')
+        self.assertNotContains(response, self.RELATED_LINK_CSS_CLASS)
+        response = self.client.get('/test_admin/admin/admin_views/plot/%d/' %
+                                   self.plot.pk)
+        self.assertContains(response, self.RELATED_LINK_CSS_CLASS)
+        self.assertContains(response, '>edit %s</a>' % self.black_knight)
+
+    def test_onetoone(self):
+        """ Confirm changelink appears for populated OneToOne fields """
+        response = self.client.get(
+            '/test_admin/admin/admin_views/plotdetails/add/')
+        self.assertNotContains(response, self.RELATED_LINK_CSS_CLASS)
+        response = self.client.get(
+            '/test_admin/admin/admin_views/plotdetails/%d/' %
+            self.plot_details.pk)
+        self.assertContains(response, self.RELATED_LINK_CSS_CLASS)
+        self.assertContains(response, '>edit %s</a>' % self.plot)
+
+    def test_unregistered(self):
+        """ Confirm changelink does *not* appear for unregistered fields """
+        response = self.client.get(
+            '/test_admin/admin/admin_views/ambush/add/')
+        self.assertNotContains(response, self.RELATED_LINK_CSS_CLASS)
+        response = self.client.get(
+            '/test_admin/admin/admin_views/ambush/%d/' %
+            self.ambush.pk)
+        self.assertNotContains(response, self.RELATED_LINK_CSS_CLASS)        
+        
+    def test_readonly(self):
+        """ Confirm changelink appears for populated readonly fields """
+        response = self.client.get(
+            '/test_admin/admin/admin_views/pizzaorder/add/')
+        self.assertNotContains(response, self.READONLY_LINK_CSS_CLASS)
+        response = self.client.get(
+            '/test_admin/admin/admin_views/pizzaorder/%d/' %
+            self.pizza_order.pk)
+        self.assertContains(response, self.READONLY_LINK_CSS_CLASS)
+        self.assertContains(response, '>edit</a>')       
+
+    def test_manytomany(self):
+        """ Confirm changelinks do *not* appear for ManyToMany fields """
+        MULTIPLE_SELECT_STRING = '<select multiple="multiple"'
+        
+        response = self.client.get(
+            '/test_admin/admin/admin_views/pizza/add/')
+        self.assertNotContains(response, self.RELATED_LINK_CSS_CLASS)
+        self.assertNotContains(response, MULTIPLE_SELECT_STRING)        
+        response = self.client.get(
+            '/test_admin/admin/admin_views/pizza/%d/' % self.pizza.pk)
+        self.assertNotContains(response, self.RELATED_LINK_CSS_CLASS)
+        self.assertNotContains(response, MULTIPLE_SELECT_STRING)        
+
+        # try non-readonly ManyToMany
+        response = self.client.get(
+            '/test_admin/admin/admin_views/donut/add/')
+        self.assertContains(response, MULTIPLE_SELECT_STRING)
+        self.assertNotContains(response, self.RELATED_LINK_CSS_CLASS)
+        response = self.client.get(
+            '/test_admin/admin/admin_views/donut/%d/' % self.donut.pk)
+        self.assertNotContains(response, self.RELATED_LINK_CSS_CLASS)
+        self.assertContains(response, MULTIPLE_SELECT_STRING)        
+
+    def test_raw_id(self):
+        """ Confirm links appears for populated raw_id_fields """
+        response = self.client.get(
+            '/test_admin/admin/admin_views/donutorder/add/')
+        self.assertNotContains(response, self.RELATED_LINK_CSS_CLASS)
+        response = self.client.get(
+            '/test_admin/admin/admin_views/donutorder/%d/' %
+            self.donut_order.pk)
+        self.assertContains(response, self.RELATED_LINK_CSS_CLASS)
+        self.assertContains(response, '>edit</a>')
Index: tests/regressiontests/admin_views/models.py
===================================================================
--- tests/regressiontests/admin_views/models.py	(revision 13290)
+++ tests/regressiontests/admin_views/models.py	(working copy)
@@ -555,6 +555,13 @@
     def __unicode__(self):
         return self.location
 
+class Ambush(models.Model):
+    hideout = models.ForeignKey(SecretHideout)
+    when = models.DateTimeField()
+
+    def __unicode__(self):
+        return u'Ambush %s at %s' % (self.hideout, self.when)
+    
 class CyclicOne(models.Model):
     name = models.CharField(max_length=25)
     two = models.ForeignKey('CyclicTwo')
@@ -579,6 +586,33 @@
 class PizzaAdmin(admin.ModelAdmin):
     readonly_fields = ('toppings',)
 
+class Donut(models.Model):
+    name = models.CharField(max_length=20)
+    toppings = models.ManyToManyField('Topping')
+
+    def __unicode__(self):
+        return self.name
+
+class DonutOrder(models.Model):
+    donut = models.ForeignKey(Donut)
+    quantity = models.IntegerField()
+
+    def __unicode__(self):
+        return '%s x %d' % (self.donut, self.quantity)
+    
+class DonutOrderAdmin(admin.ModelAdmin):
+    raw_id_fields = ('donut',)
+
+class PizzaOrder(models.Model):
+    pizza = models.ForeignKey(Pizza)
+    quantity = models.IntegerField()
+
+    def __unicode__(self):
+        return '%s x %d' % (self.pizza, self.quantity)
+    
+class PizzaOrderAdmin(admin.ModelAdmin):
+    readonly_fields = ('pizza',)
+    
 admin.site.register(Article, ArticleAdmin)
 admin.site.register(CustomArticle, CustomArticleAdmin)
 admin.site.register(Section, save_as=True, inlines=[ArticleInline])
@@ -606,6 +640,7 @@
 admin.site.register(Gadget, GadgetAdmin)
 admin.site.register(Villain)
 admin.site.register(SuperVillain)
+admin.site.register(Ambush)
 admin.site.register(Plot)
 admin.site.register(PlotDetails)
 admin.site.register(CyclicOne)
@@ -624,4 +659,8 @@
 admin.site.register(Promo)
 admin.site.register(ChapterXtra1)
 admin.site.register(Pizza, PizzaAdmin)
+admin.site.register(PizzaOrder, PizzaOrderAdmin)
 admin.site.register(Topping)
+admin.site.register(Donut)
+admin.site.register(DonutOrder, DonutOrderAdmin)
+
Index: tests/regressiontests/admin_inlines/tests.py
===================================================================
--- tests/regressiontests/admin_inlines/tests.py	(revision 13290)
+++ tests/regressiontests/admin_inlines/tests.py	(working copy)
@@ -4,6 +4,7 @@
 from models import Holder, Inner, InnerInline
 from models import Holder2, Inner2, Holder3, Inner3
 from models import Person, OutfitItem, Fashionista
+from models import Household, Individual, Phone, Email
 
 class TestInline(TestCase):
     fixtures = ['admin-views-users.xml']
@@ -100,3 +101,84 @@
         response = self.client.get(change_url)
         self.assertContains(response, 'my_awesome_admin_scripts.js')
         self.assertContains(response, 'my_awesome_inline_scripts.js')
+
+
+class TestAdminURL(TestCase):
+    fixtures = ['admin-views-users.xml']
+    
+    def get_admin_url(self, obj_or_class, add=False):
+        if add:
+            obj_id = 'add'
+            cls = obj_or_class
+        else:
+            obj_id = obj_or_class.pk
+            cls = obj_or_class.__class__
+        return '/test_admin/admin/admin_inlines/%s/%s/' % (
+            cls.__name__.lower(), obj_id)
+        
+    def setUp(self):
+        self.household = Household.objects.create()
+        self.individual = Individual.objects.create(household=self.household)
+        self.phone = Phone.objects.create(household=self.household,
+                                          number='1234567890')
+        self.email = Email.objects.create(individual=self.individual,
+                                          email='me@example.com')
+        
+        result = self.client.login(username='super', password='secret')
+        self.failUnlessEqual(result, True)
+        
+    def tearDown(self):
+        self.client.logout()
+
+    def test_admin_url(self):
+        """
+        admin_url should be set for admin-registered inline models only.
+
+        Also check to ensure URLs look correct and only set on bound forms.
+        """
+        response = self.client.get(self.get_admin_url(self.household))
+        for inline_admin_fset in response.context[-1]['inline_admin_formsets']:
+            for inline_admin_form in inline_admin_fset:
+                if inline_admin_form.form._meta.model != Individual:
+                    self.assertFalse(
+                        getattr(inline_admin_form, 'admin_url', None),
+                        'admin_url set with unregistered model')
+                elif not inline_admin_form.original:
+                    self.assertFalse(
+                        getattr(inline_admin_form, 'admin_url', None),
+                        'admin_url set on unbound form!')
+                else:
+                    self.assertTrue(inline_admin_form.admin_url,
+                                    'admin_url not set')
+                    self.assertEqual(
+                        inline_admin_form.original, self.individual,
+                        'original is not expected object')
+                    self.assertEqual(
+                        inline_admin_form.admin_url,
+                        self.get_admin_url(inline_admin_form.original),
+                        'admin_url appears incorrect')
+
+    def test_link_rendering(self):
+        """ Confirm links are displayed where appropriate. """
+        LINK_CSS_CLASS = 'inline_changelink'
+        LINK_TEXT = 'edit separately'
+
+        # test StackedInline rendering
+        
+        response = self.client.get(self.get_admin_url(Household, 'add'))
+        self.assertNotContains(response, LINK_CSS_CLASS)
+        self.assertNotContains(response, LINK_TEXT)
+        
+        response = self.client.get(self.get_admin_url(self.household))
+        self.assertContains(response, LINK_CSS_CLASS)
+        self.assertContains(response, LINK_TEXT)
+
+        # test TabularInline rendering
+
+        response = self.client.get(self.get_admin_url(Individual, 'add'))
+        self.assertNotContains(response, LINK_CSS_CLASS)
+        self.assertNotContains(response, LINK_TEXT)
+        
+        response = self.client.get(self.get_admin_url(self.individual))
+        self.assertContains(response, LINK_CSS_CLASS)
+        self.assertContains(response, LINK_TEXT)        
Index: tests/regressiontests/admin_inlines/models.py
===================================================================
--- tests/regressiontests/admin_inlines/models.py	(revision 13290)
+++ tests/regressiontests/admin_inlines/models.py	(working copy)
@@ -145,3 +145,38 @@
 
 """
 }
+
+# Test InlineAdminForm.admin_url:
+
+class Household(models.Model):
+    pass
+
+class Individual(models.Model):
+    household = models.ForeignKey(Household)
+
+class Phone(models.Model):
+    household = models.ForeignKey(Household)
+    number = models.CharField(max_length=64)
+
+class Email(models.Model):
+    individual = models.ForeignKey(Individual)
+    email = models.EmailField()
+
+class IndividualInline(admin.StackedInline):
+    model = Individual
+    extra = 1
+
+class PhoneInline(admin.StackedInline):
+    model = Phone
+    extra = 1
+
+class EmailInline(admin.TabularInline):
+    model = Email
+    extra = 1    
+    
+admin.site.register(Household, inlines=[IndividualInline, PhoneInline])
+admin.site.register(Individual, inlines=[EmailInline])
+admin.site.register(Email)
+# (Phone not registered)
+
+
Index: AUTHORS
===================================================================
--- AUTHORS	(revision 13290)
+++ AUTHORS	(working copy)
@@ -324,6 +324,7 @@
     mccutchen@gmail.com
     Paul McLanahan <paul@mclanahan.net>
     Tobias McNulty <http://www.caktusgroup.com/blog>
+    Simon Meers <http://simonmeers.com/>
     Zain Memon
     Christian Metts
     michael.mcewan@gmail.com
