Opened 13 years ago

Last modified 20 months ago

#15602 new Bug

Using get_readonly_fields and StackedInline/TabularInline admin objects doesn't allow creating new objects, immutible existing objects

Reported by: bradwhittington Owned by: nobody
Component: contrib.admin Version: dev
Severity: Normal Keywords:
Cc: jdunck@…, Solvik, wilburb, net147, Zach Borboa, vmspike@…, direx, Prateem Shrestha, Paolo Dina, Scott Stevens, Ülgen Sarıkavak Triage Stage: Accepted
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

I am using a pattern described here, on a StackedInline admin class, AKA:

    def get_readonly_fields(self, request, obj = None):
        if obj:
            return self.readonly_fields+('text','author','private')
        return self.readonly_fields

But it seems to currently be broken (in 1.2.5), I get all fields repeated twice per object, and the add new object fields are all marked read-only, as if obj is always a non-None value. If I throw logging into that method (log.debug(obj==None)), I am seeing (in an example with one inline object already created):

2011-03-14 00:23:52 SAST+0000 - DEBUG - task.admin - False
2011-03-14 00:23:52 SAST+0000 - DEBUG - task.admin - True
2011-03-14 00:23:52 SAST+0000 - DEBUG - task.admin - False
2011-03-14 00:23:52 SAST+0000 - DEBUG - task.admin - False

Change History (26)

comment:1 by Karen Tracey, 13 years ago

Triage Stage: UnreviewedAccepted

The obj passed to get_readonly_fields defined in an inline ModelAdmin looks to be the parent object, not the object for the inline itself. This looks to be similar to #15424.

comment:2 by Luke Plant, 13 years ago

Type: Bug

comment:3 by Luke Plant, 13 years ago

Severity: Normal

comment:4 by Jeremy Dunck, 13 years ago

Cc: jdunck@… added

comment:5 by Solvik, 13 years ago

Cc: Solvik added
Easy pickings: unset
UI/UX: unset

comment:6 by nivhaa@…, 13 years ago

Happens the same to me, but I'm working with 1.3... can't use get_readonly_fields on an inline models when I'm having two inlines in the same change_view page. "Please correct the errors below" is shown above, without any error shown below. when trying to debug, somewhere along the road, the formset gets this error for each inline object "This field is required" - but as I said, it's not shown in the page

comment:7 by wilburb, 12 years ago

Cc: wilburb added

comment:8 by Julien Phalip, 12 years ago

The core issue here, as pointed out by Karen, is that obj is the parent object, not a child object from the inline. Note that the same issue affects get_readonly_fields(), get_fieldsets() and get_prepopulated_fields(): https://code.djangoproject.com/browser/django/trunk/django/contrib/admin/options.py?rev=17065#L1080

Those methods are called as each inline formset is instantiated, not as each individual form within the formsets is instantiated. The inline formsets are instantiated in the admin view, whereas the individual inline forms are instantiated later during the template-rendering stage, at the same time as the related objects are fetched from the database via the corresponding inline's queryset:

https://code.djangoproject.com/browser/django/trunk/django/contrib/admin/templates/admin/change_form.html?rev=17065#L60
https://code.djangoproject.com/browser/django/trunk/django/contrib/admin/helpers.py?rev=17065#L209

The three methods get_readonly_fields(), get_fieldsets() and get_prepopulated_fields() all need to be passed the HttpRequest object. Yet, by the stage at which the inline forms are instantiated the request object isn't available.

To sum up, if we want to actually pass the inline's individual object to those methods, then some pretty significant refactoring will be necessary. Maybe this is a good time to rethink the way inlines are managed in the admin.

comment:9 by net147, 11 years ago

Cc: net147 added

comment:10 by Zach Borboa, 9 years ago

Cc: Zach Borboa added

comment:11 by David Isaacson, 9 years ago

#24185 is a duplicate of this.

comment:12 by David Isaacson, 9 years ago

I've done some additional research, and the problem actually goes much deeper than this. Not only do the results of get_readonly_fields get passed to the InlineAdminFormSet object and others in django.contrib.admin.helpers that actually render the formset (including the readonly fields), they also get passed into the formset validation logic.

