How to retrieve Images of a User being part of a Group with Collection Permissions in Wagtail? - wagtail

I have a wagtail installation using the Multisite pattern, where I have a group of user per site and each group as it's own collection.
When the User logged in the admin interface, they see in the Summary section the image count from all the collections.
But when they click the image menu, they only see the images within their group collection. I found it confusing that they could know the total count of all collections. I wanted to get the count from the collection the user had rights for.
I figured out I could override the ImagesSummaryItem and I ended up coding the following snippet of code:
class CorrectedImagesSummaryItem(SummaryItem):
order = 200
template = 'wagtailimages/homepage/site_summary_images.html'
def get_context(self):
site_name = get_site_for_user(self.request.user)['site_name']
permissions = Permission.objects.filter(
content_type=ContentType.objects.get_for_model(get_image_model()),
codename__in=['change_image', 'add_image'])
collections = Collection.objects.filter(
group_permissions__group__in=self.request.user.groups.all(),
group_permissions__permission__in=permissions
).distinct()
if collections:
image_count = get_image_model().objects.filter(collection__in=collections).count()
else:
image_count = 0
return {
'total_images': image_count,
'site_name': site_name,
}
def is_shown(self):
return permission_policy.user_has_any_permission(
self.request.user, ['change', 'add']
)
#hooks.register('construct_homepage_summary_items')
def add_corrected_images_summary_panel(request, items):
"""Replaces the Images summary panel to hide variants."""
for index, item in enumerate(items):
if item.__class__ is ImagesSummaryItem:
items[index] = CorrectedImagesSummaryItem(request)
This actually works fine, I am now showing the proper images count on the summary section but I am wondering is there a better way to query the collections of the user? Are these querysets right?
permissions = Permission.objects.filter(
content_type=ContentType.objects.get_for_model(get_image_model()),
codename__in=['change_image', 'add_image'])
collections = Collection.objects.filter(
group_permissions__group__in=self.request.user.groups.all(),
group_permissions__permission__in=permissions
).distinct()
Update
I ended up customizing the queryset for the images selection in order to only show the images within the collection the user was having access to.
In addition of the first function, I added the following code in my wagtail_hooks.py file.
#hooks.register('construct_image_chooser_queryset')
def show_collection_images_only(images, request):
# Show only the images from the collection the User has access.
collections = get_collections_from_group_permissions(request.user, ['change_image', 'add_image'])
images = images.filter(collection__in=collections)
return images
The get_collections_from_group_permissions is just a simplified function that returns exactly the Collection out of the Groups permissions the User has.
def get_collections_from_group_permissions(user, permissions):
"""
This function gets the Collections from the user groups permissions.
:param user: the user
:param permissions: the requested permissions on a Collection object
:returns: the Collections the selected User has access rights for.
"""
permissions = Permission.objects.filter(
content_type=ContentType.objects.get_for_model(get_image_model()),
codename__in=permissions)
collections = Collection.objects.filter(
group_permissions__group__in=user.groups.all(),
group_permissions__permission__in=permissions
).distinct()
return collections
With this in place, the Summary Item for the images is the number of images within the collections the User can access and when he clicks on a ImageChooserField and gets to the Image chooser, he only gets to see what is in the collections he has been granted access.

This logic is already implemented in Wagtail's permission_policy class, so this could be reduced to:
image_count = permission_policy.instances_user_has_any_permission_for(
self.request.user, ['change', 'add']
).count()
(Incidentally, the reason Wagtail itself doesn't take permissions into account when displaying this figure is that all users can see the complete set of images via the chooser popup - there's currently no 'choose' permission - so showing a reduced number would be misleading. See the discussion at https://github.com/wagtail/wagtail/issues/5129)

Related

Branching Workflows based on value of specified Page field

I have a DailyReflectionPage Model with a reflection_date field that forms the basis for the Page's slug, which is in the form YYYY-MM-DD. Here's an extract of my Page model:
class DailyReflectionPage(Page):
"""
The Daily Reflection Model
"""
...
...
reflection_date = models.DateField("Reflection Date", max_length=254)
...
...
#cached_property
def date(self):
"""
Returns the Reflection's date as a string in %Y-%m-%d format
"""
fmt = "%Y-%m-%d"
date_as_string = (self.reflection_date).strftime(fmt)
return date_as_string
...
...
def full_clean(self, *args, **kwargs):
# first call the built-in cleanups (including default slug generation)
super(DailyReflectionPage, self).full_clean(*args, **kwargs)
# now make your additional modifications
if self.slug is not self.date:
self.slug = self.date
...
...
These daily reflections are written by different authors, as part of a booklet that is published towards the end of the year, for use in the coming year. I would like to have a workflow where, for instance, the daily reflections from January to June are reviewed by one group, and those from July to December are reviewed by another group, as illustrated in the diagram below:
How can this be achieved?
This should be able to be achieved by creating ONE new Workflow Task type that has a relationship to two sets of User Groups (e.g. a/b or before/after, it is probably best to keep this generic in the model definition).
This new Task can be created as part of a new Workflow within the Wagtail admin, and each of the groups linked to the Moderator Group 1 / 2.
Wagtail's methods on the Task allow you to return approval options based on the Page model for any created workflow, from here you can look for a method that would be on the class and assign the groups from there.
The benefits of having a bit more of a generic approach is that you could leverage this for any splitting of moderator assignments as part of future Workflow tasks.
Implementation Overview
1 - read the Wagatail Docs on how to add a new Task Type and the Task model reference to understand this process.
2 - Read through the full implementation in the code of the built in GroupApprovalTask.
3 - In the GroupApprovalTask you can see that the methods with overrides all rely on the checking of self.groups but they all get the page passed in as a arg to those methods.
4 - Create a new Task that extends the Wagtail Task class and on this model create two ManyToManyField that allow for two sets of user groups being linked (note: you do not have do to this as two fields, you could put a model in the middle but the example below is just the simplest way to get to the gaol).
5 - On the DailyReflectionPage model create a method get_approval_group_key which will return maybe a simple Boolean or a 'A' or 'B' based on the business requirements you described above (check the model's date etc)
6 - In your custom Task create a method that abstracts the checking of the Page for this method and returns the Tasks' user group. You may want to add some error handling and default values. E.g. get_approval_groups
7 - Add a custom method for each of the 'start', 'user_can_access_editor', page_locked_for_user, user_can_lock, user_can_unlock, get_task_states_user_can_moderate methods that calls get_approval_group with the page and returns the values (see the code GroupApprovalTask for what these should do.
Example Code Snippets
models.py
class DailyReflectionPage(Page):
"""
The Daily Reflection Model
"""
def get_approval_group_key(self):
# custom logic here that checks all the date stuff
if date_is_after_foo:
return 'A'
return 'B'
class SplitGroupApprovalTask(Task):
## note: this is the simplest approach, two fields of linked groups, you could further refine this approach as needed.
groups_a = models.ManyToManyField(
Group,
help_text="Pages at this step in a workflow will be moderated or approved by these groups of users",
related_name="split_task_group_a",
)
groups_b = models.ManyToManyField(
Group,
help_text="Pages at this step in a workflow will be moderated or approved by these groups of users",
related_name="split_task_group_b",
)
admin_form_fields = Task.admin_form_fields + ["groups_a", "groups_b"]
admin_form_widgets = {
"groups_a": forms.CheckboxSelectMultiple,
"groups_b": forms.CheckboxSelectMultiple,
}
def get_approval_groups(self, page):
"""This method gets used by all checks when determining what group to allow/assign this Task to"""
# recommend some checks here, what if `get_approval_group` is not on the Page?
approval_group = page.specific.get_approval_group_key()
if (approval_group == 'A'):
return self.group_a
return self.group_b
# each of the following methods will need to be implemented, all checking for the correct groups for the Page when called
# def start(self, ...etc)
# def user_can_access_editor(self, ...etc)
# def page_locked_for_user(self, ...etc)
# def user_can_lock(self, ...etc)
# def user_can_unlock(self, ...etc)
def get_task_states_user_can_moderate(self, user, **kwargs):
# Note: this has not been tested, however as this method does not get `page` we must find all the tasks allowed indirectly via their TaskState pages
tasks = TaskState.objects.filter(status=TaskState.STATUS_IN_PROGRESS, task=self.task_ptr)
filtered_tasks = []
for task in tasks:
page = task.select_related('page_revision', 'task', 'page_revision__page')
groups = self.get_approval_groups(page)
if groups.filter(id__in=user.groups.all()).exists() or user.is_superuser:
filtered_tasks.append(task)
return TaskState.objects.filter(pk__in=[task.pk for task in filtered_tasks])
def get_actions(self, page, user):
# essentially a copy of this method on `GroupApprovalTask` but with the ability to have a dynamic 'group' returned.
approval_groups = self.get_approval_groups(page)
if approval_groups.filter(id__in=user.groups.all()).exists() or user.is_superuser:
return [
('reject', "Request changes", True),
('approve', "Approve", False),
('approve', "Approve with comment", True),
]
return super().get_actions(page, user)

Adding multiple owners for Google App Maker records

I need help with Google App Maker data model security. I want to set multiple owners for a single record. Like the current user + the assigned admin + super admin.
I need this because all records can have different owners and super-owners/admins.
I know that we can point google app maker to a field containing record owner's email and we can set that field to the current user at the time of the creation of the record.
record.Owner = Session.getActiveUser().getEmail();
I want to know if it is possible to have field owners or have multiple fields like owner1, owner2 and then assign access levels to owner1, owner2...
Or how can we programmatically control the access/security/permissions of records?
The solution I'd use for this one definitely involves a field on the record that contains a comma separated string of all the users who should have access to it. I've worked on the following example to explain better what I have in mind.
I created a model and is called documents and looks like this:
In a page, I have a table and a button to add new document records. The page looks like this:
When I click on the Add Document button, a dialog pops up and looks like this:
The logic on the SUBMIT button on the form above is the following:
widget.datasource.item.owners = app.user.email;
widget.datasource.createItem(function(){
app.closeDialog();
});
That will automatically assign the creator of the record the ownership. To add additional owners, I do it on an edit form. The edit form popus up when I click the edit button inside the record row. It looks like this:
As you can see, I'm using a list widget to control who the owners are. For that, it is necessary to use a <List>String custom property in the edit dialog and that will be the datasource of the list widget. In this case, I've called it owners. I've applied the following to the onClick event of the edit button:
var owners = widget.datasource.item.owners;
owners = owners ? owners.split(",") : [];
app.pageFragments.documentEdit.properties.owners = owners;
app.showDialog(app.pageFragments.documentEdit);
The add button above the list widget has the following logic for the onClick event handler:
widget.root.properties.owners.push("");
The TextBox widget inside the row of the list widget has the following logic for the onValueEdit event handler:
widget.root.properties.owners[widget.parent.childIndex] = newValue;
And the CLOSE button has the following logic for the onClick event handler:
var owners = widget.root.properties.owners || [];
if(owners && owners.length){
owners = owners.filter(function(owner){
return owner != false; //jshint ignore:line
});
}
widget.datasource.item.owners = owners.join();
app.closeDialog();
Since I want to create a logic that will load records only for authorized users, then I had to use a query script in the datasource that will serve that purpose. For that I created this function on a server script:
function getAuthorizedRecords(){
var authorized = [];
var userRoles = app.getActiveUserRoles();
var allRecs = app.models.documents.newQuery().run();
if(userRoles.indexOf(app.roles.Admins) > -1){
return allRecs;
} else {
for(var r=0; r<allRecs.length; r++){
var rec = allRecs[r];
if(rec.owners && rec.owners.indexOf(Session.getActiveUser().getEmail()) > -1){
authorized.push(rec);
}
}
return authorized;
}
}
And then on the documents datasource, I added the following to the query script:
return getAuthorizedRecords();
This solution will load all records for admin users, but for non-admin users, it will only load records where their email is located in the owners field of the record. This is the most elegant solution I could come up with and I hope it serves your purpose.
References:
https://developers.google.com/appmaker/models/datasources#query_script
https://developers.google.com/appmaker/ui/binding#custom_properties
https://developers.google.com/appmaker/ui/logic#events
https://developers-dot-devsite-v2-prod.appspot.com/appmaker/scripting/api/client#Record

Tastypie filter by minimum value

I have a Django-tastypie resource that represents a banner and has a field called impression that I increment whenever the banner appears on the site.
class BannerResource(ModelResource):
owner = fields.ForeignKey('advertisment.api.AdvertiserResource', 'owner', full=True)
class Meta:
queryset = Banner.objects.all()
resource_name = 'banner'
authorization = Authorization()
I would like to get the banner that has the minimum impression, in the official documentation there is nothing like
filtering = {'impressions': ('min',)}
I'm using BackboneJS in the front end and I could get all the banners with Backbone collection and do the filtering with JavaScript but I'm looking for a quicker way to do it.
Any ideas?
Thanks
If you'd like to retrieve banners with number of impressions greater than X you need to things. For one you need to define possible filtering operations on your resource like so (given your model has impressions field):
class BannerResource(ModelResource):
owner = fields.ForeignKey('advertisment.api.AdvertiserResource', 'owner', full=True)
class Meta:
queryset = Banner.objects.all()
resource_name = 'banner'
authorization = Authorization()
filtering = { 'impressions' : ALL }
for available options take a look at Tastypie's documentation on filtering.
Then if you made the following request:
GET http://<your_host>/v1/banners?impressions__gte=X
you should get what you need.

How can I mimic 'select_related' using google-appengine and django-nonrel?

django nonrel's documentation states: "you have to manually write code for merging the results of multiple queries (JOINs, select_related(), etc.)".
Can someone point me to any snippets that manually add the related data? #nickjohnson has an excellent post showing how to do this with the straight AppEngine models, but I'm using django-nonrel.
For my particular use I'm trying to get the UserProfiles with their related User models. This should be just two simple queries, then match the data.
However, using django-nonrel, a new query gets fired off for each result in the queryset. How can I get access to the related items in a 'select_related' sort of way?
I've tried this, but it doesn't seem to work as I'd expect. Looking at the rpc stats, it still seems to be firing a query for each item displayed.
all_profiles = UserProfile.objects.all()
user_pks = set()
for profile in all_profiles:
user_pks.add(profile.user_id) # a way to access the pk without triggering the query
users = User.objects.filter(pk__in=user_pks)
for profile in all_profiles:
profile.user = get_matching_model(profile.user_id, users)
def get_matching_model(key, queryset):
"""Generator expression to get the next match for a given key"""
try:
return (model for model in queryset if model.pk == key).next()
except StopIteration:
return None
UPDATE:
Ick... I figured out what my issue was.
I was trying to improve the efficiency of the changelist_view in the django admin. It seemed that the select_related logic above was still producing additional queries for each row in the results set when a foreign key was in my 'display_list'. However, I traced it down to something different. The above logic does not produce multiple queries (but if you more closely mimic Nick Johnson's way it will look a lot prettier).
The issue is that in django.contrib.admin.views.main on line 117 inside the ChangeList method there is the following code: result_list = self.query_set._clone(). So, even though I was properly overriding the queryset in the admin and selecting the related stuff, this method was triggering a clone of the queryset which does NOT keep the attributes on the model that I had added for my 'select related', resulting in an even more inefficient page load than when I started.
Not sure what to do about it yet, but the code that selects related stuff is just fine.
I don't like answering my own question, but the answer might help others.
Here is my solution that will get related items on a queryset based entirely on Nick Johnson's solution linked above.
from collections import defaultdict
def get_with_related(queryset, *attrs):
"""
Adds related attributes to a queryset in a more efficient way
than simply triggering the new query on access at runtime.
attrs must be valid either foreign keys or one to one fields on the queryset model
"""
# Makes a list of the entity and related attribute to grab for all possibilities
fields = [(model, attr) for model in queryset for attr in attrs]
# we'll need to make one query for each related attribute because
# I don't know how to get everything at once. So, we make a list
# of the attribute to fetch and pks to fetch.
ref_keys = defaultdict(list)
for model, attr in fields:
ref_keys[attr].append(get_value_for_datastore(model, attr))
# now make the actual queries for each attribute and store the results
# in a dict of {pk: model} for easy matching later
ref_models = {}
for attr, pk_vals in ref_keys.items():
related_queryset = queryset.model._meta.get_field(attr).rel.to.objects.filter(pk__in=set(pk_vals))
ref_models[attr] = dict((x.pk, x) for x in related_queryset)
# Finally put related items on their models
for model, attr in fields:
setattr(model, attr, ref_models[attr].get(get_value_for_datastore(model, attr)))
return queryset
def get_value_for_datastore(model, attr):
"""
Django's foreign key fields all have attributes 'field_id' where
you can access the pk of the related field without grabbing the
actual value.
"""
return getattr(model, attr + '_id')
To be able to modify the queryset on the admin to make use of the select related we have to jump through a couple hoops. Here is what I've done. The only thing changed on the 'get_results' method of the 'AppEngineRelatedChangeList' is that I removed the self.query_set._clone() and just used self.query_set instead.
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('username', 'user', 'paid')
select_related_fields = ['user']
def get_changelist(self, request, **kwargs):
return AppEngineRelatedChangeList
class AppEngineRelatedChangeList(ChangeList):
def get_query_set(self):
qs = super(AppEngineRelatedChangeList, self).get_query_set()
related_fields = getattr(self.model_admin, 'select_related_fields', [])
return get_with_related(qs, *related_fields)
def get_results(self, request):
paginator = self.model_admin.get_paginator(request, self.query_set, self.list_per_page)
# Get the number of objects, with admin filters applied.
result_count = paginator.count
# Get the total number of objects, with no admin filters applied.
# Perform a slight optimization: Check to see whether any filters were
# given. If not, use paginator.hits to calculate the number of objects,
# because we've already done paginator.hits and the value is cached.
if not self.query_set.query.where:
full_result_count = result_count
else:
full_result_count = self.root_query_set.count()
can_show_all = result_count self.list_per_page
# Get the list of objects to display on this page.
if (self.show_all and can_show_all) or not multi_page:
result_list = self.query_set
else:
try:
result_list = paginator.page(self.page_num+1).object_list
except InvalidPage:
raise IncorrectLookupParameters
self.result_count = result_count
self.full_result_count = full_result_count
self.result_list = result_list
self.can_show_all = can_show_all
self.multi_page = multi_page
self.paginator = paginator

Favoriting system on Appengine

I have the following model structure
class User(db.Model) :
nickname = db.StringProperty(required=True)
fullname = db.StringProperty(required=True)
class Article(db.Model) :
title = db.StringProperty(required=True)
body = db.StringProperty(required=True)
author = db.ReferenceProperty(User, required=True)
class Favorite(db.Model) :
who = db.ReferenceProperty(User, required=True)
what = db.ReferenceProperty(Article, required=True)
I'd like to display 10 last articles according to this pattern: article.title, article.body, article.author(nickname), info if this article has been already favorited by the signed in user.
I have added a function which I use to get the authors of these articles using only one query (it is described here)
But I don't know what to do with the favorites (I'd like to know which of the displayed articles have been favorited by me using less than 10 queries (I want to display 10 articles)). Is it possible?
You can actually do this with an amortized cost of 0 queries if you denormalize your data more! Add a favorites property to Authors which stores a list of keys of articles which the user has favorited. Then you can determine if the article is the user's favorite by simply checking this list.
If you retrieve this list of favorites when the user first logs in and just store it in your user's session data (and update it when the user adds/removes a favorite), then you won't have to query the datastore to check to see if an item is a favorite.
Suggested update to the Authors model:
class Authors(db.Model): # I think this would be better named "User"
# same properties you already had ...
favorites = db.ListProperty(db.Key, required=True, default=[])
When the user logs in, just cache their list of favorites in your session data:
session['favs'] = user.favorites
Then when you show the latest articles, you can check if they are a favorite just by seeing if each article's key is in the favorites list you cached already (or you could dynamically query the favorites list but there is really no need to).
favs = session['favs']
articles = get_ten_latest_articles()
for article in articles:
if article.key() in favs:
# ...
I think there is one more solution.
Let's add 'auto increment' fields to the User and Article class.
Then, when we want to add an entry to the Favorite class, we will also add the key name in the format which we will be able to know having auto increment value of the signed in user and the article, like this 'UserId'+id_of_the_user+'ArticleId'+id_of_an_article.
Then, when it comes to display, we will easily predict key names of the favorites and would be able to use Favorite.get_by_key_name(key_names).
An alternative solution to dound's is to store the publication date of the favorited article on the Favorite entry. Then, simply sort by that when querying.

Resources