Dynamically Limit Choices of Wagtail Edit Handler - django

I would like to limit the choices available of a field in the Wagtail-Admin edit view. My Wagtail version is 2.16.3.
In my case, if have a page model, that describes product categories. To each category might exist some tags.
There is another page model, that describes concrete products and always is a subpage of a category page.
A product can now be described with some tags, but only tags, that belong to the product's category make sense, so I would like to restrict the edit handler of the products to these tags.
My models.py looks similar to this:
class ProductTag(ClusterableModel):
name = models.CharField(_("Name"), max_length=50)
category = ParentalKey(
"ProductCategoryIndexPage", related_name="tags", on_delete=models.CASCADE
)
class ProductPage(Page):
parent_page_types = ["products.ProductCategoryIndexPage"]
tags = ParentalManyToManyField("ProductTag", related_name="products")
content_panels = Page.content_panels + [
FieldPanel("tags", widget=widgets.CheckboxSelectMultiple()),
]
class ProductCategoryIndexPage(Page):
subpage_types = ["products.ProductPage"]
content_panels = Page.content_panels + [
MultiFieldPanel(
[InlinePanel("tags", label="Tags")],
heading=_("Produkt Tags"),
),
]
My approach was to create a custom edit handler and inject the wigdet overriding the on_instance_bound method using the correct choices argument for the widget.
class ProductTagEditPanel(FieldPanel):
def on_instance_bound(self):
self.widget = widgets.CheckboxSelectMultiple(
# of_product is a custom manager method, returning only the tags, that belong to the product's category
choices=ProductTag.objects.of_product(self.instance)
)
return super().on_instance_bound()
But somewhere in the form creation process, the choices are overridden again and I cannot find a good location, where to inject the custom query.
Also looking through the wagtail issues I could only find remotely related stuff and I'm wondering, if my plans are to exotic or I'm sitting to long in front of this problem..

Related

Displaying a many-to-many relationship in Django

Background:
I have the following models for a visitor management app. Each site can have multiple visitors and each visitor can visit multiple sites. I am unable to find a way to show a list of visitors on each site or show a list of sites a visitor has visited. I have removed unnecessary fields.
.models.py
class Site(models.Model):
name = models.CharField(max_length=100, unique=True)
accomodation = models.BooleanField(default=False)
visitors = models.ManyToManyField('Visitor', blank=True)
class Visitor(models.Model):
name = models.CharField(max_length=50, null=False, blank=False)
email = models.EmailField(max_length=200, null=False, blank=False, unique=True)
...
admin.py
class AdminArea(admin.AdminSite):
vms_admin = AdminArea(name='vms_admin')
#admin.register(Site, site=vms_admin)
class SiteAdmin(admin.ModelAdmin):
fieldsets = (*removed*)
list_display = [*removed*]
list_filter = (*removed*)
#admin.register(Visitor, site=vms_admin)
class VisitorAdmin(admin.ModelAdmin):
fieldsets = (*removed*)
list_display = [*removed*]
list_filter = (*removed*)
Django Admin
This is how the list of sites looks:
Django Admin Site List
This is how the list of visitors look like:
Django Admin Visitor List
Question
How do I show the list of visitors for each site and vice versa?
For example:
If I click on Gingin Gravity Precinct, I want to see the list of visitors associated with it, in a table, below "site details". Similar to the list of visitors shown, but specific to the site.
Django Admin Specific Site
First of all, I would make the related_name explicit in model Site. This is not strictly necessary, there is a default backward reference created by Django, I just never remember the name of that, so I like to be explicit.
visitors = models.ManyToManyField('Visitor', blank=True, related_name='sites')
I assume you want to see the list of sites when you click a single Visitor, in other words, on the visitor detail admin page. In that case, all you need to do is to specify the field sites in the VisitorAdmin. Vice versa to display visitors in the SiteAdmin. You will get a list of all visitors then, in which the active ones are selected.
class SiteAdmin(Admin):
fieldsets = (
('Visitors', {'fields': ('visitors')})
)
If you want to display a (possibly long) list of sites in the visitor list admin page, what you can do is to define a property on the Visitor model, and add that property to the list_fields.
class Visitor(Model):
#property
def list_of_sites(self):
return self.sites.all().values_list(name, flat=True)
class SiteAdmin(Admin):
list_display = ['name', 'accomodation', 'list_of_sites']

How to have nested Orderables?

