Skip to content

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

Talha-Rizwan
Copy link
Contributor

@Talha-Rizwan Talha-Rizwan commented Jun 24, 2025

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

  1. Added support for a to_field_name parameter in the __init__ method of BaseChooser to store the target field name for lookups.
  2. Modified the get_instance() method to use the to_field_name for direct field lookup when provided, falling back to primary key lookup only when to_field_name is None.
  3. Updated the on_register() method to extract the to_field information from the database field's remote_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:

  • Primary key lookup (existing functionality)
  • to_field_name parameter lookup (new functionality)
  • Model instance passthrough
  • None value handling

All existing widget tests continue to pass.

Copy link
Member

@laymonage laymonage left a 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.

Comment on lines 141 to 146
# 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
Copy link
Member

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:

# 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:

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})

Copy link
Contributor Author

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.

Comment on lines 744 to 756
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)
Copy link
Member

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.

@Talha-Rizwan Talha-Rizwan requested a review from laymonage June 27, 2025 07:10
Comment on lines 57 to 59

# Store to_field_name for ForeignKey fields with to_field parameter
self.to_field_name = kwargs.pop("to_field_name", None)
Copy link
Member

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?

Comment on lines 222 to 256
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
Copy link
Member

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:

@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:

@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)
                },
            )

@Talha-Rizwan Talha-Rizwan requested a review from laymonage July 3, 2025 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
component:Choosers Modal choosers for Page, Snippet and other models component:Snippets status:Needs Work
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants