diff --git a/dev-requirements.txt b/dev-requirements.txt index d6029e7..0eb3db7 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,3 +1,3 @@ -r dev-env-requirements.txt -django==2.1.11 +django~=3.1.4 twine==1.11.0 diff --git a/django_deprecation/deprecated_field.py b/django_deprecation/deprecated_field.py index 7d26d4f..375d817 100644 --- a/django_deprecation/deprecated_field.py +++ b/django_deprecation/deprecated_field.py @@ -1,5 +1,7 @@ from django.core.exceptions import FieldError from django.db.models.fields import Field +from django.forms import Field as FormField +from django.forms.boundfield import BoundField class NullModelOptions(object): @@ -8,12 +10,13 @@ def __init__(self, field_name): @property def pk(self): - return self.get_field('pk') + return self.get_field("pk") def get_field(self, name): raise FieldError( "Cannot resolve keyword %r into field. Join on '%s'" - " not permitted." % (name, self.field_name)) + " not permitted." % (name, self.field_name) + ) class EmptyPathInfo(object): @@ -34,13 +37,14 @@ def next(self): def __getitem__(self, key): if key == -1: return self - raise Exception('This shouldn\'t be called: {}'.format(key)) + raise Exception("This shouldn't be called: {}".format(key)) class DeprecatedField(Field): @staticmethod def warn(message): import warnings + warnings.warn(message, DeprecationWarning) def __init__(self, field_path): @@ -57,20 +61,26 @@ def __set__(self, instance, value): def _warn(self): message = 'Field {module}.{model}#{name} is deprecated. Please, use field "{field_path}".' - DeprecatedField.warn(message.format( - module=self.model.__module__, - model=self.model.__name__, - name=self.name, - field_path=self.field_path, - )) - - def get_aliased_field(self): + DeprecatedField.warn( + message.format( + module=self.model.__module__, + model=self.model.__name__, + name=self.name, + field_path=self.field_path, + ) + ) + + @property + def aliased_field(self): return self.model._meta.get_field(self.field_path) def contribute_to_class(self, cls, name): super(DeprecatedField, self).contribute_to_class(cls, name, True) setattr(cls, name, self) + def formfield(self, **kwargs): + return DeprecatedFormField(aliased_field_name=self.field_path, **kwargs) + def get_attname_column(self): attname = self.get_attname() # This method is overriden because this field does not corresponds to a column. @@ -79,14 +89,42 @@ def get_attname_column(self): return attname, column def get_path_info(self, filtered_relation=None): - aliased_field = self.get_aliased_field() - if hasattr(aliased_field, 'get_path_info'): + aliased_field = self.aliased_field + if hasattr(aliased_field, "get_path_info"): func = aliased_field.get_path_info - if hasattr(func, 'im_func'): + if hasattr(func, "im_func"): func = func.im_func kwargs = {} if func.__code__.co_argcount > 1: - kwargs['filtered_relation'] = filtered_relation + kwargs["filtered_relation"] = filtered_relation return aliased_field.get_path_info(**kwargs) else: return EmptyPathInfo(aliased_field) + + def save_form_data(self, instance, data): + """ + Avoid deleting data in the aliased field if no value was provided for + the deprecated field; if both the deprecated field and alias field + are blank, and blank is False, Django will raise a ValidationError. + """ + aliased_field = self.aliased_field + if not aliased_field.blank and data is None: + return + return super().save_form_data(instance, data) + + def value_from_object(self, obj): + """ + Used by model_to_dict() and elsewhere; because this isn't a direct + access, avoid using the __get__() method and triggering a warning. + """ + return getattr(obj, self.field_path) + + +class DeprecatedFormField(FormField): + def __init__(self, aliased_field_name, **kwargs): + self.aliased_field_name = aliased_field_name + super().__init__(**kwargs) + self.required = False + + def widget_attrs(self, widget): + return {"disabled": True} diff --git a/setup.cfg b/setup.cfg index 0ad9c16..eac6498 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,7 @@ test=pytest [tool:pytest] -addopts = --cov -vv +addopts = --cov -vv -s python_files = tests/test_*.py django_find_project = false DJANGO_SETTINGS_MODULE = tests.settings diff --git a/tests/models.py b/tests/models.py index d7f7672..76c7967 100644 --- a/tests/models.py +++ b/tests/models.py @@ -11,7 +11,7 @@ def __call__(self, message): self.counter += 1 def reset(self): - self.message = '' + self.message = "" self.counter = 0 @@ -21,7 +21,7 @@ def reset(self): class Musician(models.Model): name = models.CharField(max_length=50) - title = DeprecatedField('name') + title = DeprecatedField("name") def __str__(self): return self.name @@ -29,7 +29,16 @@ def __str__(self): class Album(models.Model): artist = models.ForeignKey(Musician, on_delete=models.CASCADE) - musician = DeprecatedField('artist') + musician = DeprecatedField("artist") def __str__(self): return self.artist and self.artist.name + + +class Label(models.Model): + """ + A class where the deprecated field is pointing to a required field. + """ + + ticker = models.CharField(blank=False, null=False, max_length=50) + nyse = DeprecatedField("ticker") diff --git a/tests/settings.py b/tests/settings.py index cc1b7e5..e9450ba 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,9 +1,5 @@ -INSTALLED_APPS = ( - 'tests', -) +INSTALLED_APPS = ("tests",) DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - }, + "default": {"ENGINE": "django.db.backends.sqlite3",}, } -SECRET_KEY = 'dummy' +SECRET_KEY = "dummy" diff --git a/tests/test_deprecated_field.py b/tests/test_deprecated_field.py index 0fb1a03..6bd4a5a 100644 --- a/tests/test_deprecated_field.py +++ b/tests/test_deprecated_field.py @@ -3,9 +3,12 @@ from .models import ( Album, Musician, + Label, warn_function, ) +from django.forms.models import modelform_factory + @pytest.fixture def warn(): @@ -15,20 +18,20 @@ def warn(): @pytest.mark.django_db def test_should_return_the_same_value_as_the_aliased_field(): - musician = Musician.objects.create(name='foo') + musician = Musician.objects.create(name="foo") assert musician.title == musician.name @pytest.mark.django_db def test_should_set_value_to_the_aliased_field(): - musician = Musician.objects.create(name='foo') - musician.title = 'bar' - assert musician.name == 'bar' + musician = Musician.objects.create(name="foo") + musician.title = "bar" + assert musician.name == "bar" @pytest.mark.django_db def test_should_warn_when_accessing_it(warn): - musician = Musician.objects.create(name='foo') + musician = Musician.objects.create(name="foo") assert warn.counter == 0 assert musician.title assert warn.counter == 1 @@ -36,28 +39,61 @@ def test_should_warn_when_accessing_it(warn): @pytest.mark.django_db def test_should_warn_when_setting_it(warn): - musician = Musician.objects.create(name='foo') + musician = Musician.objects.create(name="foo") assert warn.counter == 0 - musician.title = 'bar' + musician.title = "bar" assert warn.counter == 1 @pytest.mark.django_db def test_should_warn_when_setting_it_while_creating(warn): - Musician.objects.create(title='foo') + Musician.objects.create(title="foo") assert warn.counter == 1 @pytest.mark.django_db def test_should_work_as_a_filter_parameter_when_aliased_field_is_a_char_field(): - musician = Musician.objects.create(name='foo') - search_musician = Musician.objects.filter(title='foo').first() + musician = Musician.objects.create(name="foo") + search_musician = Musician.objects.filter(title="foo").first() assert search_musician == musician @pytest.mark.django_db def test_should_work_as_a_filter_parameter_when_aliased_field_is_a_foreign_field(): - musician = Musician.objects.create(name='foo') + musician = Musician.objects.create(name="foo") album = Album.objects.create(artist=musician) search_album = Album.objects.filter(musician=musician).first() assert search_album == album + + +@pytest.mark.django_db +def test_modelform_save_with_required(warn): + form_class = modelform_factory(Label, fields="__all__") + data = {"ticker": "BKFG"} + form = form_class(data) + assert form.fields["nyse"].widget.attrs == {"disabled": True} + assert form.is_valid() + res = form.save() + assert res.ticker == "BKFG" + assert warn.counter == 0 + assert res.nyse == "BKFG" + assert warn.counter == 1 + + +@pytest.mark.django_db +def test_modelform_update_via_aliased_with_required(warn): + existing = Label.objects.create(ticker="old_ticker") + form_class = modelform_factory(Label, fields="__all__") + + data = {"ticker": "BKFG"} + form = form_class(data, instance=existing) + # This warning check caught an issue with model_to_dict() + assert warn.counter == 0 + assert form.is_valid() + res = form.save() + + # make sure none of the form actions triggered a warning, either + assert warn.counter == 0 + assert res.ticker == "BKFG" + assert res.nyse == "BKFG" + assert warn.counter == 1 # only legit warning