We have a content model with a couple of many-to-many (type) relationships:
Issue has many Articles
Article has many Authors
In this specific case, our Article and Author models are fairly small. The use case is to construct a table of contents for a magazine Issue by creating a list of Article titles and one or more Authors for each Article. Ideally, the end-user could edit the entire Issue from a single form, since the articles are only used as a table of contents.
I have tried to model this using the Page and Orderable classes, as follows. However, I am getting a KeyError when wagtail attempts to render the Issue edit form.
class Author(Orderable):
"""
Represents authorship for an article
allowing multiple authors per article
and multiple articles per author
"""
article = models.ForeignKey(
"magazine.Article",
null=True,
on_delete=models.PROTECT,
related_name="authors",
)
author = models.ForeignKey(
"wagtailcore.Page",
null=True,
on_delete=models.PROTECT,
related_name="articles_authored",
)
panels = [
PageChooserPanel(
"author", ["contact.Person", "contact.Organization"]
)
]
class Article(Orderable):
"""
An article, which can have multiple authors
"""
title = models.CharField(max_length=255)
issue = ParentalKey(
"magazine.ArchiveIssue",
null=True,
on_delete=models.PROTECT,
related_name="articles",
)
panels = [
FieldPanel("title", classname="full"),
# Multiple authors can contribute to an article
InlinePanel(
"authors",
heading="Authors",
help_text="Select one or more authors, who contributed to this article",
),
]
class Issue(Page):
"""
Represents a magazine issue
with table of contents
"""
# Add articles inline, since it is a table of contents
content_panels = Page.content_panels + [
InlinePanel(
"articles",
heading="Table of contents",
help_text="Select one or more authors, who contributed to this article",
),
]
Django throws a KeyError exception when trying to render the Wagtail edit page:
Exception Type: KeyError
Exception Value: 'authors'
I realize I may be asking too much of Wagtail here, but this seems like it might be possible. At the very least, I hope to get a better understanding of why this isn't working and might not be possible.
I have not tested the nested orderables aspect, but if planning to order Authors on Article then Author.article should be a ParentalKey, and Article would also need to extend ClusterableModel.

Putting tags in a StructBlock

I want to be able to add tagging to a custom StructBlock that I've created.
The current model looks like this
class MapsIndicatorBlock(blocks.StructBlock):
text_icon = blocks.CharBlock(
label='Maps/Indicators Text or Icon',
required=False
)
pop_up_title = blocks.CharBlock(
label='Pop-Up Title',
required=False
)
pop_up_text = blocks.RichTextBlock(
label ='Pop-Up Text/Image',
required=False
)
pop_up_colour = blocks.CharBlock(
choices=constants.BOOTSTRAP4_BUTTON_COLOUR_CHOICES,
default='btn btn-primary',
max_length=128,
required=False
)
tags = TaggableManager()
objects = models.Manager()
class Meta:
template = 'cityregiontable/map_indicator_block.html'
The TaggableManager() was designed to be used with models.model not blocks.StructBlock.
I have tried to create a way to create the tags using the following to no avail. I get the error RE: not being able to find the model for MapsIndicatorBlock. This is correct as MapsIndicatorBlock is a block, not a model.
class MITag(TaggedItemBase):
content_object = models.ForeignKey(
'MapsIndicatorBlock',
on_delete=models.CASCADE,
related_name='tagged_mi_block'
)
How can I allow a block to be have metadat tags?
Based on the docs for custom block types as a starting point we are able to generate a custom FieldBlock that leverages the existing Wagtail AdminTagWidget.
This widget does almost all of the work for you, it will pull in the available tags for autocomplete plus will save any new tags created on the fly.
It is possible to read out these tags and make them available more conveniently with a model #property or similar. Remember Streamfields store data as JSON so you do not get any of the model / database linking out of the box.
Limitations
The caveat is that the saved tags are stored as the raw strings, this means if you have some more complex use cases of tags you will have to do a bit more work to get this integrated. e.g. a tag page that shows all pages that use that tag or advanced tag editing in Wagtail's ModelAdmin.
In these cases, you can either work out a way to 'sync' the Page's tags with the StreamField tag and maybe abstract this work out to a mixin. Alternatively, you can rework your query on your tags page to also include those with the streamfield data you want.
Example Code
from itertools import chain
from django import forms
from wagtail.admin.edit_handlers import FieldPanel, StreamFieldPanel
from wagtail.admin.widgets import AdminTagWidget
from wagtail.core.blocks import CharBlock, FieldBlock, StructBlock, RichTextBlock
from wagtail.core.fields import StreamField
from wagtail.core.models import Page
class TagsBlock(FieldBlock):
"""
Basic Stream Block that will use the Wagtail tags system.
Stores the tags as simple strings only.
"""
def __init__(self, required=False, help_text=None, **kwargs):
# note - required=False is important if you are adding this tag to an existing streamfield
self.field = forms.CharField(widget=AdminTagWidget, required=False)
super().__init__(**kwargs)
class MapBlock(StructBlock):
title = CharBlock(label="Title", required=False)
content = RichTextBlock(label="Content", required=False)
tags = TagsBlock(label="Tags", required=False)
class Meta:
icon = 'site'
class LocationPage(Page):
"""
Detail for a specific location.
"""
# ... other fields
# this is the stream field added
map_info = StreamField([('Map', MapBlock(required=False))], blank=True)
#property
def get_tags(self):
"""
Helpful property to pull out the tags saved inside the struct value
Important: makes some hard assumptions about the names & structure
Does not get the id of the tag, only the strings as a list
"""
tags_all = [block.value.get('tags', '').split(',') for block in self.test_b]
tags = list(chain.from_iterable(tags_all))
return tags
# Fields to show to the editor in the admin view
content_panels = [
FieldPanel('title', classname="full"),
StreamFieldPanel('map_info'),
# ... others
]
# ... rest of page model
Thanks to this similar question about tags in streamfields, answering that helped me answer this one.
Creating a TagsBlock for StreamField

