Inline OneToOne relations in django-admin

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
'''
reverseadmin
============
Module that makes django admin handle OneToOneFields in a better way.

A common use case for one-to-one relationships is to "embed" a model
inside another one. For example, a Person may have multiple foreign
keys pointing to an Address entity, one home address, one business
address and so on. Django admin displays those relations using select
boxes, letting the user choose which address entity to connect to a
person. A more natural way to handle the relationship is using
inlines. However, since the foreign key is placed on the owning
entity, django admins standard inline classes can't be used. Which is
why I created this module that implements "reverse inlines" for this
use case.

Example:

    from django.db import models
    class Address(models.Model):
        street = models.CharField(max_length = 255)
        zipcode = models.CharField(max_length = 10)
        city = models.CharField(max_length = 255)
    class Person(models.Model):
        name = models.CharField(max_length = 255)
        business_addr = models.OneToOneField(Address,
                                             related_name = 'business_addr')
        home_addr = models.OneToOneField(Address, related_name = 'home_addr')

This is how standard django admin renders it:

    http://img9.imageshack.us/i/beforetz.png/

Here is how it looks when using the reverseadmin module:

    http://img408.imageshack.us/i/afterw.png/

You use reverseadmin in the following way:

    from django.contrib import admin
    from models import Person
    from reverseadmin import ReverseModelAdmin
    class PersonAdmin(ReverseModelAdmin):
        inline_type = 'tabular'
    admin.site.register(Person, PersonAdmin)

inline_type can be either "tabular" or "stacked" for tabular and
stacked inlines respectively.

The module is designed to work with Django 1.1.1. Since it hooks into
the internals of the admin package, it may not work with later Django
versions.
'''
from django.contrib.admin import helpers, ModelAdmin
from django.contrib.admin.options import InlineModelAdmin
from django.db import transaction
from django.db.models import OneToOneField
from django.forms import ModelForm
from django.forms.formsets import all_valid
from django.forms.models import BaseModelFormSet, modelformset_factory
from django.utils.encoding import force_unicode
from django.utils.functional import curry
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _

class ReverseInlineFormSet(BaseModelFormSet):
    '''
    A formset with either a single object or a single empty
    form. Since the formset is used to render a required OneToOne
    relation, the forms must not be empty.
    '''
    model = None
    parent_fk_name = ''
    def __init__(self,
                 data = None,
                 files = None,
                 instance = None,
                 prefix = None,
                 save_as_new = False):
        if instance.id:
            object = getattr(instance, self.parent_fk_name)
            qs = self.model.objects.filter(pk = object.id)
        else:
            qs = self.model.objects.filter(pk = -1)
            self.extra = 1
        super(ReverseInlineFormSet, self).__init__(data, files,
                                                       prefix = prefix,
                                                       queryset = qs)
        for form in self.forms:
            form.empty_permitted = False

def reverse_inlineformset_factory(parent_model,
                                  model,
                                  parent_fk_name,
                                  form = ModelForm,
                                  fields = None,
                                  exclude = None,
                                  formfield_callback = lambda f: f.formfield()):
    kwargs = {
        'form': form,
        'formfield_callback': formfield_callback,
        'formset': ReverseInlineFormSet,
        'extra': 0,
        'can_delete': False,
        'can_order': False,
        'fields': fields,
        'exclude': exclude,
        'max_num': 1,
    }
    FormSet = modelformset_factory(model, **kwargs)
    FormSet.parent_fk_name = parent_fk_name
    return FormSet

