-
-
Notifications
You must be signed in to change notification settings - Fork 4.1k
fix: BaseChooser widget handling of ForeignKey to_field parameter #13184
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix: BaseChooser widget handling of ForeignKey to_field parameter #13184
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! However, this doesn't seem like the right approach to me.
wagtail/admin/widgets/chooser.py
Outdated
# Try each unique field to see if we find a match | ||
for field_name in unique_fields: | ||
try: | ||
return self.model_class.objects.get(**{field_name: value}) | ||
except (self.model_class.DoesNotExist, ValueError, TypeError): | ||
continue |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, but this solution doesn't seem acceptable to me, as this means we essentially do brute-force database queries for every single unique field that exists on the model until we find a match.
An ideal solution is to change the original logic from querying based on pk
to instead query with the ForeignKey's to_field
directly. It might be tricky because we don't have a reference to the model field (which has the to_field
) nor the form field (which has the to_field_name
) within the widget class.
We might be able to update the BaseChooser
's init signature to also accept a to_field_name
, then pass the model field's to_field
as the value using a callable that gets registered by Wagtail. See the following example on how we do that to set the target_models
for AdminPageChooser
:
wagtail/wagtail/admin/forms/models.py
Lines 71 to 78 in c61d520
# Page chooser | |
register_form_field_override( | |
models.ForeignKey, | |
to=Page, | |
override=lambda db_field: { | |
"widget": widgets.AdminPageChooser(target_models=[db_field.remote_field.model]) | |
}, | |
) |
We probably need to do it in the ChooserViewSet
's form field override registration:
wagtail/wagtail/admin/viewsets/chooser.py
Lines 221 to 223 in c61d520
register_form_field_override( | |
ForeignKey, to=self.model, override={"widget": self.widget_class} | |
) |
Then, we'll be able to do something like
self.model_class.objects.get(**{self.to_field_name: value})
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @laymonage for the feedback, makes perfect sense, I used this approach for minimal changes only to avoid the crash.
I'll update this by keeping in mind your suggestions.
wagtail/admin/tests/test_widgets.py
Outdated
def test_get_instance_with_to_field_value(self): | ||
"""Test get_instance works with to_field values.""" | ||
widget = widgets.BaseChooser() | ||
widget.model_class = BlogCategory | ||
|
||
instance = widget.get_instance("recipes") | ||
self.assertEqual(instance, self.category) | ||
|
||
instance = widget.get_instance("news") | ||
self.assertEqual(instance, self.category2) | ||
|
||
instance = widget.get_instance("nonexistent") | ||
self.assertIsNone(instance) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test only works because we are brute forcing the problem, but we're not actually testing the integration with ForeignKey's to_field
mechanism.
wagtail/admin/widgets/chooser.py
Outdated
|
||
# Store to_field_name for ForeignKey fields with to_field parameter | ||
self.to_field_name = kwargs.pop("to_field_name", None) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it work if we just add this to the above list of variable names?
wagtail/admin/viewsets/chooser.py
Outdated
def _create_widget_with_to_field(db_field): | ||
to_field_name = getattr(db_field.remote_field, "field_name", None) | ||
widget_cls = self.widget_class | ||
if isinstance(widget_cls, type): | ||
if ( | ||
hasattr(widget_cls, "__init__") | ||
and "model" in widget_cls.__init__.__code__.co_varnames | ||
): | ||
return widget_cls(model=self.model, to_field_name=to_field_name) | ||
else: | ||
return widget_cls(to_field_name=to_field_name) | ||
else: | ||
# already an instance | ||
widget_cls_type = widget_cls.__class__ | ||
if ( | ||
hasattr(widget_cls_type, "__init__") | ||
and "model" in widget_cls_type.__init__.__code__.co_varnames | ||
): | ||
widget_instance = widget_cls_type( | ||
model=self.model, to_field_name=to_field_name | ||
) | ||
else: | ||
widget_instance = widget_cls_type(to_field_name=to_field_name) | ||
|
||
# Copy attributes from the original instance | ||
for attr in [ | ||
"choose_one_text", | ||
"choose_another_text", | ||
"link_to_chosen_text", | ||
"chooser_modal_url_name", | ||
"icon", | ||
]: | ||
if hasattr(widget_cls, attr): | ||
setattr(widget_instance, attr, getattr(widget_cls, attr)) | ||
return widget_instance |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you explain why this is necessary? Inspecting the code object (__code__
) is something we've never done in Wagtail and likely never will without a good reason, and having to copy over the chooser attributes in here seems like a sign that we should go for a different approach.
If the problem is because SnippetChooserViewSet.widget_class
actually returns an instance instead of a class:
wagtail/wagtail/snippets/views/chooser.py
Lines 77 to 79 in f2b6eea
@cached_property | |
def widget_class(self): | |
return AdminSnippetChooser(model=self.model, icon=self.icon) |
that means we probably should refactor that to return a class.
The base ChooserViewSet.widget_class
already creates the class dynamically with type()
so it shouldn't be a stretch to do the same for SnippetViewSet
:
wagtail/wagtail/admin/viewsets/chooser.py
Lines 155 to 180 in f2b6eea
@cached_property | |
def widget_class(self): | |
""" | |
Returns the form widget class for this chooser. | |
""" | |
if self.model is None: | |
widget_class_name = "ChooserWidget" | |
else: | |
if isinstance(self.model, str): | |
model_name = self.model.split(".")[-1] | |
else: | |
model_name = self.model.__name__ | |
widget_class_name = "%sChooserWidget" % model_name | |
return type( | |
widget_class_name, | |
(self.base_widget_class,), | |
{ | |
"model": self.model, | |
"choose_one_text": self.choose_one_text, | |
"choose_another_text": self.choose_another_text, | |
"link_to_chosen_text": self.edit_item_text, | |
"chooser_modal_url_name": self.get_url_name("choose"), | |
"icon": self.icon, | |
}, | |
) |
e.g.
base_widget_class = AdminSnippetChooser
@cached_property
def widget_class(self):
return type(
f"{self.model.__class__.__name__}Chooser",
(self.base_widget_class,),
{"model": self.model, "icon": self.icon},
)
I was expecting the form field override to then be:
register_form_field_override(
ForeignKey,
to=self.model,
override=lambda db_field: {
"widget": self.widget_class(to_field_name=db_field.remote_field.field_name)
},
)
Issue #13116
Fix BaseChooser widget handling of ForeignKey to_field parameter
Problem
The BaseChooser widget's get_instance() method only attempted to look up
model instances by primary key. This caused a ValueError when used with
ForeignKey fields that specify a to_field parameter pointing to a non-primary
key field (such as a slug field).
Solution
to_field_name
parameter in the__init__
method ofBaseChooser
to store the target field name for lookups.get_instance()
method to use theto_field_name
for direct field lookup when provided, falling back to primary key lookup only whento_field_name
is None.on_register()
method to extract theto_field
information from the database field'sremote_field.field_name
and pass it to widget instance.This approach maintains backward compatibility while supporting the to_field use case.
Testing
Added test cases covering:
All existing widget tests continue to pass.