Opened 8 years ago
Last modified 4 days ago
#29910 assigned Bug
Allow ManyToManyFields that target proxy models with a through table.
| Reported by: | Collin Anderson | Owned by: | David Smith |
|---|---|---|---|
| Component: | Database layer (models, ORM) | Version: | 2.1 |
| Severity: | Normal | Keywords: | |
| Cc: | cmawebsite@… | Triage Stage: | Accepted |
| Has patch: | yes | Needs documentation: | no |
| Needs tests: | no | Patch needs improvement: | no |
| Easy pickings: | no | UI/UX: | no |
Description (last modified by )
I'm running into an issue with using ManyToManyFields and Proxy models when the through models reference Proxy classes of the original model.
Change History (11)
comment:1 by , 8 years ago
| Patch needs improvement: | set |
|---|---|
| Summary: | Fix ManyToManyFields with Proxy models → ManyToManyFields that target proxy models don't return the correct model |
| Triage Stage: | Unreviewed → Accepted |
comment:2 by , 8 years ago
| Patch needs improvement: | unset |
|---|
comment:3 by , 8 years ago
| Patch needs improvement: | set |
|---|
comment:5 by , 8 years ago
| Description: | modified (diff) |
|---|---|
| Summary: | ManyToManyFields that target proxy models don't return the correct model → Allow ManyToManyFields that target proxy models with a through table. |
Clarify title/description a little.
comment:6 by , 7 years ago
| Patch needs improvement: | set |
|---|
comment:7 by , 2 years ago
| Owner: | changed from to |
|---|---|
| Status: | new → assigned |
comment:8 by , 2 years ago
| Owner: | removed |
|---|---|
| Status: | assigned → new |
comment:9 by , 20 months ago
| Owner: | set to |
|---|---|
| Status: | new → assigned |
comment:10 by , 7 months ago
| Owner: | changed from to |
|---|---|
| Patch needs improvement: | unset |
comment:11 by , 4 days ago
| Owner: | changed from to |
|---|
My GitHub account is currently suspended, so PR #20554 is gone. I also used GitHub to log in to Trac, so I can't access that account either.
I'm posting this from a new Trac account, and I can't attach files here, so I'm just pasting the diff below. This is the patch from #20554 rebased onto the current main (fb1137658ff).
There are still two review comments from the original PR that I haven't addressed in this diff:
- ontowhee asked whether
_get_m2m_attr()should use_get_concrete_model()on both sides for consistency, and the same for_get_m2m_reverse_attr(). - raffaellasuardini pointed out that the tests cover
remove(), but notclear().
David kindly offered to open a PR with this on GitHub, so feel free to take it from here and address the two review comments above as well. Thanks!
Here's the diff:
-
django/db/models/fields/related.py
diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 332a168a784..89de6e0df8c 100644
a b class ManyToManyField(RelatedField): 1645 1645 ) 1646 1646 relationship_model_name = self.remote_field.through._meta.object_name 1647 1647 self_referential = from_model == to_model 1648 1649 def _get_concrete_model(model): 1650 if model is None or isinstance(model, str): 1651 return model 1652 return model._meta.concrete_model 1653 1648 1654 # Count foreign keys in intermediate model 1649 1655 if self_referential: 1650 1656 seen_self = sum( 1651 from_model == getattr(field.remote_field, "model", None) 1657 _get_concrete_model(from_model) 1658 == _get_concrete_model(getattr(field.remote_field, "model", None)) 1652 1659 for field in self.remote_field.through._meta.fields 1653 1660 ) 1654 1661 … … class ManyToManyField(RelatedField): 1671 1678 ) 1672 1679 1673 1680 else: 1674 # Count foreign keys in relationship model1675 1681 seen_from = sum( 1676 from_model == getattr(field.remote_field, "model", None) 1682 _get_concrete_model(from_model) 1683 == _get_concrete_model(getattr(field.remote_field, "model", None)) 1677 1684 for field in self.remote_field.through._meta.fields 1678 1685 ) 1679 1686 seen_to = sum( 1680 to_model == getattr(field.remote_field, "model", None) 1687 _get_concrete_model(to_model) 1688 == _get_concrete_model(getattr(field.remote_field, "model", None)) 1681 1689 for field in self.remote_field.through._meta.fields 1682 1690 ) 1683 1691 … … class ManyToManyField(RelatedField): 1785 1793 ): 1786 1794 possible_field_names = [] 1787 1795 for f in through._meta.fields: 1788 if ( 1789 hasattr(f, "remote_field") 1790 and getattr(f.remote_field, "model", None) == related_model 1791 ): 1796 if hasattr(f, "remote_field") and _get_concrete_model( 1797 getattr(f.remote_field, "model", None) 1798 ) == _get_concrete_model(related_model): 1792 1799 possible_field_names.append(f.name) 1800 related_model_name = ( 1801 related_model 1802 if isinstance(related_model, str) 1803 else related_model._meta.object_name 1804 ) 1793 1805 if possible_field_names: 1794 1806 hint = ( 1795 1807 "Did you mean one of the following foreign keys to '%s': " 1796 1808 "%s?" 1797 % ( 1798 related_model._meta.object_name, 1799 ", ".join(possible_field_names), 1800 ) 1809 % (related_model_name, ", ".join(possible_field_names)) 1801 1810 ) 1802 1811 else: 1803 1812 hint = None … … class ManyToManyField(RelatedField): 1817 1826 else: 1818 1827 if not ( 1819 1828 hasattr(field, "remote_field") 1820 and getattr(field.remote_field, "model", None) 1821 == related_model 1822 ): 1823 related_object_name = ( 1824 related_model 1825 if isinstance(related_model, str) 1826 else related_model._meta.object_name 1829 and _get_concrete_model( 1830 getattr(field.remote_field, "model", None) 1827 1831 ) 1832 == _get_concrete_model(related_model) 1833 ): 1828 1834 errors.append( 1829 1835 checks.Error( 1830 1836 "'%s.%s' is not a foreign key to '%s'." 1831 1837 % ( 1832 1838 through._meta.object_name, 1833 1839 field_name, 1834 related_ object_name,1840 related_model_name, 1835 1841 ), 1836 1842 hint=hint, 1837 1843 obj=self, … … class ManyToManyField(RelatedField): 2012 2018 for f in self.remote_field.through._meta.fields: 2013 2019 if ( 2014 2020 f.is_relation 2015 and f.remote_field.model == related.related_model 2021 and f.remote_field.model._meta.concrete_model 2022 == related.related_model._meta.concrete_model 2016 2023 and (link_field_name is None or link_field_name == f.name) 2017 2024 ): 2018 2025 setattr(self, cache_attr, getattr(f, attr)) … … class ManyToManyField(RelatedField): 2032 2039 else: 2033 2040 link_field_name = None 2034 2041 for f in self.remote_field.through._meta.fields: 2035 if f.is_relation and f.remote_field.model == related.model: 2042 if ( 2043 f.is_relation 2044 and f.remote_field.model._meta.concrete_model 2045 == related.model._meta.concrete_model 2046 ): 2036 2047 if link_field_name is None and related.related_model == related.model: 2037 2048 # If this is an m2m-intermediate to self, 2038 2049 # the first foreign key you find will be -
tests/m2m_through/models.py
diff --git a/tests/m2m_through/models.py b/tests/m2m_through/models.py index 47a0b75f4bb..b6cd33477c1 100644
a b class Group(models.Model): 26 26 through="TestNoDefaultsOrNulls", 27 27 related_name="testnodefaultsnonulls", 28 28 ) 29 proxy_members = models.ManyToManyField( 30 Person, 31 through="ProxyMembership", 32 through_fields=("group", "proxy_person"), 33 related_name="proxy_groups", 34 ) 35 double_proxy_members = models.ManyToManyField( 36 "ProxyPerson", 37 through="DoubleProxyMembership", 38 through_fields=("proxy_group", "proxy_person"), 39 related_name="double_proxy_groups", 40 ) 29 41 30 42 class Meta: 31 43 ordering = ("name",) … … class Recipe(models.Model): 155 167 class RecipeIngredient(models.Model): 156 168 ingredient = models.ForeignKey(Ingredient, models.CASCADE, to_field="iname") 157 169 recipe = models.ForeignKey(Recipe, models.CASCADE, to_field="rname") 170 171 172 class ProxyPerson(Person): 173 class Meta: 174 proxy = True 175 176 177 class ProxyGroup(Group): 178 class Meta: 179 proxy = True 180 181 182 class ProxyMembership(models.Model): 183 proxy_person = models.ForeignKey(ProxyPerson, models.CASCADE) 184 group = models.ForeignKey(Group, models.CASCADE) 185 joined = models.DateTimeField(default=datetime.now) 186 187 188 class DoubleProxyMembership(models.Model): 189 proxy_person = models.ForeignKey(ProxyPerson, models.CASCADE) 190 proxy_group = models.ForeignKey(ProxyGroup, models.CASCADE) -
tests/m2m_through/tests.py
diff --git a/tests/m2m_through/tests.py b/tests/m2m_through/tests.py index d89d89ed8fd..b49c5b3507a 100644
a b from datetime import date, datetime, timedelta 2 2 from operator import attrgetter 3 3 4 4 from django.db import IntegrityError 5 from django.forms.models import inlineformset_factory 5 6 from django.test import TestCase 6 7 7 8 from .models import ( 8 9 CustomMembership, 10 DoubleProxyMembership, 9 11 Employee, 10 12 Event, 11 13 Friendship, … … from .models import ( 16 18 Person, 17 19 PersonChild, 18 20 PersonSelfRefM2M, 21 ProxyGroup, 22 ProxyMembership, 23 ProxyPerson, 19 24 Recipe, 20 25 RecipeIngredient, 21 26 Relationship, … … class M2mThroughToFieldsTests(TestCase): 542 547 def test_exists(self): 543 548 self.assertTrue(self.curry.ingredients.exists()) 544 549 self.assertTrue(self.tomato.recipes.exists()) 550 551 552 class M2mThroughProxyTests(TestCase): 553 @classmethod 554 def setUpTestData(cls): 555 cls.person = Person.objects.create(name="Alice") 556 cls.proxy_person = ProxyPerson.objects.create(name="Bob") 557 cls.group = Group.objects.create(name="Team") 558 cls.proxy_group = ProxyGroup.objects.create(name="Proxy Team") 559 560 def test_forward(self): 561 test_cases = [ 562 ( 563 ProxyMembership, 564 {"proxy_person": self.person, "group": self.group}, 565 self.group, 566 "proxy_members", 567 [self.person], 568 ), 569 ( 570 DoubleProxyMembership, 571 {"proxy_person": self.proxy_person, "proxy_group": self.proxy_group}, 572 self.proxy_group, 573 "double_proxy_members", 574 [self.proxy_person], 575 ), 576 ] 577 for model, create_kwargs, parent, attr, expected in test_cases: 578 with self.subTest(model=model.__name__): 579 model.objects.create(**create_kwargs) 580 self.assertSequenceEqual(list(getattr(parent, attr).all()), expected) 581 582 def test_reverse(self): 583 test_cases = [ 584 ( 585 ProxyMembership, 586 {"proxy_person": self.person, "group": self.group}, 587 self.person, 588 "proxy_groups", 589 [self.group], 590 ), 591 ( 592 DoubleProxyMembership, 593 {"proxy_person": self.proxy_person, "proxy_group": self.proxy_group}, 594 self.proxy_person, 595 "double_proxy_groups", 596 [self.proxy_group], 597 ), 598 ] 599 for model, create_kwargs, related, attr, expected in test_cases: 600 with self.subTest(model=model.__name__): 601 model.objects.create(**create_kwargs) 602 self.assertSequenceEqual(list(getattr(related, attr).all()), expected) 603 604 def test_add(self): 605 test_cases = [ 606 ( 607 self.group, 608 "proxy_members", 609 self.person, 610 ProxyMembership, 611 {"proxy_person": self.person, "group": self.group}, 612 ), 613 ( 614 self.proxy_group, 615 "double_proxy_members", 616 self.proxy_person, 617 DoubleProxyMembership, 618 {"proxy_person": self.proxy_person, "proxy_group": self.proxy_group}, 619 ), 620 ] 621 for parent, attr, member, model, filter_kwargs in test_cases: 622 with self.subTest(model=model.__name__): 623 getattr(parent, attr).add(member) 624 self.assertTrue(model.objects.filter(**filter_kwargs).exists()) 625 626 def test_remove(self): 627 test_cases = [ 628 ( 629 ProxyMembership, 630 {"proxy_person": self.person, "group": self.group}, 631 self.group, 632 "proxy_members", 633 self.person, 634 ), 635 ( 636 DoubleProxyMembership, 637 {"proxy_person": self.proxy_person, "proxy_group": self.proxy_group}, 638 self.proxy_group, 639 "double_proxy_members", 640 self.proxy_person, 641 ), 642 ] 643 for model, create_kwargs, parent, attr, member in test_cases: 644 with self.subTest(model=model.__name__): 645 model.objects.create(**create_kwargs) 646 getattr(parent, attr).remove(member) 647 self.assertFalse(model.objects.filter(**create_kwargs).exists()) 648 649 def test_inlineformset(self): 650 test_cases = [ 651 (Group, ProxyMembership, self.group), 652 (ProxyGroup, DoubleProxyMembership, self.proxy_group), 653 ] 654 for parent_model, through_model, instance in test_cases: 655 with self.subTest(through_model=through_model.__name__): 656 FormSet = inlineformset_factory( 657 parent_model, through_model, fields=["proxy_person"] 658 ) 659 formset = FormSet(instance=instance) 660 self.assertEqual(formset.model, through_model)
My comment requesting more carefully targeted tests isn't addressed.