├── .github └── workflows │ ├── python-package.yml │ └── python-publish.yml ├── .gitignore ├── .pylintrc ├── LICENSE ├── MANIFEST.in ├── README.rst ├── cuser ├── __init__.py ├── admin.py ├── apps.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_user_last_name_max_length.py │ ├── 0003_alter_user_first_name_max_length.py │ └── __init__.py ├── models.py ├── settings.py └── templates │ └── admin │ └── cuser │ └── cuser │ └── add_form.html ├── setup.py └── tox.ini /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | 4 | 'on': [push, pull_request] 5 | 6 | jobs: 7 | tox: 8 | runs-on: ubuntu-20.04 9 | strategy: 10 | matrix: 11 | include: 12 | - python-version: "3.6" 13 | tox-env: py36-django32 14 | - python-version: "3.7" 15 | tox-env: py37-django32 16 | - python-version: "3.8" 17 | tox-env: py38-django32 18 | - python-version: "3.9" 19 | tox-env: py39-django32 20 | - python-version: "3.10" 21 | tox-env: py310-django32 22 | - python-version: "3.8" 23 | tox-env: py38-django40 24 | - python-version: "3.9" 25 | tox-env: py39-django40 26 | - python-version: "3.10" 27 | tox-env: py310-django40 28 | - python-version: "3.8" 29 | tox-env: py38-django41 30 | - python-version: "3.9" 31 | tox-env: py39-django41 32 | - python-version: "3.10" 33 | tox-env: py310-django41 34 | - python-version: "3.11" 35 | tox-env: py311-django41 36 | - python-version: "3.8" 37 | tox-env: py38-django42 38 | - python-version: "3.9" 39 | tox-env: py39-django42 40 | - python-version: "3.10" 41 | tox-env: py310-django42 42 | - python-version: "3.11" 43 | tox-env: py311-django42 44 | - python-version: "3.12" 45 | tox-env: py312-django42 46 | - python-version: "3.10" 47 | tox-env: py310-django50 48 | - python-version: "3.11" 49 | tox-env: py311-django50 50 | - python-version: "3.12" 51 | tox-env: py312-django50 52 | - python-version: "3.10" 53 | tox-env: py310-django51 54 | - python-version: "3.11" 55 | tox-env: py311-django51 56 | - python-version: "3.12" 57 | tox-env: py312-django51 58 | - python-version: "3.13" 59 | tox-env: py313-django51 60 | - python-version: "3.10" 61 | tox-env: py310-django52 62 | - python-version: "3.11" 63 | tox-env: py311-django52 64 | - python-version: "3.12" 65 | tox-env: py312-django52 66 | - python-version: "3.13" 67 | tox-env: py313-django52 68 | steps: 69 | - uses: actions/checkout@v3 70 | 71 | - name: Set up Python ${{ matrix.python-version }} 72 | uses: actions/setup-python@v4 73 | with: 74 | python-version: ${{ matrix.python-version }} 75 | 76 | - name: Set up Pip cache 77 | uses: actions/cache@v3 78 | with: 79 | path: ~/.cache/pip 80 | key: ${{ runner.os }}-pip-${{ matrix.tox-env }}-${{ hashFiles('setup.py', 'tox.ini') }} 81 | restore-keys: | 82 | ${{ runner.os }}-pip-${{ matrix.tox-env }}- 83 | 84 | - name: Install setuptools 85 | run: pip install setuptools 86 | 87 | - name: Install tox 88 | run: pip install tox==3.* 89 | 90 | - name: Run Tox env '${{ matrix.tox-env }}' 91 | run: tox -e ${{ matrix.tox-env }} 92 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Deploy 3 | 4 | 'on': 5 | create: 6 | tags: 7 | - '**' 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.9' 22 | 23 | - name: Set up Pip cache 24 | uses: actions/cache@v3 25 | with: 26 | path: ~/.cache/pip 27 | key: ${{ runner.os }}-pip-py39-django32-${{ hashFiles('setup.py', 'tox.ini') }} 28 | restore-keys: | 29 | ${{ runner.os }}-pip-py39-django32- 30 | 31 | - name: Install dependencies 32 | run: pip install build==0.8.* 33 | 34 | - name: Build package 35 | run: python -m build 36 | 37 | - name: Publish package 38 | uses: pypa/gh-action-pypi-publish@release/v1 39 | with: 40 | user: __token__ 41 | password: ${{ secrets.PYPI_API_TOKEN }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | build/ 3 | dist/ 4 | *.egg-info/ 5 | *.egg 6 | *.py[cod] 7 | __pycache__/ 8 | *.so 9 | *~ 10 | .tox/ 11 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=migrations 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) [2016] [Thomas F. Meagher] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include cuser/templates * 2 | include LICENSE 3 | include README.rst 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | CUser, make email the USERNAME\_FIELD 2 | ===================================== 3 | 4 | CUser makes it easy to use email address as your identification token 5 | instead of a username. 6 | 7 | CUser is a custom Django user model (extends ``AbstractBaseUser``) so it 8 | takes a tiny amount of effort to use. 9 | 10 | The only difference between CUser and the vanilla Django ``User`` is email 11 | address is the ``USERNAME_FIELD`` (and username does not exist). 12 | 13 | CUser supports Django 3.2 - 5.2. If you need to use CUser with 14 | Django 1.8 - 3.1, you must install an older, unmaintained version of 15 | CUser, as noted in the "Install & Set up" section. 16 | 17 | Why use CUser? 18 | -------------- 19 | 20 | Because you want everything in ``django.contrib.auth`` except for the 21 | ``username`` field and you also want users to **log in with email addresses**. 22 | And you don't want to create your own custom user model or authentication 23 | backend. 24 | 25 | Install & Set up 26 | ---------------- 27 | 28 | **Important:** To keep things simple, the steps below will guide you through 29 | the process of using CUser's ``CUser`` model for your Django project's user 30 | model. However, it is strongly recommended that you set up a custom user model 31 | that extends CUser's ``AbstractCUser`` class, even if CUser's ``CUser`` model 32 | is sufficient for you (this way, you can customize the user model if the need 33 | arises). If you would *not* like to follow this recommendation and just want to 34 | use CUser's ``CUser`` model, simply follow the steps below (you can skip the 35 | rest of this paragraph). If you *would* like to follow this recommendation, you 36 | should still follow the steps below, but with the following adjustments: After 37 | step 3, follow 38 | `these instructions `_, 39 | but instead of using ``from django.contrib.auth.models import AbstractUser`` 40 | use ``from cuser.models import AbstractCUser`` and instead of using 41 | ``from django.contrib.auth.admin import UserAdmin`` use 42 | ``from cuser.admin import UserAdmin``. Then for step 4 of the steps below, you 43 | should set ``AUTH_USER_MODEL`` to your custom user model instead of CUser's 44 | ``CUser`` model. You should then run ``python manage.py makemigrations``. After 45 | that, you may follow the remaining steps below just the way they are. 46 | 47 | 1. If your Django project previously used Django's default user model, 48 | ``django.contrib.auth.models.User``, or if you are unfamiliar with using 49 | custom user models, jump to **Notes** first (then come 50 | back). Otherwise, continue onward! 51 | 52 | 2. Install with ``pip``: 53 | 54 | .. code-block:: shell 55 | 56 | # Django 3.2 - 5.2 57 | pip install django-username-email 58 | 59 | # Django 3.1 (unmaintained) 60 | pip install django-username-email==2.4.2 61 | 62 | # Django 2.2 or 3.0 (unmaintained) 63 | pip install django-username-email==2.3.1 64 | 65 | # Django 2.0 or 2.1 (unmaintained) 66 | pip install django-username-email==2.2.4 67 | 68 | # Django 1.11 (unmaintained) 69 | pip install django-username-email==2.1.6 70 | 71 | # Django 1.8 - 1.10 (unmaintained) 72 | pip install django-username-email==2.1.2 73 | 74 | 3. Add ``cuser`` to your ``INSTALLED_APPS`` setting: 75 | 76 | .. code-block:: python 77 | 78 | INSTALLED_APPS = [ 79 | ... 80 | 'cuser', 81 | ] 82 | 83 | 4. Specify the custom model as the default user model for your project 84 | using the ``AUTH_USER_MODEL`` setting in your settings.py: 85 | 86 | .. code-block:: python 87 | 88 | AUTH_USER_MODEL = 'cuser.CUser' 89 | 90 | 5. If you use Django's default ``AuthenticationForm`` class, it is 91 | strongly recommended that you replace it with the one included with 92 | CUser. This will make the ```` have its ``type`` attribute set 93 | to ``email`` and browsers' autocomplete feature will suggest email 94 | addresses instead of usernames. For example, if your project is using 95 | Django's default ``LoginView`` view (or ``login`` view in Django < 1.11), this is 96 | what you would put in your urls.py in order to make use of CUser's 97 | ``AuthenticationForm`` class: 98 | 99 | .. code-block:: python 100 | 101 | from cuser.forms import AuthenticationForm 102 | from django.conf.urls import include, url 103 | from django.contrib.auth.views import LoginView 104 | 105 | urlpatterns = [ 106 | url(r'^accounts/login/$', LoginView.as_view(authentication_form=AuthenticationForm), name='login'), 107 | url(r'^accounts/', include('django.contrib.auth.urls')), 108 | ... 109 | ] 110 | 111 | Or if you're using Django < 1.11: 112 | 113 | .. code-block:: python 114 | 115 | from cuser.forms import AuthenticationForm 116 | from django.conf.urls import include, url 117 | from django.contrib.auth.views import login 118 | 119 | urlpatterns = [ 120 | url(r'^accounts/login/$', login, {'authentication_form': AuthenticationForm}, name='login'), 121 | url(r'^accounts/', include('django.contrib.auth.urls')), 122 | ... 123 | ] 124 | 125 | 6. Run migrations. 126 | 127 | .. code-block:: shell 128 | 129 | python manage.py migrate 130 | 131 | 7. There is a good chance that you want foo@example.com and FOO@example.com to 132 | be treated as the same email address. There is a variety of ways to go about 133 | doing this. How you handle it will depend on the needs of your project and 134 | personal preference, so CUser does not provide a solution for this out of 135 | the box. You will need to address this yourself if this applies to you. If 136 | you're using CUser's ``AuthenticationForm`` class (see step 5), you may want 137 | to subclass it and override ``error_messages['invalid_login']``. 138 | 139 | Configuration 140 | ------------- 141 | 142 | To override any of the default settings, create a dictionary named ``CUSER`` in 143 | your settings.py with each setting you want to override. For example: 144 | 145 | .. code-block:: python 146 | 147 | CUSER = { 148 | 'app_verbose_name': 'Authentication and Authorization', 149 | 'register_proxy_auth_group_model': True, 150 | } 151 | 152 | These are the settings: 153 | 154 | ``app_verbose_name`` (default: ``_("Custom User")``) 155 | **************************************************** 156 | 157 | This controls the value that CUser will use for its ``AppConfig`` class' 158 | ``verbose_name``. 159 | 160 | ``register_proxy_auth_group_model`` (default: ``False``) 161 | ******************************************************** 162 | 163 | When set to ``True``, CUser's admin.py will unregister Django's default 164 | ``Group`` model and register its own proxy model of Django's default ``Group`` 165 | model (also named ``Group``). This is useful if you want Django's default 166 | ``Group`` model to appear in the same part of the admin as CUser's ``CUser`` 167 | model. 168 | 169 | Notes 170 | ----- 171 | 172 | If you have tables referencing Django's ``User`` model, you will have to 173 | delete those table and migrations, then re-migrate. This will ensure 174 | everything is set up correctly from the beginning. 175 | 176 | Instead of referring to User directly, you should reference the user model 177 | using ``django.contrib.auth.get_user_model()`` 178 | 179 | When you define a foreign key or many-to-many relations to the ``User`` 180 | model, you should specify the custom model using the ``AUTH_USER_MODEL`` 181 | setting. 182 | 183 | For example: 184 | 185 | .. code-block:: python 186 | 187 | from django.conf import settings 188 | from django.db import models 189 | 190 | class Profile(models.Model): 191 | user = models.ForeignKey( 192 | settings.AUTH_USER_MODEL, 193 | on_delete=models.CASCADE, 194 | ) 195 | 196 | License 197 | ------- 198 | 199 | Released under the MIT license. See LICENSE for details. 200 | 201 | Original author 202 | --------------- 203 | 204 | Tom Meagher 205 | 206 | Questions, comments, or anything else? 207 | -------------------------------------- 208 | 209 | - Open an issue 210 | -------------------------------------------------------------------------------- /cuser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ataylor32/django-username-email/eb410ac26af9bb27d4c6958f91e44a6781b2b3ed/cuser/__init__.py -------------------------------------------------------------------------------- /cuser/admin.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.contrib import admin 3 | from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin 4 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 5 | from django.contrib.auth.models import Group as StockGroup 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | from cuser.forms import AdminUserCreationForm, UserChangeForm 9 | from cuser.models import CUser, Group 10 | from cuser.settings import CUSER_SETTINGS 11 | 12 | 13 | @admin.register(CUser) 14 | class UserAdmin(BaseUserAdmin): 15 | add_form_template = 'admin/cuser/cuser/add_form.html' 16 | fieldsets = ( 17 | (None, {'fields': ('email', 'password')}), 18 | (_('Personal info'), {'fields': ('first_name', 'last_name')}), 19 | (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', 20 | 'groups', 'user_permissions')}), 21 | (_('Important dates'), {'fields': ('last_login', 'date_joined')}), 22 | ) 23 | form = UserChangeForm 24 | add_form = AdminUserCreationForm 25 | list_display = ('email', 'first_name', 'last_name', 'is_staff') 26 | search_fields = ('email', 'first_name', 'last_name') 27 | ordering = ('email',) 28 | 29 | def get_fieldsets(self, request, obj=None): 30 | if not obj: 31 | add_fieldsets = ( 32 | (None, { 33 | 'classes': ('wide',), 34 | 'fields': ['email', 'password1', 'password2'], 35 | }), 36 | ) 37 | 38 | if django.VERSION >= (5, 1): 39 | add_fieldsets[0][1]['fields'].insert(1, 'usable_password') 40 | 41 | return add_fieldsets 42 | 43 | return super().get_fieldsets(request, obj) 44 | 45 | 46 | if CUSER_SETTINGS['register_proxy_auth_group_model']: 47 | admin.site.unregister(StockGroup) 48 | 49 | @admin.register(Group) 50 | class GroupAdmin(BaseGroupAdmin): 51 | pass 52 | -------------------------------------------------------------------------------- /cuser/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from .settings import CUSER_SETTINGS 3 | 4 | 5 | class CuserConfig(AppConfig): 6 | name = 'cuser' 7 | verbose_name = CUSER_SETTINGS['app_verbose_name'] 8 | default_auto_field = 'django.db.models.AutoField' 9 | -------------------------------------------------------------------------------- /cuser/forms.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django import forms 3 | from django.contrib.auth import (authenticate, get_user_model, 4 | password_validation) 5 | from django.contrib.auth.forms import ReadOnlyPasswordHashField 6 | from django.utils.translation import gettext_lazy as _ 7 | from django.views.decorators.debug import sensitive_variables 8 | 9 | from cuser.models import CUser 10 | 11 | try: 12 | from django.contrib.auth.forms import (SetPasswordMixin, 13 | SetUnusablePasswordMixin) 14 | except ImportError: 15 | pass 16 | 17 | UserModel = get_user_model() 18 | 19 | 20 | class AuthenticationForm(forms.Form): 21 | """ 22 | Base class for authenticating users. Extend this to get a form that accepts 23 | email/password logins. 24 | """ 25 | email = forms.EmailField( 26 | label=_("Email address"), 27 | max_length=254, 28 | widget=forms.EmailInput(attrs={'autofocus': True}), 29 | ) 30 | password = forms.CharField( 31 | label=_("Password"), 32 | strip=False, 33 | widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), 34 | ) 35 | 36 | error_messages = { 37 | 'invalid_login': _( 38 | "Please enter a correct %(username)s and password. Note that both " 39 | "fields may be case-sensitive." 40 | ), 41 | 'inactive': _("This account is inactive."), 42 | } 43 | 44 | def __init__(self, request=None, *args, **kwargs): # pylint: disable=keyword-arg-before-vararg 45 | """ 46 | The 'request' parameter is set for custom auth use by subclasses. 47 | The form data comes in via the standard 'data' kwarg. 48 | """ 49 | self.request = request 50 | self.user_cache = None 51 | super().__init__(*args, **kwargs) 52 | 53 | self.username_field = UserModel._meta.get_field(UserModel.USERNAME_FIELD) 54 | 55 | @sensitive_variables() 56 | def clean(self): 57 | email = self.cleaned_data.get('email') 58 | password = self.cleaned_data.get('password') 59 | 60 | if email and password: 61 | self.user_cache = authenticate(self.request, email=email, password=password) 62 | if self.user_cache is None: 63 | raise self.get_invalid_login_error() 64 | else: 65 | self.confirm_login_allowed(self.user_cache) 66 | 67 | return self.cleaned_data 68 | 69 | def confirm_login_allowed(self, user): 70 | """ 71 | Controls whether the given User may log in. This is a policy setting, 72 | independent of end-user authentication. This default behavior is to 73 | allow login by active users, and reject login by inactive users. 74 | 75 | If the given user cannot log in, this method should raise a 76 | ``forms.ValidationError``. 77 | 78 | If the given user may log in, this method should return None. 79 | """ 80 | if not user.is_active: 81 | raise forms.ValidationError( 82 | self.error_messages['inactive'], 83 | code='inactive', 84 | ) 85 | 86 | def get_user(self): 87 | return self.user_cache 88 | 89 | def get_invalid_login_error(self): 90 | return forms.ValidationError( 91 | self.error_messages['invalid_login'], 92 | code='invalid_login', 93 | params={'username': self.username_field.verbose_name}, 94 | ) 95 | 96 | 97 | if django.VERSION >= (5, 1): 98 | class UserCreationForm(SetPasswordMixin, forms.ModelForm): 99 | """ 100 | A form that creates a user, with no privileges, from the given email and 101 | password. 102 | """ 103 | 104 | email = forms.EmailField( 105 | label=_("Email address"), 106 | max_length=254, 107 | widget=forms.EmailInput(attrs={'autofocus': True}), 108 | ) 109 | password1, password2 = SetPasswordMixin.create_password_fields() 110 | 111 | class Meta: 112 | model = CUser 113 | fields = ("email",) 114 | 115 | def clean(self): 116 | self.validate_passwords() 117 | return super().clean() 118 | 119 | def _post_clean(self): 120 | super()._post_clean() 121 | # Validate the password after self.instance is updated with form data 122 | # by super(). 123 | self.validate_password_for_user(self.instance) 124 | 125 | def save(self, commit=True): 126 | user = super().save(commit=False) 127 | user = self.set_password_and_save(user, commit=commit) 128 | if commit and hasattr(self, "save_m2m"): 129 | self.save_m2m() 130 | return user 131 | 132 | class AdminUserCreationForm(SetUnusablePasswordMixin, UserCreationForm): 133 | usable_password = SetUnusablePasswordMixin.create_usable_password_field() 134 | 135 | def __init__(self, *args, **kwargs): 136 | super().__init__(*args, **kwargs) 137 | 138 | if django.VERSION >= (5, 2): 139 | self.fields["password1"].required = False 140 | self.fields["password2"].required = False 141 | else: 142 | class UserCreationForm(forms.ModelForm): 143 | """ 144 | A form that creates a user, with no privileges, from the given email and 145 | password. 146 | """ 147 | error_messages = { 148 | 'password_mismatch': _('The two password fields didn’t match.'), 149 | } 150 | email = forms.EmailField( 151 | label=_("Email address"), 152 | max_length=254, 153 | widget=forms.EmailInput(attrs={'autofocus': True}), 154 | ) 155 | password1 = forms.CharField( 156 | label=_("Password"), 157 | strip=False, 158 | widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), 159 | help_text=password_validation.password_validators_help_text_html(), 160 | ) 161 | password2 = forms.CharField( 162 | label=_("Password confirmation"), 163 | widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), 164 | strip=False, 165 | help_text=_("Enter the same password as before, for verification."), 166 | ) 167 | 168 | class Meta: 169 | model = CUser 170 | fields = ("email",) 171 | 172 | def clean_password2(self): 173 | password1 = self.cleaned_data.get("password1") 174 | password2 = self.cleaned_data.get("password2") 175 | if password1 and password2 and password1 != password2: 176 | raise forms.ValidationError( 177 | self.error_messages['password_mismatch'], 178 | code='password_mismatch', 179 | ) 180 | return password2 181 | 182 | def _post_clean(self): 183 | super()._post_clean() 184 | # Validate the password after self.instance is updated with form data 185 | # by super(). 186 | password = self.cleaned_data.get('password2') 187 | if password: 188 | try: 189 | password_validation.validate_password(password, self.instance) 190 | except forms.ValidationError as error: 191 | self.add_error('password2', error) 192 | 193 | def save(self, commit=True): 194 | user = super().save(commit=False) 195 | user.set_password(self.cleaned_data["password1"]) 196 | if commit: 197 | user.save() 198 | if django.VERSION >= (4, 2) and hasattr(self, "save_m2m"): 199 | self.save_m2m() 200 | return user 201 | 202 | class AdminUserCreationForm(UserCreationForm): 203 | pass 204 | 205 | 206 | class UserChangeForm(forms.ModelForm): 207 | email = forms.EmailField( 208 | label=_("Email address"), 209 | max_length=254, 210 | widget=forms.EmailInput(), 211 | ) 212 | password = ReadOnlyPasswordHashField( 213 | label=_("Password"), 214 | help_text=_( 215 | 'Raw passwords are not stored, so there is no way to see this ' 216 | 'user’s password, but you can change the password using ' 217 | 'this form.' 218 | ), 219 | ) 220 | 221 | class Meta: 222 | model = CUser 223 | fields = '__all__' 224 | 225 | def __init__(self, *args, **kwargs): 226 | super().__init__(*args, **kwargs) 227 | 228 | password = self.fields.get('password') 229 | if password: 230 | if django.VERSION >= (4, 2): 231 | password.help_text = password.help_text.format( 232 | f"../../{self.instance.pk}/password/" 233 | ) 234 | else: 235 | password.help_text = password.help_text.format('../password/') 236 | user_permissions = self.fields.get('user_permissions') 237 | if user_permissions: 238 | user_permissions.queryset = user_permissions.queryset.select_related('content_type') 239 | 240 | if django.VERSION < (3, 2): 241 | def clean_password(self): 242 | # Regardless of what the user provides, return the initial value. 243 | # This is done here, rather than on the field, because the 244 | # field does not have access to the initial value 245 | return self.initial.get('password') 246 | -------------------------------------------------------------------------------- /cuser/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | import django.utils.timezone 6 | import cuser.models 7 | import django.contrib.auth.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('auth', '0006_require_contenttypes_0002'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='CUser', 19 | fields=[ 20 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 21 | ('password', models.CharField(max_length=128, verbose_name='password')), 22 | ('last_login', models.DateTimeField(null=True, verbose_name='last login', blank=True)), 23 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 24 | ('email', models.EmailField(unique=True, max_length=254, verbose_name='email address', error_messages={'unique': 'A user with that email address already exists.'})), 25 | ('first_name', models.CharField(max_length=30, verbose_name='first name', blank=True)), 26 | ('last_name', models.CharField(max_length=30, verbose_name='last name', blank=True)), 27 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 28 | ('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')), 29 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 30 | ], 31 | options={ 32 | 'abstract': False, 33 | 'swappable': 'AUTH_USER_MODEL', 34 | 'verbose_name': 'user', 35 | 'verbose_name_plural': 'users', 36 | }, 37 | managers=[ 38 | ('objects', cuser.models.CUserManager()), 39 | ], 40 | ), 41 | migrations.CreateModel( 42 | name='Group', 43 | fields=[ 44 | ], 45 | options={ 46 | 'verbose_name': 'group', 47 | 'verbose_name_plural': 'groups', 48 | 'proxy': True, 49 | }, 50 | bases=('auth.group',), 51 | managers=[ 52 | ('objects', django.contrib.auth.models.GroupManager()), 53 | ], 54 | ), 55 | migrations.AddField( 56 | model_name='cuser', 57 | name='groups', 58 | field=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'), 59 | ), 60 | migrations.AddField( 61 | model_name='cuser', 62 | name='user_permissions', 63 | field=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'), 64 | ), 65 | ] 66 | -------------------------------------------------------------------------------- /cuser/migrations/0002_alter_user_last_name_max_length.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('cuser', '0001_initial'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name='cuser', 13 | name='last_name', 14 | field=models.CharField(blank=True, max_length=150, verbose_name='last name'), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /cuser/migrations/0003_alter_user_first_name_max_length.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('cuser', '0002_alter_user_last_name_max_length'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name='cuser', 13 | name='first_name', 14 | field=models.CharField(blank=True, max_length=150, verbose_name='first name'), 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /cuser/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ataylor32/django-username-email/eb410ac26af9bb27d4c6958f91e44a6781b2b3ed/cuser/migrations/__init__.py -------------------------------------------------------------------------------- /cuser/models.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.db import models 3 | from django.contrib import auth 4 | from django.contrib.auth.models import ( 5 | BaseUserManager, PermissionsMixin, AbstractBaseUser 6 | ) 7 | from django.contrib.auth.models import Group as BaseGroup 8 | from django.core.mail import send_mail 9 | from django.utils import timezone 10 | from django.utils.translation import gettext_lazy as _ 11 | 12 | if django.VERSION >= (3, 2): 13 | from django.contrib.auth.hashers import make_password 14 | 15 | 16 | class CUserManager(BaseUserManager): 17 | use_in_migrations = True 18 | 19 | def _create_user(self, email, password, **extra_fields): 20 | """ 21 | Create and save a user with the given email and password. 22 | """ 23 | if not email: 24 | raise ValueError('The given email must be set') 25 | email = self.normalize_email(email) 26 | user = self.model(email=email, **extra_fields) 27 | 28 | if django.VERSION >= (3, 2): 29 | user.password = make_password(password) 30 | else: 31 | user.set_password(password) 32 | 33 | user.save(using=self._db) 34 | return user 35 | 36 | def create_user(self, email, password=None, **extra_fields): 37 | extra_fields.setdefault('is_staff', False) 38 | extra_fields.setdefault('is_superuser', False) 39 | return self._create_user(email, password, **extra_fields) 40 | 41 | def create_superuser(self, email, password=None, **extra_fields): 42 | extra_fields.setdefault('is_staff', True) 43 | extra_fields.setdefault('is_superuser', True) 44 | 45 | if extra_fields.get('is_staff') is not True: 46 | raise ValueError('Superuser must have is_staff=True.') 47 | if extra_fields.get('is_superuser') is not True: 48 | raise ValueError('Superuser must have is_superuser=True.') 49 | 50 | return self._create_user(email, password, **extra_fields) 51 | 52 | def with_perm(self, perm, is_active=True, include_superusers=True, backend=None, obj=None): 53 | if backend is None: 54 | backends = auth._get_backends(return_tuples=True) # pylint: disable=protected-access 55 | if len(backends) == 1: 56 | backend, _ = backends[0] 57 | else: 58 | raise ValueError( 59 | 'You have multiple authentication backends configured and ' 60 | 'therefore must provide the `backend` argument.' 61 | ) 62 | elif not isinstance(backend, str): 63 | raise TypeError( 64 | 'backend must be a dotted import path string (got %r).' 65 | % backend 66 | ) 67 | else: 68 | backend = auth.load_backend(backend) 69 | if hasattr(backend, 'with_perm'): 70 | return backend.with_perm( 71 | perm, 72 | is_active=is_active, 73 | include_superusers=include_superusers, 74 | obj=obj, 75 | ) 76 | return self.none() 77 | 78 | 79 | class AbstractCUser(AbstractBaseUser, PermissionsMixin): 80 | """ 81 | An abstract base class implementing a fully featured User model with 82 | admin-compliant permissions. 83 | 84 | Email and password are required. Other fields are optional. 85 | """ 86 | email = models.EmailField( 87 | _('email address'), 88 | unique=True, 89 | error_messages={ 90 | 'unique': _("A user with that email address already exists."), 91 | }, 92 | ) 93 | first_name = models.CharField(_('first name'), max_length=150, blank=True) 94 | last_name = models.CharField(_('last name'), max_length=150, blank=True) 95 | is_staff = models.BooleanField( 96 | _('staff status'), 97 | default=False, 98 | help_text=_('Designates whether the user can log into this admin site.'), 99 | ) 100 | is_active = models.BooleanField( 101 | _('active'), 102 | default=True, 103 | help_text=_( 104 | 'Designates whether this user should be treated as active. ' 105 | 'Unselect this instead of deleting accounts.' 106 | ), 107 | ) 108 | date_joined = models.DateTimeField(_('date joined'), default=timezone.now) 109 | 110 | objects = CUserManager() 111 | 112 | EMAIL_FIELD = 'email' 113 | USERNAME_FIELD = 'email' 114 | 115 | class Meta: 116 | verbose_name = _('user') 117 | verbose_name_plural = _('users') 118 | abstract = True 119 | 120 | def clean(self): 121 | super().clean() 122 | self.email = self.__class__.objects.normalize_email(self.email) 123 | 124 | def get_full_name(self): 125 | """ 126 | Return the first_name plus the last_name, with a space in between. 127 | """ 128 | full_name = '%s %s' % (self.first_name, self.last_name) 129 | return full_name.strip() 130 | 131 | def get_short_name(self): 132 | """Return the short name for the user.""" 133 | return self.first_name 134 | 135 | def email_user(self, subject, message, from_email=None, **kwargs): 136 | """Send an email to this user.""" 137 | send_mail(subject, message, from_email, [self.email], **kwargs) 138 | 139 | 140 | class CUser(AbstractCUser): 141 | """ 142 | Users within the Django authentication system are represented by this 143 | model. 144 | 145 | Password and email are required. Other fields are optional. 146 | """ 147 | class Meta(AbstractCUser.Meta): 148 | swappable = 'AUTH_USER_MODEL' 149 | 150 | 151 | class Group(BaseGroup): 152 | class Meta: 153 | verbose_name = _('group') 154 | verbose_name_plural = _('groups') 155 | proxy = True 156 | -------------------------------------------------------------------------------- /cuser/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | CUSER_SETTINGS = { 5 | 'app_verbose_name': _("Custom User"), 6 | 'register_proxy_auth_group_model': False, 7 | } 8 | 9 | if hasattr(settings, 'CUSER'): 10 | CUSER_SETTINGS.update(settings.CUSER) 11 | -------------------------------------------------------------------------------- /cuser/templates/admin/cuser/cuser/add_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/auth/user/add_form.html" %} 2 | {% load i18n %} 3 | 4 | {% block form_top %} 5 | {% if not is_popup %} 6 |

