Opened 20 months ago
Closed 20 months ago
#35301 closed Bug (fixed)
Overriding a @property of an abstract model with a GenericRelation causes a models.E025 error.
| Reported by: | Sage Abdullah | Owned by: | Adam Johnson |
|---|---|---|---|
| Component: | Database layer (models, ORM) | Version: | dev |
| Severity: | Release blocker | Keywords: | |
| Cc: | Adam Johnson | Triage Stage: | Accepted |
| Has patch: | yes | Needs documentation: | no |
| Needs tests: | no | Patch needs improvement: | no |
| Easy pickings: | no | UI/UX: | no |
Description
As of faeb92ea13f0c1b2cc83f45b512f2c41cfb4f02d, Django traverses the model's MRO to find the names of properties, to later be used in different places, such as the check for models.E025. However, this does not take into account any properties that may have been overridden by the final concrete model into something else (that's not a @property).
The previous logic only checks for attributes in dir(self.model), so if the @property has been overridden, it will not trigger the error.
As an example use case, in Wagtail we have a Revision model that uses a GenericForeignKey to allow saving revisions of any model. For its companion, we have an abstract model called RevisionMixin that gives you methods like save_revision, as well as a revisions property to query the revisions. The default revisions property is implemented as a @property instead of a proper GenericRelation, because we need to handle the case where the model may use multi-table inheritance (#31269).
In Wagtail, we handle it by having two content types in the Revision model: the base_content_type (the first non-abstract model) and the content_type (of the most specific model). In the base RevisionMixin abstract class, we define a revisions @property that queries the Revision model directly using the most basic content type, e.g. Revision.objects.filter(base_content_type=self.get_base_content_type(), object_id=str(self.pk)). This ensures that the revisions property always returns the correct items, regardless which model (parent vs. child) is used for querying.
For models that don't use multi-table inheritance, we've been suggesting developers to override the revisions @property with a GenericRelation directly (e.g. revisions = GenericRelation(...)). This allows them to define the related_query_name, without having to use a different name for the GenericRelation itself (e.g. _revisions) and without having to override the revisions @property to return that GenericRelation.
Now that I'm aware of the system check, I'm also not sure if it's safe to override a @property with a GenericRelation in a subclass. There might be quirks of @property that would interfere with how GenericRelation works, that I didn't know of. But if it's safe, then the error shouldn't have been raised.
It looks like the new Django behaviour might not be intended, as the PR and ticket for that commit seem to suggest it was only meant as an optimisation.
If Django would like to keep its new behaviour, I could see a few options for us to proceed:
a) Use cached_property instead to bypass the system check (not sure if this is a good idea)
b) Communicate to developers that they should not override the @property directly with a GenericRelation, and should instead define the GenericRelation with a different name e.g. _revisions and override the default @property to return that GenericRelation.
I have created a simpler reproduction here: https://github.com/laymonage/django-e025-repro, with models that use tags instead of revisions. It also simulates how we worked around #31269.
Thanks!
Change History (6)
comment:1 by , 20 months ago
| Summary: | Overriding a @property of an abstract class with a GenericRelation causes a models.E025 error. → Overriding a @property of an abstract model with a GenericRelation causes a models.E025 error. |
|---|
comment:2 by , 20 months ago
| Cc: | added |
|---|---|
| Severity: | Normal → Release blocker |
| Triage Stage: | Unreviewed → Accepted |
comment:3 by , 20 months ago
Yeah thanks. I have an idea to fix this by keeping track of seen names whilst iterating. Will give it a try tonight.
comment:4 by , 20 months ago
Thanks both! Here's a much more minimal reproduction that still somewhat makes sense.
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.db import models
class Generic(models.Model):
content_type = models.ForeignKey(
ContentType,
related_name="+",
on_delete=models.CASCADE,
)
object_id = models.CharField(max_length=255)
content_object = GenericForeignKey()
class Abstract(models.Model):
@property
def generics(self):
return Generic.objects.filter(
content_type=ContentType.objects.get_for_model(self),
object_id=str(self.pk),
)
class Meta:
abstract = True
class Concrete(Abstract):
generics = GenericRelation(Generic, related_query_name="concrete")
comment:5 by , 20 months ago
| Has patch: | set |
|---|---|
| Owner: | changed from to |
| Status: | new → assigned |
Thanks for the detailed report and early testing!