Error when previewing Wagtail page and getting related inlines - wagtail

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.

Related

Wagtail many-to-many links between different Page models

Does anyone have or know of a recipe (sample code and/or instructions) on setting up many-to-many relationships between different Page models? If I have PersonPage and SitePage models, how do I connect the pages (a person can work at multiple sites and a site can have multiple people working there)?
Here's what I've found related to, but not directly on, this topic—
Wagtail docs: from a search for "many-to-many" the only hit is in the section on the taggit module (Recipes page).
Wagtail docs: the only reference to the ParentalManyToManyField is a demo of how it can be used to create M2Ms between pages and categories (Tutorial)
This 2015 post on M2M relationships in Wagtail (it's referenced in an SO 'answer' to basically the same question I'm asking here). Although it doesn't discuss page-page relationships the approach presented might be adapted to work. My modified imitation failed with various errors depending on how I tried to set up the InlinePanel call — but the sample code from the post fails in just the same ways, so either it wasn't tested or it's been made obsolete in 2.x.
class PersonPage(Page):
pass
PersonPage.content_panels = [
InlinePanel('ps_links', label='PS Links'),
]
class PersonSitePageLink():
spage = models.ForeignKey('SitePage', on_delete=models.SET_NULL, related_name='sites')
ppage = ParentalKey('PersonPage', related_name='ps_links', on_delete=models.SET_NULL,)
panels = [
FieldPanel('spage')
]
class SitePage(Page):
pass
This technique works fine for relating a Page model to itself, but expanding it to encompass two distinct models creates two parallel but unconnected sets of relationships (you can pick arbitrary Bug pages to link to any Plant page, or vice versa, but the Plants you picked don't show when you edit Bugs). I see why in the code, I think, but I don't see how to make a single M2M connection between the two pages.
class PlantPage(Page):
related_bugs = ParentalManyToManyField('BugPage', blank=True)
content_panels = Page.content_panels + [
FieldPanel('related_bugs'),
]
class BugPage(Page):
related_plants = ParentalManyToManyField('PlantPage', blank=True)
content_panels = Page.content_panels + [
FieldPanel('related_plants'),
]
This one also only talks about intra-page model (rather than inter-page model) M2Ms. (It is pre-ParentalManyToManyField and in fact only available from the Wayback Machine.)
I hope this helps, I took inspiration from this article about moving from ParentalManyToManyField to a central model that 'links' each page from this AccordBox article.
It turns out that InlinePanel does not fully support ParentalManyToManyField, hence the issues you were running into.
I was able to implement a refined approach to your option one above and it should solve your problem.
A reminder that all Page models already extend ClusterableModel so there is no need to add that to any models you create.
Overview
Create a new 'relation' that extends models.Model which will be the relation between these two page models.
Each field within this new model will be the two page types via the model-cluster ParentalKey each with a logical related_name set that is the OTHER side of the relationship.
No need to set panels on this model as we will declare the panels individually via the panels kwarg to InlinePanel - see the InlinePanel docs.
Finally, each individual Page's content_panels has an InlinePanel added that refers to the central relation model indirectly via that model's related_name, adding the other side reference to PageChooserPanel.
Example Code
from modelcluster.fields import ParentalKey
from wagtail.admin.edit_handlers import FieldPanel, InlinePanel, PageChooserPanel
class PersonPageSitePageRelation(models.Model):
person = ParentalKey('app.PersonPage', on_delete=models.CASCADE, related_name='sites')
site = ParentalKey('app.SitePage', on_delete=models.CASCADE, related_name='people')
# Optional: some additional fields (e.g. 'note') for this relation
# Important: NOT setting any `panels` here, will be set individually for each 'direction'
class Meta:
unique_together = ('person', 'site')
class PersonPage(Page):
# ... fields (note: `sites` does NOT need to be declared as a field)
# Now we add an `InlinePanel` that will connect to the parental connection to PersonPageSitePageRelation via the related name `sites`, but the panels available will be the PersonPageSitePageRelation's field `site`
content_panels = Page.content_panels + [
# ... other FieldPanel etc
InlinePanel('sites', label='Related Sites', [PageChooserPanel('site')]),
]
class SitePage(Page):
# ... fields (note: `people` does NOT need to be declared as a field)
# Now we add an `InlinePanel` that will connect to the parental connection to PersonPageSitePageRelation via the related name `people`, but the panels available will be the PersonPageSitePageRelation's field `person`
content_panels = Page.content_panels + [
# ... other FieldPanel etc
InlinePanel('people', label='Related People', panels=[PageChooserPanel('person')]),
]
Further Reading
Read about Django Modelcluster - which is the library that ParentalKey comes from.

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

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.

Can I use a recursive ParentalKey in Wagtail?

Say I have the following model in a Wagtail app:
# models.py
from django.db import models
from django.db.models.deletion import CASCADE
from wagtail.admin.edit_handlers import InlinePanel, FieldPanel
from modelcluster.models import ParentalKey, ClusterableModel
class Person(ClusterableModel):
name = models.CharField(max_length=300)
contact_for = ParentalKey(
'self', on_delete=CASCADE, null=True, related_name='contacts'
)
panels = [
FieldPanel('name'),
FieldPanel('contact_for'),
InlinePanel('contacts')
]
And the following hooks:
# wagtail_hooks.py
from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register
from home.models import Person
class PersonAdmin(ModelAdmin):
model = Person
menu_label = 'People'
menu_icon = 'list-ul'
menu_order = 200
add_to_settings_menu = False
exclude_from_explorer = False
list_display = ('name',)
search_fields = ('name',)
list_filter = ('contact_for',)
modeladmin_register(PersonAdmin)
When running the above and navigating to Admin > People > Add Person I will get a RecursionError exception (maximum recursion depth exceeded).
I'm guessing this is a Wagtail related issue, because I can use the class as intended in IPython:
./manage.py shell -i ipython
In [1]: from home.models import Person
In [2]: mike = Person(name='Mike')
In [3]: mike.contacts = [Person(name='Sam'), Person(name='John')]
In [4]: mike.contacts
Out[4]: <modelcluster.fields.create_deferring_foreign_related_manager.<locals>.DeferringRelatedManager at 0x7f36e9548af0>
In [5]: mike.save()
In [6]: [person.name for person in Person.objects.all()]
Out[6]: ['Mike', 'Sam', 'John']
In [7]: Person.objects.all()[1].contact_for.name
Out[7]: 'Mike'
So, is there a way I can make use of a recursive ParentalKey in Wagtail? What am I doing wrong/missing?
Edit: I just found this answer. So I'm wondering if I should even be trying to use ParentalKey (and ParentalManyToManyField) for non Page models.
Edit 2: For anyone interested, I ended up splitting my Model to avoid a recursive key. InlinePanels do work with ModelAdmin classes, it's just Wagtail doesn't seem to support recursive keys. I have also opted to use Snippets and SnippetChooserPanel where it felt appropriate.
A ParentalKey means that the child model is notionally treated as 'part of' the parent model and doesn't exist as an independent entity - for example, an image gallery being part of a page - for purposes such as versioning, and moderation workflow. (In Wagtail, non-page models handled through snippets or ModelAdmin don't have these features, but in order for them to share the same InlinePanel mechanism as pages, they also use ParentalKey.)
In this case, a Person is not part of another Person, and needs to be editable independently of the 'parent', so a ParentalKey isn't appropriate here. Instead, you should use a ForeignKey, which just indicates some relation between the models. This does mean that you can't use an InlinePanel to manage multiple Person records within the same view - you'll have to edit them separately, and use something like SnippetChooserPanel or a simple dropdown to set up the relations between them.

How to manage third party app migrations with custom WAGTAILIMAGES_IMAGE_MODEL

Situation
I have a custom image and rendition model, and have followed the wagtail v2.4 guide to implement them:
class AccreditedImage(AbstractImage):
"""
AccreditedImage - Customised image model with optional caption and accreditation
"""
caption = models.CharField(max_length=255, blank=True)
accreditation = models.CharField(max_length=255, blank=True, null=True)
admin_form_fields = Image.admin_form_fields + (
'caption',
'accreditation',
)
class Meta:
verbose_name = 'Accredited image'
verbose_name_plural = 'Accredited images'
def __str__(self):
credit = ' ({})'.format(self.accreditation) if (self.accreditation is not None) and (len(self.accreditation) > 0) else ''
return '{}{}'.format(self.title, credit)
class AccreditedRendition(AbstractRendition):
"""
AccreditedRendition - stores renditions for the AccreditedImage model
"""
image = models.ForeignKey(AccreditedImage, on_delete=models.CASCADE, related_name='renditions')
class Meta:
unique_together = (('image', 'filter_spec', 'focal_point_key'),)
verbose_name = 'Accredited Image Rendition'
verbose_name_plural = 'Accredited Image Renditions'
In settings I have:
WAGTAILIMAGES_IMAGE_MODEL = 'cms.AccreditedImage'
But, I have two third party plugins installed: puput and wagtail_events, each of which use a foreign key to wagtail images.
When I run `manage.py makemigrations, additional migrations are created in the puput and wagtail_events site_packages folders to handle the change in FK. The migrations look like this:
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('puput', '0005_blogpage_main_color'),
]
operations = [
migrations.AlterField(
model_name='blogpage',
name='header_image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='cms.AccreditedImage', verbose_name='Header image'),
),
migrations.AlterField(
model_name='entrypage',
name='header_image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='cms.AccreditedImage', verbose_name='Header image'),
),
]
The Problem
If I actually apply these migrations, then puput or wagtail_events releases a new version, then the migration history becomes corrupted - e.g. my autogenerated 0006* migration on puput and their new 0006* migration fork the history
The Question
Is there a way of overcoming this? Or a recommended practice for what to do?
At this point I'm in very early beta, so I could dump the entire DB and start again if the recommended strategy is to set this up from the outset to avoid the issue.
Thanks for any help, folks!
Answer 1 - if you have control over your third party libraries
The initial migration in the third party library should define a swappable dependency, for example:
from wagtail.images import get_image_model_string
dependencies = [
migrations.swappable_dependency(get_image_model_string()),
]
operations = [
migrations.CreateModel(
name='ThirdPartyModel',
fields=[
...
('image', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, to=get_image_model_string())),
],
...
),
This is not automatically created by makemigrations. get_image_model_string needs to be used like this in every migration affecting that FK, made through the entire migration history of the library.
If you change the setting at some point in the project, you will still need to do a data migration ('Migrate an existing swappable dependency' might help), but this solves the forking problem described above if starting clean.
It has the drawback of requiring control over the third party library. I'm not holding my breath for a project like puput to go back and alter their early migration history to allow for a swappable image model (puput's initial migration hard-codes wagtailimages.Image). But I've implemented this for wagtail_events (my own project) to save other people this hassle.
Answer 2 - if you don't have control
Ugh. I've been working on this a while and all candidate solutions are all pretty horrible. I considered getting my custom image class to impersonate wagtail.images.model.Image via the db_table meta attributes, and even by creating another app which essentially duplicates wagtail images. It's all either a lot of work or super hacky.
I've chosen to take over migrations manually using the MIGRATION_MODULES setting.
For my scenario, I've taken the entire migration history of puput and copied all the files into a separate folder, root/custom_puput_migrations/. I set
MIGRATION_MODULES = {
'puput': 'custom_puput_migrations'
}
WAGTAILIMAGES_IMAGE_MODEL = 'otherapp.AccreditedImage'
Then I pull the ol' switcharoo by editing 0001_initial.py in that folder to refer to the model via the setting, rather than by hard coding:
...
from wagtail.images import get_image_model_string
class Migration(migrations.Migration):
dependencies = [
...
migrations.swappable_dependency(get_image_model_string())
]
operations = [
migrations.CreateModel(
name='BlogPage',
fields=[
...
('header_image', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, verbose_name='Header image', blank=True, to=get_image_model_string(), null=True)),
],
...
Drawbacks
1) The actual table relation created is not strictly determined by the migration file, but by the setting, which could change independently. If you prefer to avoid this, you could instead simply hard-code your referred model in the custom migration.
2) This approach leaves you pretty vulnerable to developers upgrading the library version requirement without realising that they also have to manually copy across the migrations files. I suggest a check (f/ex ensuring that the number of files in the default migrations folder is the same as the number of files in the custom one) before allowing the app to boot, to ensure your development and production databases all run on the same set of migrations.

Case insensitive Charfield in django models

I am trying to achieve a category model where name has unique=True,
but practically I can still add same category name with different cases.
i.e. I have a category called Food
I am still able to add food, FOOD, fOod, FOOd
Is their any philosophy behind this? or it is a work in progress.
Cause in real world if I think of Category Food, it will always be food, no matter what case it has used to mention itself.
Thank you in advance to look at this.
To answer my own question:
I have found I can have clean method on my model. So I added
class Category(models.Model):
name = models.CharField(max_length=200, unique=True)
def clean(self):
self.name = self.name.capitalize()
It is capitalising the first letter, which is then handled by the save method, which calls the validate_unique method to raise error.
You can use Postgre specific model field called Citext fields (case insensitive fields).
There are three option at the moment:
class CICharField(**options), class CIEmailField(**options) and class CITextField(**options)
Example:
from django.db import models
from django.contrib.postgres.fields import CICharField
class Category(models.Model):
name = CICharField(verbose_name="Name", max_length=255)
But don't forget to create an extension for the citext fields.
See here.
Basically, you have to add the extension class in the migration file, inside the operations array, before the first CreateModel operation.
# migration file
operations = [
CITextExtension(), # <------ here
migrations.CreateModel(
...
),
...,
]
Setting the column to case-insensitive collation should fix this. You may need to do it at the SQL level.

Resources