class ReverseInlineModelAdmin(InlineModelAdmin):
    '''
    Use the name and the help_text of the owning models field to
    render the verbose_name and verbose_name_plural texts.
    '''
    def __init__(self,
                 parent_model,
                 parent_fk_name,
                 model, admin_site,
                 inline_type):
        self.template = 'admin/edit_inline/%s.html' % inline_type
        self.parent_fk_name = parent_fk_name
        self.model = model
        field_descriptor = getattr(parent_model, self.parent_fk_name)
        field = field_descriptor.field
        
        self.verbose_name_plural = field.verbose_name.title()
        self.verbose_name = field.help_text
        if not self.verbose_name:
            self.verbose_name = self.verbose_name_plural
        super(ReverseInlineModelAdmin, self).__init__(parent_model, admin_site)

    def get_formset(self, request, obj = None, **kwargs):
        if self.declared_fieldsets:
            fields = flatten_fieldsets(self.declared_fieldsets)
        else:
            fields = None
        if self.exclude is None:
            exclude = []
        else:
            exclude = list(self.exclude)
        # if exclude is an empty list we use None, since that's the actual
        # default
        exclude = (exclude + kwargs.get("exclude", [])) or None
        defaults = {
            "form": self.form,
            "fields": fields,
            "exclude": exclude,
            "formfield_callback": curry(self.formfield_for_dbfield, request=request),
        }
        defaults.update(kwargs)
        return reverse_inlineformset_factory(self.parent_model,
                                             self.model,
                                             self.parent_fk_name,
                                             **defaults)

class ReverseModelAdmin(ModelAdmin):
    '''
    Patched ModelAdmin class. The add_view method is overridden to
    allow the reverse inline formsets to be saved before the parent
    model.
    '''
    def __init__(self, model, admin_site):
        
        super(ReverseModelAdmin, self).__init__(model, admin_site)
        if self.exclude is None:
            self.exclude = []
        for field in model._meta.fields:
            if isinstance(field, OneToOneField):
                name = field.name
                parent = field.related.parent_model
                inline = ReverseInlineModelAdmin(model,
                                                 name,
                                                 parent,
                                                 admin_site,
                                                 self.inline_type)
                self.inline_instances.append(inline)
                self.exclude.append(name)
        
    def add_view(self, request, form_url='', extra_context=None):
        "The 'add' admin view for this model."
        model = self.model
        opts = model._meta

        if not self.has_add_permission(request):
            raise PermissionDenied

        ModelForm = self.get_form(request)
        formsets = []
        if request.method == 'POST':
            form = ModelForm(request.POST, request.FILES)
            if form.is_valid():
                form_validated = True
                new_object = self.save_form(request, form, change=False)
            else:
                form_validated = False
                new_object = self.model()
            prefixes = {}
            for FormSet in self.get_formsets(request):
                prefix = FormSet.get_default_prefix()
                prefixes[prefix] = prefixes.get(prefix, 0) + 1
                if prefixes[prefix] != 1:
                    prefix = "%s-%s" % (prefix, prefixes[prefix])
                formset = FormSet(data=request.POST, files=request.FILES,
                                  instance=new_object,
                                  save_as_new=request.POST.has_key("_saveasnew"),
                                  prefix=prefix)
                formsets.append(formset)
            if all_valid(formsets) and form_validated:
                # Here is the modified code.
                for formset, inline in zip(formsets, self.inline_instances):
                    if not isinstance(inline, ReverseInlineModelAdmin):
                        continue
                    obj = formset.save()[0]
                    setattr(new_object, inline.parent_fk_name, obj)
                self.save_model(request, new_object, form, change=False)
                form.save_m2m()
                for formset in formsets:
                    self.save_formset(request, form, formset, change=False)

                self.log_addition(request, new_object)
                return self.response_add(request, new_object)
        else:
            # Prepare the dict of initial data from the request.
            # We have to special-case M2Ms as a list of comma-separated PKs.
            initial = dict(request.GET.items())
            for k in initial:
                try:
                    f = opts.get_field(k)
                except models.FieldDoesNotExist:
                    continue
                if isinstance(f, models.ManyToManyField):
                    initial[k] = initial[k].split(",")
            form = ModelForm(initial=initial)
            prefixes = {}
            for FormSet in self.get_formsets(request):
                prefix = FormSet.get_default_prefix()
                prefixes[prefix] = prefixes.get(prefix, 0) + 1
                if prefixes[prefix] != 1:
                    prefix = "%s-%s" % (prefix, prefixes[prefix])
                formset = FormSet(instance=self.model(), prefix=prefix)
                formsets.append(formset)

        adminForm = helpers.AdminForm(form, list(self.get_fieldsets(request)), self.prepopulated_fields)
        media = self.media + adminForm.media

        inline_admin_formsets = []
        for inline, formset in zip(self.inline_instances, formsets):
            fieldsets = list(inline.get_fieldsets(request))
            inline_admin_formset = helpers.InlineAdminFormSet(inline, formset, fieldsets)
            inline_admin_formsets.append(inline_admin_formset)
            media = media + inline_admin_formset.media

        context = {
            'title': _('Add %s') % force_unicode(opts.verbose_name),
            'adminform': adminForm,
            'is_popup': request.REQUEST.has_key('_popup'),
            'show_delete': False,
            'media': mark_safe(media),
            'inline_admin_formsets': inline_admin_formsets,
            'errors': helpers.AdminErrorList(form, formsets),
            'root_path': self.admin_site.root_path,
            'app_label': opts.app_label,
        }
        context.update(extra_context or {})
        return self.render_change_form(request, context, form_url=form_url, add=True)
    add_view = transaction.commit_on_success(add_view)