Wagtail: get parent page groups that have specific rights (edit, delete)

I have a categorypage -> articlepage hierarchy in wagtail. The article page has an author field, which currently shows all users in the system. I want to filter the authors of the article based on the group of the parent category page.
models.py
from django.contrib.auth.models import Group
class CategoryPage(Page): # query service, api
blurb = models.CharField(max_length=300, blank=False)
content_panels = Page.content_panels + [
FieldPanel('blurb', classname="full")
]
subpage_types = ['cms.ArticlePage']
class ArticlePage(Page): # how to do X with query service
...
author = models.ForeignKey(User, on_delete=models.PROTECT, default=1,
# limit_choices_to=get_article_editors,
help_text="The page author (you may plan to hand off this page for someone else to write).")
def get_article_editors():
# get article category
# get group for category
g = Group.objects.get(name='??')
return {'groups__in': [g, ]}
This question (limit_choices_to) is almost what I'm after, but I'm not sure how to retrieve the group for the parent page before the article itself is created?
This question seems to do the trick in accessing parent pages on creation, but I remain unsure of how to find the groups that can edit the parent page.
Unfortunately I am not aware of a way for the limit_choices_to function to receive a reference to the parent objects. Your second link is on the right track, we will need to provide our own base form to the page and tweak the queryset of the author field.
from django.contrib.auth.models import Group
from wagtail.wagtailadmin.forms import WagtailAdminPageForm
from wagtail.wagtailcore.models import Page
class ArticlePageForm(WagtailAdminPageForm):
def __init__(self, data=None, files=None, parent_page=None, *args, **kwargs):
super().__init__(data, files, parent_page, *args, **kwargs)
# Get the parent category page if `instance` is present, fallback to `parent_page` otherwise.
# We're trying to get the parent category page from the `instance` first
# because `parent_page` is only set when the page is new (haven't been saved before).
instance = kwargs.get('instance')
category_page = instance.get_parent() if instance and instance.pk else parent_page
if not category_page:
return # Do not continue if we failed to find the parent category page.
# Get the groups which have permissions on the parent category page.
groups = Group.objects.filter(page_permissions__page_id=category_page.pk).distinct()
if not groups:
return # Do not continue if we failed to find any groups.
# Filter the queryset of the `author` field.
self.fields['author'].queryset = self.fields['author'].queryset.filter(groups__in=groups)
class ArticlePage(Page):
base_form_class = ArticlePageForm
A quick note on the way we query the groups:
When you set page permissions in Wagtail, you actually create a GroupPagePermission which has 2 main attributes, group and page. The related_name of the group's foreign key of the GroupPagePermission is defined as page_permissions and every time you create a ForeignKey like with page, it actually creates a field called page_id. Therefore we can follow the relationship and filter the groups by page_permissions__page_id with the ID of the parent category page.

Exclude a field from Wagtail Page revisions

I have a Wagtail model that extends the base Page model:
models.py
class EmployeePage(Page):
eid = models.PositiveIntegerField(unique=True)
active = models.BooleanField(blank=True)
first_name = models.CharField(max_length=50)
last_name = models.CharField(max_length=50)
...
content_panels = [
FieldPanel('eid'),
FieldPanel('first_name'),
FieldPanel('last_name'),
]
I am only updating the active field directly to the live model via daily API import script, so I want it excluded from the CMS entirely.
import_script.py
employee = EmployeePage.objects.get(eid=imported_row.eid)
employee.active = imported_row.active
employee.save()
I'm able to exclude the active field from the CMS edit view by not including it in the content_panels above, but this appears to just be cosmetic as a value is still always included in page revisions, which is overriding my imported value. How can I have a field that is excluded from page revisions?
Here's a solution that's kinda hacky, but seems to work. Instead of excluding the field from page revisions, add code to the import script that updates all page revisions.
import_script.py
employee = EmployeePage.objects.get(eid=imported_row.eid)
employee.status = imported_row.status
employee.save()
# Updates all page revisions
revisions = PageRevision.objects.filter(page=employee)
for r in revisions:
r.active = imported_row.active
r.save()