wagtail

Unknown field(s) (comment_notifications) specified for BlogPage in wagtail


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.


Solution

  • 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.