diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..358f3c4 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings") + + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/second_testapp/__init__.py b/second_testapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/second_testapp/migrations/0001_initial.py b/second_testapp/migrations/0001_initial.py new file mode 100644 index 0000000..394f82f --- /dev/null +++ b/second_testapp/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.2 on 2023-06-20 07:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('testapp', '0003_product_order'), + ] + + operations = [ + migrations.CreateModel( + name='BaseReview', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('second_testapp.orderreview', 'order review')], db_index=True, max_length=255)), + ('rating', models.IntegerField()), + ('order', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order_review', to='testapp.order')), + ('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='testapp.product')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OrderReview', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('second_testapp.basereview',), + ), + ] diff --git a/second_testapp/migrations/__init__.py b/second_testapp/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/second_testapp/models.py b/second_testapp/models.py new file mode 100644 index 0000000..3857687 --- /dev/null +++ b/second_testapp/models.py @@ -0,0 +1,12 @@ +from django.db import models + +from typedmodels.models import TypedModel + + +class BaseReview(TypedModel): + rating = models.IntegerField() + + +class OrderReview(BaseReview): + product = models.ForeignKey("testapp.Product", on_delete=models.CASCADE, related_name="reviews", null=True) + order = models.OneToOneField("testapp.Order", on_delete=models.CASCADE, related_name="order_review", null=True) diff --git a/test_settings.py b/test_settings.py index fe98d20..6da3d13 100644 --- a/test_settings.py +++ b/test_settings.py @@ -1,6 +1,7 @@ INSTALLED_APPS = ( "typedmodels", "django.contrib.contenttypes", + "second_testapp", # purposefully before testapp to test related models with lazy loading "testapp", ) MIDDLEWARE_CLASSES = () diff --git a/testapp/migrations/0003_product_order.py b/testapp/migrations/0003_product_order.py new file mode 100644 index 0000000..4b814d5 --- /dev/null +++ b/testapp/migrations/0003_product_order.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.2 on 2023-06-20 07:53 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('testapp', '0002_developer_employee_manager'), + ] + + operations = [ + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='Product name', max_length=255)), + ], + ), + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_id', models.CharField(default='123', max_length=255)), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='testapp.product')), + ], + ), + ] diff --git a/testapp/models.py b/testapp/models.py index ae85908..f0a36d4 100644 --- a/testapp/models.py +++ b/testapp/models.py @@ -134,3 +134,12 @@ class Developer(Employee): class Manager(Employee): # Adds the _exact_ same field as Developer. Shouldn't error. name = models.CharField(max_length=255, null=True) + + +class Product(models.Model): + name = models.CharField(max_length=255, default="Product name") + + +class Order(models.Model): + external_id = models.CharField(max_length=255, default="123") + product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="orders") diff --git a/typedmodels/models.py b/typedmodels/models.py index 2d0830f..2ada6f0 100644 --- a/typedmodels/models.py +++ b/typedmodels/models.py @@ -1,6 +1,4 @@ -import inspect from functools import partial -import types from django.core.exceptions import FieldDoesNotExist, FieldError from django.core.serializers.python import Serializer as _PythonSerializer @@ -8,6 +6,7 @@ from django.db import models from django.db.models.base import ModelBase, DEFERRED from django.db.models.fields import Field +from django.db.models.fields.related import RelatedField from django.db.models.options import make_immutable_fields_list from django.utils.encoding import smart_str @@ -95,21 +94,12 @@ class Meta: ) ) - if isinstance(field, models.fields.related.RelatedField): + if isinstance(field, RelatedField): # Monkey patching field instance to make do_related_class use created class instead of base_class. # Actually that class doesn't exist yet, so we just monkey patch base_class for a while, # changing _meta.model_name, so accessor names are generated properly. # We'll do more stuff when the class is created. - old_do_related_class = field.do_related_class - - def do_related_class(self, other, cls): - base_class_name = base_class.__name__ - cls._meta.model_name = classname.lower() - old_do_related_class(other, cls) - cls._meta.model_name = base_class_name.lower() - - field.do_related_class = types.MethodType(do_related_class, field) - if isinstance(field, models.fields.related.RelatedField): + field.do_related_class = _get_related_class_function(base_class, classname, field) remote_field = field.remote_field if ( isinstance(remote_field.model, TypedModel) @@ -453,6 +443,24 @@ def _get_unique_checks(self, exclude=None, **kwargs): return unique_checks, date_checks +def _get_related_class_function(base_class, classname, field): + """ + Returns a function that can be used to replace a RelatedField's + do_related_class method. This function is used to monkey patch + RelatedFields on subclasses of TypedModel to use the created class + instead of the base class. + """ + old_do_related_class = field.do_related_class + + def do_related_class(self, other, cls): + base_class_name = base_class.__name__ + cls._meta.model_name = classname.lower() + old_do_related_class(other, cls) + cls._meta.model_name = base_class_name.lower() + + return partial(do_related_class, field) + + # Monkey patching Python and XML serializers in Django to use model name from base class. # This should be preferably done by changing __unicode__ method for ._meta attribute in each model, # but it doesn’t work. diff --git a/typedmodels/tests.py b/typedmodels/tests.py index 98223e7..c9f4f5b 100644 --- a/typedmodels/tests.py +++ b/typedmodels/tests.py @@ -3,6 +3,8 @@ import pytest +from second_testapp.models import OrderReview + try: import yaml @@ -28,6 +30,8 @@ UniqueIdentifier, Child2, Employee, + Product, + Order, ) @@ -444,3 +448,24 @@ class Tester2(Employee): class Tester3(Employee): name = models.IntegerField(null=True) + + +def test_related_name_is_preserved_for_foreign_keys(db): + """Regression test for the following scenario: + A subclass of a typed model has foreign key to two models in a different app, while they are also related + to each other. + """ + product = Product.objects.create(name="test") + order = Order.objects.create(product=product) + + order_review = OrderReview.objects.create( + order=order, + product=product, + rating=5, + ) + assert order_review.order == order + assert order_review.product == product + + assert order.order_review == order_review + assert product.reviews.first() == order_review +