The way formsets work is to take a single form class and instantiate it multiple times; an inline model formset uses a constructed ModelForm subclass. (see django.forms.models.modelformset_factory)

Now, the results of get_readonly_fields are added to this constructed form class as the Meta.exclude list, the list of fields that are excluded from validation. Because this same class is instantiated multiple times by the formset, every instance of the form must have the same fields excluded. So, as it stands now, every instance must have the same readonly fields - because you want to exclude at least these of fields from validation. Decisions can't be made on a per-form/per-inline-model-instance basis.

The only real "solutions" that come to mind both involve _underscore methods, which is exactly what you're not supposed to do:

  • The simplest is to allow the custom model form class to be created with exclude=(), and let the individual forms in the formset be instantiated. Then we'd tweak each form's _meta.exclude as we wanted to include the results of its get_readonly_fields.
  • Another possibility is to use a custom formset that overrides BaseModelFormSet._construct_form, the method that model formsets use to get each model instance in the queryset and to instantiate each of their individual forms. Instead of instantiating the formset's normal form, we'd have to check the readonly fields on the instance, create a new model form class for that particular exclude list, and instantiate a single instance of that class. Whew.

I suppose the ideal solution would be to allow a model form's exclude to be callable with the form's instance, but that's outside the scope of this ticket I imagine.

Of course none of this solves the earlier problem of what to do with the request, but in comparison that seems like a fairly simple problem to have - worst case you send it along when you instantiate the InlineAdminForm wrapper or bind up a functools.partial version of get_readonly_fields with the request in it. But perhaps there's even a cleaner way, I didn't look into this too much as it depends on the larger problem.

So, I guess in conclusion - how bad is it considered to mess with a form's _meta after it's been instantiated? Would that be a deal-breaker even if it fixed this?

Last edited 9 years ago by David Isaacson (previous) (diff)

comment:13 by Mikhail, 8 years ago

Cc: vmspike@… added
Version: 1.2master

5 years old ticket and no decision...
Suppose to change version to master. Has the same issue with django 1.9.

comment:14 by Jamie Bliss, 8 years ago

I can confirm this for 1.10.1.

My two cents, I consider the current behavior (passing the top-level object as the obj parameter) to be The Wrong Thing. An Inline Admin should get its own model.

This bug significantly hinders the ability of get_readonly_fields() to be useful in inline admins because you can only mark a field as read-only for every instance of the admin. Which I could just do with readonly_fields.

in reply to:  14 comment:15 by Jimmy Merrild Krag, 7 years ago

Replying to Jamie Bliss:

My two cents, I consider the current behavior (passing the top-level object as the obj parameter) to be The Wrong Thing. An Inline Admin should get its own model.

This bug significantly hinders the ability of get_readonly_fields() to be useful in inline admins because you can only mark a field as read-only for every instance of the admin. Which I could just do with readonly_fields.

I agree completely. Actually my current work is hindered by it.

in reply to:  14 comment:16 by direx, 6 years ago

Cc: direx added

Replying to Jamie Bliss:

My two cents, I consider the current behavior (passing the top-level object as the obj parameter) to be The Wrong Thing. An Inline Admin should get its own model.

Full ACK. And this is still the current behavior in Django 2.0 (and master).

comment:17 by Prateem Shrestha, 6 years ago

Cc: Prateem Shrestha added

This is impacting a project I am working on as well.

Last edited 6 years ago by Prateem Shrestha (previous) (diff)

comment:18 by Paolo Dina, 6 years ago

Cc: Paolo Dina added

comment:19 by John, 6 years ago

I'm using Django 2.0.7 and have the same issue. However, I've a dumb solution to this (untested).

I'm building an account-transaction admin page. As one would expected, the AccountAdmin will have TransactionStackedInlines. Undoubtably, we use the inline form to add new transaction with most fields editable. And for existing transaction, we want them as read-only.

