How can permissions be applied to individual fields of a Wagtail page?
Let's say we have a page like this one:
class HomePage(Page):
body = RichTextField(blank=True)
content_panels = Page.content_panels + [
FieldPanel('body', classname="full"),
]
Everyone should be allowed to edit the title - but only users with a certain permission should be able to alter the body.
I realize this is a really old question now, but just in case people come across it in the future, here's how my code shop does this in Wagtail 2.3. It may or may not work in later versions.
Add the following to the Page subclass you've written:
panels = [
...
MultiFieldPanel(
[
FieldPanel('display_locations', widget=forms.CheckboxSelectMultiple),
StreamFieldPanel('assets'),
],
heading='Admin-only Fields',
# NOTE: The 'admin-only' class is how EventPage.get_edit_handler() identifies this MultiFieldPanel.
classname='collapsible admin-only'
),
...
]
class Meta:
verbose_name = 'Event'
verbose_name_plural = 'Events'
ordering = ['start_date', 'title']
permissions = (
('can_access_admin_fields', 'Can access Event Admin fields'),
)
#classmethod
def get_edit_handler(cls):
"""
We override this method (which is added to the Page class in wagtail.admin.edit_handlers) in order to enforce
our custom field-level permissions.
"""
# Do the same thing that wagtail.admin.edit_handlers.get_edit_handler() would do...
bound_handler = cls.edit_handler.bind_to_model(cls)
# ... then enforce admin-only field permissions on the result.
current_request = get_current_request()
# This method gets called during certain manage.py commands, so we need to be able to gracefully fail if there
# is no current request. Thus, if there is no current request, the admin-only fields are removed.
if current_request is None or not current_request.user.has_perm('calendar_app.can_access_admin_fields'):
# We know for sure that bound_handler.children[0].children is the list of Panels in the Content tab.
# We must search through that list to find the admin-only MultiFieldPanel, and remove it.
# The [:] gets us a copy of the list, so altering the original doesn't change what we're looping over.
for child in bound_handler.children[0].children[:]:
if 'admin-only' in child.classname:
bound_handler.children[0].children.remove(child)
break
return bound_handler
This is, obviously, quite funky and fragile. But it's the only solution I could find.
Wagtail do not support field based permission control. But you can achieve this by enabling/disabling fields of the page's form. If you add a field to a page, you can pass whether that field should be enabled or disabled in the constructor. But how can you do that runtime?
Every HTML page with a form in wagtail is associated with a Django Form. You can change the default form to a your own one like this.
from django.db import models
from wagtail.admin.forms.pages import WagtailAdminPageForm
from wagtail.core.models import Page
from wagtail.admin.edit_handlers import FieldPanel
class HomeForm(WagtailAdminPageForm):
pass
class HomePage(Page):
body = models.CharField(max_length=500, default='', blank=True)
content_panels = Page.content_panels + [
FieldPanel('body'),
]
base_form_class = HomeForm # Tell wagtail to user our form
This way, every time you load the create view or edit view of HomePage, wagtail will create an instance from HomeForm class. Now what you have to do is, to check the user status and enable/disable the required field when creating an instance of the HomeForm.
For this example, I will enable the body field only when the user is a superuser.
class HomeForm(WagtailAdminPageForm):
# Override the constructor to do the things at object creation.
def __init__(self, data=None, files=None, parent_page=None, subscription=None, *args, **kwargs):
super().__init__(data, files, parent_page, subscription, *args, **kwargs)
user = kwargs['for_user'] # Get the user accessing the form
is_superuser = user.is_superuser
body = self.fields.get('body') # Get the body field
body.disabled = not is_superuser # Disable the body field if user is not a superuser
This way, every time a non superuser loads the create or edit page, the body field will be disabled.
But if you want to remove access only from edit page, you need to use self.initial variable. This variable is a dictionary with initial values to be used when showing the form.
You can check the value of a required field (like title) from self.initial and if the field's value is empty, that means the create page is loaded and if there is a value, that means the edit page is loaded.
Related
I'm developing blog app with wagtail.
main point about blog page:
Writer can write "title", "introduction" and "body".
Firstly, writer can submit title.
After finishing "title task", writer can edit and submit body.
When writer do title task, writer can not edit body field.
Furthermore, when writer do body task, writer can not edit title field.
I want to change permission dynamically for titleField and bodyField(RichTextField) but I could not figure out how to do it.
I thought editing hook about #hooks.register("after_edit_page") in wagtail_hooks.py can reach resolving.
I tried to use PagePermissionHelper, PermissionHelper.
Proposed Solution
The Wagtail documentation regarding Customising generated forms explains the method to override the form that gets generated when editing/creating a new page.
The WagtailAdminPageForm extends the Django ModelForm and you can extend this further to add custom clean/__init__/save etc methods to add essentially any logic you want to both how the form renders and what errors get provided to the user before the save gets applied.
Django ModelForm documentation.
By default you do not have acesss to the request object on form creation, but you do get it on the save method so it would be possible to easily do some user basic logic there.
If you need further customisation, you can dig into Wagtail edit handers (search through the source code) and you can create your own edit handler that can pass in the request to your custom BlogPageForm.
Note: If the eventual goal is to add a full on 'process' based page editing workflow, you may want to look at Wagtails' ModelAdmin and essentially just build the blog workflow completely in isolation of the normal page structure and then restructure permissions so that blog editors cannot access the normal page tree but can only access your custom workflow.
Example Code
This is just a basic example of a custom form for a BlogPage model.
__init__ can be extended to add custom logic to how the form gets generated (e.g. make some fields read only or even 'hide' some fields).
save can be extended to add server side validation to the read only fields and also provide user facing error messaging.
It is possible to add logic for a 'new' page creation along with logic for editing an existing page by checking if the self.instance.pk (primary key) exists.
# other imports
from wagtail.admin.forms import WagtailAdminPageForm
class BlogPageForm(WagtailAdminPageForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
instance = getattr(self, 'instance', None)
if not instance.pk:
# this is a NEW blog entry form - only allow title to be enabled, disable other fields
self.fields['description'].widget.attrs['readonly'] = True
if instance.pk:
# assume title has been entered and saved at this point (required for a new post)
# disable the title field
self.fields['title'].widget.attrs['readonly'] = True
def clean(self):
cleaned_data = super().clean()
instance = getattr(self, 'instance', None)
title = cleaned_data['title']
description = cleaned_data['description']
if not instance.pk:
# this is a NEW blog entry, check that only the title has been entered
if not title:
self.add_error('title', 'title must be edited before all other fields')
return cleaned_data
if description:
self.add_error('description', 'description cannot be entered until title has been completed')
if instance.pk:
# an existing blog entry, do not allow title to be changed
print('title', instance.title, title)
if instance.title != title:
self.add_error('title', 'title cannot be edited after the initial save')
class BlogPage(Page):
# ...fields
base_form_class = BlogPageForm
Can be done easy with additional custom.css file
With insert_global_admin_css Wagtail hook, add path to your custom.css file. Here is link for documentation: https://docs.wagtail.io/en/latest/reference/hooks.html#insert-global-admin-css
Then add classname(eg. "myReadonlyInput") to FieldPanel in Page model. This will add new class to li element with input field.
FieldPanel("field_name", classname="myReadonlyInput"),
In custom.css file add pointer-events:none; to input field that belongs to new class for li element:
li.myReadonlyInput div.input input {
background-color: rgb(239 239 239);
color: rgb(99 99 99);
pointer-events:none;
cursor:text;
}
That way only with adding classname to any field model, input field will be grayed out and not reachable.
Context
in django administration site
I am adding a new item in a model via add button
when the form is displayed, I have a dropDown with some options to choose, I choose option B and I fill other fields to complete the form
I click on button save and add another
When the new "add form view" is displayed
I want my dropdown to set on the last choice (B option) I selected before
What is your suggestion to implement that as simple is possible?
Thank you
I can offer the following solution:
Create author (fk auth.User) and created (datetime auto_now_add=True) fields in your model.
Override get_form function in model admin class, get the last object created by the current user in it and put the required initial value in the foreign key field
Code examples (not tested, it's just an idea):
# yourapp/models.py
from django.db import models
class ModelOne(models.Model):
pass
class ModelTwo(models.Model):
model_one = models.ForeignKey(ModelOne)
created = models.DateTimeField(auto_now_add=True)
author = models.ForeignKey('auth.User')
# yourapp/admin.py
from django.contrib import admin
from yourapp.models import ModelTwo
#admin.register(ModelTwo)
class ModelTwoAdmin(admin.ModelAdmin):
def get_form(self, request, obj=None, **kwargs):
form = super(ModelTwoAdmin, self).get_form(request, obj, **kwargs)
if '_addanother' in request.POST:
latest_object = ModelTwo.objects.filter(author=request.user).latest('created')
form.base_fields['model_one'].initial = latest_object.model_one
return form
Thanks a lot devxplorer,
after tried your code and did some tests, I got this solution works for me :
#app/admin.py
def get_changeform_initial_data(self,request):
latest_object = ModelTwo.objects.filter(author=request.user).latest('created')
return {'model_one': latest_object.model_one_id}
I have 3 types of users in my models.py
class Customer(models.model)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name=‘Customer’)
class ClientTypeA(models.model)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name=‘ClientA’)
class ClientTypeB(models.model)
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name=‘ClientB’)
I was using permissions in my base template to render the correlating sidebar, but now I am also incorporating a specific dashboard for each user along with the sidebar, so I find it would be simpler to create home views for each of the user types.
Once I log a user in it redirects them to my home view - so I came up with this in my views.py
def home(request):
if request.user.is_customer:
return redirect(customer_home)
elif request.user.is_clientA:
return redirect(clientA_home)
elif request.user.is_clientB:
return redirect(clientB_home)
else:
return render(request, 'home.html')
The redirects called will simply take them to there corresponding home pages.
I know my is_customer, is_clientA, is_clientB do not automatically come with django, how and where do I add these custom permissions? to my models.py? What would I set them equal to in order to simply check is the type of user active? Do I even need custom permissions or is there a simpler way to call the type of user?
Am I using to many if statements? (I'm trying to keep it simple and fast)
Since all the models have one-to-one fields to the User model, you can use has_attr to check whether the user has a row for that model. You don't have to create permissions.
if hasattr(request.user, 'customer'):
return redirect('customer_home')
If you only have three customer types, then the if/elif/else is probably ok. As the number increases, you can change it to something like the following.
customer_types = [
('customer', 'customer_home'),
('clienttypea', 'ClientA_home'),
...
]
for field_name, redirect_url in customer_types:
if hasattr(request.user, field_name):
return redirect(redirect_url)
# fallback
return render(request, 'home.html')
One of my Django admin "edit object" pages started loading very slowly because of a ForeignKey on another object there that has a lot of instances. Is there a way I could tell Django to render the field, but not send any options, because I'm going to pull them via AJAX based on a choice in another SelectBox?
You can set the queryset of that ModelChoiceField to empty in your ModelForm.
class MyAdminForm(forms.ModelForm):
def __init__(self):
self.fields['MY_MODEL_CHOIE_FIELD'].queryset = RelatedModel.objects.empty()
class Meta:
model = MyModel
fields = [...]
I think you can try raw_id_fields
By default, Django’s admin uses a select-box interface () for fields that are ForeignKey. Sometimes you don’t want to incur the overhead of having to select all the related instances to display in the drop-down.
raw_id_fields is a list of fields you would like to change into an Input widget for either a ForeignKey or ManyToManyField
Or you need to create a custom admin form
MY_CHOICES = (
('', '---------'),
)
class MyAdminForm(forms.ModelForm):
my_field = forms.ChoiceField(choices=MY_CHOICES)
class Meta:
model = MyModel
fields = [...]
class MyAdmin(admin.ModelAdmin):
form = MyAdminForm
Neither of the other answers worked for me, so I read Django's internals and tried on my own:
class EmptySelectWidget(Select):
"""
A class that behaves like Select from django.forms.widgets, but doesn't
display any options other than the empty and selected ones. The remaining
ones can be pulled via AJAX in order to perform chaining and save
bandwidth and time on page generation.
To use it, specify the widget as described here in "Overriding the
default fields":
https://docs.djangoproject.com/en/1.9/topics/forms/modelforms/
This class is related to the following StackOverflow problem:
> One of my Django admin "edit object" pages started loading very slowly
> because of a ForeignKey on another object there that has a lot of
> instances. Is there a way I could tell Django to render the field, but
> not send any options, because I'm going to pull them via AJAX based on
> a choice in another SelectBox?
Source: http://stackoverflow.com/q/37327422/1091116
"""
def render_options(self, *args, **kwargs):
# copy the choices so that we don't risk affecting validation by
# references (I hadn't checked if this works without this trick)
choices_copy = self.choices
self.choices = [('', '---------'), ]
ret = super(EmptySelectWidget, self).render_options(*args, **kwargs)
self.choices = choices_copy
return ret
I want to add a text next to a field of the django admin interface.
The warning needs to created at runtime inside a python method. I know python and the django ORM well, but I don't know how to get the text next the field.
The text should be a warning. Raising ValidationError in clean() is not a solution, since
the user can't edit the page any more. It should be just a warning message.
You can use custom ModelForm subclass for the admin, adding help_text attribute for the field in question at its initialization, and style it appropriately.
# forms.py
class YourModelForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(YourModelForm, self).__init__(*args, **kwargs)
self.fields['field_in_question'].help_text = generate_warning()
# admin.py
class YourModelAdmin(admin.ModelAdmin):
form = forms.YourModelForm
# And here you can specify custom CSS / JS which would make
# `help_text` for that particular field look like a warning.
# Or you can make it generic--say, style (and maybe reposition w/js) all tags
# like <span class="warning"> that occur within the help text of any field.
class Media:
css = {"all": ("admin_warning.css", )}
js = ("admin_warning.js", )
If you want to do it in changelist view, you can write in model method, which returns string in format you want, and put name of that method in list_display in admin.
class MyModel(models.Model):
myfield = models.CharField(max_length=100)
def myfield_with_warning(self):
return '%s - %s' % (self.myfield, '<span class="warn">My warning message</p>'
myfield_with_warning.short_description = 'My field verbose name'
myfield_with_warning.allow_tags = True
class MyModelAdmin(models.ModelAdmin):
list_display = ('myfield_with_warning',)
If it's not what you need, write more precisely, where do you want to display warning message.
I think the simplest way would be to override the specific admin page for that model. This is described here in the Django documentation. The template you need to override is probably change_form.html. Within these template displayed object is available in the template variable original.
I would add a method or property to you model, that generates and returns the error message and call this method from the template.
Edit: Have a look at contrib/admin/templates/admin/change_form.html there is a include for includes/fieldset.html that displays the the fields of the admin site. You could put some code there that chckes if the model has some special named attribute and if so it is displayed. You could them simply override that change_form.html for all models with your custom one.