Fixed #25005 -- Made date and time fields with auto_now/auto_now_add use effective default.

Thanks to Andriy Sokolovskiy for initial patch.
This commit is contained in:
Iacopo Spalletti 2016-04-02 15:49:32 +02:00 committed by Tim Graham
parent f5ff5010cd
commit 49c57f8565
8 changed files with 243 additions and 9 deletions

View File

@ -1,9 +1,10 @@
import hashlib
import logging
from datetime import datetime
from django.db.backends.utils import truncate_name
from django.db.transaction import atomic
from django.utils import six
from django.utils import six, timezone
from django.utils.encoding import force_bytes
logger = logging.getLogger('django.db.backends.schema')
@ -201,6 +202,15 @@ class BaseDatabaseSchemaEditor(object):
default = six.binary_type()
else:
default = six.text_type()
elif getattr(field, 'auto_now', False) or getattr(field, 'auto_now_add', False):
default = datetime.now()
internal_type = field.get_internal_type()
if internal_type == 'DateField':
default = default.date
elif internal_type == 'TimeField':
default = default.time
elif internal_type == 'DateTimeField':
default = timezone.now
else:
default = None
# If it's a callable, call it

View File

@ -802,9 +802,15 @@ class MigrationAutodetector(object):
# You can't just add NOT NULL fields with no default or fields
# which don't allow empty strings as default.
preserve_default = True
if (not field.null and not field.has_default() and not field.many_to_many and
not (field.blank and field.empty_strings_allowed)):
time_fields = (models.DateField, models.DateTimeField, models.TimeField)
if (not field.null and not field.has_default() and
not field.many_to_many and
not (field.blank and field.empty_strings_allowed) and
not (isinstance(field, time_fields) and field.auto_now)):
field = field.clone()
if isinstance(field, time_fields) and field.auto_now_add:
field.default = self.questioner.ask_auto_now_add_addition(field_name, model_name)
else:
field.default = self.questioner.ask_not_null_addition(field_name, model_name)
preserve_default = False
self.add_operation(

View File

@ -76,6 +76,11 @@ class MigrationQuestioner(object):
"Do you really want to merge these migrations?"
return self.defaults.get("ask_merge", False)
def ask_auto_now_add_addition(self, field_name, model_name):
"Adding an auto_now_add field to a model"
# None means quit
return None
class InteractiveMigrationQuestioner(MigrationQuestioner):
@ -101,17 +106,36 @@ class InteractiveMigrationQuestioner(MigrationQuestioner):
pass
result = input("Please select a valid option: ")
def _ask_default(self):
def _ask_default(self, default=''):
"""
Prompt for a default value.
The ``default`` argument allows providing a custom default value (as a
string) which will be shown to the user and used as the return value
if the user doesn't provide any other input.
"""
print("Please enter the default value now, as valid Python")
if default:
print(
"You can accept the default '{}' by pressing 'Enter' or you "
"can provide another value.".format(default)
)
print("The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now")
print("Type 'exit' to exit this prompt")
while True:
if default:
prompt = "[default: {}] >>> ".format(default)
else:
prompt = ">>> "
if six.PY3:
# Six does not correctly abstract over the fact that
# py3 input returns a unicode string, while py2 raw_input
# returns a bytestring.
code = input(">>> ")
code = input(prompt)
else:
code = input(">>> ").decode(sys.stdin.encoding)
code = input(prompt).decode(sys.stdin.encoding)
if not code and default:
code = default
if not code:
print("Please enter some code, or 'exit' (with no quotes) to exit.")
elif code == "exit":
@ -186,6 +210,25 @@ class InteractiveMigrationQuestioner(MigrationQuestioner):
False,
)
def ask_auto_now_add_addition(self, field_name, model_name):
"Adding an auto_now_add field to a model"
if not self.dry_run:
choice = self._choice_input(
"You are trying to add the field '{}' with 'auto_now_add=True' "
"to {} without a default; the database needs something to "
"populate existing rows.\n".format(field_name, model_name),
[
"Provide a one-off default now (will be set on all "
"existing rows)",
"Quit, and let me add a default in models.py",
]
)
if choice == 2:
sys.exit(3)
else:
return self._ask_default(default='timezone.now')
return None
class NonInteractiveMigrationQuestioner(MigrationQuestioner):
@ -196,3 +239,7 @@ class NonInteractiveMigrationQuestioner(MigrationQuestioner):
def ask_not_null_alteration(self, field_name, model_name):
# We can't ask the user, so set as not provided.
return NOT_PROVIDED
def ask_auto_now_add_addition(self, field_name, model_name):
# We can't ask the user, so act like the user aborted.
sys.exit(3)

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
operations = [
migrations.CreateModel(
name='Entry',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
],
),
]

View File