Since, existing transactions look better in tabular inline (not all fields displayed), while new transaction is better in stacked form, I created two inlines. Here is my codes:

@admin.register(models.Account)
class AccountAdmin(admin.ModelAdmin):
	inlines = [
		inlines.TransactionTabularInline,
		inlines.TransactionStackedInline,
	]

And for the two inlines, I want that the top, tabular inline has no "add new" action, while the bottom stacked inline shows nothing but a new transaction:

class TransactionTabularInline(admin.TabularInline):
	model = models.Transaction
	extra = 1  # extra must be 1 to make the magic work
	max_num = 0  # somehow this disable the "add" because max_num > extra
	fields = (contains only a handful of fields)
	can_delete = False
	show_change_link = True  # allow viewing the full entry in another page 
	readonly_fields = [
			the list of 'fields'
		]


class TransactionStackedInline(admin.StackedInline):
	model = models.Transaction
	extra = 0

	def get_queryset(self, request):
		queryset = super().get_queryset(request)
		return queryset.none()  # no existing records will appears

There will be an arrow on each row in tabular inline, which points to TransactionAdmin, in which get_readonly_fields will work as every one expected.

I think you can use two (stacked) inlines, one for existing records with more readonly_fields, and one without readonly but return an empty query_set.

I found queryset.none() from django's code, when users has no permission on the object. So, I suppose this is "okay" when I do not want to display existing records for my own reason.

Last edited 6 years ago by John (previous) (diff)

comment:20 by Scott Stevens, 5 years ago

Cc: Scott Stevens added

This also affects has_<value>_permission calls, and actually causes the wrong object to be checked for permissions.

I feel as though this increases the severity, since it no longer just affects what fields are read-only, but is now a failure to enforce permissions correctly (and could allow object permissions where it's expected to be prohibited).

I'm unsure if we need a new bug for this, or to expand the scope of this existing ticket (rename/etc.).

comment:21 by Carlton Gibson, 5 years ago

Given the change it would involve, I think this will probably need to be resolved as a documentation issue.

That they're passed the parent object means that get_readonly_fields() &co as they appear on an inline have the same signature but a different semantics from when they appear on the normal ModelAdmin. As such, they should be documented separately in the InlineModelAdmin ref.

Similar differences are already documented for the various InlineModelAdmin versions of the `has_<value>_permission()` methods.

On the actual issue, could it be resolved with permissions? If you have add but not change on the inline model (possibly via custom has_add_permission() and has_change_permission() methods) do you not get the allow creating new objects, immutible existing objects from the title?

in reply to:  21 comment:22 by Gualberto Escalante, 4 years ago

Nice solution, this worked in my scenario.

Replying to Carlton Gibson:

On the actual issue, could it be resolved with permissions? If you have add but not change on the inline model (possibly via custom has_add_permission() and has_change_permission() methods) do you not get the allow creating new objects, immutible existing objects from the title?

in reply to:  14 comment:23 by Wojciech Bartosiak, 4 years ago

Replying to Jamie Bliss:

My two cents, I consider the current behavior (passing the top-level object as the obj parameter) to be The Wrong Thing. An Inline Admin should get its own model.

I need to agree with you and inform that this still exists in Django 3.0.2

comment:24 by John Baker, 4 years ago

This issue is also affecting my current project. I expected obj within get_readonly_fields() to be the inline instance, and not the parent.

I have a simplified permissions system that does not check per object permissions, and cannot implement the workaround Carlton suggested.

comment:25 by WhiteSage, 3 years ago

One can selectively disable certain fields on the add_fields method of the associated formset as a workaround:

class MyFormSet(BaseInlineFormSet):
    def add_fields(self, form, index):
        super().add_fields(form, index) 
        if form.initial:
            form.fields['my_field'].disabled = True

class MyInline(admin.TabularInline):
    model = MyModel
    formset = MyFormSet
Last edited 3 years ago by WhiteSage (previous) (diff)

comment:26 by Ülgen Sarıkavak, 20 months ago

Cc: Ülgen Sarıkavak added
Note: See TracTickets for help on using tickets.
Back to Top