More like this

  1. Django Admin inline preview by timbroder 4 years, 2 months ago
  2. Admin page without inlines dinamically by davmuz 1 year, 10 months ago
  3. model save memos by depaolim 10 months, 1 week ago
  4. Printing inline formsets as UL / P by ikke 4 years, 8 months ago
  5. Making a django inline (model) formset really tabular by fnl 5 years ago

Comments

nonico (on October 19, 2010):

This is a nice a very usefull snippet!

I've been using it not only with OneToOne relations but with ForeignKey also changing the line 172: .... #172: if isinstance(field, OneToOneField): if isinstance(field, ForeignKey): if not isinstance(field, OneToOneField): # I added an attr 'inline_foreignkey' in the model admin # telling which fields are to be inlined if not hasattr(self, 'inline_foreignkey'): return if not field.name in self.inline_foreignkey: continue ....

It works just fine, but django doesn't use the Admin/Form defined for the related model.

When I define a custom widget for a field (in the form of the related model) it appears fine in the admin page of the related model, but not in owning model admin page (the default widget is used).

Do you have any tip how to solve this?

Thank you!

#

nonico (on October 19, 2010):

.... #172: if isinstance(field, OneToOneField): if isinstance(field, ForeignKey): if not isinstance(field, OneToOneField): # I added an attr 'inline_foreignkey' in the model admin # telling which fields are to be inlined if not hasattr(self, 'inline_foreignkey'): continue if not field.name in self.inline_foreignkey: continue ....

#

NickJones (on November 5, 2010):

As you originally speculated, this doesn't work with later versions of Django. The problem arises on line 907 of contrib/admin/options.py which states:

for FormSet, inline in zip(self.get_formsets(request, obj), self.inline_instances):

instead of in version 1.1.1:

for FormSet in self.get_formsets(request, obj):

where the extra self.inline_instances throws up an error saying

__init__ received an unknown keyword "queryset"

I've found the easiest way to get it working is to copy the entire change_view function from 1.2.1 into ReverseModelAdmin and then revert lines 907 and 912/913 to the state they were in 1.1.1. Then change line 81 in the snippet to "if instance.pk" rather than "if instance.id" and it should work. I know this is a filthy hack but I've run out of time on this project!

#

mox (on September 12, 2011):

Has anyone modified this useful snippets in order to be less coupled with the django core? I would like to use it in django 1.2 ad well as in django 1.3?

#

roberts81 (on February 27, 2012):

I would also find this useful in Django 1.3 +, but don't have the know how to figure out which things are updates the django core since 1.1.1 and which things are intentional changes by the author. If anyone has or does update this patch to work with newer django versions (or comes up with a non-patch way to do it) and can post your code on this site, it would be much appreciated.

#

mulatto401 (on November 14, 2012):

I have this working in 1.3.

As NickJones had pointed out, there is an error about a 'queryset' argument. To fix this please make the following edit:

class ReverseInlineFormSet(BaseModelFormSet): ''' A formset with either a single object or a single empty form. Since the formset is used to render a required OneToOne relation, the forms must not be empty. ''' model = None parent_fk_name = '' def init(self, data = None, files = None, instance = None, prefix = None, save_as_new = False, queryset=None <<< ADD THIS AS A PARAMETER):

Or if you are looking at the code snippet as is. Line 79 is where the last parameter is declared, go ahead and add another one "queryset=None".

This worked for me and is fast.

Thank you everyone for the comments and updates.

#

ramusus (on December 20, 2012):

updated with ability define custom forms:

https://gist.github.com/4343464

#

(Forgotten your password?)