Repository: pytest-dev/pytest-django

Issue:
Email based user model without username field
I recently worked on a Django project with a custom user model, which uses an email as the username field and does not use the original username field. To make pytest-django work I have locally overwritten some fixtures of pytest-django.

To eventually get rid of the overrides I added tests (extended the `test_custom_user_model` test in `tests/test_fixtures.py`) to the changes made in #246.

I hope the tests are sufficient to get this merged, if not I am open for hints to write some more.
Also I hope it's fine that I created a new pull request for this (didn't know how to proceed).
I'm pretty new to python still, so don't hesitate to nitpick on the changes made :cold_sweat: 

If user model changes the username field, admin_user fixture will pass it twice
Consider a model like this:

```python
class User(AbstractBaseUser):
    name = models.CharField(max_length=255, unique=True)
    USERNAME_FIELD = 'name'
[...]
```

(And of course ```AUTH_USER_MODEL``` points at this model.)

Then the `admin_user` fixture will raise an error:
```python
db = None, django_user_model = <class 'sis.models.User'>, django_username_field = 'name'

    @pytest.fixture()
    def admin_user(db, django_user_model, django_username_field):
        """A Django admin user.
    
        This uses an existing user with username "admin", or creates a new one with
        password "password".
        """
        UserModel = django_user_model
        username_field = django_username_field
    
        try:
            user = UserModel._default_manager.get(**{username_field: 'admin'})
        except UserModel.DoesNotExist:
            extra_fields = {}
            if username_field != 'username':
                extra_fields[username_field] = 'admin'
            user = UserModel._default_manager.create_superuser(
>               'admin', 'admin@example.com', 'password', **extra_fields)
E           TypeError: create_superuser() got multiple values for keyword argument 'name'

UserModel  = <class 'sis.models.User'>
db         = None
django_user_model = <class 'sis.models.User'>
django_username_field = 'name'
extra_fields = {'name': 'admin'}
username_field = 'name'

../../../virtualenvs/sis/lib/python2.7/site-packages/pytest_django/fixtures.py:245: TypeError
================================== 1 error in 3.03 seconds ==================================

Compilation exited abnormally with code 1 at Thu Feb  2 09:41:48
```

This is because the name `admin` is passed as first argument and another time in the `**extra_fields`.

Either it should not be assumed that the username is the first argument (which seems safer) '''or''' the name should be dropped from `extra_fields`. My intuition is to prefer the first, i.e. only passing it in `extra_fields`.


