What is the difference between ChildPage.objects.child_of(self) and ParentPage.get_children()? - wagtail

I think that ChildPage.objects.child_of(self) and ParentPage.get_children() produce same result because subpage_types of ParentPage is just one ['ChildPage'].
But when I try to filter the result of ParentPage.get_children() there is an error.
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
child = self.get_children().live().public() # <- don't works
child = ChildPage.objects.child_of(self).live().public() # <- works
if request.GET.get('tag', None):
tags = request.GET.get('tag')
child = child.filter(tags__slug__in=[tags]) # <- error here
context["child"] = child
return context
Traceback (most recent call last):
Cannot resolve keyword 'tags' into field. Choices are: alias_of, alias_of_id, aliases, blogindexpage, blogpage, content_type, content_type_id, depth, draft_title, expire_at, expired, first_published_at, formsubmission, go_live_at, group_permissions, has_unpublished_changes, homepage, id, last_published_at, latest_revision_created_at, live, live_revision, live_revision_id, locale, locale_id, locked, locked_at, locked_by, locked_by_id, numchild, owner, owner_id, partnerindexpage, partnerpage, path, redirect, revisions, search_description, seo_title, show_in_menus, sites_rooted_here, slug, title, translation_key, url_path, view_restrictions, workflow_states, workflowpage

With self.get_children(), the page type of the child pages is not known in advance - a page's children may include multiple different types. Since Django querysets don't (as standard*) support combining data from multiple models, the results are returned as the basic Page type, which just contains the core fields common to all pages such as the title and slug. Filtering on tags therefore fails, because that field does not exist on the Page model.
With ChildPage.objects.child_of(self), Django knows in advance that the page type is ChildPage - if self had any child pages of other types - they would not be included in the results - so it can query directly on the ChildPage table, and consequently all fields of ChildPage (including tags) are available for filtering.
* Wagtail does provide a specific() method on the queryset to pull in the full data of the pages, but this is implemented as a postprocessing step after the main database query is done, so this still won't allow you to filter on fields that aren't part of the base Page model.

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)

How to make two fields unique of same model in django

I'm using DispensingUnit class name and having two fields 'keypad1_sr_no_hw' and 'keypad2_sr_no_hw'.
How can we compare the uniqueness of these two fields meaning two fields never having same combination ofvalues?
Used this unique_together, but its not working.
class Meta:
unique_together = (("keypad1_sr_no_hw", "keypad2_sr_no_hw",))
class DispensingUnit(models.Model):
keypad1_sr_no_hw = models.CharField(U'Keypad 1', max_length=20 ,)
keypad2_sr_no_hw = models.CharField(U'Keypad 2', max_length=20,)
value in keypad1_sr_no_hw is KP2019310001 and in keypad2_sr_no_hw KP2019310001 and still get saved.
I expect that when both the values are same it will show error and values do not get stored.
Have you tried overriding the save method, do something along the line
def save(self, *args, **kwargs):
if self.keypad1_sr_no_hw == self.keypad2_sr_no_hw:
raise ValidationError("keypad1_sr_no_hw and keypad2_sr_no_hw have same values")
return super(DispensingUnit, self).save(*args, **kwargs)
I know It might not be what you are looking for, but it will do the job for you, I am not sure that any built-in django property exists that can compare multiple fields for Uniqueness.

Error when previewing Wagtail page and getting related inlines

I'm getting errors when previewing Wagtail pages, but they're fine when published and viewed live. My set-up is something like this:
from django.db import models
from modelcluster.fields import ParentalKey
from wagtail.core.models import Orderable, Page
from wagtail.snippets.models import register_snippet
#register_snippet
class Author(models.Model):
name = models.CharField(max_length=255, blank=False)
class ArticleAuthorRelationship(Orderable, models.Model):
author = models.ForeignKey('Author',
on_delete=models.CASCADE,
related_name='articles')
page = ParentalKey('ArticlePage',
on_delete=models.CASCADE,
related_name='authors')
class ArticlePage(Page):
def get_authors(self):
"""Returns a list of Author objects associated with this article."""
return [a.author for a in self.authors.all().order_by('author__name')]
In a template for an ArticlePage I call self.get_authors() to get a list of authors. This works fine if the article is 'live', or if I call the same method on the object in the shell, but when previewing the page I get this:
File "/Users/phil/Projects/myproject/myapp/articles/models/pages.py", line 551, in get_authors
return [a.author for a in self.authors.all().order_by('author__name')]
File "/Users/phil/.local/share/virtualenvs/myproject-zPWVWoxf/lib/python3.6/site-packages/modelcluster/queryset.py", line 467, in order_by
sort_by_fields(results, fields)
File "/Users/phil/.local/share/virtualenvs/myproject-zPWVWoxf/lib/python3.6/site-packages/modelcluster/utils.py", line 19, in sort_by_fields
items.sort(key=lambda x: (getattr(x, key) is not None, getattr(x, key)), reverse=reverse)
File "/Users/phil/.local/share/virtualenvs/myproject-zPWVWoxf/lib/python3.6/site-packages/modelcluster/utils.py", line 19, in <lambda>
items.sort(key=lambda x: (getattr(x, key) is not None, getattr(x, key)), reverse=reverse)
AttributeError: 'ArticleAuthorRelationship' object has no attribute 'author__name'
I'm stumped - I don't understand what's different about previewing a Wagtail page compared to viewing it normally. Something odd in modelcluster?
Yes, this is a limitation of the django-modelcluster module. In order for Django queryset methods such as order_by to work on in-memory relations that don't match the real database state (which is the case when previewing, as well as a few other situations such as viewing old revisions), modelcluster has to "fake" the operations that would normally be done through a SQL query. There are some limitations to how well the "faking" works, and some operations (such as raw SQL queries) will realistically never be possible.
The lack of support for order_by through a foreign key is a known limitation: https://github.com/wagtail/django-modelcluster/issues/45
Until this is fixed, a workaround would be to surround the query in a try/except AttributeError block and fall back on the unordered list.

