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 Collin Anderson)

I'm running into an issue with using ManyToManyFields and Proxy models when the through models reference Proxy classes of the original model.

https://github.com/django/django/pull/10591

Change History (11)

comment:1 by Tim Graham, 8 years ago

Patch needs improvement: set
Summary: Fix ManyToManyFields with Proxy modelsManyToManyFields that target proxy models don't return the correct model
Triage Stage: UnreviewedAccepted

comment:2 by Asif Saifuddin Auvi, 8 years ago

Patch needs improvement: unset

comment:3 by Tim Graham, 8 years ago

Patch needs improvement: set

My comment requesting more carefully targeted tests isn't addressed.

comment:4 by Collin Anderson, 8 years ago

Patch needs improvement: unset

Ok, I just added some tests.

comment:5 by Collin Anderson, 8 years ago

Description: modified (diff)
Summary: ManyToManyFields that target proxy models don't return the correct modelAllow ManyToManyFields that target proxy models with a through table.

Clarify title/description a little.

comment:6 by Tim Graham, 7 years ago

Patch needs improvement: set

comment:7 by wookkl, 2 years ago

Owner: changed from nobody to wookkl
Status: newassigned

comment:8 by wookkl, 2 years ago

Owner: wookkl removed
Status: assignednew

comment:9 by Anvansh Singh, 20 months ago

Owner: set to Anvansh Singh
Status: newassigned

comment:10 by JaeHyuckSa, 7 months ago

Owner: changed from Anvansh Singh to JaeHyuckSa
Patch needs improvement: unset

comment:11 by jaehyuck.sa.dev, 4 days ago

Owner: changed from JaeHyuckSa to David Smith

My GitHub account is currently suspended, so PR ​https://github.com/django/django/pull/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 https://github.com/django/django/pull/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 not clear().

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):  
    16451645                    )
    16461646            relationship_model_name = self.remote_field.through._meta.object_name
    16471647            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
    16481654            # Count foreign keys in intermediate model
    16491655            if self_referential:
    16501656                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))
    16521659                    for field in self.remote_field.through._meta.fields
    16531660                )
    16541661
    class ManyToManyField(RelatedField):  
    16711678                    )
    16721679
    16731680            else:
    1674                 # Count foreign keys in relationship model
    16751681                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))
    16771684                    for field in self.remote_field.through._meta.fields
    16781685                )
    16791686                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))
    16811689                    for field in self.remote_field.through._meta.fields
    16821690                )
    16831691
    class ManyToManyField(RelatedField):  
    17851793                ):
    17861794                    possible_field_names = []
    17871795                    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):
    17921799                            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                    )
    17931805                    if possible_field_names:
    17941806                        hint = (
    17951807                            "Did you mean one of the following foreign keys to '%s': "
    17961808                            "%s?"
    1797                             % (
    1798                                 related_model._meta.object_name,
    1799                                 ", ".join(possible_field_names),
    1800                             )
     1809                            % (related_model_name, ", ".join(possible_field_names))
    18011810                        )
    18021811                    else:
    18031812                        hint = None
    class ManyToManyField(RelatedField):  
    18171826                    else:
    18181827                        if not (
    18191828                            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)
    18271831                            )
     1832                            == _get_concrete_model(related_model)
     1833                        ):
    18281834                            errors.append(
    18291835                                checks.Error(
    18301836                                    "'%s.%s' is not a foreign key to '%s'."
    18311837                                    % (
    18321838                                        through._meta.object_name,
    18331839                                        field_name,
    1834                                         related_object_name,
     1840                                        related_model_name,
    18351841                                    ),
    18361842                                    hint=hint,
    18371843                                    obj=self,
    class ManyToManyField(RelatedField):  
    20122018        for f in self.remote_field.through._meta.fields:
    20132019            if (
    20142020                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
    20162023                and (link_field_name is None or link_field_name == f.name)
    20172024            ):
    20182025                setattr(self, cache_attr, getattr(f, attr))
    class ManyToManyField(RelatedField):  
    20322039        else:
    20332040            link_field_name = None
    20342041        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            ):
    20362047                if link_field_name is None and related.related_model == related.model:
    20372048                    # If this is an m2m-intermediate to self,
    20382049                    # 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):  
    2626        through="TestNoDefaultsOrNulls",
    2727        related_name="testnodefaultsnonulls",
    2828    )
     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    )
    2941
    3042    class Meta:
    3143        ordering = ("name",)
    class Recipe(models.Model):  
    155167class RecipeIngredient(models.Model):
    156168    ingredient = models.ForeignKey(Ingredient, models.CASCADE, to_field="iname")
    157169    recipe = models.ForeignKey(Recipe, models.CASCADE, to_field="rname")
     170
     171
     172class ProxyPerson(Person):
     173    class Meta:
     174        proxy = True
     175
     176
     177class ProxyGroup(Group):
     178    class Meta:
     179        proxy = True
     180
     181
     182class 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
     188class 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  
    22from operator import attrgetter
    33
    44from django.db import IntegrityError
     5from django.forms.models import inlineformset_factory
    56from django.test import TestCase
    67
    78from .models import (
    89    CustomMembership,
     10    DoubleProxyMembership,
    911    Employee,
    1012    Event,
    1113    Friendship,
    from .models import (  
    1618    Person,
    1719    PersonChild,
    1820    PersonSelfRefM2M,
     21    ProxyGroup,
     22    ProxyMembership,
     23    ProxyPerson,
    1924    Recipe,
    2025    RecipeIngredient,
    2126    Relationship,
    class M2mThroughToFieldsTests(TestCase):  
    542547    def test_exists(self):
    543548        self.assertTrue(self.curry.ingredients.exists())
    544549        self.assertTrue(self.tomato.recipes.exists())
     550
     551
     552class 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)
Last edited 4 days ago by jaehyuck.sa.dev (previous) (diff)
Note: See TracTickets for help on using tickets.
Back to Top