Discussion:
# [Codecov](https://codecov.io/gh/pytest-dev/pytest-django/pull/748?src=pr&el=h1) Report
> Merging [#748](https://codecov.io/gh/pytest-dev/pytest-django/pull/748?src=pr&el=desc) into [master](https://codecov.io/gh/pytest-dev/pytest-django/commit/33aa666024a7edd1899cce39731df3304c3df8c5?src=pr&el=desc) will **decrease** coverage by `0.04%`.
> The diff coverage is `93.75%`.

[![Impacted file tree graph](https://codecov.io/gh/pytest-dev/pytest-django/pull/748/graphs/tree.svg?width=650&token=0kaoCar5bF&height=150&src=pr)](https://codecov.io/gh/pytest-dev/pytest-django/pull/748?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #748      +/-   ##
==========================================
- Coverage   98.06%   98.02%   -0.05%     
==========================================
  Files          32       32              
  Lines        1862     1872      +10     
  Branches      147      151       +4     
==========================================
+ Hits         1826     1835       +9     
  Misses         24       24              
- Partials       12       13       +1
```


| [Impacted Files](https://codecov.io/gh/pytest-dev/pytest-django/pull/748?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [tests/test\_fixtures.py](https://codecov.io/gh/pytest-dev/pytest-django/pull/748/diff?src=pr&el=tree#diff-dGVzdHMvdGVzdF9maXh0dXJlcy5weQ==) | `99.61% <100%> (+0.01%)` | :arrow_up: |
| [pytest\_django/fixtures.py](https://codecov.io/gh/pytest-dev/pytest-django/pull/748/diff?src=pr&el=tree#diff-cHl0ZXN0X2RqYW5nby9maXh0dXJlcy5weQ==) | `95.95% <87.5%> (-0.46%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pytest-dev/pytest-django/pull/748?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pytest-dev/pytest-django/pull/748?src=pr&el=footer). Last update [33aa666...ac7016d](https://codecov.io/gh/pytest-dev/pytest-django/pull/748?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

This fixes that one issue

```python
@pytest.fixture()
def admin_user(db, django_user_model, django_username_field):
    """A Django admin user.

    This uses an existing user with username "admin", or creates a new one with
    password "password".
    """
    UserModel = django_user_model
    username_field = django_username_field

    try:
        user = UserModel._default_manager.get(**{username_field: 'admin'})
    except UserModel.DoesNotExist:
        extra_fields = {}
        if username_field != 'username':
            extra_fields[username_field] = 'admin'
        user = UserModel._default_manager.create_superuser(
            password='password', **extra_fields)
    return user
```

Unfortunately I can't offer a pull request, as I'm not working on current `pytest_django` (still using django 1.6) and also our User model has other quirks, so I need to override the admin_user fixture for those quirks anyways. But I hope that still helps.
I just noticed that the `admin_client` fixture assumes that the `User` model has a field `username`.

Maybe it would be best to drop the `username_field` special case altogether and tell people they can override the fixtures for their models in `conftest.py`, which is what I'm doing.

Leaving this open, but I'm not really expecting you to fix this ;)

Resolving patch:
diff --git a/pytest_django/fixtures.py b/pytest_django/fixtures.py
--- a/pytest_django/fixtures.py
+++ b/pytest_django/fixtures.py
@@ -293,11 +293,12 @@ def admin_user(db, django_user_model, django_username_field):
         # so we can assume so as well.
         user = UserModel._default_manager.get_by_natural_key(username)
     except UserModel.DoesNotExist:
-        extra_fields = {}
-        if username_field not in ("username", "email"):
-            extra_fields[username_field] = "admin"
         user = UserModel._default_manager.create_superuser(
-            username, "admin@example.com", "password", **extra_fields
+            **{
+                "email": "admin@example.com",
+                "password": "password",
+                username_field: username,
+            }
         )
     return user
 


Test patch:
diff --git a/pytest_django_test/settings_mysql_innodb.py b/pytest_django_test/settings_mysql_innodb.py
--- a/pytest_django_test/settings_mysql_innodb.py
+++ b/pytest_django_test/settings_mysql_innodb.py
@@ -2,6 +2,7 @@
 
 from .settings_base import *  # noqa: F401 F403
 
+
 DATABASES = {
     "default": {
         "ENGINE": "django.db.backends.mysql",
@@ -9,6 +10,13 @@
         "USER": environ.get("TEST_DB_USER", "root"),
         "PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
         "HOST": environ.get("TEST_DB_HOST", "localhost"),
-        "OPTIONS": {"init_command": "SET default_storage_engine=InnoDB"},
-    }
+        "OPTIONS": {
+            "init_command": "SET default_storage_engine=InnoDB",
+            "charset": "utf8mb4",
+        },
+        "TEST": {
+            "CHARSET": "utf8mb4",
+            "COLLATION": "utf8mb4_unicode_ci",
+        },
+    },
 }
diff --git a/pytest_django_test/settings_mysql_myisam.py b/pytest_django_test/settings_mysql_myisam.py
--- a/pytest_django_test/settings_mysql_myisam.py
+++ b/pytest_django_test/settings_mysql_myisam.py
@@ -2,6 +2,7 @@
 
 from .settings_base import *  # noqa: F401 F403
 
+
 DATABASES = {
     "default": {
         "ENGINE": "django.db.backends.mysql",
@@ -9,6 +10,13 @@
         "USER": environ.get("TEST_DB_USER", "root"),
         "PASSWORD": environ.get("TEST_DB_PASSWORD", ""),
         "HOST": environ.get("TEST_DB_HOST", "localhost"),
-        "OPTIONS": {"init_command": "SET default_storage_engine=MyISAM"},
-    }
+        "OPTIONS": {
+            "init_command": "SET default_storage_engine=MyISAM",
+            "charset": "utf8mb4",
+        },
+        "TEST": {
+            "CHARSET": "utf8mb4",
+            "COLLATION": "utf8mb4_unicode_ci",
+        },
+    },
 }
diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py
--- a/tests/test_fixtures.py
+++ b/tests/test_fixtures.py
@@ -483,15 +483,40 @@ def test_with_live_server(live_server):
 def test_custom_user_model(django_testdir, username_field):
     django_testdir.create_app_file(
         """
-        from django.contrib.auth.models import AbstractUser
+        from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
         from django.db import models
 
-        class MyCustomUser(AbstractUser):
+        class MyCustomUserManager(BaseUserManager):
+            def create_user(self, {username_field}, password=None, **extra_fields):
+                extra_fields.setdefault('is_staff', False)
+                extra_fields.setdefault('is_superuser', False)
+                user = self.model({username_field}={username_field}, **extra_fields)
+                user.set_password(password)
+                user.save()
+                return user
+
+            def create_superuser(self, {username_field}, password=None, **extra_fields):
+                extra_fields.setdefault('is_staff', True)
+                extra_fields.setdefault('is_superuser', True)
+                return self.create_user(
+                    {username_field}={username_field},
+                    password=password,
+                    **extra_fields
+                )
+
+        class MyCustomUser(AbstractBaseUser, PermissionsMixin):
+            email = models.EmailField(max_length=100, unique=True)
             identifier = models.CharField(unique=True, max_length=100)
+            is_staff = models.BooleanField(
+                'staff status',
+                default=False,
+                help_text='Designates whether the user can log into this admin site.'
+            )
 
-            USERNAME_FIELD = '%s'
-        """
-        % (username_field),
+            objects = MyCustomUserManager()
+
+            USERNAME_FIELD = '{username_field}'
+        """.format(username_field=username_field),
         "models.py",
     )
     django_testdir.create_app_file(
@@ -551,19 +576,13 @@ class Migration(migrations.Migration):
                 ('password', models.CharField(max_length=128, verbose_name='password')),
                 ('last_login', models.DateTimeField(null=True, verbose_name='last login', blank=True)),
                 ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
-                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, max_length=30, validators=[django.core.validators.RegexValidator(r'^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.', 'invalid')], help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True, verbose_name='username')),
-                ('first_name', models.CharField(max_length=30, verbose_name='first name', blank=True)),
-                ('last_name', models.CharField(max_length=30, verbose_name='last name', blank=True)),
-                ('email', models.EmailField(max_length=254, verbose_name='email address', blank=True)),
+                ('email', models.EmailField(error_messages={'unique': 'A user with that email address already exists.'}, max_length=100, unique=True, verbose_name='email address')),
                 ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
-                ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
-                ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
                 ('identifier', models.CharField(unique=True, max_length=100)),
                 ('groups', models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Group', blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', verbose_name='groups')),
                 ('user_permissions', models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Permission', blank=True, help_text='Specific permissions for this user.', verbose_name='user permissions')),
             ],
             options={
-                'abstract': False,
                 'verbose_name': 'user',
                 'verbose_name_plural': 'users',
             },