How can I set the number of initial empty (orderable) items in an InlinePanel? - wagtail

I have an Orderable model called SetListItem with a ParentalKey on a ClusterableModel called FloorWithSets. The parent FloorWithSets model defines using an InlinePanel to control adding/ordering/removing of the SetListItems. The issue I have is that the admin form automatically renders three empty SetListItems for each FloorWithSets, and I cannot find any way to control this setting.
The InlinePanel class takes parameters to, e.g. set the minimum and maximum number of items, but nothing to set the number of initial empty items rendered.
I cannot find any information about this in the Wagtail docs. I've also dug into the source for InlinePanel and EditHandler but cannot find anything I could override.
I do see from the InlinePanel template file that there is a hidden input with id ending -INITIAL_FORMS which is being rendered via self.formset.management_form. The value of this field is consistently lower than a neighbouring hidden input with id ending -TOTAL_FORMS, which makes sense. I just don't understand where the value is coming from or how to control it.
The only information I can find about this INITIAL_FORMS all seems to relate to testing, (e.g. this documentation) and I cannot see how to relate what that says to what I need.
class FloorWithSets(ClusterableModel):
page = ParentalKey(EventPage, on_delete=models.CASCADE, related_name='floor_with_sets')
FLOOR_CHOICES = [
('1', 'X'),
('2', 'Y'),
('3', 'Z'),
]
floor = models.CharField(
max_length=1,
choices=FLOOR_CHOICES,
default='1',
)
panels = [
FieldPanel('floor'),
InlinePanel('set_list', label=_("set")),
]
class SetListItem(Orderable):
floor = ParentalKey(FloorWithSets, on_delete=models.CASCADE, related_name='set_list')
artist = models.CharField(max_length=255, blank=True, verbose_name=_('artist'))
label = models.CharField(max_length=255, blank=True, verbose_name=_('label'))
start_time = models.TimeField(blank=True, null=True, verbose_name=_('start time'))
end_time = models.TimeField(blank=True, null=True, verbose_name=_('end time'))
set_list_item = FieldRowPanel([
FieldPanel('artist', classname="col6"),
FieldPanel('label', classname="col6")
])
set_list_item_details = FieldRowPanel([
FieldPanel('start_time', classname="col6"),
FieldPanel('end_time', classname="col6")
])
panels = [set_list_item, set_list_item_details]

I think I've found a solution. Try creating a custom Form class for your EventPage model with a custom metaclass like so:
class EventPageFormMetaclass(WagtailAdminModelFormMetaclass):
#classmethod
def child_form(cls):
return EventPageForm
class EventPageForm(WagtailAdminPageForm, metaclass=EventPageFormMetaclass):
pass
class EventPage(Page):
# Whatever you have in your model
base_form_class = EventPageForm
I believe the problem stems from the fact that the ClusterFormMetaclass is hard-coded to create instances of ClusterForm for child models. So your EventPage gets a WagtailAdminPageForm, but the FloorWithSets models gets a ClusterForm. If you stop there, it's fine, but when FloorWithSets generates it's inline panels, it does so as a ClusterForm, whose metaclass has extra_form_count set to 3, as opposed to the WagtailAdminPageForm whose metaclass has it set to 0.
So the solution above creates a new Form class, whose metaclass overrides the child_form class method to return a Form class with extra_form_count set to 0.
Whew.

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.

Filtering on PageQuerySet (which is retuned by specific()) is returning FieldError in Wagtail

