From 03dbdfd9bbbbd0b0172aad648c6bbe3f39541137 Mon Sep 17 00:00:00 2001 From: Hasan Ramezani Date: Wed, 31 Jul 2019 17:06:59 +0200 Subject: [PATCH] Fixed #29019 -- Added ManyToManyField support to REQUIRED_FIELDS. --- .../management/commands/createsuperuser.py | 44 +++++++--- docs/releases/3.0.txt | 3 + docs/topics/auth/customizing.txt | 8 ++ tests/auth_tests/models/__init__.py | 10 ++- tests/auth_tests/models/with_many_to_many.py | 40 +++++++++ tests/auth_tests/test_management.py | 85 ++++++++++++++++++- 6 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 tests/auth_tests/models/with_many_to_many.py diff --git a/django/contrib/auth/management/commands/createsuperuser.py b/django/contrib/auth/management/commands/createsuperuser.py index ece66dfad1..3b76477f01 100644 --- a/django/contrib/auth/management/commands/createsuperuser.py +++ b/django/contrib/auth/management/commands/createsuperuser.py @@ -51,11 +51,28 @@ class Command(BaseCommand): default=DEFAULT_DB_ALIAS, help='Specifies the database to use. Default is "default".', ) - for field in self.UserModel.REQUIRED_FIELDS: - parser.add_argument( - '--%s' % field, - help='Specifies the %s for the superuser.' % field, - ) + for field_name in self.UserModel.REQUIRED_FIELDS: + field = self.UserModel._meta.get_field(field_name) + if field.many_to_many: + if field.remote_field.through and not field.remote_field.through._meta.auto_created: + raise CommandError( + "Required field '%s' specifies a many-to-many " + "relation through model, which is not supported." + % field_name + ) + else: + parser.add_argument( + '--%s' % field_name, action='append', + help=( + 'Specifies the %s for the superuser. Can be used ' + 'multiple times.' % field_name, + ), + ) + else: + parser.add_argument( + '--%s' % field_name, + help='Specifies the %s for the superuser.' % field_name, + ) def execute(self, *args, **options): self.stdin = options.get('stdin', sys.stdin) # Used for testing @@ -75,8 +92,8 @@ class Command(BaseCommand): user_data[PASSWORD_FIELD] = None try: if options['interactive']: - # Same as user_data but with foreign keys as fake model - # instances instead of raw IDs. + # Same as user_data but without many to many fields and with + # foreign keys as fake model instances instead of raw IDs. fake_user_data = {} if hasattr(self.stdin, 'isatty') and not self.stdin.isatty(): raise NotRunningInTTYException @@ -111,10 +128,17 @@ class Command(BaseCommand): message = self._get_input_message(field) input_value = self.get_input_data(field, message) user_data[field_name] = input_value - fake_user_data[field_name] = input_value + if field.many_to_many and input_value: + if not input_value.strip(): + user_data[field_name] = None + self.stderr.write('Error: This field cannot be blank.') + continue + user_data[field_name] = [pk.strip() for pk in input_value.split(',')] + if not field.many_to_many: + fake_user_data[field_name] = input_value # Wrap any foreign keys in fake model instances - if field.remote_field: + if field.many_to_one: fake_user_data[field_name] = field.remote_field.model(input_value) # Prompt for a password if the model has one. @@ -199,7 +223,7 @@ class Command(BaseCommand): " (leave blank to use '%s')" % default if default else '', ' (%s.%s)' % ( field.remote_field.model._meta.object_name, - field.remote_field.field_name, + field.m2m_target_field_name() if field.many_to_many else field.remote_field.field_name, ) if field.remote_field else '', ) diff --git a/docs/releases/3.0.txt b/docs/releases/3.0.txt index b9afc2ad59..722baeeeaa 100644 --- a/docs/releases/3.0.txt +++ b/docs/releases/3.0.txt @@ -118,6 +118,9 @@ Minor features password and required fields, when a corresponding command line argument isn't provided in non-interactive mode. +* :attr:`~django.contrib.auth.models.CustomUser.REQUIRED_FIELDS` now supports + :class:`~django.db.models.ManyToManyField`\s. + :mod:`django.contrib.contenttypes` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/topics/auth/customizing.txt b/docs/topics/auth/customizing.txt index 7d287051d8..afc9bb3e62 100644 --- a/docs/topics/auth/customizing.txt +++ b/docs/topics/auth/customizing.txt @@ -576,6 +576,14 @@ password resets. You must then provide some key implementation details: ``REQUIRED_FIELDS`` has no effect in other parts of Django, like creating a user in the admin. + .. versionadded:: 3.0 + + :attr:`REQUIRED_FIELDS` now supports + :class:`~django.db.models.ManyToManyField`\s without a custom + through model. Since there is no way to pass model instances during + the :djadmin:`createsuperuser` prompt, expect the user to enter IDs + of existing instances of the class to which the model is related. + For example, here is the partial definition for a user model that defines two required fields - a date of birth and height:: diff --git a/tests/auth_tests/models/__init__.py b/tests/auth_tests/models/__init__.py index 785e6953ff..003d8eeaa7 100644 --- a/tests/auth_tests/models/__init__.py +++ b/tests/auth_tests/models/__init__.py @@ -11,11 +11,15 @@ from .uuid_pk import UUIDUser from .with_foreign_key import CustomUserWithFK, Email from .with_integer_username import IntegerUsernameUser from .with_last_login_attr import UserWithDisabledLastLoginField +from .with_many_to_many import ( + CustomUserWithM2M, CustomUserWithM2MThrough, Organization, +) __all__ = ( 'CustomPermissionsUser', 'CustomUser', 'CustomUserNonUniqueUsername', - 'CustomUserWithFK', 'CustomUserWithoutIsActiveField', 'Email', - 'ExtensionUser', 'IntegerUsernameUser', 'IsActiveTestUser1', 'MinimalUser', - 'NoPasswordUser', 'Proxy', 'UUIDUser', 'UserProxy', + 'CustomUserWithFK', 'CustomUserWithM2M', 'CustomUserWithM2MThrough', + 'CustomUserWithoutIsActiveField', 'Email', 'ExtensionUser', + 'IntegerUsernameUser', 'IsActiveTestUser1', 'MinimalUser', + 'NoPasswordUser', 'Organization', 'Proxy', 'UUIDUser', 'UserProxy', 'UserWithDisabledLastLoginField', ) diff --git a/tests/auth_tests/models/with_many_to_many.py b/tests/auth_tests/models/with_many_to_many.py new file mode 100644 index 0000000000..82142048ec --- /dev/null +++ b/tests/auth_tests/models/with_many_to_many.py @@ -0,0 +1,40 @@ +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager +from django.db import models + + +class Organization(models.Model): + name = models.CharField(max_length=255) + + +class CustomUserWithM2MManager(BaseUserManager): + def create_superuser(self, username, orgs, password): + user = self.model(username=username) + user.set_password(password) + user.save(using=self._db) + user.orgs.add(*orgs) + return user + + +class CustomUserWithM2M(AbstractBaseUser): + username = models.CharField(max_length=30, unique=True) + orgs = models.ManyToManyField(Organization) + + custom_objects = CustomUserWithM2MManager() + + USERNAME_FIELD = 'username' + REQUIRED_FIELDS = ['orgs'] + + +class CustomUserWithM2MThrough(AbstractBaseUser): + username = models.CharField(max_length=30, unique=True) + orgs = models.ManyToManyField(Organization, through='Membership') + + custom_objects = CustomUserWithM2MManager() + + USERNAME_FIELD = 'username' + REQUIRED_FIELDS = ['orgs'] + + +class Membership(models.Model): + user = models.ForeignKey(CustomUserWithM2MThrough, on_delete=models.CASCADE) + organization = models.ForeignKey(Organization, on_delete=models.CASCADE) diff --git a/tests/auth_tests/test_management.py b/tests/auth_tests/test_management.py index bf9b063102..7792a374f8 100644 --- a/tests/auth_tests/test_management.py +++ b/tests/auth_tests/test_management.py @@ -23,8 +23,8 @@ from django.test import TestCase, override_settings from django.utils.translation import gettext_lazy as _ from .models import ( - CustomUser, CustomUserNonUniqueUsername, CustomUserWithFK, Email, - UserProxy, + CustomUser, CustomUserNonUniqueUsername, CustomUserWithFK, + CustomUserWithM2M, Email, Organization, UserProxy, ) MOCK_INPUT_KEY_TO_PROMPTS = { @@ -500,6 +500,87 @@ class CreatesuperuserManagementCommandTestCase(TestCase): test(self) + @override_settings(AUTH_USER_MODEL='auth_tests.CustomUserWithM2m') + def test_fields_with_m2m(self): + new_io = StringIO() + org_id_1 = Organization.objects.create(name='Organization 1').pk + org_id_2 = Organization.objects.create(name='Organization 2').pk + call_command( + 'createsuperuser', + interactive=False, + username='joe', + orgs=[org_id_1, org_id_2], + stdout=new_io, + ) + command_output = new_io.getvalue().strip() + self.assertEqual(command_output, 'Superuser created successfully.') + user = CustomUserWithM2M._default_manager.get(username='joe') + self.assertEqual(user.orgs.count(), 2) + + @override_settings(AUTH_USER_MODEL='auth_tests.CustomUserWithM2M') + def test_fields_with_m2m_interactive(self): + new_io = StringIO() + org_id_1 = Organization.objects.create(name='Organization 1').pk + org_id_2 = Organization.objects.create(name='Organization 2').pk + + @mock_inputs({ + 'password': 'nopasswd', + 'Username: ': 'joe', + 'Orgs (Organization.id): ': '%s, %s' % (org_id_1, org_id_2), + }) + def test(self): + call_command( + 'createsuperuser', + interactive=True, + stdout=new_io, + stdin=MockTTY(), + ) + command_output = new_io.getvalue().strip() + self.assertEqual(command_output, 'Superuser created successfully.') + user = CustomUserWithM2M._default_manager.get(username='joe') + self.assertEqual(user.orgs.count(), 2) + + test(self) + + @override_settings(AUTH_USER_MODEL='auth_tests.CustomUserWithM2M') + def test_fields_with_m2m_interactive_blank(self): + new_io = StringIO() + org_id = Organization.objects.create(name='Organization').pk + entered_orgs = [str(org_id), ' '] + + def return_orgs(): + return entered_orgs.pop() + + @mock_inputs({ + 'password': 'nopasswd', + 'Username: ': 'joe', + 'Orgs (Organization.id): ': return_orgs, + }) + def test(self): + call_command( + 'createsuperuser', + interactive=True, + stdout=new_io, + stderr=new_io, + stdin=MockTTY(), + ) + self.assertEqual( + new_io.getvalue().strip(), + 'Error: This field cannot be blank.\n' + 'Superuser created successfully.', + ) + + test(self) + + @override_settings(AUTH_USER_MODEL='auth_tests.CustomUserWithM2MThrough') + def test_fields_with_m2m_and_through(self): + msg = ( + "Required field 'orgs' specifies a many-to-many relation through " + "model, which is not supported." + ) + with self.assertRaisesMessage(CommandError, msg): + call_command('createsuperuser') + def test_default_username(self): """createsuperuser uses a default username when one isn't provided.""" # Get the default username before creating a user.