I use wagtail 6.3 and Django 5.1.3 for a bilingual blog application. When I tried to make migration I got the following error (full traceback):
Traceback (most recent call last):
File "/home/roland/Documents/developments/ai_blog2/manage.py", line 10, in <module>
execute_from_command_line(sys.argv)
File "/home/roland/.pyenv/versions/3.12.7/envs/ai_blog2/lib/python3.12/site-packages/django/core/management/__init__.py", line 442, in execute_from_command_line
utility.execute()
File "/home/roland/.pyenv/versions/3.12.7/envs/ai_blog2/lib/python3.12/site-packages/django/core/management/__init__.py", line 436, in execute
self.fetch_command(subcommand).run_from_argv(self.argv)
File "/home/roland/.pyenv/versions/3.12.7/envs/ai_blog2/lib/python3.12/site-packages/django/core/management/base.py", line 413, in run_from_argv
self.execute(*args, **cmd_options)
File "/home/roland/.pyenv/versions/3.12.7/envs/ai_blog2/lib/python3.12/site-packages/django/core/management/base.py", line 454, in execute
self.check()
File "/home/roland/.pyenv/versions/3.12.7/envs/ai_blog2/lib/python3.12/site-packages/django/core/management/base.py", line 486, in check
all_issues = checks.run_checks(
^^^^^^^^^^^^^^^^^^
File "/home/roland/.pyenv/versions/3.12.7/envs/ai_blog2/lib/python3.12/site-packages/django/core/checks/registry.py", line 88, in run_checks
new_errors = check(app_configs=app_configs, databases=databases)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/roland/.pyenv/versions/3.12.7/envs/ai_blog2/lib/python3.12/site-packages/wagtail/admin/checks.py", line 70, in get_form_class_check
if not issubclass(edit_handler.get_form_class(), WagtailAdminPageForm):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/roland/.pyenv/versions/3.12.7/envs/ai_blog2/lib/python3.12/site-packages/wagtail/admin/panels/base.py", line 134, in get_form_class
return get_form_for_model(
^^^^^^^^^^^^^^^^^^^
File "/home/roland/.pyenv/versions/3.12.7/envs/ai_blog2/lib/python3.12/site-packages/wagtail/admin/panels/base.py", line 48, in get_form_for_model
return metaclass(class_name, tuple(bases), form_class_attrs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/roland/.pyenv/versions/3.12.7/envs/ai_blog2/lib/python3.12/site-packages/permissionedforms/forms.py", line 30, in __new__
new_class = super().__new__(mcs, name, bases, attrs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/roland/.pyenv/versions/3.12.7/envs/ai_blog2/lib/python3.12/site-packages/modelcluster/forms.py", line 259, in __new__
new_class = super().__new__(cls, name, bases, attrs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/roland/.pyenv/versions/3.12.7/envs/ai_blog2/lib/python3.12/site-packages/django/forms/models.py", line 334, in __new__
raise FieldError(message)
django.core.exceptions.FieldError: Unknown field(s) (comment_notifications) specified for BlogPage
The following model definitions I use like BlogePage, BlogCategoryPage, Author, etc:
from django.db import models
from wagtail.models import DraftStateMixin, RevisionMixin, PreviewableMixin, TranslatableMixin, Page
from wagtail.snippets.models import register_snippet
from wagtail.admin.panels import FieldPanel, MultiFieldPanel
from wagtail.fields import StreamField, RichTextField
from blog.blocks import BlogContentBlock, NavbarBlock
from wagtail.search import index
from wagtailseo.models import SeoMixin, SeoType, TwitterCard
from wagtail.images import get_image_model
from modelcluster.fields import ParentalManyToManyField
from django import forms
from django.utils.text import slugify
from wagtail.admin.forms import WagtailAdminModelForm
from wagtail.admin.widgets import AdminTagWidget
@register_snippet
class Navbar(TranslatableMixin, DraftStateMixin, RevisionMixin, PreviewableMixin, models.Model):
name = models.CharField(max_length=255)
content = StreamField( [('navbar', NavbarBlock())], min_num=1, max_num=1, use_json_field=True)
panels = [
FieldPanel('name'),
FieldPanel('content'),
]
def get_preview_template(self, request, mode_name):
print(f"Mode name: {mode_name}\nRequest: {request}")
return "blog/tags/navbar.html"
def __str__(self):
return self.name
@register_snippet
class Author(TranslatableMixin, models.Model):
"""Authors for the blog
Args:
TranslatableMixin (_type_): To make it translateable
DraftStateMixin (_type_): To make it draftable
RevisionMixin (_type_): Let editors to make revisions
PreviewableMixin (_type_): Let editors to preview
models (_type_): Classical Django model
"""
name = models.CharField(max_length=40, blank=False, help_text="Name of this author")
short_bio = RichTextField(blank=True, help_text="Short bio of this author")
twitter_link = models.URLField(max_length=100, blank=True, help_text="Twitter of this author")
linkedin_link = models.URLField(max_length=100, blank=True, help_text="Linkedin of this author" )
tiktok_link = models.URLField(max_length=100, blank=True, help_text="Tiktok of this author")
medium_link = models.URLField(max_length=100, blank=True, help_text="Medium of this author")
portrait = models.ForeignKey(
'wagtailimages.Image',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text="Portrait of this author"
)
seo_title = models.CharField(max_length=70, blank=False, unique=False, help_text="Title shown on Google search when tag listing page returned. Max length is 70.")
seo_meta = models.CharField(max_length=160, blank=False, unique=False, help_text="Meta description shown on Google search when tag listing page returned. Max length is 160.")
panels = [
FieldPanel('name'),
FieldPanel('twitter_link'),
FieldPanel('linkedin_link'),
FieldPanel('tiktok_link'),
FieldPanel('medium_link'),
FieldPanel('short_bio'),
FieldPanel('portrait'),
FieldPanel('seo_title'),
FieldPanel('seo_meta')
]
class Meta:
verbose_name = "Author"
verbose_name_plural = "Authors"
constraints = [
models.UniqueConstraint(fields=('translation_key', 'locale'), name='unique_translation_key_locale_blog_author'),
]
def get_preview_template(self, request, mode_name):
return "blog/previews/advert.html"
def __str__(self):
return self.name
@register_snippet
class MyTag(TranslatableMixin, index.Indexed):
name = models.CharField(max_length=100)
slug = models.SlugField(blank=True)
seo_description = models.TextField(blank=True)
seo_title = models.CharField(max_length=255, blank=True)
og_image = models.ForeignKey(
get_image_model(),
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+'
)
panels = [
FieldPanel('name'),
FieldPanel('slug'),
MultiFieldPanel([
FieldPanel('seo_title'),
FieldPanel('seo_description'),
FieldPanel('og_image'),
], heading="SEO Settings")
]
search_fields = [
index.SearchField('name'),
index.SearchField('seo_description'),
]
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def __str__(self):
return self.name
class Meta:
verbose_name = "Tag"
verbose_name_plural = "Tags"
unique_together = [
('translation_key', 'locale'),
('slug', 'locale')
]
# Custom form for the BlogPage that handles tag filtering
class BlogPageForm(WagtailAdminModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Get the instance's locale if it exists (for editing) or the default locale (for new pages)
locale = self.instance.locale if self.instance and hasattr(self.instance, 'locale') else None
if locale:
# Filter tags by locale
filtered_tags = MyTag.objects.filter(locale=locale)
# Set up the autocomplete widget with filtered queryset
self.fields['tags'].widget = AdminTagWidget(
model=MyTag,
queryset=filtered_tags,
)
# Update the queryset for the field itself
self.fields['tags'].queryset = filtered_tags
class BlogPage(SeoMixin, Page):
author = models.ForeignKey(Author, on_delete=models.SET_NULL,null=True, related_name='blog_pages', help_text="Author of this page")
tags = ParentalManyToManyField(MyTag, blank=True, related_name='blog_pages')
intro = RichTextField(blank=True, help_text="Intro of this blog page")
image = models.ForeignKey(
get_image_model(),
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text="Hero image of this blog page"
)
featured = models.BooleanField(default=False, help_text="Featured blog page")
content = StreamField([('blog_content', BlogContentBlock())],
min_num=1,
use_json_field=True)
# Indicate this is article-style content.
seo_content_type = SeoType.ARTICLE
# Change the Twitter card style.
seo_twitter_card = TwitterCard.LARGE
# Provide intro as a fallback of search_description. It the latter is not defined, intro will be used
seo_description_sources = [
"search_description",
"intro",
]
parent_page_types = ['blog.BlogCategoryPage']
subpage_types = []
# Add some basic validation and debugging
def clean(self):
cleaned_data = super().clean()
# Add validation to ensure tags match page locale
if self.locale and self.tags.all():
mismatched_tags = self.tags.exclude(locale=self.locale)
if mismatched_tags.exists():
raise forms.ValidationError({
'tags': _("All tags must be in the same language as the page. "
"Please remove or translate these tags: %s") %
", ".join(tag.name for tag in mismatched_tags)
})
return cleaned_data
base_form_class = BlogPageForm
content_panels = Page.content_panels + [
FieldPanel('image'),
FieldPanel('author'),
FieldPanel('tags'),
FieldPanel('featured'),
FieldPanel('intro'),
FieldPanel('content')
]
promote_panels = SeoMixin.seo_panels
class Meta:
verbose_name = "Blog Page"
verbose_name_plural = "Blog Pages"
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
# Get the current post's category and tags
current_category = self.category
current_tags = set(self.tags.values_list('name', flat=True))
# Get all published blog posts except the current one
all_posts = BlogPage.objects.live().exclude(id=self.id)
# List to hold the related posts with their similarity score
related_posts = []
# Calculate similarity score for each post
for post in all_posts:
post_category = post.category
post_tags = set(post.tags.values_list('name', flat=True))
# Intersection calculation for categories and tags. Later semantics will be used
category_score = 2 if current_category == post_category else 0 # Category gets 2 times more weight
tag_score = len(current_tags.intersection(post_tags))
# Total similarity score
total_score = category_score + tag_score
if total_score > 0:
related_posts.append((post, total_score))
# Sort the related posts by the score in descending order and limit the number (e.g., top 3)
related_posts = sorted(related_posts, key=lambda x: x[1], reverse=True)[:3]
# Add the related posts to the context
context['related_posts'] = [post for post, score in related_posts]
return context
class BlogCategoryPage(SeoMixin, Page):
intro = RichTextField(blank=True, help_text="Intro of this blog categorypage")
image = models.ForeignKey(
'wagtailimages.Image',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text="Hero image of this blog category page"
)
content_panels = Page.content_panels + [
FieldPanel('intro'),
FieldPanel('image'),
]
promote_panels = SeoMixin.seo_panels
# Provide intro as a fallback of search_description. It the latter is not defined, intro will be used
seo_description_sources = [
"search_description",
"intro",
]
parent_page_types = ['home.HomePage']
subpage_types = ['blog.BlogPage']
class Meta:
verbose_name = "Blog Category Page"
verbose_name_plural = "Blog Category Pages"
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
# TODO: Cache this
context['blog_pages'] = self.get_children().live().order_by('-last_published_at')
return context
def __str__(self):
return f"Blog Category Page for {self.title}"
def get_tag_counts(object: BlogCategoryPage = None):
"""
Get the tags used by blog pages and their number of occurances.
If BlogCategoryPage is provided, only the tags used in that category will be returned, otherwise all tags will be returned.
Current implementation is RDBMS based but later it will be replaced by solr
"""
if object:
tag_counts = MyTag.objects.filter(blogpage__in=object.get_children().live()).annotate(count=models.Count('blogpage')).order_by('-count')
else:
tag_counts = MyTag.objects.annotate(count=models.Count('blogpage')).order_by('-count')
return tag_counts
Analyzing the Traceback, I guess the problem is related to the special form I use for tagging. It is included above as BlogPageForm. This form must be locale aware and suggest tags from MyTag snippet filtered to the locale of the blog page.
I do not understand where this referred field comes from. I researched it which object include such a field like comment_notifications. It turned out that this boolean field is included in a class inherited from models.Model and called PageSubscription.
class BlogPageForm(WagtailAdminModelForm):
As per the docs on customising generated forms, forms for page models should inherit from wagtail.admin.forms.WagtailAdminPageForm
rather than WagtailAdminModelForm
.