I am using subclassed model of Wagtail Page.
In below code you can see that PhoenixPage is base page which subclasses Wagtail Page model.
PhoenixArticlePage & PhoenixMealPrepPage subclasses PhoenixPage
PhoenixArticleIndexPage subclasses PhoenixBaseIndexPage which in turn subclasses PhoenixPage
Idea is to use PhoenixArticleIndexPage for all other article pages.
Problem is even after using the specific() method on queryset i am unable to use filter or any other operation on the queryset.
i tried using order_by() as well as filter()
Can someone share some insights here ? what might be wrong ?
Here is a model example:
class PhoenixPage(Page):
"""
General use page with caching, templating, and SEO functionality.
All pages should inherit from this.
"""
class Meta:
verbose_name = _("Phoenix Page")
# Do not allow this page type to be created in wagtail admin
is_creatable = False
tags = ClusterTaggableManager(
through=PhoenixBaseTag,
verbose_name="Tags",
blank=True,
related_name="phoenixpage_tags",
)
class PhoenixBaseIndexPage(PaginatedListPageMixin, PhoenixPage):
class meta:
verbose_name = "Phoenix Base Index Page"
app_label = "v1"
index_show_subpages_default = True
is_creatable = False
class PhoenixArticleIndexPage(PhoenixBaseIndexPage):
class Meta:
verbose_name = "Phoenix Article Index Page"
app_label = "v1"
class PhoenixArticlePage(PhoenixPage):
class Meta:
verbose_name = "Phoenix Article Page"
app_label = "v1"
subpage_types = []
parent_page_types = ["v1.PhoenixArticleIndexPage"]
class PhoenixMealPrepPage(PhoenixPage):
class Meta:
verbose_name = "Phoenix Meal Prep Page"
app_label = "v1"
subpage_types = []
parent_page_types = ["v1.PhoenixArticleIndexPage"]
Here are shell queries i tried.
Index page
In [4]: a = PhoenixArticleIndexPage.objects.all()[0]
In [5]: a
Out[5]: <PhoenixArticleIndexPage: articles>
As expected, get_children returning all instances of Wagtail Page.
In [6]: a.get_children()
Out[6]: <PageQuerySet [<Page: article title>, <Page: article title2>, <Page: Our 30-Day Reset Recipes Are So Easy AND Delicious>]>
Getting specific children from the Index page.
In [7]: a.get_children().specific()
Out[7]: <PageQuerySet [<PhoenixArticlePage: article title>, <PhoenixArticlePage: article title2>, <PhoenixMealPrepPage: Our 30-Day Reset Recipes Are So Easy AND Delicious>]>
Get Tag and try to filter the queryset
In [8]: q = a.get_children().specific()
In [12]: m = PhoenixTag.objects.get(slug='meal')
In [16]: k={"tags":m}
In [19]: q.filter(**k)
***FieldError: Cannot resolve keyword 'tags' into field. Choices are ...***
But if i go to particular entry in queryset then i can see tags field on it.
In [15]: q[2]
Out[15]: <PhoenixMealPrepPage: Our 30-Day Reset Recipes Are So Easy AND Delicious>
In [16]: q[2].tags
Out[16]: <modelcluster.contrib.taggit._ClusterTaggableManager at 0x1060832b0>
Could be different question all together but for reference adding it here.
Found the corner case of using difference() and specific() method on a queryset.
In [87]: q = PhoenixPage.objects.child_of(a).live()
In [89]: f = q.filter(featured=True)[:3]
In [91]: l = q.difference(f)
In [93]: l.order_by(a.index_order_by).specific() . <-- does not work
DatabaseError: ORDER BY term does not match any column in the result set.
The specific() method on PageQuerySet works by running the initial query on the basic Page model as normal, then running additional queries - one for each distinct page type found in the results - to retrieve the information from the specific page models. This means it's not possible to use fields from the specific model in filter or order_by clauses, because those have to be part of the initial query, and at that point Django has no way to know which page models are involved.
However, if you know that your query should only ever return pages of one particular type (PhoenixPage in this case) containing the field you want to filter/order on, you can reorganise your query expression so that the query happens on that model instead:
PhoenixPage.objects.child_of(a).filter(tags=m).specific()

Wagtail: foreign key to streamfield value

I would like to allow a dropdown within wagtail admin to select from values (blocks) contained within another model's streamfield, is that possible? I imagined something like:
Feedback(page):
paper = models.ForeignKey('PaperPage', on_delete=models.CASCADE, null=True, blank=False, help_text="The paper associated with this feedback. Auto assigned.")
content_panels = [
InlinePanel('paper__drafts_id', label='Draft') <--- this doesn't work
]
where
Paper(page):
drafts = StreamField(
[
('draft_block', blocks.ListBlock(blocks.StructBlock([
('date', blocks.DateTimeBlock(required=True, label='Date draft uploaded')),
('uploaded_by', MemberBlock(required=False, label='Uploaded by',
help_text="Who is uploading this draft.")),
('draft_file', DocumentChooserBlock(required=False, label='Upload file')),
]), template='papers/blocks/drafts.html')),
],
blank=True
)
but I'm not sure if this is even possible? Any suggestions would be most appreciated. Thanks!
This is one downside of StreamField - the data is not stored as "true" database objects, but only as JSON text stored against the page, so there's no way to define relations such as ForeignKeys pointing to individual items in that data.
If there's only one block type in the stream, as in your example code, then it would be a better fit to define 'draft' as a child object (with an InlinePanel) on the Paper model instead; it will then exist as a true database model.

Why is the SnippetChooserPanel not opening in Wagtail?

