diff --git a/AUTHORS.rst b/AUTHORS.rst index 6cc53931..47dbf348 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -42,6 +42,7 @@ Contributors * Jacob Rief * James Murty * Jedediah Smith (proxy models support) +* Jesús Leganés-Combarro (Auto-discover child models and inlines, #582) * John Furr * Jonas Haag * Jonas Obrist diff --git a/polymorphic/admin/helpers.py b/polymorphic/admin/helpers.py index 6ed6a109..076e1818 100644 --- a/polymorphic/admin/helpers.py +++ b/polymorphic/admin/helpers.py @@ -63,6 +63,7 @@ def __iter__(self): for form in self.formset.extra_forms + self.formset.empty_forms: model = form._meta.model child_inline = self.opts.get_child_inline_instance(model) + yield PolymorphicInlineAdminForm( formset=self.formset, form=form, @@ -141,3 +142,26 @@ def get_inline_formsets(self, request, formsets, inline_instances, obj=None, *ar admin_formset.request = request admin_formset.obj = obj return inline_admin_formsets + + +def get_leaf_subclasses(cls, exclude=None): + "Get leaf subclasses of `cls` class" + + if exclude is None: + exclude = () + + elif not isinstance(exclude, (list, tuple)): + # Accept single instance in `exclude` + exclude = (exclude,) + + result = [] + + subclasses = cls.__subclasses__() + + if subclasses: + for subclass in subclasses: + result.extend(get_leaf_subclasses(subclass, exclude)) + elif not (cls in exclude or (hasattr(cls, "_meta") and cls._meta.abstract)): + result.append(cls) + + return result diff --git a/polymorphic/admin/inlines.py b/polymorphic/admin/inlines.py index 00fd29d2..42d6bc91 100644 --- a/polymorphic/admin/inlines.py +++ b/polymorphic/admin/inlines.py @@ -20,7 +20,7 @@ ) from polymorphic.formsets.utils import add_media -from .helpers import PolymorphicInlineSupportMixin +from .helpers import PolymorphicInlineSupportMixin, get_leaf_subclasses class PolymorphicInlineModelAdmin(InlineModelAdmin): @@ -53,7 +53,14 @@ class PolymorphicInlineModelAdmin(InlineModelAdmin): #: Inlines for all model sub types that can be displayed in this inline. #: Each row is a :class:`PolymorphicInlineModelAdmin.Child` - child_inlines = () + child_inlines = None + + #: The models that should be excluded from the auto-discovered leaf + #: model sub types that can be displayed in this inline. This can be + #: a list of models or a single model. It's useful to exclude + #: non-abstract base models (abstract models are always excluded) + #: when they don't have defined any child models. + exclude_children = None def __init__(self, parent_model, admin_site): super().__init__(parent_model, admin_site) @@ -77,12 +84,44 @@ def __init__(self, parent_model, admin_site): for child_inline in self.child_inline_instances: self._child_inlines_lookup[child_inline.model] = child_inline + def get_child_inlines(self): + """ + Return the derived inline classes which this admin should handle + + This should return a list of tuples, exactly like + :attr:`child_inlines` is. + + The inline classes can be retrieved as + ``base_inline.__subclasses__()``, a setting in a config file, or + a query of a plugin registration system at your option + """ + if self.child_inlines is not None: + return self.child_inlines + + child_inlines = get_leaf_subclasses( + PolymorphicInlineModelAdmin.Child, self.exclude_children + ) + child_inlines = tuple( + inline + for inline in child_inlines + if (inline.model is not None and issubclass(inline.model, self.model)) + ) + + if child_inlines: + return child_inlines + + raise ImproperlyConfigured( + f"No child inlines found for '{self.model.__name__}', please " + "define the 'child_inlines' attribute or overwrite the " + "'get_child_inlines()' method." + ) + def get_child_inline_instances(self): """ :rtype List[PolymorphicInlineModelAdmin.Child] """ instances = [] - for ChildInlineType in self.child_inlines: + for ChildInlineType in self.get_child_inlines(): instances.append(ChildInlineType(parent_inline=self)) return instances diff --git a/polymorphic/admin/parentadmin.py b/polymorphic/admin/parentadmin.py index 98ec5093..45238651 100644 --- a/polymorphic/admin/parentadmin.py +++ b/polymorphic/admin/parentadmin.py @@ -19,6 +19,7 @@ from polymorphic.utils import get_base_polymorphic_model from .forms import PolymorphicModelChoiceForm +from .helpers import get_leaf_subclasses class RegistrationClosed(RuntimeError): @@ -51,6 +52,13 @@ class PolymorphicParentModelAdmin(admin.ModelAdmin): #: The child models that should be displayed child_models = None + #: The models that should be excluded from the auto-discovered child + #: leaf models that should be displayed. This can be a list of + #: models or a single model. It's useful to exclude non-abstract + #: base models (abstract models are always excluded) when they don't + #: have defined any child models. + exclude_children = None + #: Whether the list should be polymorphic too, leave to ``False`` to optimize polymorphic_list = False @@ -109,24 +117,41 @@ def register_child(self, model, model_admin): def get_child_models(self): """ Return the derived model classes which this admin should handle. - This should return a list of tuples, exactly like :attr:`child_models` is. - The model classes can be retrieved as ``base_model.__subclasses__()``, - a setting in a config file, or a query of a plugin registration system at your option + This should return a list of tuples, exactly like + :attr:`child_models` is. + + The model classes can be retrieved as + ``base_model.__subclasses__()``, a setting in a config file, or + a query of a plugin registration system at your option """ - if self.child_models is None: - raise NotImplementedError("Implement get_child_models() or child_models") + if self.child_models is not None: + return self.child_models - return self.child_models + child_models = get_leaf_subclasses(self.base_model, self.exclude_children) + + if child_models: + return child_models + + raise ImproperlyConfigured( + f"No child models found for '{self.base_model.__name__}', please " + "define the 'child_models' attribute or overwrite the " + "'get_child_models' method." + ) def get_child_type_choices(self, request, action): """ Return a list of polymorphic types for which the user has the permission to perform the given action. """ self._lazy_setup() + + child_models = self._child_models + if not child_models: + raise ImproperlyConfigured("No child models are available.") + choices = [] content_types = ContentType.objects.get_for_models( - *self.get_child_models(), for_concrete_models=False + *child_models, for_concrete_models=False ) for model, ct in content_types.items(): diff --git a/pyproject.toml b/pyproject.toml index 4706713b..a28a1ee5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ requires = [ [tool.ruff] line-length = 99 +[tool.ruff.lint] extend-ignore = [ "E501", ] @@ -18,7 +19,7 @@ select = [ "W", ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "example/**" = [ "F401", "F403",