Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Natural Key support to ForeignKeyWidget #1371

Merged
merged 21 commits into from
Apr 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ dist/
.coverage
*.sw[po]

# IDE support
.vscode

tests/database.db
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ The following is a list of much appreciated contributors:
* striveforbest (Alex Zagoro)
* josx (José Luis Di Biase)
* Jan Rydzewski
* rpsands (Ryan P. Sands)
* 2ykwang (Yeongkwang Yang)
* KamilRizatdinov (Kamil Rizatdinov)
* Mark Walker
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Enhancements

- Default format selections set correctly for export action (#1389)
- Added option to store raw row values in each row's `RowResult` (#1393)
- Add natural key support to ForeignKeyWidget (#1371)

Development
###########
Expand Down
52 changes: 52 additions & 0 deletions docs/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,58 @@ and exporting resource.
:doc:`/api_widgets`
available widget types and options.

Django Natural Keys
===================

The ForeignKeyWidget also supports using Django's natural key functions. A
manager class with the get_by_natural_key function is require for importing
foreign key relationships by the field model's natural key, and the model must
have a natural_key function that can be serialized as a JSON list in order to
export data.

The primary utility for natural key functionality is to enable exporting data
that can be imported into other Django environments with different numerical
primary key sequences. The natural key functionality enables handling more
complex data than specifying either a single field or the PK.

The example below illustrates how to create a field on the BookResource that
imports and exports its author relationships using the natural key functions
on the Author model and modelmanager.

::

from import_export.fields import Field
from import_export.widgets import ForeignKeyWidget

class AuthorManager(models.Manager):

def get_by_natural_key(self, name):
return self.get(name=name)

class Author(models.Model):

objects = AuthorManager()

name = models.CharField(max_length=100)
birthday = models.DateTimeField(auto_now_add=True)

def natural_key(self):
return (self.name,)

class BookResource(resources.ModelResource):

author = Field(
column_name = "author",
attribute = "author",
widget = ForeignKeyWidget(Author, use_natural_foreign_keys=True)
)

class Meta:
model = Book

Read more at `Django Serialization <https://docs.djangoproject.com/en/4.0/topics/serialization>`_

matthewhegarty marked this conversation as resolved.
Show resolved Hide resolved

Importing data
==============

Expand Down
26 changes: 20 additions & 6 deletions import_export/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,8 @@ def render(self, value, obj=None):
class ForeignKeyWidget(Widget):
"""
Widget for a ``ForeignKey`` field which looks up a related model using
"natural keys" in both export and import.
either the PK or a user specified field that uniquely identifies the
instance in both export and import.

The lookup field defaults to using the primary key (``pk``) as lookup
criterion but can be customised to use any field on the related model.
Expand Down Expand Up @@ -368,13 +369,17 @@ class BookResource(resources.ModelResource):

class Meta:
fields = ('author',)

:param model: The Model the ForeignKey refers to (required).
:param field: A field on the related model used for looking up a particular object.
:param field: A field on the related model used for looking up a particular
object.
:param use_natural_foreign_keys: Use natural key functions to identify
related object, default to False
"""
def __init__(self, model, field='pk', *args, **kwargs):
def __init__(self, model, field='pk', use_natural_foreign_keys=False, *args, **kwargs):
pokken-magic marked this conversation as resolved.
Show resolved Hide resolved
matthewhegarty marked this conversation as resolved.
Show resolved Hide resolved
self.model = model
self.field = field
self.use_natural_foreign_keys = use_natural_foreign_keys
super().__init__(*args, **kwargs)

def get_queryset(self, value, row, *args, **kwargs):
Expand Down Expand Up @@ -403,7 +408,12 @@ def get_queryset(self, value, row, *args, **kwargs):
def clean(self, value, row=None, *args, **kwargs):
val = super().clean(value)
if val:
return self.get_queryset(value, row, *args, **kwargs).get(**{self.field: val})
if self.use_natural_foreign_keys:
# natural keys will always be a tuple, which ends up as a json list.
value = json.loads(value)
return self.model.objects.get_by_natural_key(*value)
else:
return self.get_queryset(value, row, *args, **kwargs).get(**{self.field: val})
else:
return None

Expand All @@ -414,7 +424,11 @@ def render(self, value, obj=None):
attrs = self.field.split('__')
for attr in attrs:
try:
value = getattr(value, attr, None)
if self.use_natural_foreign_keys:
# inbound natural keys must be a json list.
return json.dumps(value.natural_key())
else:
value = getattr(value, attr, None)
except (ValueError, ObjectDoesNotExist):
# needs to have a primary key value before a many-to-many
# relationship can be used.
Expand Down
47 changes: 46 additions & 1 deletion tests/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,32 @@
from django.db import models


class AuthorManager(models.Manager):
matthewhegarty marked this conversation as resolved.
Show resolved Hide resolved
"""
Used to enable the get_by_natural_key method.
NOTE: Manager classes are only required to enable
using the natural key functionality of ForeignKeyWidget
"""

def get_by_natural_key(self, name):
"""
Django pattern function for finding an author by its name
"""
return self.get(name=name)
class Author(models.Model):

objects = AuthorManager()

name = models.CharField(max_length=100)
birthday = models.DateTimeField(auto_now_add=True)

def natural_key(self):
"""
Django pattern function for serializing a model by its natural key
Used only by the ForeignKeyWidget using use_natural_foreign_keys.
"""
return (self.name,)

def __str__(self):
return self.name

Expand All @@ -32,8 +54,23 @@ class Category(models.Model):
def __str__(self):
return self.name

class BookManager(models.Manager):
"""
Added to enable get_by_natural_key method
NOTE: Manager classes are only required to enable
using the natural key functionality of ForeignKeyWidget
"""

def get_by_natural_key(self, name, author):
"""
Django pattern function for returning a book by its natural key
"""
return self.get(name=name, author=Author.objects.get_by_natural_key(author))

class Book(models.Model):

objects = BookManager()

name = models.CharField('Book name', max_length=100)
author = models.ForeignKey(Author, blank=True, null=True, on_delete=models.CASCADE)
author_email = models.EmailField('Author email', max_length=75, blank=True)
Expand All @@ -45,6 +82,15 @@ class Book(models.Model):

categories = models.ManyToManyField(Category, blank=True)

def natural_key(self):
"""
Django pattern function for serializing a book by its natural key.
Used only by the ForeignKeyWidget using use_natural_foreign_keys.
"""
return (self.name,) + self.author.natural_key()

natural_key.dependencies = ['core.Author']

def __str__(self):
return self.name

Expand All @@ -63,7 +109,6 @@ class Child(models.Model):
def __str__(self):
return '%s - child of %s' % (self.name, self.parent.name)


class Profile(models.Model):
user = models.OneToOneField('auth.User', on_delete=models.CASCADE)
is_private = models.BooleanField(default=True)
Expand Down
43 changes: 40 additions & 3 deletions tests/core/tests/test_widgets.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import json
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from unittest import mock, skipUnless

import django
import pytz
from core.models import Author, Category
from core.models import Author, Book, Category
from django.test import TestCase
from django.test.utils import override_settings
from django.utils import timezone
Expand Down Expand Up @@ -292,7 +293,10 @@ class ForeignKeyWidgetTest(TestCase):

def setUp(self):
self.widget = widgets.ForeignKeyWidget(Author)
self.natural_key_author_widget = widgets.ForeignKeyWidget(Author, use_natural_foreign_keys=True)
self.natural_key_book_widget = widgets.ForeignKeyWidget(Book, use_natural_foreign_keys=True )
self.author = Author.objects.create(name='Foo')
self.book = Book.objects.create(name="Bar", author=self.author)

def test_clean(self):
self.assertEqual(self.widget.clean(self.author.id), self.author)
Expand Down Expand Up @@ -340,8 +344,41 @@ def attr(self):
t = TestObj()
self.widget = widgets.ForeignKeyWidget(mock.Mock(), "attr")
self.assertIsNone(self.widget.render(t))



def test_author_natural_key_clean(self):
"""
Ensure that we can import an author by its natural key. Note that
this will always need to be an iterable.
Generally this will be rendered as a list.
"""
self.assertEqual(
self.natural_key_author_widget.clean( json.dumps(self.author.natural_key()) ), self.author )

def test_author_natural_key_render(self):
"""
Ensure we can render an author by its natural key. Natural keys will always be
tuples.
"""
self.assertEqual(
self.natural_key_author_widget.render(self.author), json.dumps(self.author.natural_key()) )

def test_book_natural_key_clean(self):
"""
Use the book case to validate a composite natural key of book name and author
can be cleaned.
"""
self.assertEqual(
self.natural_key_book_widget.clean( json.dumps(self.book.natural_key())), self.book
)

def test_book_natural_key_render(self):
"""
Use the book case to validate a composite natural key of book name and author
can be rendered
"""
self.assertEqual(
self.natural_key_book_widget.render(self.book), json.dumps(self.book.natural_key())
)

class ManyToManyWidget(TestCase):

Expand Down