Wagtail ModelAdmin > How to use custom validation?

I am using wagtail ModelAdmin for some of my non page models and want to add some custom validation.
This is some of the code.
class EditPlanningView(EditView):
def publish_url(self):
return self.url_helper.get_action_url('publish', self.pk_quoted)
def unpublish_url(self):
return self.url_helper.get_action_url('unpublish', self.pk_quoted)
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid():
instance = form.save(commit=False)
if bool(request.POST.get('action-publish')):
try:
instance.publish(commit=True)
except PublishWithoutMeetingError as e:
form.add_error(
'planning_meeting',
e
)
return self.form_invalid(form)
When validation fails the invalid form is returned, but the error I added is not bound to the field. In stead a 'general error message' appears at the top.
Can someone help me out?
Cheers,
Robert
I think the error is in the following lines.
form.add_error(
'planning_meeting',
e
)
Actually can't say anything without knowing about PublishWithoutMeetingError, the type of e. Better to replace e with a string. And make sure the post method is not throwing any exceptions. Other than that, what you have done is correct. Read the following to also to check if you have missed any point.
Long Answer
There are two ways that you can achieve showing an error messages in forms.
Overriding the Form
Overriding the EditView
In both of these cases, you are going to use a method called add_error. That method takes 2 argument, field and error. From these two, error is the most important argument. The field simply state the field of the form that this error applies to. This can be None.
The error argument can be multiple types.
The error argument can be an instance of str. Then wagtail will assign the given error to the given field.
The error argument can be an instance of list of str. Then wagtail will assign the given list of errors to the given field.
The error argument can be an instance of dict with str keys and str or list of str values. In this case field should be None. The keys will be used as the fields for the errors given by values.
The error argument can be an instance of ValidationError exception. You can create a ValidationError using a str, list, or dict, which represent the above three cases.
Overriding the Form
In the form clean method need to be overridden in order to find errors.
from wagtail.admin.forms.models import WagtailAdminModelForm
class ExtraForm(WagtailAdminModelForm):
def clean(self):
cleaned_data = super().clean() # Get the already cleaned data. Same as self.cleaned_data in this case. But this way is better.
title = cleaned_data.get('title') # Get the cleaned title
if title is None: # Title is never None here, but still..
return cleaned_data
title = title.strip() # Do some formatting if needed
if title.startswith('A'): # Validation
self.add_error('title', 'Title cannot start with A') # Validation error
else:
cleaned_data['title'] = title # Use the formatted title
return cleaned_data
class MyModel(models.Model):
id = models.AutoField(primary_key=True)
title = models.CharField(max_length=500, default='', blank=False)
# Or any other fields you have
base_form_class = ExtraForm # Tell wagtail to use ExtraForm instead of the default one
Overriding the EditView
This way is same as the way that you have mentioned in the question. You need to override post method. You need to check if the form associated with the EditView is valid or invalid and return the appropriate form.
To check validity, is_valid method of the form is used by default. That method will clean the form and check if there are errors added to the form.
If form is valid, you need to return self.valid_form and self.invalid_form otherwise.
Unlike overriding the Form, you can access the request here.
class MyEditView(EditView):
def post(self, request, *args, **kwargs):
form = self.get_form() # Get the form associated with this edit view
if form.is_valid(): # Check if the form pass the default checks
my_field = request.POST.get('my_field') # You can access the request
title = form.cleaned_data.get('title') # You can access the form data
if title != my_field: # Validation
form.add_error('title', 'Title must match my_field') # Validation error
return self.form_invalid(form) # Return invalid form if there are validation errors
return self.form_valid(form) # Return the valid form if there are no validation errors
else:
return self.form_invalid(form) # Return invalid form if default check failed
class MyModelAdmin(ModelAdmin):
model = MyModel
menu_label = 'My Model'
list_display = ('id', 'title')
search_fields = (
'title',
)
edit_view_class = MyEditView # Tell wagtail to use MyEditView instead of the default one.

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

Resources