Some time ago I stopped using #register_snippet to decorate Snippets. This takes the Snippet out of the snippets section of admin.
Instead I used the wagtail_hooks.py to show the snippet directly in the left admin panel for user convenience. See below. This works nicely as the user can go directly to the snippet and you can also alter the displayed fields and ordering of fields - nice.
So in the below example I removed the line that says #register_snippet. What's the catch? The SnippetChooserPanel does not work! Later I was building a complex model and the SnippetChooserPanel did not work. I wasted quite a bit of time thinking the problem was in the complexity of my model. I want to save others' time!
wagtail_hooks.py:
from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register
from wagtail.wagtailsnippets.models import register_snippet
from demo.models import Advert
class AdvertAdmin(ModelAdmin):
model = Advert
modeladmin_register(AdvertAdmin)
Here is the snippet example from Wagtail: snippets
#register_snippet #<------- Source of issue (I removed this line!)
#python_2_unicode_compatible # provide equivalent __unicode__ and __str__ methods on Python 2
class Advert(models.Model):
url = models.URLField(null=True, blank=True)
text = models.CharField(max_length=255)
panels = [
FieldPanel('url'),
FieldPanel('text'),
]
def __str__(self):
return self.text
class BookPage(Page):
advert = models.ForeignKey(
'demo.Advert',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+'
)
content_panels = Page.content_panels + [
SnippetChooserPanel('advert'),
# ...
]
If you make your Snippets editable via modelAdmin you still need to apply the decorator #register_snippet. Otherwise the chooser panel route/view won't be available. This view is requested by the ajax request fired on SnippetChooser modal open. Missing #register snippet will trow a 404.
You can register menu items via construct_main_menu hook. You can use the same hook to remove exiting menu-items. If you do not want the 'Snippets' menu item remove it. In wagtail_hooks.py:
#hooks.register('construct_main_menu')
def hide_snippet(request, menu_items):
menu_items[:] = [item for item in menu_items if item.name != 'snippets']
The solution is always use #register_snippet decorator otherwise the SnippetChooserPanel doesn't work!
#register_snippet
#python_2_unicode_compatible
class Advert(models.Model):
url = models.URLField(null=True, blank=True)
text = models.CharField(max_length=255)
panels = [
FieldPanel('url'),
FieldPanel('text'),
]
def __str__(self):
return self.text

django model/modelForm - How to get dynamic choices in choiceField?

i'm experimenting with django and the builtin admin interface.
I basically want to have a field that is a drop down in the admin UI. The drop down choices should be all the directories available in a specified directory.
If i define a field like this:
test_folder_list = models.FilePathField(path=/some/file/path)
it shows me all the files in the directory, but not the directories.
Does anyone know how i can display the folders?
also i tried doing
test_folder_list = models.charField(max_length=100, choices=SOME_LIST)
where SOME_LIST is a list i populate using some custom code to read the folders in a directory. This works but it doesn't refresh. i.e. the choice list is limited to a snapshot of whatever was there when running the app for the first time.
thanks in advance.
update:
after some thinking and research i discovered what i want may be to either
1. create my own widget that is based on forms.ChoiceField
or
2. pass my list of folders to the choice list when it is rendered to the client
for 1. i tried a custom widget.
my model looks like
class Test1(models.Model):
test_folder_ddl = models.CharField(max_length=100)
then this is my custom widget:
class FolderListDropDown(forms.Select):
def __init__(self, attrs=None, target_path):
target_folder = '/some/file/path'
dir_contents = os.listdir(target_folder)
directories = []
for item in dir_contents:
if os.path.isdir(''.join((target_folder,item,))):
directories.append((item, item),)
folder_list = tuple(directories)
super(FolderListDropDown, self).__init__(attrs=attrs, choices=folder_list)
then i did this in my modelForm
class test1Form(ModelForm):
test_folder_ddl = forms.CharField(widget=FolderListDropDown())
and it didn't seem to work.What i mean by that is django didn't want to use my widget and instead rendered the default textinput you get when you use a CharField.
for 2. I tried this in my ModelForm
class test1Form(ModelForm):
test_folder_ddl = forms.CharField(widget=FolderListDropDown())
test_folder_ddl.choices = {some list}
I also tried
class test1Form(ModelForm):
test_folder_ddl = forms.ChoiceField(choices={some list})
and it would still render the default char field widget.
Anyone know what i'm doing wrong?
Yay solved. after beating my head all day and going through all sorts of examples by people i got this to work.
basically i had the right idea with #2. The steps are
- Create a ModelForm of our model
- override the default form field user for a models.CharField. i.e. we want to explcitly say use a choiceField.
- Then we have to override how the form is instantiated so that we call the thing we want to use to generate our dynamic list of choices
- then in our ModelAdmin make sure we explicitly tell the admin to use our ModelForm
class Test1(models.Model):
test_folder_ddl = models.CharField(max_length=100)
class Test1Form(ModelForm):
test_folder_ddl = forms.choiceField()
def __init__(self, *args, **kwargs):
super(Test1Form, self).__init__(*args, **kwargs)
self.fields['test_folder_ddl'].choices = utility.get_folder_list()
class Test1Admin(admin.ModelAdmin):
form = Test1Form
I use a generator:
see git://gist.github.com/1118279.git
import pysvn
class SVNChoices(DynamicChoice):
"""
Generate a choice from somes files in a svn repo
""""
SVNPATH = 'http://xxxxx.com/svn/project/trunk/choices/'
def generate(self):
def get_login( realm, username, may_save ):
return True, 'XXX', 'xxxxx', True
client = pysvn.Client()
client.callback_get_login = get_login
return [os.path.basename(sql[0].repos_path) for sql in client.list(self.SVNPATH)[1:]]

Resources