{% trans 'First, enter an email address and password. Then, you’ll be able to edit more user options.' %}

7 | {% else %} 8 |

{% trans "Enter an email address and password." %}

9 | {% endif %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from setuptools import setup, find_namespace_packages 3 | 4 | here = path.abspath(path.dirname(__file__)) 5 | 6 | with open(path.join(here, 'README.rst')) as f: 7 | long_description = f.read() 8 | 9 | setup( 10 | name='django-username-email', 11 | 12 | version='2.5.7', 13 | 14 | description='Custom Django User model that makes email the USERNAME_FIELD.', 15 | long_description=long_description, 16 | 17 | url='https://github.com/ataylor32/django-username-email/', 18 | 19 | author='Adam Taylor', 20 | author_email='ataylor32@gmail.com', 21 | 22 | license='MIT', 23 | 24 | classifiers=[ 25 | 'Development Status :: 5 - Production/Stable', 26 | 27 | 'Intended Audience :: Developers', 28 | 'Topic :: Internet :: WWW/HTTP', 29 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 30 | 31 | 'License :: OSI Approved :: MIT License', 32 | 33 | 'Programming Language :: Python', 34 | 'Programming Language :: Python :: 3.6', 35 | 'Programming Language :: Python :: 3.7', 36 | 'Programming Language :: Python :: 3.8', 37 | 'Programming Language :: Python :: 3.9', 38 | 'Programming Language :: Python :: 3.10', 39 | 'Programming Language :: Python :: 3.11', 40 | 'Programming Language :: Python :: 3.12', 41 | 'Programming Language :: Python :: 3.13', 42 | 43 | 'Environment :: Web Environment', 44 | 'Framework :: Django', 45 | 'Framework :: Django :: 3.2', 46 | 'Framework :: Django :: 4.0', 47 | 'Framework :: Django :: 4.1', 48 | 'Framework :: Django :: 4.2', 49 | 'Framework :: Django :: 5.0', 50 | 'Framework :: Django :: 5.1', 51 | 'Framework :: Django :: 5.2', 52 | 'Operating System :: OS Independent', 53 | ], 54 | keywords='user email username', 55 | 56 | packages=find_namespace_packages(), 57 | include_package_data=True, 58 | 59 | install_requires=[ 60 | 'django>=3.2', 61 | ] 62 | ) 63 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{36,37,38,39,310}-django32 4 | py{38,39,310}-django40 5 | py{38,39,310,311}-django41 6 | py{38,39,310,311,312}-django42 7 | py{310,311,312}-django50 8 | py{310,311,312,313}-django{51,52} 9 | 10 | [testenv] 11 | deps = 12 | django32: Django>=3.2,<4.0 13 | django40: Django>=4.0,<4.1 14 | django41: Django>=4.1,<4.2 15 | django42: Django>=4.2,<5.0 16 | django50: Django>=5.0,<5.1 17 | django51: Django>=5.1,<5.2 18 | django52: Django>=5.2,<6.0 19 | py36: pylint==2.9.6 20 | py{37,38,39,310,311,312,313}: pylint==2.17.2 21 | commands = 22 | pylint --disable=R,C cuser 23 | --------------------------------------------------------------------------------