@ -61,6 +61,18 @@ class AutodetectorTests(TestCase):
("id", models.AutoField(primary_key=True)),
("name", models.CharField(max_length=200, default='Ada Lovelace')),
])
author_dates_of_birth_auto_now = ModelState("testapp", "Author", [
("id", models.AutoField(primary_key=True)),
("date_of_birth", models.DateField(auto_now=True)),
("date_time_of_birth", models.DateTimeField(auto_now=True)),
("time_of_birth", models.TimeField(auto_now=True)),
])
author_dates_of_birth_auto_now_add = ModelState("testapp", "Author", [
("id", models.AutoField(primary_key=True)),
("date_of_birth", models.DateField(auto_now_add=True)),
("date_time_of_birth", models.DateTimeField(auto_now_add=True)),
("time_of_birth", models.TimeField(auto_now_add=True)),
])
author_name_deconstructible_1 = ModelState("testapp", "Author", [
("id", models.AutoField(primary_key=True)),
("name", models.CharField(max_length=200, default=DeconstructibleObject())),
@ -634,6 +646,51 @@ class AutodetectorTests(TestCase):
self.assertOperationTypes(changes, 'testapp', 0, ["AddField"])
self.assertOperationAttributes(changes, "testapp", 0, 0, name="name")
@mock.patch('django.db.migrations.questioner.MigrationQuestioner.ask_not_null_addition',
side_effect=AssertionError("Should not have prompted for not null addition"))
def test_add_date_fields_with_auto_now_not_asking_for_default(self, mocked_ask_method):
# Make state
before = self.make_project_state([self.author_empty])
after = self.make_project_state([self.author_dates_of_birth_auto_now])
autodetector = MigrationAutodetector(before, after)
changes = autodetector._detect_changes()
# Right number/type of migrations?
self.assertNumberMigrations(changes, 'testapp', 1)
self.assertOperationTypes(changes, 'testapp', 0, ["AddField", "AddField", "AddField"])
self.assertOperationFieldAttributes(changes, "testapp", 0, 0, auto_now=True)
self.assertOperationFieldAttributes(changes, "testapp", 0, 1, auto_now=True)
self.assertOperationFieldAttributes(changes, "testapp", 0, 2, auto_now=True)
@mock.patch('django.db.migrations.questioner.MigrationQuestioner.ask_not_null_addition',
side_effect=AssertionError("Should not have prompted for not null addition"))
def test_add_date_fields_with_auto_now_add_not_asking_for_null_addition(self, mocked_ask_method):
# Make state
before = self.make_project_state([self.author_empty])
after = self.make_project_state([self.author_dates_of_birth_auto_now_add])
autodetector = MigrationAutodetector(before, after)
changes = autodetector._detect_changes()
# Right number/type of migrations?
self.assertNumberMigrations(changes, 'testapp', 1)
self.assertOperationTypes(changes, 'testapp', 0, ["AddField", "AddField", "AddField"])
self.assertOperationFieldAttributes(changes, "testapp", 0, 0, auto_now_add=True)
self.assertOperationFieldAttributes(changes, "testapp", 0, 1, auto_now_add=True)
self.assertOperationFieldAttributes(changes, "testapp", 0, 2, auto_now_add=True)
@mock.patch('django.db.migrations.questioner.MigrationQuestioner.ask_auto_now_add_addition')
def test_add_date_fields_with_auto_now_add_asking_for_default(self, mocked_ask_method):
# Make state
before = self.make_project_state([self.author_empty])
after = self.make_project_state([self.author_dates_of_birth_auto_now_add])
autodetector = MigrationAutodetector(before, after)
changes = autodetector._detect_changes()
# Right number/type of migrations?
self.assertNumberMigrations(changes, 'testapp', 1)
self.assertOperationTypes(changes, 'testapp', 0, ["AddField", "AddField", "AddField"])
self.assertOperationFieldAttributes(changes, "testapp", 0, 0, auto_now_add=True)
self.assertOperationFieldAttributes(changes, "testapp", 0, 1, auto_now_add=True)
self.assertOperationFieldAttributes(changes, "testapp", 0, 2, auto_now_add=True)
self.assertEqual(mocked_ask_method.call_count, 3)
def test_remove_field(self):
"""Tests autodetection of removed fields."""
# Make state

View File

@ -4,6 +4,7 @@ from __future__ import unicode_literals
import codecs
import importlib
import os
import sys
from django.apps import apps
from django.core.management import CommandError, call_command
@ -1078,6 +1079,30 @@ class MakeMigrationsTests(MigrationTestBase):
with self.assertRaisesMessage(InconsistentMigrationHistory, msg):
call_command("makemigrations")
@mock.patch('django.db.migrations.questioner.input', return_value='1')
@mock.patch('django.db.migrations.questioner.sys.stdin', mock.MagicMock(encoding=sys.getdefaultencoding()))
def test_makemigrations_auto_now_add_interactive(self, *args):
"""
makemigrations prompts the user when adding auto_now_add to an existing
model.
"""
class Entry(models.Model):
title = models.CharField(max_length=255)
creation_date = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = 'migrations'
# Monkeypatch interactive questioner to auto accept
with mock.patch('django.db.migrations.questioner.sys.stdout', new_callable=six.StringIO) as prompt_stdout:
out = six.StringIO()
with self.temporary_migration_module(module='migrations.test_auto_now_add'):
call_command('makemigrations', 'migrations', interactive=True, stdout=out)
output = force_text(out.getvalue())
prompt_output = force_text(prompt_stdout.getvalue())
self.assertIn("You can accept the default 'timezone.now' by pressing 'Enter'", prompt_output)
self.assertIn("Add field creation_date to entry", output)
class SquashMigrationsTests(MigrationTestBase):
"""

View File

@ -18,8 +18,9 @@ from django.db.models.fields.related import (
)
from django.db.transaction import atomic
from django.test import (
TransactionTestCase, skipIfDBFeature, skipUnlessDBFeature,
TransactionTestCase, mock, skipIfDBFeature, skipUnlessDBFeature,
)
from django.utils.timezone import UTC
from .fields import (
CustomManyToManyField, InheritedManyToManyField, MediumBlobField,
@ -124,8 +125,17 @@ class SchemaTests(TransactionTestCase):
constraints_for_column.append(name)
return sorted(constraints_for_column)
# Tests
def check_added_field_default(self, schema_editor, model, field, field_name, expected_default,
cast_function=None):
with connection.cursor() as cursor:
schema_editor.add_field(model, field)
cursor.execute("SELECT {} FROM {};".format(field_name, model._meta.db_table))
database_default = cursor.fetchall()[0][0]
if cast_function and not type(database_default) == type(expected_default):
database_default = cast_function(database_default)
self.assertEqual(database_default, expected_default)
# Tests
def test_creation_deletion(self):
"""
Tries creating a model's table, and then deleting it.
@ -1833,3 +1843,63 @@ class SchemaTests(TransactionTestCase):
new_field.set_attributes_from_name('id')
with connection.schema_editor() as editor:
editor.alter_field(Node, old_field, new_field)
@mock.patch('django.db.backends.base.schema.datetime')
@mock.patch('django.db.backends.base.schema.timezone')
def test_add_datefield_and_datetimefield_use_effective_default(self, mocked_datetime, mocked_tz):
"""
effective_default() should be used for DateField, DateTimeField, and
TimeField if auto_now or auto_add_now is set (#25005).
"""
now = datetime.datetime(month=1, day=1, year=2000, hour=1, minute=1)
now_tz = datetime.datetime(month=1, day=1, year=2000, hour=1, minute=1, tzinfo=UTC())
mocked_datetime.now = mock.MagicMock(return_value=now)
mocked_tz.now = mock.MagicMock(return_value=now_tz)
# Create the table
with connection.schema_editor() as editor:
editor.create_model(Author)
# Check auto_now/auto_now_add attributes are not defined
columns = self.column_classes(Author)
self.assertNotIn("dob_auto_now", columns)
self.assertNotIn("dob_auto_now_add", columns)
self.assertNotIn("dtob_auto_now", columns)
self.assertNotIn("dtob_auto_now_add", columns)
self.assertNotIn("tob_auto_now", columns)
self.assertNotIn("tob_auto_now_add", columns)
# Create a row
Author.objects.create(name='Anonymous1')
# Ensure fields were added with the correct defaults
dob_auto_now = DateField(auto_now=True)
dob_auto_now.set_attributes_from_name('dob_auto_now')
self.check_added_field_default(
editor, Author, dob_auto_now, 'dob_auto_now', now.date(),
cast_function=lambda x: x.date(),
)
dob_auto_now_add = DateField(auto_now_add=True)
dob_auto_now_add.set_attributes_from_name('dob_auto_now_add')
self.check_added_field_default(
editor, Author, dob_auto_now_add, 'dob_auto_now_add', now.date(),
cast_function=lambda x: x.date(),
)
dtob_auto_now = DateTimeField(auto_now=True)
dtob_auto_now.set_attributes_from_name('dtob_auto_now')
self.check_added_field_default(
editor, Author, dtob_auto_now, 'dtob_auto_now', now,
)
dt_tm_of_birth_auto_now_add = DateTimeField(auto_now_add=True)
dt_tm_of_birth_auto_now_add.set_attributes_from_name('dtob_auto_now_add')
self.check_added_field_default(
editor, Author, dt_tm_of_birth_auto_now_add, 'dtob_auto_now_add', now,
)
tob_auto_now = TimeField(auto_now=True)
tob_auto_now.set_attributes_from_name('tob_auto_now')
self.check_added_field_default(
editor, Author, tob_auto_now, 'tob_auto_now', now.time(),
cast_function=lambda x: x.time(),
)
tob_auto_now_add = TimeField(auto_now_add=True)
tob_auto_now_add.set_attributes_from_name('tob_auto_now_add')
self.check_added_field_default(
editor, Author, tob_auto_now_add, 'tob_auto_now_add', now.time(),
cast_function=lambda x: x.time(),
)