├── tests
├── tests
│ ├── __init__.py
│ ├── migrations
│ │ ├── __init__.py
│ │ └── 0001_initial.py
│ ├── urls.py
│ ├── sqlite_test_settings.py
│ ├── models.py
│ ├── settings.py
│ ├── fixtures
│ │ ├── authtoolstestdata.json
│ │ └── customusertestdata.json
│ └── tests.py
├── .gitignore
└── manage.py
├── authtools
├── migrations
│ ├── __init__.py
│ ├── 0002_django18.py
│ ├── 0003_auto_20160128_0912.py
│ └── 0001_initial.py
├── apps.py
├── __init__.py
├── backends.py
├── admin.py
├── models.py
└── forms.py
├── docs
├── contributing.rst
├── changelog.rst
├── _themes
│ └── kr
│ │ ├── theme.conf
│ │ ├── relations.html
│ │ ├── layout.html
│ │ └── static
│ │ ├── small_flask.css
│ │ └── flasky.css_t
├── _ext
│ └── djangodocs.py
├── how-to
│ ├── index.rst
│ ├── admin.py
│ ├── invitation-email.rst
│ └── migrate-to-a-custom-user-model.rst
├── talks.rst
├── index.rst
├── admin.rst
├── backends.rst
├── forms.rst
├── intro.rst
├── Makefile
└── conf.py
├── requirements-dev.txt
├── .gitignore
├── .readthedocs.yaml
├── MANIFEST.in
├── Makefile
├── tox.ini
├── README.rst
├── LICENSE
├── CONTRIBUTING.rst
├── setup.py
├── RELEASES.rst
├── .github
└── workflows
│ └── ci.yml
└── CHANGES.rst
/tests/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/authtools/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/.gitignore:
--------------------------------------------------------------------------------
1 | /auth_tests/
2 |
--------------------------------------------------------------------------------
/tests/tests/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/tests/urls.py:
--------------------------------------------------------------------------------
1 | urlpatterns = []
2 |
--------------------------------------------------------------------------------
/docs/contributing.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../CONTRIBUTING.rst
2 |
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | :tocdepth: 1
2 |
3 | .. _changes:
4 |
5 | .. include:: ../CHANGES.rst
6 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | tox
2 | coverage
3 | Sphinx
4 | zest.releaser[recommended]
5 | Django>=1.11
6 | -e .
7 |
--------------------------------------------------------------------------------
/docs/_themes/kr/theme.conf:
--------------------------------------------------------------------------------
1 | [theme]
2 | inherit = basic
3 | stylesheet = flasky.css
4 | pygments_style = flask_theme_support.FlaskyStyle
5 |
6 | [options]
7 | touch_icon =
8 |
--------------------------------------------------------------------------------
/authtools/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class AuthtoolsConfig(AppConfig):
5 | name = 'authtools'
6 | default_auto_field = 'django.db.models.AutoField'
7 |
--------------------------------------------------------------------------------
/docs/_ext/djangodocs.py:
--------------------------------------------------------------------------------
1 | def setup(app):
2 | app.add_crossref_type(
3 | directivename="setting",
4 | rolename="setting",
5 | indextemplate="pair: %s; setting",
6 | )
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *.egg-info/
3 | docs/_build/
4 | tests/sqlite_database
5 | tests/.coverage
6 | tests/htmlcov/
7 | dist/
8 | build/
9 | .tox
10 | /django.tar.gz
11 | .idea/
12 | .DS_Store
13 |
--------------------------------------------------------------------------------
/docs/how-to/index.rst:
--------------------------------------------------------------------------------
1 | Tutorials
2 | =========
3 |
4 | Here is a list of tutorials for dealing with custom User models.
5 |
6 | .. toctree::
7 | :maxdepth: 1
8 |
9 | migrate-to-a-custom-user-model
10 | invitation-email
11 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | build:
4 | os: ubuntu-20.04
5 | tools:
6 | python: "3.9"
7 |
8 | sphinx:
9 | configuration: docs/conf.py
10 |
11 | python:
12 | install:
13 | - requirements: requirements-dev.txt
14 |
--------------------------------------------------------------------------------
/tests/tests/sqlite_test_settings.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 |
3 | from tests.settings import *
4 |
5 | DATABASES = {
6 | 'default': {
7 | 'ENGINE': 'django.db.backends.sqlite3',
8 | 'NAME': '',
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/authtools/__init__.py:
--------------------------------------------------------------------------------
1 | try:
2 | from importlib.metadata import version
3 |
4 | __version__ = version('django-authtools')
5 | except ImportError:
6 | import pkg_resources
7 |
8 | __version__ = pkg_resources.get_distribution('django-authtools').version
9 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.rst CHANGES.rst CONTRIBUTING.rst RELEASES.rst LICENSE Makefile
2 | include requirements-dev.txt
3 | include tox.ini
4 | include .readthedocs.yaml
5 | recursive-include tests *.py
6 | recursive-include tests *.json
7 | recursive-include docs *
8 | prune docs/_build
9 | global-exclude *.pyc
10 |
--------------------------------------------------------------------------------
/docs/talks.rst:
--------------------------------------------------------------------------------
1 | Talks
2 | =====
3 |
4 | 2013 August 27 - Boulder Django
5 | -------------------------------
6 |
7 | You can see the slides for "django-authtools, Custom User Model for
8 | Everyone!" given at `Boulder Django
9 | `_ on `Speaker Deck
10 | `_.
11 |
--------------------------------------------------------------------------------
/tests/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import sys
4 | import warnings
5 |
6 | import django
7 |
8 | warnings.simplefilter('error')
9 |
10 | if __name__ == "__main__":
11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
12 |
13 | from django.core.management import execute_from_command_line
14 |
15 | execute_from_command_line(sys.argv)
16 |
--------------------------------------------------------------------------------
/authtools/migrations/0002_django18.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import unicode_literals
3 |
4 | from django.db import models, migrations
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ('authtools', '0001_initial'),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterField(
15 | model_name='user',
16 | name='last_login',
17 | field=models.DateTimeField(null=True, verbose_name='last login', blank=True),
18 | ),
19 | ]
20 |
--------------------------------------------------------------------------------
/docs/_themes/kr/relations.html:
--------------------------------------------------------------------------------
1 | Related Topics
2 |
20 |
--------------------------------------------------------------------------------
/tests/tests/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | from authtools.models import AbstractEmailUser
4 |
5 |
6 | class User(AbstractEmailUser):
7 | full_name = models.CharField('full name', max_length=255, blank=True)
8 | preferred_name = models.CharField('preferred name', max_length=255,
9 | blank=True)
10 |
11 | REQUIRED_FIELDS = ['full_name', 'preferred_name']
12 |
13 | class Meta:
14 | # We need this line to prevent manage.py validate clashes.
15 | swappable = 'AUTH_USER_MODEL'
16 |
17 | def get_full_name(self):
18 | return self.full_name
19 |
20 | def get_short_name(self):
21 | return self.preferred_name
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | TESTS=tests authtools
2 | SETTINGS=tests.sqlite_test_settings
3 | COVERAGE_COMMAND=
4 |
5 | test: test-builtin test-authtools test-customuser
6 |
7 | test-builtin:
8 | cd tests && DJANGO_SETTINGS_MODULE=$(SETTINGS) $(COVERAGE_COMMAND) ./manage.py test --traceback $(TESTS) --verbosity=2
9 |
10 | test-authtools:
11 | +AUTH_USER_MODEL='authtools.User' make test-builtin
12 |
13 | test-customuser:
14 | +AUTH_USER_MODEL='tests.User' make test-builtin
15 |
16 | coverage:
17 | +make test COVERAGE_COMMAND='coverage run --source=authtools --branch --parallel-mode'
18 | cd tests && coverage combine && coverage html
19 |
20 | docs:
21 | cd docs && $(MAKE) html
22 |
23 | .PHONY: test test-builtin test-authtools test-customuser coverage docs
24 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist=
3 | py37-dj{22,30,32,32}
4 | py{38,39}-dj{22,30,31,32,40,41,42}
5 | py{10}-dj{32,40,41,42,50,51,52}
6 | py{11,12}-dj{42,50,51,52}
7 | py313-dj{51,52}
8 | [testenv]
9 | python=
10 | py37: python3.7
11 | py38: python3.8
12 | py39: python3.9
13 | py310: python3.10
14 | py311: python3.11
15 | py312: python3.12
16 | py313: python3.13
17 | commands=
18 | /usr/bin/env
19 | make test
20 | deps=
21 | dj22: Django>=2.2,<2.3
22 | dj30: Django>=3.0,<3.1
23 | dj31: Django>=3.1,<3.2
24 | dj32: Django>=3.2,<3.3
25 | dj40: Django>=4.0,<4.1
26 | dj41: Django>=4.1,<4.2
27 | dj42: Django>=4.2,<4.3
28 | dj50: Django>=5.0,<5.1
29 | dj51: Django>=5.1,<5.2
30 | dj52: Django>=5.2,<5.3
31 | whitelist_externals=
32 | env
33 | make
34 |
--------------------------------------------------------------------------------
/authtools/migrations/0003_auto_20160128_0912.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.9.1 on 2016-01-28 15:12
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | ('authtools', '0002_django18'),
12 | ]
13 |
14 | operations = [
15 | migrations.AlterField(
16 | model_name='user',
17 | name='email',
18 | field=models.EmailField(max_length=255, unique=True, verbose_name='email address'),
19 | ),
20 | migrations.AlterField(
21 | model_name='user',
22 | name='is_active',
23 | field=models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active'),
24 | ),
25 | ]
26 |
--------------------------------------------------------------------------------
/docs/_themes/kr/layout.html:
--------------------------------------------------------------------------------
1 | {%- extends "basic/layout.html" %}
2 | {%- block extrahead %}
3 | {{ super() }}
4 | {% if theme_touch_icon %}
5 |
6 | {% endif %}
7 |
9 |
10 | {% endblock %}
11 | {%- block relbar2 %}{% endblock %}
12 | {%- block footer %}
13 |
16 |
17 |
18 |
19 | {%- endblock %}
20 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | django-authtools
2 | ================
3 |
4 | |Build status|
5 |
6 | .. |Build status| image:: https://github.com/fusionbox/django-authtools/actions/workflows/ci.yml/badge.svg
7 | :target: https://github.com/fusionbox/django-authtools/actions/workflows/ci.yml
8 | :alt: Build Status
9 |
10 |
11 | A custom user model app for Django 2.2+ that features email as username and
12 | other things. It tries to stay true to the built-in user model for the most
13 | part.
14 |
15 | Read the `django-authtools documentation
16 | `_.
17 |
18 | Quickstart
19 | ==========
20 |
21 | Before you use this, you should probably read the documentation about `custom
22 | User models
23 | `_.
24 |
25 | 1. Install the package:
26 |
27 | .. code-block:: bash
28 |
29 | $ pip install django-authtools
30 |
31 | 2. Add ``authtools`` to your ``INSTALLED_APPS``.
32 |
33 | 3. Add the following to your settings.py:
34 |
35 | .. code-block:: python
36 |
37 | AUTH_USER_MODEL = 'authtools.User'
38 |
39 | 4. Enjoy.
40 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | django-authtools
2 | ================
3 |
4 | A custom user model app for Django 2.2+. It tries to stay true to the built-in
5 | User model for the most part. The main differences between authtools and
6 | django.contrib.auth are a User model with email as username.
7 |
8 | It provides its own custom User model, ModelAdmin, and Forms. The Admin classes
9 | and forms, however, are all User model agnostic, so they will work with any
10 | User model. django-authtools also provides base classes that make creating
11 | your own custom User model easier.
12 |
13 | Contents:
14 |
15 | .. toctree::
16 | :maxdepth: 2
17 |
18 | intro
19 | admin
20 | forms
21 | backends
22 | how-to/index
23 | talks
24 | contributing
25 | changelog
26 |
27 | Development
28 | -----------
29 |
30 | Development for django-authtools happens on `GitHub
31 | `_. Pull requests are welcome.
32 | Continuous integration uses `GitHub Actions
33 | `_.
34 |
35 | |Build status|
36 |
37 | .. |Build status| image:: https://github.com/fusionbox/django-authtools/actions/workflows/ci.yml/badge.svg
38 | :target: https://github.com/fusionbox/django-authtools/actions/workflows/ci.yml
39 | :alt: Build Status
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2013, Fusionbox, Inc.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | - Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 | - Redistributions in binary form must reproduce the above copyright notice,
10 | this list of conditions and the following disclaimer in the documentation
11 | and/or other materials provided with the distribution.
12 |
13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23 |
--------------------------------------------------------------------------------
/docs/admin.rst:
--------------------------------------------------------------------------------
1 | Admin
2 | =====
3 |
4 | .. currentmodule:: authtools.admin
5 |
6 | django-authtools provides a couple of Admin classes. The default one is
7 | :class:`NamedUserAdmin`, which provides an admin similar to
8 | django.contrib.auth. If you are not using the
9 | :class:`~authtools.models.AbstractNamedUser`, you might want the
10 | :class:`UserAdmin` instead.
11 |
12 | If you are using your own user model, authtools won't register an Admin class to
13 | avoid problems. If you define ``REQUIRED_FIELDS`` on your custom model, authtools
14 | will add those to the first fieldset.
15 |
16 |
17 | .. class:: NamedUserAdmin
18 |
19 | This is the default Admin that is used if you use
20 | :class:`authtools.models.User` as you ``AUTH_USER_MODEL``. Provides an admin
21 | for the default :class:`authtools.models.User` model. It includes the
22 | default Permissions and Important Date sections.
23 |
24 | .. class:: UserAdmin
25 |
26 | Provides a generic admin class for any User model. It behaves as similarly
27 | to the built-in UserAdmin class as possible.
28 |
29 | .. class:: StrippedUserAdmin
30 |
31 | Provides a simpler view on the UserAdmin, it doesn't include the Permission
32 | filters or the Important Dates section.
33 |
34 |
35 | .. class:: StrippedNamedUserAdmin
36 |
37 | Same as StrippedUserAdmin, but for a User model that has a ``name`` field.
38 |
--------------------------------------------------------------------------------
/docs/backends.rst:
--------------------------------------------------------------------------------
1 | Authentication Backends
2 | =======================
3 |
4 | .. currentmodule:: authtools.backends
5 |
6 | django-authtools provides two authentication backend classes. These backends offer more customization
7 | for authentication.
8 |
9 | .. class:: CaseInsensitiveUsernameFieldModelBackend
10 |
11 | Enables case-insensitive logins for the User model. It works by simply lowercasing usernames
12 | before trying to authenticate.
13 |
14 | There is also a :class:`CaseInsensitiveUsernameFieldBackendMixin` if you need more flexibility.
15 |
16 | To use this backend class, add it to your settings:
17 |
18 | .. code-block:: python
19 |
20 | # settings.py
21 | AUTHENTICATION_BACKENDS = [
22 | 'authtools.backends.CaseInsensitiveUsernameFieldModelBackend',
23 | ]
24 |
25 | .. warning::
26 |
27 | Use of this mixin assumes that all usernames are stored in their lowercase form, and
28 | that there is no way to have usernames differing only in case. If usernames can differ in
29 | case, this authentication backend mixin could cause errors in user authentication. It is
30 | advised that you use this mixin in conjuction with the
31 | :class:`~authtools.forms.CaseInsensitiveUsernameFieldCreationForm` form.
32 |
33 | .. class:: CaseInsensitiveUsernameFieldBackendMixin
34 |
35 | Mixin enabling case-insensitive logins.
36 |
--------------------------------------------------------------------------------
/docs/_themes/kr/static/small_flask.css:
--------------------------------------------------------------------------------
1 | /*
2 | * small_flask.css_t
3 | * ~~~~~~~~~~~~~~~~~
4 | *
5 | * :copyright: Copyright 2010 by Armin Ronacher.
6 | * :license: Flask Design License, see LICENSE for details.
7 | */
8 |
9 | body {
10 | margin: 0;
11 | padding: 20px 30px;
12 | }
13 |
14 | div.documentwrapper {
15 | float: none;
16 | background: white;
17 | }
18 |
19 | div.sphinxsidebar {
20 | display: block;
21 | float: none;
22 | width: 102.5%;
23 | margin: 50px -30px -20px -30px;
24 | padding: 10px 20px;
25 | background: #333;
26 | color: white;
27 | }
28 |
29 | div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p,
30 | div.sphinxsidebar h3 a {
31 | color: white;
32 | }
33 |
34 | div.sphinxsidebar a {
35 | color: #aaa;
36 | }
37 |
38 | div.sphinxsidebar p.logo {
39 | display: none;
40 | }
41 |
42 | div.document {
43 | width: 100%;
44 | margin: 0;
45 | }
46 |
47 | div.related {
48 | display: block;
49 | margin: 0;
50 | padding: 10px 0 20px 0;
51 | }
52 |
53 | div.related ul,
54 | div.related ul li {
55 | margin: 0;
56 | padding: 0;
57 | }
58 |
59 | div.footer {
60 | display: none;
61 | }
62 |
63 | div.bodywrapper {
64 | margin: 0;
65 | }
66 |
67 | div.body {
68 | min-height: 0;
69 | padding: 0;
70 | }
71 |
72 | .rtd_doc_footer {
73 | display: none;
74 | }
75 |
76 | .document {
77 | width: auto;
78 | }
79 |
80 | .footer {
81 | width: auto;
82 | }
83 |
84 | .footer {
85 | width: auto;
86 | }
87 |
88 | .github {
89 | display: none;
90 | }
--------------------------------------------------------------------------------
/CONTRIBUTING.rst:
--------------------------------------------------------------------------------
1 | Contributing
2 | ------------
3 |
4 | We welcome contributions of all sizes, whether it be a small text change or a large new feature.
5 | Here are are some steps for getting started contributing.
6 |
7 |
8 |
9 | Getting Started
10 | ===============
11 |
12 | 1. Install the development requirements::
13 |
14 | $ pip install -r requirements-dev.txt
15 |
16 |
17 | Running Tests
18 | =============
19 |
20 | The best way to run the tests is using `tox`_. You can run the tests on all of our supported
21 | Python and Django versions by running::
22 |
23 | $ tox
24 |
25 | You can also run specific targets using the ``-e`` flag. ::
26 |
27 | $ tox -e py33-dj18
28 |
29 | A full list of available tox environments is in the ``tox.ini`` configuration file.
30 |
31 | django-authtools comes with a test suite that inherits from the built-in Django auth test suite.
32 | This helps us ensure compatibility with Django and that we can get a little bit of code reuse. The
33 | tests are run three times against three different User models.
34 |
35 | You can get a test coverage report by running ``make coverage``. We do not strive for 100% coverage
36 | on django-authtools, but it is still a useful metric.
37 |
38 | .. _tox: http://tox.readthedocs.org/en/latest/
39 |
40 |
41 | Building Documentation
42 | ======================
43 |
44 | You can build the documentation by running ::
45 |
46 | $ make docs
47 |
48 | If you are editing the ``README.rst`` file, please make sure that it compiles correctly using the
49 | ``longtest`` command that is provided by ``zest.releaser``. ::
50 |
51 | $ longtest
52 |
--------------------------------------------------------------------------------
/authtools/backends.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.backends import ModelBackend
2 |
3 |
4 | class CaseInsensitiveUsernameFieldBackendMixin(object):
5 | """
6 | This authentication backend assumes that usernames are email addresses and simply
7 | lowercases a username before an attempt is made to authenticate said username using a
8 | superclass's authenticate method. This superclass should be either a user-defined
9 | authentication backend, or a Django-provided authentication backend (e.g., ModelBackend).
10 |
11 | Example usage:
12 | See CaseInsensitiveUsernameFieldBackend, below.
13 |
14 | NOTE:
15 | A word of caution. Use of this backend presupposes a way to ensure that users cannot
16 | create usernames that differ only in case (e.g., joe@test.org and JOE@test.org). It is
17 | advised that you use this backend in conjunction with the
18 | CaseInsensitiveUsernameFieldCreationForm provided in the forms module.
19 | """
20 |
21 | def authenticate(self, request, username=None, password=None, **kwargs):
22 | if username is not None:
23 | username = username.lower()
24 |
25 | return super(CaseInsensitiveUsernameFieldBackendMixin, self).authenticate(
26 | request,
27 | username=username,
28 | password=password,
29 | **kwargs
30 | )
31 |
32 | class CaseInsensitiveUsernameFieldModelBackend(
33 | CaseInsensitiveUsernameFieldBackendMixin,
34 | ModelBackend):
35 | pass
36 |
37 |
38 | # alias for the old name for backwards-compatability
39 | CaseInsensitiveEmailBackendMixin = CaseInsensitiveUsernameFieldBackendMixin
40 | CaseInsensitiveEmailModelBackend = CaseInsensitiveUsernameFieldModelBackend
41 |
--------------------------------------------------------------------------------
/tests/tests/settings.py:
--------------------------------------------------------------------------------
1 | from __future__ import print_function
2 |
3 | import os
4 |
5 | from django import VERSION as DJANGO_VERSION
6 |
7 | SECRET_KEY = 'w6bidenrf5q%byf-q82b%pli50i0qmweus6gt_3@k$=zg7ymd3'
8 | SITE_ID = 1
9 |
10 | INSTALLED_APPS = (
11 | 'django.contrib.sessions',
12 | 'django.contrib.contenttypes',
13 | 'django.contrib.auth',
14 | 'django.contrib.admin',
15 | 'django.contrib.staticfiles',
16 | 'django.contrib.sites',
17 | 'django.contrib.messages',
18 | 'tests',
19 | 'authtools',
20 | )
21 |
22 | MIDDLEWARE = [
23 | 'django.middleware.common.CommonMiddleware',
24 | 'django.middleware.csrf.CsrfViewMiddleware',
25 | 'django.contrib.sessions.middleware.SessionMiddleware',
26 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
27 | 'django.contrib.messages.middleware.MessageMiddleware',
28 | ]
29 |
30 |
31 | DATABASES = {
32 | 'default': {
33 | 'ENGINE': 'django.db.backends.sqlite3',
34 | 'NAME': 'sqlite_database',
35 | }
36 | }
37 |
38 | ROOT_URLCONF = 'tests.urls'
39 |
40 | STATIC_URL = '/static/'
41 | DEBUG = True
42 |
43 | AUTH_USER_MODEL = os.environ.get('AUTH_USER_MODEL', 'auth.User')
44 |
45 | print('Using %s as the AUTH_USER_MODEL.' % AUTH_USER_MODEL)
46 |
47 |
48 | TEMPLATES = [
49 | {
50 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
51 | 'APP_DIRS': True,
52 | 'OPTIONS': {
53 | 'context_processors': [
54 | 'django.contrib.auth.context_processors.auth',
55 | 'django.contrib.messages.context_processors.messages',
56 | ],
57 | },
58 | },
59 | ]
60 |
61 | PASSWORD_HASHERS = [
62 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher',
63 | ]
64 |
65 | USE_TZ = False
66 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import io
3 | import os
4 |
5 | from setuptools import setup, find_packages
6 |
7 | __doc__ = "Custom user model app for Django featuring email as username."
8 |
9 |
10 | def read(fname):
11 | return io.open(os.path.join(os.path.dirname(__file__), fname), encoding="utf-8").read()
12 |
13 |
14 | install_requires = [
15 | 'Django>=2.2',
16 | ]
17 |
18 | setup(
19 | name='django-authtools',
20 | version='2.0.3.dev0',
21 | author='Fusionbox, Inc.',
22 | author_email='programmers@fusionbox.com',
23 | description=__doc__,
24 | long_description='\n\n'.join([read('README.rst'), read('CHANGES.rst')]),
25 | url='https://django-authtools.readthedocs.org/',
26 | license='BSD',
27 | packages=[package for package in find_packages() if package.startswith('authtools')],
28 | install_requires=install_requires,
29 | zip_safe=False,
30 | include_package_data=True,
31 | classifiers=[
32 | 'Development Status :: 5 - Production/Stable',
33 | 'Environment :: Web Environment',
34 | 'Framework :: Django',
35 | 'Intended Audience :: Developers',
36 | 'License :: OSI Approved :: BSD License',
37 | 'Natural Language :: English',
38 | 'Programming Language :: Python',
39 | 'Programming Language :: Python :: 3',
40 | 'Programming Language :: Python :: 3.5',
41 | 'Programming Language :: Python :: 3.6',
42 | 'Programming Language :: Python :: 3.7',
43 | 'Programming Language :: Python :: 3.8',
44 | 'Programming Language :: Python :: 3.9',
45 | 'Programming Language :: Python :: 3.10',
46 | 'Programming Language :: Python :: 3.11',
47 | 'Programming Language :: Python :: 3.12',
48 | 'Programming Language :: Python :: 3.13',
49 | ],
50 | )
51 |
--------------------------------------------------------------------------------
/docs/forms.rst:
--------------------------------------------------------------------------------
1 | Forms
2 | =====
3 |
4 | .. currentmodule:: authtools.forms
5 |
6 | django-authtools provides several Form classes that mimic the forms in
7 | django.contrib.auth.forms, but work better with ``USERNAME_FIELD`` and
8 | ``REQUIRED_FIELDS``. These forms don't require the
9 | :class:`authtools.models.User` class in order to work, they should work with any
10 | User model that follows the :class:`User class contract `.
11 |
12 | .. class:: UserCreationForm
13 |
14 | Basically the same as django.contrib.auth, but respects ``USERNAME_FIELD``
15 | and ``User.REQUIRED_FIELDS``.
16 |
17 | .. class:: CaseInsensitiveUsernameFieldCreationForm
18 |
19 | This is the same form as ``UserCreationForm``, but with an added method, ``clean_username``
20 | which lowercases the username before saving. It is recommended that you use this form if you
21 | choose to use either the
22 | :class:`~authtools.backends.CaseInsensitiveUsernameFieldModelBackend` authentication backend
23 | class.
24 |
25 | .. note::
26 |
27 | This form is also available sa CaseInsensitiveEmailUserCreationForm for
28 | backwards compatibility.
29 |
30 | .. class:: FriendlyPasswordResetForm
31 |
32 | Basically the same as
33 | :class:`django:django.contrib.auth.forms.PasswordResetForm`, but checks the
34 | email address against the database and gives a friendly error message.
35 |
36 | .. warning::
37 |
38 | This form leaks user email addresses.
39 |
40 | It also provides a Widget class.
41 |
42 | .. class:: BetterReadOnlyPasswordHashWidget
43 |
44 | This is basically the same as django's ``ReadOnlyPasswordHashWidget``, but
45 | it provides a less intimidating user interface. Whereas django's Widget
46 | displays the password hash with it's salt,
47 | :class:`BetterReadOnlyPasswordHashWidget` simply presents a string of
48 | asterisks.
49 |
--------------------------------------------------------------------------------
/tests/tests/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.5 on 2020-04-01 15:51
2 |
3 | from django.db import migrations, models
4 | import django.utils.timezone
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | initial = True
10 |
11 | dependencies = [
12 | ('auth', '0009_alter_user_last_name_max_length'),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name='User',
18 | fields=[
19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20 | ('password', models.CharField(max_length=128, verbose_name='password')),
21 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
22 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
23 | ('email', models.EmailField(max_length=255, unique=True, verbose_name='email address')),
24 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
25 | ('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')),
26 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
27 | ('full_name', models.CharField(blank=True, max_length=255, verbose_name='full name')),
28 | ('preferred_name', models.CharField(blank=True, max_length=255, verbose_name='preferred name')),
29 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
30 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
31 | ],
32 | options={
33 | 'swappable': 'AUTH_USER_MODEL',
34 | },
35 | ),
36 | ]
37 |
--------------------------------------------------------------------------------
/authtools/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import unicode_literals
3 |
4 | from django.db import models, migrations
5 | import django.utils.timezone
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | ('auth', '__first__'),
12 | ]
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name='User',
17 | fields=[
18 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
19 | ('password', models.CharField(max_length=128, verbose_name='password')),
20 | ('last_login', models.DateTimeField(default=django.utils.timezone.now, verbose_name='last login')),
21 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
22 | ('email', models.EmailField(unique=True, max_length=255, verbose_name='email address', db_index=True)),
23 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
24 | ('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')),
25 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
26 | ('name', models.CharField(max_length=255, verbose_name='name')),
27 | ('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')),
28 | ('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')),
29 | ],
30 | options={
31 | 'ordering': ['name', 'email'],
32 | 'abstract': False,
33 | 'verbose_name': 'user',
34 | 'swappable': 'AUTH_USER_MODEL',
35 | 'verbose_name_plural': 'users',
36 | },
37 | bases=(models.Model,),
38 | ),
39 | ]
40 |
--------------------------------------------------------------------------------
/authtools/admin.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | import copy
4 |
5 | from django.contrib import admin
6 | from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
7 | from django.contrib.auth import get_user_model
8 | from django.contrib.auth.forms import UserCreationForm
9 | from django.utils.translation import gettext_lazy as _
10 |
11 | from authtools.forms import UserChangeForm
12 | from authtools.models import User
13 |
14 | USERNAME_FIELD = get_user_model().USERNAME_FIELD
15 |
16 | REQUIRED_FIELDS = (USERNAME_FIELD,) + tuple(get_user_model().REQUIRED_FIELDS)
17 |
18 | BASE_FIELDS = (None, {
19 | 'fields': REQUIRED_FIELDS + ('password',),
20 | })
21 |
22 | SIMPLE_PERMISSION_FIELDS = (_('Permissions'), {
23 | 'fields': ('is_active', 'is_staff', 'is_superuser',),
24 | })
25 |
26 | ADVANCED_PERMISSION_FIELDS = copy.deepcopy(SIMPLE_PERMISSION_FIELDS)
27 | ADVANCED_PERMISSION_FIELDS[1]['fields'] += ('groups', 'user_permissions',)
28 |
29 | DATE_FIELDS = (_('Important dates'), {
30 | 'fields': ('last_login', 'date_joined',),
31 | })
32 |
33 |
34 | class StrippedUserAdmin(DjangoUserAdmin):
35 | # The forms to add and change user instances
36 | add_form_template = None
37 | add_form = UserCreationForm
38 | form = UserChangeForm
39 |
40 | # The fields to be used in displaying the User model.
41 | # These override the definitions on the base UserAdmin
42 | # that reference specific fields on auth.User.
43 | list_display = ('is_active', USERNAME_FIELD, 'is_superuser', 'is_staff',)
44 | list_display_links = (USERNAME_FIELD,)
45 | list_filter = ('is_superuser', 'is_staff', 'is_active',)
46 | fieldsets = (
47 | BASE_FIELDS,
48 | SIMPLE_PERMISSION_FIELDS,
49 | )
50 | add_fieldsets = (
51 | (None, {
52 | 'fields': REQUIRED_FIELDS + (
53 | 'password1',
54 | 'password2',
55 | ),
56 | }),
57 | )
58 | search_fields = (USERNAME_FIELD,)
59 | ordering = None
60 | filter_horizontal = tuple()
61 | readonly_fields = ('last_login', 'date_joined')
62 |
63 |
64 | class StrippedNamedUserAdmin(StrippedUserAdmin):
65 | list_display = ('is_active', 'email', 'name', 'is_superuser', 'is_staff',)
66 | list_display_links = ('email', 'name',)
67 | search_fields = ('email', 'name',)
68 |
69 |
70 | class UserAdmin(StrippedUserAdmin):
71 | fieldsets = (
72 | BASE_FIELDS,
73 | ADVANCED_PERMISSION_FIELDS,
74 | DATE_FIELDS,
75 | )
76 | filter_horizontal = ('groups', 'user_permissions',)
77 |
78 |
79 | class NamedUserAdmin(UserAdmin, StrippedNamedUserAdmin):
80 | pass
81 |
82 |
83 | # If the model has been swapped, this is basically a noop.
84 | admin.site.register(User, NamedUserAdmin)
85 |
--------------------------------------------------------------------------------
/docs/how-to/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django import forms
3 | from django.contrib.auth import get_user_model
4 | from django.contrib.auth.forms import PasswordResetForm
5 | from django.utils.crypto import get_random_string
6 |
7 | from authtools.admin import NamedUserAdmin
8 | from authtools.forms import UserCreationForm
9 |
10 | User = get_user_model()
11 |
12 |
13 | class UserCreationForm(UserCreationForm):
14 | """
15 | A UserCreationForm with optional password inputs.
16 | """
17 |
18 | def __init__(self, *args, **kwargs):
19 | super(UserCreationForm, self).__init__(*args, **kwargs)
20 | self.fields['password1'].required = False
21 | self.fields['password2'].required = False
22 | # If one field gets autocompleted but not the other, our 'neither
23 | # password or both password' validation will be triggered.
24 | self.fields['password1'].widget.attrs['autocomplete'] = 'off'
25 | self.fields['password2'].widget.attrs['autocomplete'] = 'off'
26 |
27 | def clean_password2(self):
28 | password1 = self.cleaned_data.get("password1")
29 | password2 = super(UserCreationForm, self).clean_password2()
30 | if bool(password1) ^ bool(password2):
31 | raise forms.ValidationError("Fill out both fields")
32 | return password2
33 |
34 |
35 | class UserAdmin(NamedUserAdmin):
36 | """
37 | A UserAdmin that sends a password-reset email when creating a new user,
38 | unless a password was entered.
39 | """
40 | add_form = UserCreationForm
41 | add_fieldsets = (
42 | (None, {
43 | 'description': (
44 | "Enter the new user's name and email address and click save."
45 | " The user will be emailed a link allowing them to login to"
46 | " the site and set their password."
47 | ),
48 | 'fields': ('email', 'name',),
49 | }),
50 | ('Password', {
51 | 'description': "Optionally, you may set the user's password here.",
52 | 'fields': ('password1', 'password2'),
53 | 'classes': ('collapse', 'collapse-closed'),
54 | }),
55 | )
56 |
57 | def save_model(self, request, obj, form, change):
58 | if not change and not obj.has_usable_password():
59 | # Django's PasswordResetForm won't let us reset an unusable
60 | # password. We set it above super() so we don't have to save twice.
61 | obj.set_password(get_random_string())
62 | reset_password = True
63 | else:
64 | reset_password = False
65 |
66 | super(UserAdmin, self).save_model(request, obj, form, change)
67 |
68 | if reset_password:
69 | reset_form = PasswordResetForm({'email': obj.email})
70 | assert reset_form.is_valid()
71 | reset_form.save(
72 | subject_template_name='registration/account_creation_subject.txt',
73 | email_template_name='registration/account_creation_email.html',
74 | )
75 |
76 | admin.site.unregister(User)
77 | admin.site.register(User, UserAdmin)
78 |
--------------------------------------------------------------------------------
/RELEASES.rst:
--------------------------------------------------------------------------------
1 | Release Process
2 | ===============
3 |
4 | django-authtools uses `zest.releaser`_ to manage releases. For a fuller
5 | understanding of the release process, please read zest.releaser's
6 | documentation, this document is more of a cheat sheet.
7 |
8 | Getting Setup
9 | -------------
10 |
11 | You will need to install zest.releaser::
12 |
13 | $ pip install -r requirements-dev.txt
14 |
15 | You will also need to configure your ``.pypirc`` file to have the PyPI
16 | credentials. Ask one of the other Fusionbox Programmers how to do that.
17 |
18 | Releases
19 | --------
20 |
21 | The process for releases is the same regardless of whether it's a patch, minor,
22 | or major release. It is as follows.
23 |
24 | 1. Add the changes to ``CHANGES.rst``. Don't commit. NOTE: You do not have to replace "(unreleased)"
25 | with the desired release date; zest.releaser will do this automatically.
26 | 2. Run the ``longtest`` command to make sure that the ``README.rst`` and
27 | ``CHANGES.rst`` files are valid.
28 | 3. Commit changes with a commit message like "CHANGES for 1.1.0".
29 | 4. Run the ``fullrelease`` command.
30 |
31 |
32 | Editing the Changelog
33 | ---------------------
34 |
35 | Editing the changelog is very important. It is where we write down all of our
36 | release notes and upgrade instructions. Please spend time when editing the
37 | changelog.
38 |
39 | One way to help getting the changes for new versions is to run the following
40 | commands::
41 |
42 | $ git tag | sort -rn # figure out the latest tag (imagine it's 1.0.0)
43 | 1.0.0
44 | $ git log HEAD ^1.0.0
45 |
46 | This will show all the commits that are in HEAD that weren't in the last
47 | release.
48 |
49 | If possible, it's nice to add a credit line with the author's name and the
50 | issue number of GitHub.
51 |
52 | Deciding on a Version Number
53 | ----------------------------
54 |
55 | Here are some nominal guidelines for deciding on version numbers when cutting
56 | releases. If you feel the need to deviate from them, go ahead. If you find
57 | yourself deviating every time, please update this document.
58 |
59 | This is not semver, but it's similar.
60 |
61 | Patch Release (1.0.x)
62 | ^^^^^^^^^^^^^^^^^^^^^
63 |
64 | Bug fixes, documentation, and general project maintenance.
65 |
66 | Avoid backwards incompatible changes like the plague.
67 |
68 | Minor Release (1.x.0)
69 | ^^^^^^^^^^^^^^^^^^^^^
70 |
71 | New features, and anything in patch releases.
72 |
73 | Try to avoid backwards incompatible changes, but if you feel like you need
74 | (especially for security), it's acceptable.
75 |
76 | Major Release (x.0.0)
77 | ^^^^^^^^^^^^^^^^^^^^^
78 |
79 | Really Cool New Features, and anything that you include in a minor release.
80 |
81 | Backwards incompatibility is more acceptable here, although still frowned upon.
82 |
83 |
84 | Additional Reading
85 | ------------------
86 |
87 | - `zest.releaser Version handling `_
88 | - `PEP 396 - Module Version Numbers `_
89 | - `PEP 440 - Version Identification and Dependency Specification `_
90 |
91 | .. _zest.releaser: http://zestreleaser.readthedocs.org/
92 |
--------------------------------------------------------------------------------
/authtools/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
4 | from django.core.mail import send_mail
5 | from django.db import models
6 | from django.utils import timezone
7 | from django.utils.translation import gettext_lazy as _
8 |
9 |
10 | class UserManager(BaseUserManager):
11 | def create_user(self, email, password=None, **kwargs):
12 | email = self.normalize_email(email)
13 | user = self.model(email=email, **kwargs)
14 | user.set_password(password)
15 | user.save(using=self._db)
16 | return user
17 |
18 | def create_superuser(self, **kwargs):
19 | kwargs.setdefault('is_staff', True)
20 | kwargs.setdefault('is_superuser', True)
21 |
22 | if kwargs.get('is_staff') is not True:
23 | raise ValueError('Superuser must have is_staff=True.')
24 | if kwargs.get('is_superuser') is not True:
25 | raise ValueError('Superuser must have is_superuser=True.')
26 |
27 | return self.create_user(**kwargs)
28 |
29 | def get_by_natural_key(self, email):
30 | normalized_email = self.normalize_email(email)
31 | return self.get(**{self.model.USERNAME_FIELD: normalized_email})
32 |
33 |
34 | class AbstractEmailUser(AbstractBaseUser, PermissionsMixin):
35 | email = models.EmailField(_('email address'), max_length=255, unique=True)
36 |
37 | is_staff = models.BooleanField(_('staff status'), default=False,
38 | help_text=_('Designates whether the user can log into this admin '
39 | 'site.'))
40 | is_active = models.BooleanField(_('active'), default=True,
41 | help_text=_('Designates whether this user should be treated as '
42 | 'active. Unselect this instead of deleting accounts.'))
43 | date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
44 |
45 | objects = UserManager()
46 |
47 | USERNAME_FIELD = 'email'
48 | REQUIRED_FIELDS = []
49 |
50 | class Meta:
51 | abstract = True
52 | ordering = ['email']
53 |
54 | def clean(self):
55 | super().clean()
56 | self.email = self.__class__.objects.normalize_email(self.email)
57 |
58 | def get_full_name(self):
59 | return self.email
60 |
61 | def get_short_name(self):
62 | return self.email
63 |
64 | def email_user(self, subject, message, from_email=None, **kwargs):
65 | """Sends an email to this User."""
66 |
67 | send_mail(subject, message, from_email, [self.email], **kwargs)
68 |
69 | class AbstractNamedUser(AbstractEmailUser):
70 | name = models.CharField(_('name'), max_length=255)
71 |
72 | REQUIRED_FIELDS = ['name']
73 |
74 | class Meta:
75 | abstract = True
76 | ordering = ['name', 'email']
77 |
78 | def __str__(self):
79 | return '{name} <{email}>'.format(
80 | name=self.name,
81 | email=self.email,
82 | )
83 |
84 | def get_full_name(self):
85 | return self.name
86 |
87 | def get_short_name(self):
88 | return self.name
89 |
90 |
91 | class User(AbstractNamedUser):
92 | class Meta(AbstractNamedUser.Meta):
93 | swappable = 'AUTH_USER_MODEL'
94 | verbose_name = _('user')
95 | verbose_name_plural = _('users')
96 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | build:
6 | runs-on: ubuntu-latest
7 | strategy:
8 | fail-fast: false
9 | matrix:
10 | include:
11 | # Django 2.2
12 | - django-version: "2.2"
13 | python-version: "3.8"
14 | - django-version: "2.2"
15 | python-version: "3.9"
16 |
17 | # Django 3.0
18 | - django-version: "3.0"
19 | python-version: "3.8"
20 | - django-version: "3.0"
21 | python-version: "3.9"
22 |
23 | # Django 3.1
24 | - django-version: "3.1"
25 | python-version: "3.8"
26 | - django-version: "3.1"
27 | python-version: "3.9"
28 |
29 | # Django 3.2
30 | - django-version: "3.2"
31 | python-version: "3.8"
32 | - django-version: "3.2"
33 | python-version: "3.9"
34 | - django-version: "3.2"
35 | python-version: "3.10"
36 |
37 | # Django 4.0
38 | - django-version: "4.0"
39 | python-version: "3.8"
40 | - django-version: "4.0"
41 | python-version: "3.9"
42 | - django-version: "4.0"
43 | python-version: "3.10"
44 |
45 | # Django 4.1
46 | - django-version: "4.1"
47 | python-version: "3.8"
48 | - django-version: "4.1"
49 | python-version: "3.9"
50 | - django-version: "4.1"
51 | python-version: "3.10"
52 |
53 | # Django 4.2
54 | - django-version: "4.2"
55 | python-version: "3.8"
56 | - django-version: "4.2"
57 | python-version: "3.9"
58 | - django-version: "4.2"
59 | python-version: "3.10"
60 | - django-version: "4.2"
61 | python-version: "3.11"
62 |
63 | # Django 5.0
64 | - django-version: "5.0"
65 | python-version: "3.10"
66 | - django-version: "5.0"
67 | python-version: "3.11"
68 | - django-version: "5.0"
69 | python-version: "3.12"
70 |
71 | # Django 5.1
72 | - django-version: "5.1"
73 | python-version: "3.10"
74 | - django-version: "5.1"
75 | python-version: "3.11"
76 | - django-version: "5.1"
77 | python-version: "3.12"
78 | - django-version: "5.1"
79 | python-version: "3.13"
80 |
81 | # Django 5.2
82 | - django-version: "5.2"
83 | python-version: "3.10"
84 | - django-version: "5.2"
85 | python-version: "3.11"
86 | - django-version: "5.2"
87 | python-version: "3.12"
88 | - django-version: "5.2"
89 | python-version: "3.13"
90 |
91 | steps:
92 | - uses: actions/checkout@v4
93 |
94 | - name: Set up Python ${{ matrix.python-version }}
95 | uses: actions/setup-python@v5
96 | with:
97 | python-version: ${{ matrix.python-version }}
98 |
99 | - name: Upgrade pip version
100 | run: python -m pip install -U pip
101 |
102 | - name: Upgrade django version
103 | run: python -m pip install "Django~=${{ matrix.django-version }}"
104 |
105 | - name: Install authtools
106 | run: python -m pip install -e .
107 |
108 | - name: Run Tests
109 | run: |
110 | make test
111 |
--------------------------------------------------------------------------------
/tests/tests/fixtures/authtoolstestdata.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "pk": "1",
4 | "model": "authtools.user",
5 | "fields": {
6 | "name": "Test Client",
7 | "name": "Test Client",
8 | "is_active": true,
9 | "is_superuser": false,
10 | "is_staff": false,
11 | "last_login": "2006-12-17 07:03:31",
12 | "groups": [],
13 | "user_permissions": [],
14 | "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161",
15 | "email": "testclient@example.com",
16 | "date_joined": "2006-12-17 07:03:31"
17 | }
18 | },
19 | {
20 | "pk": "2",
21 | "model": "authtools.user",
22 | "fields": {
23 | "name": "Inactive User",
24 | "is_active": false,
25 | "is_superuser": false,
26 | "is_staff": false,
27 | "last_login": "2006-12-17 07:03:31",
28 | "groups": [],
29 | "user_permissions": [],
30 | "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161",
31 | "email": "testclient2@example.com",
32 | "date_joined": "2006-12-17 07:03:31"
33 | }
34 | },
35 | {
36 | "pk": "3",
37 | "model": "authtools.user",
38 | "fields": {
39 | "name": "Staff Member",
40 | "is_active": true,
41 | "is_superuser": false,
42 | "is_staff": true,
43 | "last_login": "2006-12-17 07:03:31",
44 | "groups": [],
45 | "user_permissions": [],
46 | "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161",
47 | "email": "staffmember@example.com",
48 | "date_joined": "2006-12-17 07:03:31"
49 | }
50 | },
51 | {
52 | "pk": "4",
53 | "model": "authtools.user",
54 | "fields": {
55 | "name": "Empty Password",
56 | "is_active": true,
57 | "is_superuser": false,
58 | "is_staff": false,
59 | "last_login": "2006-12-17 07:03:31",
60 | "groups": [],
61 | "user_permissions": [],
62 | "password": "",
63 | "email": "empty_password@example.com",
64 | "date_joined": "2006-12-17 07:03:31"
65 | }
66 | },
67 | {
68 | "pk": "5",
69 | "model": "authtools.user",
70 | "fields": {
71 | "name": "Unmanageable Password",
72 | "is_active": true,
73 | "is_superuser": false,
74 | "is_staff": false,
75 | "last_login": "2006-12-17 07:03:31",
76 | "groups": [],
77 | "user_permissions": [],
78 | "password": "$",
79 | "email": "unmanageable_password@example.com",
80 | "date_joined": "2006-12-17 07:03:31"
81 | }
82 | },
83 | {
84 | "pk": "6",
85 | "model": "authtools.user",
86 | "fields": {
87 | "name": "Unknown Password",
88 | "is_active": true,
89 | "is_superuser": false,
90 | "is_staff": false,
91 | "last_login": "2006-12-17 07:03:31",
92 | "groups": [],
93 | "user_permissions": [],
94 | "password": "foo$bar",
95 | "email": "unknown_password@example.com",
96 | "date_joined": "2006-12-17 07:03:31"
97 | }
98 | }
99 | ]
100 |
--------------------------------------------------------------------------------
/tests/tests/fixtures/customusertestdata.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "pk": "1",
4 | "model": "tests.user",
5 | "fields": {
6 | "full_name": "Test Client",
7 | "preferred_name": "Test",
8 | "is_active": true,
9 | "is_superuser": false,
10 | "is_staff": false,
11 | "last_login": "2006-12-17 07:03:31",
12 | "groups": [],
13 | "user_permissions": [],
14 | "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161",
15 | "email": "testclient@example.com",
16 | "date_joined": "2006-12-17 07:03:31"
17 | }
18 | },
19 | {
20 | "pk": "2",
21 | "model": "tests.user",
22 | "fields": {
23 | "full_name": "Inactive User",
24 | "preferred_name": "Inactive",
25 | "is_active": false,
26 | "is_superuser": false,
27 | "is_staff": false,
28 | "last_login": "2006-12-17 07:03:31",
29 | "groups": [],
30 | "user_permissions": [],
31 | "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161",
32 | "email": "testclient2@example.com",
33 | "date_joined": "2006-12-17 07:03:31"
34 | }
35 | },
36 | {
37 | "pk": "3",
38 | "model": "tests.user",
39 | "fields": {
40 | "full_name": "Staff Member",
41 | "preferred_name": "Staff",
42 | "is_active": true,
43 | "is_superuser": false,
44 | "is_staff": true,
45 | "last_login": "2006-12-17 07:03:31",
46 | "groups": [],
47 | "user_permissions": [],
48 | "password": "sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161",
49 | "email": "staffmember@example.com",
50 | "date_joined": "2006-12-17 07:03:31"
51 | }
52 | },
53 | {
54 | "pk": "4",
55 | "model": "tests.user",
56 | "fields": {
57 | "full_name": "Empty Password",
58 | "preferred_name": "Empty",
59 | "is_active": true,
60 | "is_superuser": false,
61 | "is_staff": false,
62 | "last_login": "2006-12-17 07:03:31",
63 | "groups": [],
64 | "user_permissions": [],
65 | "password": "",
66 | "email": "empty_password@example.com",
67 | "date_joined": "2006-12-17 07:03:31"
68 | }
69 | },
70 | {
71 | "pk": "5",
72 | "model": "tests.user",
73 | "fields": {
74 | "full_name": "Unmanageable Password",
75 | "preferred_name": "Unmanageable",
76 | "is_active": true,
77 | "is_superuser": false,
78 | "is_staff": false,
79 | "last_login": "2006-12-17 07:03:31",
80 | "groups": [],
81 | "user_permissions": [],
82 | "password": "$",
83 | "email": "unmanageable_password@example.com",
84 | "date_joined": "2006-12-17 07:03:31"
85 | }
86 | },
87 | {
88 | "pk": "6",
89 | "model": "tests.user",
90 | "fields": {
91 | "full_name": "Unknown Password",
92 | "preferred_name": "Unknown",
93 | "is_active": true,
94 | "is_superuser": false,
95 | "is_staff": false,
96 | "last_login": "2006-12-17 07:03:31",
97 | "groups": [],
98 | "user_permissions": [],
99 | "password": "foo$bar",
100 | "email": "unknown_password@example.com",
101 | "date_joined": "2006-12-17 07:03:31"
102 | }
103 | }
104 | ]
105 |
--------------------------------------------------------------------------------
/docs/how-to/invitation-email.rst:
--------------------------------------------------------------------------------
1 | How To Create Users Without Setting Their Password
2 | ==================================================
3 |
4 | When creating a new user through Django's admin interface, you are asked
5 | to enter the new user's password. This is less than ideal, because it
6 | requires the admin to think of a password for someone else, communicate
7 | it to them somehow, and then the user must remember to change it. A
8 | better way would be to send a password-reset email to the new user,
9 | allowing them to enter their own password.
10 |
11 | To implement this, we need to provide a user-creation form that has an
12 | optional (instead of required, like the built-in form) password field
13 | and a User admin that uses the form and sends the password-reset email
14 | when creating a new user.
15 |
16 |
17 | We'll subclass :class:`~authtools.forms.UserCreationForm` to create a form with
18 | optional password fields::
19 |
20 | from django import forms
21 | from authtools.forms import UserCreationForm
22 |
23 | class UserCreationForm(UserCreationForm):
24 | """
25 | A UserCreationForm with optional password inputs.
26 | """
27 |
28 | def __init__(self, *args, **kwargs):
29 | super(UserCreationForm, self).__init__(*args, **kwargs)
30 | self.fields['password1'].required = False
31 | self.fields['password2'].required = False
32 | # If one field gets autocompleted but not the other, our 'neither
33 | # password or both password' validation will be triggered.
34 | self.fields['password1'].widget.attrs['autocomplete'] = 'off'
35 | self.fields['password2'].widget.attrs['autocomplete'] = 'off'
36 |
37 | def clean_password2(self):
38 | password1 = self.cleaned_data.get("password1")
39 | password2 = super(UserCreationForm, self).clean_password2()
40 | if bool(password1) ^ bool(password2):
41 | raise forms.ValidationError("Fill out both fields")
42 | return password2
43 |
44 | Then an admin class that uses our form and sends the email::
45 |
46 | from django.contrib.auth import get_user_model
47 | from django.contrib.auth.forms import PasswordResetForm
48 | from django.utils.crypto import get_random_string
49 | from authtools.admin import NamedUserAdmin
50 |
51 | User = get_user_model()
52 |
53 | class UserAdmin(NamedUserAdmin):
54 | """
55 | A UserAdmin that sends a password-reset email when creating a new user,
56 | unless a password was entered.
57 | """
58 | add_form = UserCreationForm
59 | add_fieldsets = (
60 | (None, {
61 | 'description': (
62 | "Enter the new user's name and email address and click save."
63 | " The user will be emailed a link allowing them to login to"
64 | " the site and set their password."
65 | ),
66 | 'fields': ('email', 'name',),
67 | }),
68 | ('Password', {
69 | 'description': "Optionally, you may set the user's password here.",
70 | 'fields': ('password1', 'password2'),
71 | 'classes': ('collapse', 'collapse-closed'),
72 | }),
73 | )
74 |
75 | def save_model(self, request, obj, form, change):
76 | if not change and (not form.cleaned_data['password1'] or not obj.has_usable_password()):
77 | # Django's PasswordResetForm won't let us reset an unusable
78 | # password. We set it above super() so we don't have to save twice.
79 | obj.set_password(get_random_string())
80 | reset_password = True
81 | else:
82 | reset_password = False
83 |
84 | super(UserAdmin, self).save_model(request, obj, form, change)
85 |
86 | if reset_password:
87 | reset_form = PasswordResetForm({'email': obj.email})
88 | assert reset_form.is_valid()
89 | reset_form.save(
90 | request=request,
91 | use_https=request.is_secure(),
92 | subject_template_name='registration/account_creation_subject.txt',
93 | email_template_name='registration/account_creation_email.html',
94 | )
95 |
96 | Using :class:`django:django.contrib.auth.forms.PasswordResetForm` allows us to
97 | share the email-sending code with Django. If you wanted to change the template
98 | the email uses, ``email_template_name`` would be the place to do it.
99 |
100 | Now we can replace the installed UserAdmin with our own. ::
101 |
102 | from django.contrib import admin
103 | admin.site.unregister(User)
104 | admin.site.register(User, UserAdmin)
105 |
106 |
107 | You can view the :download:`complete admin.py file here. `
108 |
--------------------------------------------------------------------------------
/docs/intro.rst:
--------------------------------------------------------------------------------
1 | Introduction
2 | ============
3 |
4 | Before you use this, you should probably read the documentation about :ref:`custom User models `.
5 |
6 | Installation
7 | ------------
8 |
9 | 1. Install the package::
10 |
11 | $ pip install django-authtools
12 |
13 | Or you can install it from source::
14 |
15 | $ pip install -e git+http://github.com/fusionbox/django-authtools@master#egg=django-authtools-dev
16 |
17 | 2. Add ``authtools`` to your ``INSTALLED_APPS``.
18 |
19 | 3. Run the authtools migrations::
20 |
21 | $ python manage.py migrate
22 |
23 |
24 | Quick Setup
25 | -----------
26 |
27 | If you want to use the User model provided by authtools (a sensible choice), there are three short steps.
28 |
29 | 1. Add ``authtools`` to your ``INSTALLED_APPS``.
30 |
31 | 2. Add the following to your settings.py::
32 |
33 | AUTH_USER_MODEL = 'authtools.User'
34 |
35 | This will set you up with a custom user that
36 |
37 | - uses email as username
38 | - has one ``name`` field instead of ``first_name``, ``last_name`` (see `Falsehoods Programmers Believe About Names `_)
39 |
40 | It also gives you a registered admin class that has a less intimidating
41 | :class:`ReadOnlyPasswordHashWidget `.
42 |
43 |
44 | But it's supposed to be a *custom* User model!
45 | ----------------------------------------------
46 |
47 | Making a User model that only concerns itself with authentication and
48 | authorization just *might* be a good idea. I recommend you read these:
49 |
50 | - `The User-Profile Pattern in Django `_
51 | - `Williams, Master of the "Come From" `_
52 |
53 | Also, please read this quote from the `Django documentation
54 | `_:
55 |
56 | .. warning::
57 |
58 | Think carefully before handling information not directly related to
59 | authentication in your custom User Model.
60 |
61 | It may be better to store app-specific user information in a model that has
62 | a relation with the User model. That allows each app to specify its own
63 | user data requirements without risking conflicts with other apps. On the
64 | other hand, queries to retrieve this related information will involve a
65 | database join, which may have an effect on performance.
66 |
67 | However, there are many valid reasons for wanting a User model that you can
68 | change things on. django-authtools allows you to do that too.
69 | django-authtools provides a couple of abstract classes for subclassing.
70 |
71 | .. class:: authtools.models.AbstractEmailUser
72 |
73 | A no-frills email as username model that satisifes the User contract and
74 | the permissions API needed for the Admin site.
75 |
76 | .. class:: authtools.models.AbstractNamedUser
77 |
78 | A subclass of :class:`~authtools.models.AbstractEmailUser` that adds a name
79 | field.
80 |
81 | If want to make your custom User model, you can use one of these base classes.
82 |
83 | .. tip::
84 |
85 | If you are just adding some methods to the User model, but not changing the
86 | database fields, you should consider using a proxy model.
87 |
88 | If you wanted a User model that had ``full_name`` and ``preferred_name``
89 | fields instead of just ``name``, you could do this::
90 |
91 | from authtools.models import AbstractEmailUser
92 |
93 | class User(AbstractEmailUser):
94 | full_name = models.CharField('full name', max_length=255, blank=True)
95 | preferred_name = models.CharField('preferred name',
96 | max_length=255, blank=True)
97 |
98 | def get_full_name(self):
99 | return self.full_name
100 |
101 | def get_short_name(self):
102 | return self.preferred_name
103 |
104 | Caveats
105 | -------
106 |
107 | There are a couple of limitations to using the User model provided by authtools.
108 |
109 | The :class:`~authtools.models.User` provided by authtools specifies an email of ``max_length=255``. This works fine for PostgreSQL, but may cause issues with some other databases (MYSQL, MariaDB) where unique indexes can only be created with 191 characters. For this reason, Django's ``User`` model has a ``username`` field of ``max_length=150``. If you use one of these databases, you may want to subclass :class:`~authtools.models.AbstractEmailUser` or :class:`~authtools.models.AbstractNamedUser` and set the ``username`` field to ``max_length=191``. See the `Django docs `_ for more about this issue.
110 |
111 |
112 | Authtools specifies ``DEFAULT_AUTO_FIELD='django.db.models.AutoField'`` to prevent new migrations in existing projects when upgrading to Django >= 3.2. If you want to use ``django.db.models.BigAutoField``, you should subclass :class:`~authtools.models.AbstractEmailUser` or :class:`~authtools.models.AbstractNamedUser`. See the `Django 3.2 release notes `_ for more information.
113 |
--------------------------------------------------------------------------------
/CHANGES.rst:
--------------------------------------------------------------------------------
1 | CHANGES
2 | =======
3 |
4 | 2.0.3 (unreleased)
5 | ------------------
6 |
7 | - Nothing changed yet.
8 |
9 |
10 | 2.0.2 (2025-09-29)
11 | ------------------
12 |
13 | - Support the change in Django 5.0+ to a password change button on user edit pages in the Django admin (Jeff Bradberry #129)
14 | - Add test coverage for Django 5.1, 5.2
15 | - Add test coverage for Python 3.13
16 | - Python 3.7 is no longer availble in GitHub runner using ubuntu-latest
17 |
18 |
19 | 2.0.1 (2024-03-19)
20 | ------------------
21 |
22 | - Resolve `SHA1PasswordHasher` deprecation warning for Django 4.0 and above
23 | - Resolve `pkg_resources` deprecation warning for Python 3.8 and above
24 | - Add test coverage for Django 4.1, 4.2, and 5.0
25 | - Add test coverage for Python 3.11 and 3.12
26 | - Python 3.5 and 3.6 are no longer availble in GitHub runner using `ubuntu-latest`
27 |
28 |
29 | 2.0.0 (2022-07-29)
30 | ------------------
31 | ** BREAKING **
32 |
33 | Remove views and URLs. You can now use the ones built in to Django. Removes
34 | support for Django 1.11 and Python 2.
35 |
36 | - Add support for Django 2.2, 3.0, 3.1, 3.2, and 4.0.
37 | - Fix bug where request is not properly set on AuthenticationForm (#102)
38 | - Make UserAdmin compatible with Django 2.0
39 | - Fixes a bug where the password change link would not format correctly
40 | - Fixes a bug where BetterReadOnlyPasswordWidget would not work on a view only permission
41 | - Documentation fixes (#87, #117)
42 | - Set DEFAULT_AUTO_FIELD to AutoField in AuthtoolsConfig (#123)
43 | - Silences warning and prevents new migrations when using authtools with Django >= 3.2
44 | - Normalize email in User clean method and UserManager get_by_natural_key method (weslord #112)
45 | - Fixes a bug where email would not be normalized when creating a user in the admin
46 | - Migrate from TravisCI to GitHub Actions
47 |
48 |
49 | 1.7.0 (2019-06-26)
50 | ------------------
51 |
52 | - Fix bug when using Django 1.11 where resetting a password when already logged in
53 | as another user caused an error
54 | - Remove support for Django versions below 1.11 and Python below 2.7 and 3.6
55 |
56 |
57 | 1.6.0 (2017-06-14)
58 | ------------------
59 |
60 | - Add support for Django 1.9, 1.10, 1.11 (Jared Proffitt #82)
61 | - Remove old conditional imports dating as far back as Django 1.5
62 | - Update readme
63 |
64 |
65 | 1.5.0 (2016-03-26)
66 | ------------------
67 |
68 | - Update various help_text fields to match Django 1.9 (Wenze van Klink #51, Gavin Wahl #64, Jared Proffitt #67, Ivan VenOsdel #69)
69 | - Documentation fixes (Yuki Izumi #52, Pi Delport #60, Germán Larraín #65)
70 | - Made case-insensitive tooling work with more than just USERNAME_FIELD='username' (Jared Proffitt, Rocky Meza #72, #73)
71 |
72 |
73 | 1.4.0 (2015-11-02)
74 | ------------------
75 |
76 | - Dropped Django 1.7 compatibility (Antoine Catton)
77 | - Add Django 1.8 compatibility (Antoine Catton, Gavin Wahl, #56)
78 | - **Backwards Incompatible:** Remove 1.6 URLs (Antoine Catton)
79 | - **Backwards Incompatible:** Remove view functions
80 |
81 | 1.3.0 (unreleased)
82 | ------------------
83 |
84 | - Added Django 1.7 compatibility (Antoine Catton, Rocky Meza, #35)
85 | - ``LoginView.disallow_authenticated`` was changed to ``LoginView.allow_authenticated``
86 | - ``LoginView.disallow_authenticated`` was deprecated.
87 | - **Backwards Incompatible:** ``LoginView.allow_authenticated`` is now ``True``
88 | by default (which is the default behavior in Django)
89 | - Create migrations for authtools.
90 |
91 | If updating from an older authtools, these migrations must be run on your apps::
92 |
93 | $ python manage.py migrate --fake authtools 0001_initial
94 |
95 | $ python manage.py migrate
96 |
97 |
98 | 1.2.0 (2015-04-02)
99 | ------------------
100 |
101 | - Add CaseInsensitiveEmailUserCreationForm for creating users with lowercased email address
102 | usernames (Bradley Gordon, #31, #11)
103 | - Add CaseInsensitiveEmailBackendMixin, CaseInsensitiveEmailModelBackend for authenticating
104 | case-insensitive email address usernames (Bradley Gordon, #31, #11)
105 | - Add tox support for test running (Piper Merriam, #25)
106 |
107 |
108 | 1.1.0 (2015-02-24)
109 | ------------------
110 |
111 | - PasswordChangeView now handles a ``next`` URL parameter (#24)
112 |
113 | 1.0.0 (released August 16, 2014)
114 | --------------------------------
115 |
116 | - Add friendly_password_reset view and FriendlyPasswordResetForm (Antoine Catton, #18)
117 | - **Bugfix** Allow LOGIN_REDIRECT_URL to be unicode (Alan Johnson, Gavin Wahl, Rocky Meza, #13)
118 | - **Backwards Incompatible** Dropped support for Python 3.2
119 |
120 | 0.2.2 (released July 21, 2014)
121 | ------------------------------
122 |
123 | - Update safe urls in tests
124 | - Give the ability to restrain which users can reset their password
125 | - Add send_mail to AbstractEmailUser. (Jorge C. Leitão)
126 |
127 |
128 | 0.2.1
129 | -----
130 |
131 | - Bugfix: UserAdmin was expecting a User with a `name` field.
132 |
133 | 0.2.0
134 | -----
135 |
136 | - Django 1.6 support.
137 |
138 | Django 1.6 `broke backwards compatibility
139 | `_
140 | of the ``password_reset_confirm`` view. Be sure to update any references to
141 | this URL. Rather than using a separate view for each encoding, authtools uses
142 | `a single view
143 | `_
144 | that works with both.
145 |
146 | - Bugfix: if LOGIN_URL was a URL name, it wasn't being reversed in the
147 | PasswordResetConfirmView.
148 |
149 | 0.1.2 (released July 01, 2013)
150 | ------------------------------
151 |
152 | - Use ``prefetch_related`` in the
153 | `UserChangeForm `_
154 | to avoid doing hundreds of ``ContentType`` queries. The form from
155 | Django has the same feature, it wasn't copied over correctly in our
156 | original form.
157 |
158 | 0.1.1 (released May 30, 2013)
159 | -----------------------------
160 |
161 | * some bugfixes:
162 |
163 | - Call ``UserManager.normalize_email`` on an instance, not a class.
164 | - ``authtools.models.User`` should inherit its parent's ``Meta``.
165 |
166 | 0.1.0 (released May 28, 2013)
167 | -----------------------------
168 |
169 | - django-authtools
170 |
--------------------------------------------------------------------------------
/authtools/forms.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | from django import forms
4 | from django.forms.utils import flatatt
5 | from django.contrib.auth.forms import (
6 | ReadOnlyPasswordHashField, ReadOnlyPasswordHashWidget,
7 | PasswordResetForm as OldPasswordResetForm,
8 | UserChangeForm as DjangoUserChangeForm,
9 | AuthenticationForm as DjangoAuthenticationForm,
10 | )
11 | from django.contrib.auth import get_user_model, password_validation
12 | from django.contrib.auth.hashers import identify_hasher, UNUSABLE_PASSWORD_PREFIX
13 | from django.utils.translation import gettext_lazy as _, gettext
14 | from django.utils.html import format_html
15 |
16 | User = get_user_model()
17 |
18 |
19 | def is_password_usable(pw):
20 | """Decide whether a password is usable only by the unusable password prefix.
21 |
22 | We can't use django.contrib.auth.hashers.is_password_usable either, because
23 | it not only checks against the unusable password, but checks for a valid
24 | hasher too. We need different error messages in those cases.
25 | """
26 |
27 | return not pw.startswith(UNUSABLE_PASSWORD_PREFIX)
28 |
29 |
30 | class BetterReadOnlyPasswordHashWidget(ReadOnlyPasswordHashWidget):
31 | """
32 | A ReadOnlyPasswordHashWidget that has a less intimidating output.
33 | """
34 |
35 | def get_context(self, name, value, attrs):
36 | context = super().get_context(name, value, attrs)
37 | if any(item.get('value') for item in context['summary']):
38 | context['summary'] = [{'label': gettext('*************')}]
39 | return context
40 |
41 |
42 | class UserChangeForm(DjangoUserChangeForm):
43 | def __init__(self, *args, **kwargs):
44 | super(UserChangeForm, self).__init__(*args, **kwargs)
45 | password = self.fields.get('password')
46 | if password:
47 | password.widget = BetterReadOnlyPasswordHashWidget()
48 |
49 |
50 | class UserCreationForm(forms.ModelForm):
51 | """
52 | A form for creating new users. Includes all the required
53 | fields, plus a repeated password.
54 | """
55 |
56 | error_messages = {
57 | 'password_mismatch': _("The two password fields didn't match."),
58 | 'duplicate_username': _("A user with that %(username)s already exists."),
59 | }
60 |
61 | password1 = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
62 | password2 = forms.CharField(label=_("Password confirmation"),
63 | widget=forms.PasswordInput,
64 | help_text=_("Enter the same password as above,"
65 | " for verification."))
66 |
67 | class Meta:
68 | model = User
69 | fields = (User.USERNAME_FIELD,) + tuple(User.REQUIRED_FIELDS)
70 |
71 | def __init__(self, *args, **kwargs):
72 | super(UserCreationForm, self).__init__(*args, **kwargs)
73 |
74 | def validate_uniqueness_of_username_field(value):
75 | # Since User.username is unique, this check is redundant,
76 | # but it sets a nicer error message than the ORM. See #13147.
77 | try:
78 | User._default_manager.get_by_natural_key(value)
79 | except User.DoesNotExist:
80 | return value
81 | raise forms.ValidationError(self.error_messages['duplicate_username'] % {
82 | 'username': User.USERNAME_FIELD,
83 | })
84 |
85 | self.fields[User.USERNAME_FIELD].validators.append(validate_uniqueness_of_username_field)
86 |
87 | def clean_password2(self):
88 | # Check that the two password entries match
89 | password1 = self.cleaned_data.get("password1")
90 | password2 = self.cleaned_data.get("password2")
91 | if password1 and password2 and password1 != password2:
92 | raise forms.ValidationError(self.error_messages['password_mismatch'])
93 | return password2
94 |
95 | def _post_clean(self):
96 | super(UserCreationForm, self)._post_clean()
97 | # Validate the password after self.instance is updated with form data
98 | # by super().
99 | password = self.cleaned_data.get('password2')
100 | if password:
101 | try:
102 | password_validation.validate_password(password, self.instance)
103 | except forms.ValidationError as error:
104 | self.add_error('password2', error)
105 |
106 | def save(self, commit=True):
107 | # Save the provided password in hashed format
108 | user = super(UserCreationForm, self).save(commit=False)
109 | user.set_password(self.cleaned_data["password1"])
110 | if commit:
111 | user.save()
112 | return user
113 |
114 |
115 | class CaseInsensitiveUsernameFieldCreationForm(UserCreationForm):
116 | """
117 | This form is the same as UserCreationForm, except that usernames are lowercased before they
118 | are saved. This is to disallow the existence of email address usernames which differ only in
119 | case.
120 | """
121 | def clean_USERNAME_FIELD(self):
122 | username = self.cleaned_data.get(User.USERNAME_FIELD)
123 | if username:
124 | username = username.lower()
125 |
126 | return username
127 |
128 | # set the correct clean method on the class so that child classes can override and call super()
129 | setattr(
130 | CaseInsensitiveUsernameFieldCreationForm,
131 | 'clean_' + User.USERNAME_FIELD,
132 | CaseInsensitiveUsernameFieldCreationForm.clean_USERNAME_FIELD
133 | )
134 |
135 | # alias for the old name for backwards-compatability
136 | CaseInsensitiveEmailUserCreationForm = CaseInsensitiveUsernameFieldCreationForm
137 |
138 |
139 | class FriendlyPasswordResetForm(OldPasswordResetForm):
140 | error_messages = dict(getattr(OldPasswordResetForm, 'error_messages', {}))
141 | error_messages['unknown'] = _("This email address doesn't have an "
142 | "associated user account. Are you "
143 | "sure you've registered?")
144 |
145 | def clean_email(self):
146 | """Return an error message if the email address being reset is unknown.
147 |
148 | This is to revert https://code.djangoproject.com/ticket/19758
149 | The bug #19758 tries not to leak emails through password reset because
150 | only usernames are unique in Django's default user model.
151 |
152 | django-authtools leaks email addresses through the registration form.
153 | In the case of django-authtools not warning the user doesn't add any
154 | security, and worsen user experience.
155 | """
156 |
157 | email = self.cleaned_data['email']
158 | results = list(self.get_users(email))
159 |
160 | if not results:
161 | raise forms.ValidationError(self.error_messages['unknown'])
162 | return email
163 |
164 |
165 | class AuthenticationForm(DjangoAuthenticationForm):
166 | def __init__(self, request=None, *args, **kwargs):
167 | super(AuthenticationForm, self).__init__(request, *args, **kwargs)
168 | username_field = User._meta.get_field(User.USERNAME_FIELD)
169 | self.fields['username'].widget = username_field.formfield().widget
170 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # User-friendly check for sphinx-build
11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
13 | endif
14 |
15 | # Internal variables.
16 | PAPEROPT_a4 = -D latex_paper_size=a4
17 | PAPEROPT_letter = -D latex_paper_size=letter
18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
19 | # the i18n builder cannot share the environment and doctrees with the others
20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
21 |
22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
23 |
24 | help:
25 | @echo "Please use \`make ' where is one of"
26 | @echo " html to make standalone HTML files"
27 | @echo " dirhtml to make HTML files named index.html in directories"
28 | @echo " singlehtml to make a single large HTML file"
29 | @echo " pickle to make pickle files"
30 | @echo " json to make JSON files"
31 | @echo " htmlhelp to make HTML files and a HTML help project"
32 | @echo " qthelp to make HTML files and a qthelp project"
33 | @echo " devhelp to make HTML files and a Devhelp project"
34 | @echo " epub to make an epub"
35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
36 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
38 | @echo " text to make text files"
39 | @echo " man to make manual pages"
40 | @echo " texinfo to make Texinfo files"
41 | @echo " info to make Texinfo files and run them through makeinfo"
42 | @echo " gettext to make PO message catalogs"
43 | @echo " changes to make an overview of all changed/added/deprecated items"
44 | @echo " xml to make Docutils-native XML files"
45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes"
46 | @echo " linkcheck to check all external links for integrity"
47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
48 |
49 | clean:
50 | rm -rf $(BUILDDIR)/*
51 |
52 | html:
53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
54 | @echo
55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
56 |
57 | dirhtml:
58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
59 | @echo
60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
61 |
62 | singlehtml:
63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
64 | @echo
65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
66 |
67 | pickle:
68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
69 | @echo
70 | @echo "Build finished; now you can process the pickle files."
71 |
72 | json:
73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
74 | @echo
75 | @echo "Build finished; now you can process the JSON files."
76 |
77 | htmlhelp:
78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
79 | @echo
80 | @echo "Build finished; now you can run HTML Help Workshop with the" \
81 | ".hhp project file in $(BUILDDIR)/htmlhelp."
82 |
83 | qthelp:
84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
85 | @echo
86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-authtools.qhcp"
89 | @echo "To view the help file:"
90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-authtools.qhc"
91 |
92 | devhelp:
93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
94 | @echo
95 | @echo "Build finished."
96 | @echo "To view the help file:"
97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-authtools"
98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-authtools"
99 | @echo "# devhelp"
100 |
101 | epub:
102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
103 | @echo
104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
105 |
106 | latex:
107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
108 | @echo
109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
111 | "(use \`make latexpdf' here to do that automatically)."
112 |
113 | latexpdf:
114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
115 | @echo "Running LaTeX files through pdflatex..."
116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
118 |
119 | latexpdfja:
120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
121 | @echo "Running LaTeX files through platex and dvipdfmx..."
122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
124 |
125 | text:
126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
127 | @echo
128 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
129 |
130 | man:
131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
132 | @echo
133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
134 |
135 | texinfo:
136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
137 | @echo
138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
139 | @echo "Run \`make' in that directory to run these through makeinfo" \
140 | "(use \`make info' here to do that automatically)."
141 |
142 | info:
143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
144 | @echo "Running Texinfo files through makeinfo..."
145 | make -C $(BUILDDIR)/texinfo info
146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
147 |
148 | gettext:
149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
150 | @echo
151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
152 |
153 | changes:
154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
155 | @echo
156 | @echo "The overview file is in $(BUILDDIR)/changes."
157 |
158 | linkcheck:
159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
160 | @echo
161 | @echo "Link check complete; look for any errors in the above output " \
162 | "or in $(BUILDDIR)/linkcheck/output.txt."
163 |
164 | doctest:
165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
166 | @echo "Testing of doctests in the sources finished, look at the " \
167 | "results in $(BUILDDIR)/doctest/output.txt."
168 |
169 | xml:
170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
171 | @echo
172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
173 |
174 | pseudoxml:
175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
176 | @echo
177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
178 |
--------------------------------------------------------------------------------
/docs/how-to/migrate-to-a-custom-user-model.rst:
--------------------------------------------------------------------------------
1 | How To Migrate to a Custom User Model
2 | =====================================
3 |
4 |
5 | If you are using the built-in Django User model and you want to switch to an
6 | authtools-based User model, there are certain steps you have to take in order
7 | to keep all of your data. These are steps that have worked for me in the past,
8 | maybe they will help to inform your journey.
9 |
10 | This tutorial assumes that you already have users in your database and that you need
11 | to preserve that data. If you don't already have users in your database, you can
12 | switch easily already.
13 |
14 | Step 1: Backup your database
15 | ----------------------------
16 |
17 | There are several commands for doing this depending on your RDBMS (``pg_dump``,
18 | ``mysqldump``, ``cp``). If you don't want to worry about those, you could also
19 | look for a solution like `django-backupdb
20 | `_. You *do not* want to start
21 | this process without having a backup of your database.
22 |
23 |
24 | Step 2: Make a new app
25 | ----------------------
26 |
27 | This is the app where your custom User model will live. I usually call this
28 | app ``accounts``. ::
29 |
30 | $ python manage.py startapp accounts
31 |
32 | In your new app, edit the models file and add the following::
33 |
34 | from django.db import models
35 | from django.contrib.auth.models import AbstractUser
36 |
37 | class User(AbstractUser):
38 | class Meta:
39 | db_table = 'auth_user'
40 |
41 |
42 | This will put the User model in the same database table as the old one. This
43 | is not ideal, but it is the easiest way to do this migration.
44 |
45 | Add your ``accounts`` app to :django:setting:`INSTALLED_APPS`.
46 |
47 | Set the :django:setting:`AUTH_USER_MODEL` setting to point to your new User
48 | model. ::
49 |
50 | AUTH_USER_MODEL = 'accounts.User'
51 |
52 | If your code has any references to Django's ``User`` model, you will have to go through and replace them with `generic references `_. In most places, this means using ``get_user_model()`` instead of ``User``.
53 | For models with a database relationship to ``User``, you should use ``settings.AUTH_USER_MODEL``.
54 |
55 |
56 | Step 3: Seize control
57 | ---------------------
58 |
59 | Generate an initial migration for the ``accounts`` app. ::
60 |
61 | $ python manage.py makemigrations accounts
62 |
63 | If you are working on a new database and are running the migrations from
64 | scratch, you can run that migration normally. However, if you are working on an
65 | existing database, this migration will fail because the tables it attempts to
66 | create already exist. In this type of situation, the solution would usually be to fake apply the migration,
67 | but doing so in this case will cause Django to raise an :class:`InconsistentMigrationHistory` exception.
68 | There a couple of ways around this.
69 |
70 | One solution would be to delete all your old migration files, truncate the migrations table in the database,
71 | create new migrations, and then fake apply them as outlined `in this tutorial `_.
72 |
73 | This is not ideal. Instead, I suggest another solution that preserves your migration history. Thanks to `this blog post by Tobias McNulty `_ for the idea.
74 |
75 | Start by opening up a database shell. ::
76 |
77 | $ python manage.py dbshell
78 |
79 | Then manually add the migration to the database like this: ::
80 |
81 | INSERT INTO django_migrations (app, name, applied) VALUES ('accounts', '0001_initial', CURRENT_TIMESTAMP);
82 |
83 | Finally, update the ``django_content_type`` table with the new ``app_label`` so that existing references will point to your new user model. You can then exit the shell. ::
84 |
85 | UPDATE django_content_type SET app_label = 'accounts' WHERE app_label = 'auth' and model = 'user';
86 |
87 | .. warning ::
88 |
89 | Make sure to test this process in a staging environment. If your deployment process automatically runs ``migrate``, you will need to run the 2 SQL statements above
90 | beforehand or the migration command will fail.
91 |
92 |
93 |
94 |
95 | Step 4: Conquer
96 | ---------------
97 |
98 | Your ``accounts`` app is now the authoritative source for the User model. You
99 | are in charge now.
100 |
101 | Go build stuff.
102 |
103 |
104 | Optional Step 5: Customize
105 | --------------------------
106 |
107 | .. warning ::
108 |
109 | There is a potential unique constraint failure here. If you don't have
110 | emails for all of your users, you won't be able to migrate. If you don't
111 | have emails for all of your users, they won't be able to log in either, so
112 | you should make sure that you have all of those email addresses first.
113 |
114 | Now that you have control of the User model, there are tons of customizations
115 | that you can do. One thing that I like to do is treat ``email`` as the username
116 | and get rid of ``first_name``/``last_name`` in favor of a single ``name``
117 | field. Here's how I've done it in the past.
118 |
119 | 1. Install django-authtools. ::
120 |
121 | $ pip install django-authtools
122 |
123 | 2. Add ``authtools`` to your ``INSTALLED_APPS``. ::
124 |
125 | INSTALLED_APPS = (
126 | ...
127 | 'authtools',
128 | ...
129 | )
130 |
131 |
132 | 3. Add the fields that I want to User. In this case, all I want to add is
133 | ``name``. ``email`` already exists on User, but I do need to make it
134 | unique if I'm going to treat it as a username.
135 |
136 | Here is an implementation of the User model using
137 | :class:`authtools.models.AbstractNamedUser` as a base. It preserves all of
138 | the fields that are on the built-in User model, but adds ``name`` and
139 | treats ``email`` as the username. ::
140 |
141 | from django.db import models
142 | from django.utils.translation import gettext_lazy as _
143 |
144 | from authtools.models import AbstractNamedUser
145 |
146 |
147 | class User(AbstractNamedUser):
148 | username = models.CharField(_('username'), max_length=30, unique=True)
149 | first_name = models.CharField(_('first name'), max_length=30, blank=True)
150 | last_name = models.CharField(_('last name'), max_length=30, blank=True)
151 |
152 | class Meta:
153 | db_table = 'auth_user'
154 |
155 | I still have ``first_name`` and ``last_name`` because I have to preserve
156 | that data, I will get rid of those fields in step 5.
157 |
158 |
159 | 4. Make a migration to add those fields. ::
160 |
161 | $ python manage.py makemigrations accounts
162 |
163 |
164 | 5. Add python functions to run with the migration that consolidate ``first_name``/``last_name`` into ``name`` (and vice-versa when rolling-back). ::
165 |
166 | def forwards(apps, schema_editor):
167 | User = apps.get_model('accounts', 'User')
168 | for user in User.objects.all():
169 | user.name = user.first_name + ' ' + user.last_name
170 | user.save()
171 |
172 | def backwards(apps, schema_editor):
173 | User = apps.get_model('accounts', 'User')
174 | for user in User.objects.all():
175 | user.first_name, _, user.last_name = user.name.partition(' ')
176 | user.save()
177 |
178 | Add these functions to the list of operations in the generated migration file. ::
179 |
180 | operations = [
181 | ...,
182 | migrations.RunPython(forwards, backwards),
183 | ]
184 |
185 | The backwards migration does make some assumptions about how names work,
186 | but those are the assumptions you are forced to make when using a system
187 | that assumes people have two names.
188 |
189 |
190 | 6. Delete the columns you don't want on your User model. For me, that's
191 | ``username``, ``first_name``, and ``last_name``. My User model now looks
192 | like this::
193 |
194 | class User(AbstractNamedUser):
195 | class Meta:
196 | db_table = 'auth_user'
197 |
198 |
199 | 7. Generate a migration that deletes those extra fields. ::
200 |
201 | $ python manage.py makemigrations accounts
202 |
203 | 8. Run the migrations. ::
204 |
205 | $ python manage.py migrate accounts
206 |
207 |
208 | 9. Watch `YouTube `_. You are
209 | done.
210 |
211 | .. _this blog post by Tobias McNulty: https://www.caktusgroup.com/blog/2019/04/26/how-switch-custom-django-user-model-mid-project/
212 |
--------------------------------------------------------------------------------
/docs/_themes/kr/static/flasky.css_t:
--------------------------------------------------------------------------------
1 | /*
2 | * flasky.css_t
3 | * ~~~~~~~~~~~~
4 | *
5 | * :copyright: Copyright 2010 by Armin Ronacher. Modifications by Kenneth Reitz.
6 | * :license: Flask Design License, see LICENSE for details.
7 | */
8 |
9 | {% set page_width = '940px' %}
10 | {% set sidebar_width = '220px' %}
11 |
12 | @import url("basic.css");
13 |
14 | /* -- page layout ----------------------------------------------------------- */
15 |
16 | body {
17 | font-family: 'goudy old style', 'minion pro', 'bell mt', Georgia, 'Hiragino Mincho Pro';
18 | font-size: 17px;
19 | background-color: white;
20 | color: #000;
21 | margin: 0;
22 | padding: 0;
23 | }
24 |
25 | div.document {
26 | width: {{ page_width }};
27 | margin: 30px auto 0 auto;
28 | }
29 |
30 | div.documentwrapper {
31 | float: left;
32 | width: 100%;
33 | }
34 |
35 | div.bodywrapper {
36 | margin: 0 0 0 {{ sidebar_width }};
37 | }
38 |
39 | div.sphinxsidebar {
40 | width: {{ sidebar_width }};
41 | }
42 |
43 | hr {
44 | border: 1px solid #B1B4B6;
45 | }
46 |
47 | div.body {
48 | background-color: #ffffff;
49 | color: #3E4349;
50 | padding: 0 30px 0 30px;
51 | }
52 |
53 | img.floatingflask {
54 | padding: 0 0 10px 10px;
55 | float: right;
56 | }
57 |
58 | div.footer {
59 | width: {{ page_width }};
60 | margin: 20px auto 30px auto;
61 | font-size: 14px;
62 | color: #888;
63 | text-align: right;
64 | }
65 |
66 | div.footer a {
67 | color: #888;
68 | }
69 |
70 | div.related {
71 | display: none;
72 | }
73 |
74 | div.sphinxsidebar a {
75 | color: #444;
76 | text-decoration: none;
77 | border-bottom: 1px dotted #999;
78 | }
79 |
80 | div.sphinxsidebar a:hover {
81 | border-bottom: 1px solid #999;
82 | }
83 |
84 | div.sphinxsidebar {
85 | font-size: 14px;
86 | line-height: 1.5;
87 | }
88 |
89 | div.sphinxsidebarwrapper {
90 | padding: 18px 10px;
91 | }
92 |
93 | div.sphinxsidebarwrapper p.logo {
94 | padding: 0;
95 | margin: -10px 0 0 -20px;
96 | text-align: center;
97 | }
98 |
99 | div.sphinxsidebar h3,
100 | div.sphinxsidebar h4 {
101 | font-family: 'Garamond', 'Georgia', serif;
102 | color: #444;
103 | font-size: 24px;
104 | font-weight: normal;
105 | margin: 0 0 5px 0;
106 | padding: 0;
107 | }
108 |
109 | div.sphinxsidebar h4 {
110 | font-size: 20px;
111 | }
112 |
113 | div.sphinxsidebar h3 a {
114 | color: #444;
115 | }
116 |
117 | div.sphinxsidebar p.logo a,
118 | div.sphinxsidebar h3 a,
119 | div.sphinxsidebar p.logo a:hover,
120 | div.sphinxsidebar h3 a:hover {
121 | border: none;
122 | }
123 |
124 | div.sphinxsidebar p {
125 | color: #555;
126 | margin: 10px 0;
127 | }
128 |
129 | div.sphinxsidebar ul {
130 | margin: 10px 0;
131 | padding: 0;
132 | color: #000;
133 | }
134 |
135 | div.sphinxsidebar input {
136 | border: 1px solid #ccc;
137 | font-family: 'Georgia', serif;
138 | font-size: 1em;
139 | }
140 |
141 | /* -- body styles ----------------------------------------------------------- */
142 |
143 | a {
144 | color: #004B6B;
145 | text-decoration: underline;
146 | }
147 |
148 | a:hover {
149 | color: #6D4100;
150 | text-decoration: underline;
151 | }
152 |
153 | div.body h1,
154 | div.body h2,
155 | div.body h3,
156 | div.body h4,
157 | div.body h5,
158 | div.body h6 {
159 | font-family: 'Garamond', 'Georgia', serif;
160 | font-weight: normal;
161 | margin: 30px 0px 10px 0px;
162 | padding: 0;
163 | }
164 |
165 | div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; }
166 | div.body h2 { font-size: 180%; }
167 | div.body h3 { font-size: 150%; }
168 | div.body h4 { font-size: 130%; }
169 | div.body h5 { font-size: 100%; }
170 | div.body h6 { font-size: 100%; }
171 |
172 | a.headerlink {
173 | color: #ddd;
174 | padding: 0 4px;
175 | text-decoration: none;
176 | }
177 |
178 | a.headerlink:hover {
179 | color: #444;
180 | background: #eaeaea;
181 | }
182 |
183 | div.body p, div.body dd, div.body li {
184 | line-height: 1.4em;
185 | }
186 |
187 | div.admonition {
188 | background: #fafafa;
189 | margin: 20px -30px;
190 | padding: 10px 30px;
191 | border-top: 1px solid #ccc;
192 | border-bottom: 1px solid #ccc;
193 | }
194 |
195 | div.admonition tt.xref, div.admonition a tt {
196 | border-bottom: 1px solid #fafafa;
197 | }
198 |
199 | dd div.admonition {
200 | margin-left: -60px;
201 | padding-left: 60px;
202 | }
203 |
204 | div.admonition p.admonition-title {
205 | font-family: 'Garamond', 'Georgia', serif;
206 | font-weight: normal;
207 | font-size: 24px;
208 | margin: 0 0 10px 0;
209 | padding: 0;
210 | line-height: 1;
211 | }
212 |
213 | div.admonition p.last {
214 | margin-bottom: 0;
215 | }
216 |
217 | div.highlight {
218 | background-color: white;
219 | }
220 |
221 | dt:target, .highlight {
222 | background: #FAF3E8;
223 | }
224 |
225 | div.note {
226 | background-color: #eee;
227 | border: 1px solid #ccc;
228 | }
229 |
230 | div.seealso {
231 | background-color: #ffc;
232 | border: 1px solid #ff6;
233 | }
234 |
235 | div.topic {
236 | background-color: #eee;
237 | }
238 |
239 | p.admonition-title {
240 | display: inline;
241 | }
242 |
243 | p.admonition-title:after {
244 | content: ":";
245 | }
246 |
247 | pre, tt {
248 | font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
249 | font-size: 0.9em;
250 | }
251 |
252 | img.screenshot {
253 | }
254 |
255 | tt.descname, tt.descclassname {
256 | font-size: 0.95em;
257 | }
258 |
259 | tt.descname {
260 | padding-right: 0.08em;
261 | }
262 |
263 | img.screenshot {
264 | -moz-box-shadow: 2px 2px 4px #eee;
265 | -webkit-box-shadow: 2px 2px 4px #eee;
266 | box-shadow: 2px 2px 4px #eee;
267 | }
268 |
269 | table.docutils {
270 | border: 1px solid #888;
271 | -moz-box-shadow: 2px 2px 4px #eee;
272 | -webkit-box-shadow: 2px 2px 4px #eee;
273 | box-shadow: 2px 2px 4px #eee;
274 | }
275 |
276 | table.docutils td, table.docutils th {
277 | border: 1px solid #888;
278 | padding: 0.25em 0.7em;
279 | }
280 |
281 | table.field-list, table.footnote {
282 | border: none;
283 | -moz-box-shadow: none;
284 | -webkit-box-shadow: none;
285 | box-shadow: none;
286 | }
287 |
288 | table.footnote {
289 | margin: 15px 0;
290 | width: 100%;
291 | border: 1px solid #eee;
292 | background: #fdfdfd;
293 | font-size: 0.9em;
294 | }
295 |
296 | table.footnote + table.footnote {
297 | margin-top: -15px;
298 | border-top: none;
299 | }
300 |
301 | table.field-list th {
302 | padding: 0 0.8em 0 0;
303 | }
304 |
305 | table.field-list td {
306 | padding: 0;
307 | }
308 |
309 | table.footnote td.label {
310 | width: 0px;
311 | padding: 0.3em 0 0.3em 0.5em;
312 | }
313 |
314 | table.footnote td {
315 | padding: 0.3em 0.5em;
316 | }
317 |
318 | dl {
319 | margin: 0;
320 | padding: 0;
321 | }
322 |
323 | dl dd {
324 | margin-left: 30px;
325 | }
326 |
327 | blockquote {
328 | margin: 0 0 0 30px;
329 | padding: 0;
330 | }
331 |
332 | ul, ol {
333 | margin: 10px 0 10px 30px;
334 | padding: 0;
335 | }
336 |
337 | pre {
338 | background: #eee;
339 | padding: 7px 30px;
340 | margin: 15px -30px;
341 | line-height: 1.3em;
342 | }
343 |
344 | dl pre, blockquote pre, li pre {
345 | margin-left: -60px;
346 | padding-left: 60px;
347 | }
348 |
349 | dl dl pre {
350 | margin-left: -90px;
351 | padding-left: 90px;
352 | }
353 |
354 | tt {
355 | background-color: #ecf0f3;
356 | color: #222;
357 | /* padding: 1px 2px; */
358 | }
359 |
360 | tt.xref, a tt {
361 | background-color: #FBFBFB;
362 | border-bottom: 1px solid white;
363 | }
364 |
365 | a.reference {
366 | text-decoration: none;
367 | border-bottom: 1px dotted #004B6B;
368 | }
369 |
370 | a.reference:hover {
371 | border-bottom: 1px solid #6D4100;
372 | }
373 |
374 | a.footnote-reference {
375 | text-decoration: none;
376 | font-size: 0.7em;
377 | vertical-align: top;
378 | border-bottom: 1px dotted #004B6B;
379 | }
380 |
381 | a.footnote-reference:hover {
382 | border-bottom: 1px solid #6D4100;
383 | }
384 |
385 | a:hover tt {
386 | background: #EEE;
387 | }
388 |
389 |
390 | @media screen and (max-width: 600px) {
391 |
392 | div.sphinxsidebar {
393 | display: none;
394 | }
395 |
396 | div.document {
397 | width: 100%;
398 |
399 | }
400 |
401 | div.documentwrapper {
402 | margin-left: 0;
403 | margin-top: 0;
404 | margin-right: 0;
405 | margin-bottom: 0;
406 | }
407 |
408 | div.bodywrapper {
409 | margin-top: 0;
410 | margin-right: 0;
411 | margin-bottom: 0;
412 | margin-left: 0;
413 | }
414 |
415 | ul {
416 | margin-left: 0;
417 | }
418 |
419 | .document {
420 | width: auto;
421 | }
422 |
423 | .footer {
424 | width: auto;
425 | }
426 |
427 | .bodywrapper {
428 | margin: 0;
429 | }
430 |
431 | .footer {
432 | width: auto;
433 | }
434 |
435 | .github {
436 | display: none;
437 | }
438 |
439 | }
440 |
441 | /* misc. */
442 |
443 | .revsys-inline {
444 | display: none!important;
445 | }
446 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # django-authtools documentation build configuration file, created by
4 | # sphinx-quickstart on Thu May 23 14:33:19 2013.
5 | #
6 | # This file is execfile()d with the current directory set to its containing dir.
7 | #
8 | # Note that not all possible configuration values are present in this
9 | # autogenerated file.
10 | #
11 | # All configuration values have a default; values that are commented out
12 | # serve to show the default.
13 |
14 | import sys, os
15 |
16 | sys.path.append(os.path.abspath('_themes'))
17 | sys.path.append(os.path.abspath('_ext'))
18 |
19 | import pkg_resources
20 | distribution = pkg_resources.get_distribution('django-authtools')
21 |
22 | # If extensions (or modules to document with autodoc) are in another directory,
23 | # add these directories to sys.path here. If the directory is relative to the
24 | # documentation root, use os.path.abspath to make it absolute, like shown here.
25 | #sys.path.insert(0, os.path.abspath('.'))
26 |
27 | # -- General configuration -----------------------------------------------------
28 |
29 | # If your documentation needs a minimal Sphinx version, state it here.
30 | #needs_sphinx = '1.0'
31 |
32 | # Add any Sphinx extension module names here, as strings. They can be extensions
33 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
34 | extensions = ['sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode',
35 | 'sphinx.ext.intersphinx', 'djangodocs']
36 |
37 | # Add any paths that contain templates here, relative to this directory.
38 | templates_path = ['_templates']
39 |
40 | # The suffix of source filenames.
41 | source_suffix = '.rst'
42 |
43 | # The encoding of source files.
44 | #source_encoding = 'utf-8-sig'
45 |
46 | # The master toctree document.
47 | master_doc = 'index'
48 |
49 | # General information about the project.
50 | project = u'django-authtools'
51 | copyright = u'2013, Fusionbox, Inc.'
52 |
53 | # The version info for the project you're documenting, acts as replacement for
54 | # |version| and |release|, also used in various other places throughout the
55 | # built documents.
56 | #
57 | # The full version, including alpha/beta/rc tags.
58 | release = distribution.version
59 | # The short X.Y version.
60 | version = '.'.join(release.split('.')[:2])
61 |
62 | # The language for content autogenerated by Sphinx. Refer to documentation
63 | # for a list of supported languages.
64 | #language = None
65 |
66 | # There are two options for replacing |today|: either, you set today to some
67 | # non-false value, then it is used:
68 | #today = ''
69 | # Else, today_fmt is used as the format for a strftime call.
70 | #today_fmt = '%B %d, %Y'
71 |
72 | # List of patterns, relative to source directory, that match files and
73 | # directories to ignore when looking for source files.
74 | exclude_patterns = ['_build']
75 |
76 | # The reST default role (used for this markup: `text`) to use for all documents.
77 | #default_role = None
78 |
79 | # If true, '()' will be appended to :func: etc. cross-reference text.
80 | #add_function_parentheses = True
81 |
82 | # If true, the current module name will be prepended to all description
83 | # unit titles (such as .. function::).
84 | #add_module_names = True
85 |
86 | # If true, sectionauthor and moduleauthor directives will be shown in the
87 | # output. They are ignored by default.
88 | #show_authors = False
89 |
90 | # The name of the Pygments (syntax highlighting) style to use.
91 | pygments_style = 'sphinx'
92 |
93 | # A list of ignored prefixes for module index sorting.
94 | #modindex_common_prefix = []
95 |
96 | # If true, keep warnings as "system message" paragraphs in the built documents.
97 | #keep_warnings = False
98 |
99 |
100 | # -- Options for HTML output ---------------------------------------------------
101 |
102 | # The theme to use for HTML and HTML Help pages. See the documentation for
103 | # a list of builtin themes.
104 | html_theme = 'kr'
105 |
106 | # Theme options are theme-specific and customize the look and feel of a theme
107 | # further. For a list of options available for each theme, see the
108 | # documentation.
109 | #html_theme_options = {}
110 |
111 | # Add any paths that contain custom themes here, relative to this directory.
112 | html_theme_path = ['_themes']
113 |
114 | # The name for this set of Sphinx documents. If None, it defaults to
115 | # " v documentation".
116 | #html_title = None
117 |
118 | # A shorter title for the navigation bar. Default is the same as html_title.
119 | #html_short_title = None
120 |
121 | # The name of an image file (relative to this directory) to place at the top
122 | # of the sidebar.
123 | #html_logo = None
124 |
125 | # The name of an image file (within the static path) to use as favicon of the
126 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
127 | # pixels large.
128 | #html_favicon = None
129 |
130 | # Add any paths that contain custom static files (such as style sheets) here,
131 | # relative to this directory. They are copied after the builtin static files,
132 | # so a file named "default.css" will overwrite the builtin "default.css".
133 | html_static_path = ['_static']
134 |
135 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
136 | # using the given strftime format.
137 | #html_last_updated_fmt = '%b %d, %Y'
138 |
139 | # If true, SmartyPants will be used to convert quotes and dashes to
140 | # typographically correct entities.
141 | #html_use_smartypants = True
142 |
143 | # Custom sidebar templates, maps document names to template names.
144 | #html_sidebars = {}
145 |
146 | # Additional templates that should be rendered to pages, maps page names to
147 | # template names.
148 | #html_additional_pages = {}
149 |
150 | # If false, no module index is generated.
151 | #html_domain_indices = True
152 |
153 | # If false, no index is generated.
154 | #html_use_index = True
155 |
156 | # If true, the index is split into individual pages for each letter.
157 | #html_split_index = False
158 |
159 | # If true, links to the reST sources are added to the pages.
160 | #html_show_sourcelink = True
161 |
162 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
163 | #html_show_sphinx = True
164 |
165 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
166 | #html_show_copyright = True
167 |
168 | # If true, an OpenSearch description file will be output, and all pages will
169 | # contain a tag referring to it. The value of this option must be the
170 | # base URL from which the finished HTML is served.
171 | #html_use_opensearch = ''
172 |
173 | # This is the file name suffix for HTML files (e.g. ".xhtml").
174 | #html_file_suffix = None
175 |
176 | # Output file base name for HTML help builder.
177 | htmlhelp_basename = 'django-authtoolsdoc'
178 |
179 |
180 | # -- Options for LaTeX output --------------------------------------------------
181 |
182 | latex_elements = {
183 | # The paper size ('letterpaper' or 'a4paper').
184 | #'papersize': 'letterpaper',
185 |
186 | # The font size ('10pt', '11pt' or '12pt').
187 | #'pointsize': '10pt',
188 |
189 | # Additional stuff for the LaTeX preamble.
190 | #'preamble': '',
191 | }
192 |
193 | # Grouping the document tree into LaTeX files. List of tuples
194 | # (source start file, target name, title, author, documentclass [howto/manual]).
195 | latex_documents = [
196 | ('index', 'django-authtools.tex', u'django-authtools Documentation',
197 | u'Fusionbox, Inc.', 'manual'),
198 | ]
199 |
200 | # The name of an image file (relative to this directory) to place at the top of
201 | # the title page.
202 | #latex_logo = None
203 |
204 | # For "manual" documents, if this is true, then toplevel headings are parts,
205 | # not chapters.
206 | #latex_use_parts = False
207 |
208 | # If true, show page references after internal links.
209 | #latex_show_pagerefs = False
210 |
211 | # If true, show URL addresses after external links.
212 | #latex_show_urls = False
213 |
214 | # Documents to append as an appendix to all manuals.
215 | #latex_appendices = []
216 |
217 | # If false, no module index is generated.
218 | #latex_domain_indices = True
219 |
220 |
221 | # -- Options for manual page output --------------------------------------------
222 |
223 | # One entry per manual page. List of tuples
224 | # (source start file, name, description, authors, manual section).
225 | man_pages = [
226 | ('index', 'django-authtools', u'django-authtools Documentation',
227 | [u'Fusionbox, Inc.'], 1)
228 | ]
229 |
230 | # If true, show URL addresses after external links.
231 | #man_show_urls = False
232 |
233 |
234 | # -- Options for Texinfo output ------------------------------------------------
235 |
236 | # Grouping the document tree into Texinfo files. List of tuples
237 | # (source start file, target name, title, author,
238 | # dir menu entry, description, category)
239 | texinfo_documents = [
240 | ('index', 'django-authtools', u'django-authtools Documentation',
241 | u'Fusionbox, Inc.', 'django-authtools', 'A custom User model for everybody!',
242 | 'Miscellaneous'),
243 | ]
244 |
245 | # Documents to append as an appendix to all manuals.
246 | #texinfo_appendices = []
247 |
248 | # If false, no module index is generated.
249 | #texinfo_domain_indices = True
250 |
251 | # How to display URL addresses: 'footnote', 'no', or 'inline'.
252 | #texinfo_show_urls = 'footnote'
253 |
254 | # If true, do not generate a @detailmenu in the "Top" node's menu.
255 | #texinfo_no_detailmenu = False
256 |
257 | intersphinx_mapping = {
258 | 'django': ('https://docs.djangoproject.com/en/stable/',
259 | 'https://docs.djangoproject.com/en/stable/_objects/'),
260 | }
261 |
262 | sys.path.insert(0, os.path.abspath('../tests'))
263 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings'
264 |
--------------------------------------------------------------------------------
/tests/tests/tests.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from unittest import skipIf, skipUnless
4 |
5 | import django
6 | from django.core import mail
7 |
8 | from django.test import TestCase
9 | from django.test.utils import override_settings
10 | from django.utils.encoding import force_str
11 | from django.utils.translation import gettext as _
12 | from django.forms.fields import Field
13 | from django.conf import settings
14 | from django.contrib.auth import get_user_model
15 |
16 | from authtools.admin import BASE_FIELDS
17 | from authtools.forms import (
18 | UserCreationForm,
19 | UserChangeForm,
20 | CaseInsensitiveUsernameFieldCreationForm,
21 | CaseInsensitiveEmailUserCreationForm,
22 | )
23 |
24 | User = get_user_model()
25 |
26 |
27 | def skipIfNotCustomUser(test_func):
28 | return skipIf(settings.AUTH_USER_MODEL == 'auth.User', 'Built-in User model in use')(test_func)
29 |
30 |
31 | def skipIfCustomUser(test_func):
32 | """
33 | Copied from django.contrib.auth.tests.utils, This is deprecated in the future, but we still
34 | need it for some of our tests.
35 | """
36 | return skipIf(settings.AUTH_USER_MODEL != 'auth.User', 'Custom user model in use')(test_func)
37 |
38 |
39 | class UserCreationFormTest(TestCase):
40 | def setUp(self):
41 | # in built-in UserManager, the order of arguments is:
42 | # username, email, password
43 | # in authtools UserManager, the order of arguments is:
44 | # USERNAME_FIELD, password
45 | User.objects.create_user('testclient@example.com', password='test123')
46 | self.username = User.USERNAME_FIELD
47 |
48 | def test_user_already_exists(self):
49 | # The benefit of the custom validation message is only available if the
50 | # messages are translated. We won't be able to translate all the
51 | # strings if we don't know what the username will be ahead of time.
52 | data = {
53 | self.username: 'testclient@example.com',
54 | 'password1': 'test123',
55 | 'password2': 'test123',
56 | }
57 | form = UserCreationForm(data)
58 | self.assertFalse(form.is_valid())
59 | self.assertEqual(form[self.username].errors, [
60 | force_str(form.error_messages['duplicate_username']) % {'username': self.username}])
61 |
62 | def test_password_verification(self):
63 | # The verification password is incorrect.
64 | data = {
65 | self.username: 'jsmith',
66 | 'password1': 'test123',
67 | 'password2': 'test',
68 | }
69 | form = UserCreationForm(data)
70 | self.assertFalse(form.is_valid())
71 | self.assertEqual(form["password2"].errors,
72 | [force_str(form.error_messages['password_mismatch'])])
73 |
74 | def test_both_passwords(self):
75 | # One (or both) passwords weren't given
76 | data = {self.username: 'jsmith'}
77 | form = UserCreationForm(data)
78 | required_error = [force_str(Field.default_error_messages['required'])]
79 | self.assertFalse(form.is_valid())
80 | self.assertEqual(form['password1'].errors, required_error)
81 | self.assertEqual(form['password2'].errors, required_error)
82 |
83 | data['password2'] = 'test123'
84 | form = UserCreationForm(data)
85 | self.assertFalse(form.is_valid())
86 | self.assertEqual(form['password1'].errors, required_error)
87 | self.assertEqual(form['password2'].errors, [])
88 |
89 | def test_normalizes_email(self):
90 | data = {
91 | 'password1': 'test123',
92 | 'password2': 'test123',
93 | self.username: 'test@Example.com',
94 | }
95 | if settings.AUTH_USER_MODEL == 'auth.User':
96 | data['email'] = 'test@Example.com'
97 | elif settings.AUTH_USER_MODEL == 'authtools.User':
98 | data['name'] = 'John Smith'
99 | elif settings.AUTH_USER_MODEL != 'tests.User':
100 | assert False, "I don't know your user model"
101 |
102 | form = UserCreationForm(data)
103 | self.assertTrue(form.is_valid())
104 | u = form.save()
105 | self.assertEqual(u.email, 'test@example.com')
106 |
107 | def test_success(self):
108 | # The success case.
109 | data = {
110 | self.username: 'jsmith@example.com',
111 | 'password1': 'test123',
112 | 'password2': 'test123',
113 | }
114 |
115 | if settings.AUTH_USER_MODEL == 'authtools.User':
116 | data['name'] = 'John Smith'
117 |
118 | form = UserCreationForm(data)
119 | self.assertTrue(form.is_valid())
120 | u = form.save()
121 | self.assertEqual(getattr(u, self.username), 'jsmith@example.com')
122 | self.assertTrue(u.check_password('test123'))
123 | self.assertEqual(u, User._default_manager.get_by_natural_key('jsmith@example.com'))
124 |
125 | def test_generated_fields_list(self):
126 | if settings.AUTH_USER_MODEL == 'auth.User':
127 | fields = ('username', 'email', 'password1', 'password2')
128 | elif settings.AUTH_USER_MODEL == 'authtools.User':
129 | fields = ('email', 'name', 'password1', 'password2')
130 | elif settings.AUTH_USER_MODEL == 'tests.User':
131 | fields = ('email', 'full_name', 'preferred_name', 'password1', 'password2')
132 | else:
133 | assert False, "I don't know your user model"
134 |
135 | form = UserCreationForm()
136 | self.assertSequenceEqual(list(form.fields.keys()), fields)
137 |
138 | def test_uses_auth_password_validators(self):
139 | with self.settings(
140 | AUTH_PASSWORD_VALIDATORS=[
141 | {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}
142 | ]
143 | ):
144 | data = {
145 | self.username: 'jsmith@example.com',
146 | 'password1': 'a',
147 | 'password2': 'a',
148 | }
149 |
150 | if settings.AUTH_USER_MODEL == 'authtools.User':
151 | data['name'] = 'John Smith'
152 |
153 | form = UserCreationForm(data)
154 | self.assertFalse(form.is_valid())
155 |
156 |
157 | @skipIfCustomUser
158 | @override_settings(USE_TZ=False)
159 | class UserChangeFormTest(TestCase):
160 | @classmethod
161 | def setUpTestData(cls):
162 | cls.u1 = User.objects.create(
163 | password='pbkdf2_sha256$600000$U3$rhq9d758wGq/JU5/+bkG8OqDVY05d04zKD4cuamR+Sk=',
164 | last_login=datetime.datetime(2006, 12, 17, 7, 3, 31), is_superuser=False, username='testclient',
165 | first_name='Test', last_name='Client', email='testclient@example.com', is_staff=False, is_active=True,
166 | date_joined=datetime.datetime(2006, 12, 17, 7, 3, 31)
167 | )
168 | # cls.u3 = User.objects.create(
169 | # password='sha1$6efc0$f93efe9fd7542f25a7be94871ea45aa95de57161',
170 | # last_login=datetime.datetime(2006, 12, 17, 7, 3, 31), is_superuser=False, username='staff',
171 | # first_name='Staff', last_name='Member', email='staffmember@example.com', is_staff=True, is_active=True,
172 | # date_joined=datetime.datetime(2006, 12, 17, 7, 3, 31)
173 | # )
174 | cls.u4 = User.objects.create(
175 | password='', last_login=datetime.datetime(2006, 12, 17, 7, 3, 31), is_superuser=False,
176 | username='empty_password', first_name='Empty', last_name='Password', email='empty_password@example.com',
177 | is_staff=False, is_active=True, date_joined=datetime.datetime(2006, 12, 17, 7, 3, 31)
178 | )
179 | cls.u5 = User.objects.create(
180 | password='$', last_login=datetime.datetime(2006, 12, 17, 7, 3, 31), is_superuser=False,
181 | username='unmanageable_password', first_name='Unmanageable', last_name='Password',
182 | email='unmanageable_password@example.com', is_staff=False, is_active=True,
183 | date_joined=datetime.datetime(2006, 12, 17, 7, 3, 31)
184 | )
185 | cls.u6 = User.objects.create(
186 | password='foo$bar', last_login=datetime.datetime(2006, 12, 17, 7, 3, 31), is_superuser=False,
187 | username='unknown_password', first_name='Unknown', last_name='Password',
188 | email='unknown_password@example.com', is_staff=False, is_active=True,
189 | date_joined=datetime.datetime(2006, 12, 17, 7, 3, 31)
190 | )
191 |
192 | def test_bug_14242(self):
193 | # A regression test, introduce by adding an optimization for the
194 | # UserChangeForm.
195 |
196 | class MyUserForm(UserChangeForm):
197 | def __init__(self, *args, **kwargs):
198 | super(MyUserForm, self).__init__(*args, **kwargs)
199 | self.fields['groups'].help_text = 'These groups give users different permissions'
200 |
201 | class Meta(UserChangeForm.Meta):
202 | fields = ('groups',)
203 |
204 | # Just check we can create it
205 | MyUserForm({})
206 |
207 | def test_unsuable_password(self):
208 | user = User.objects.get(username='empty_password')
209 | user.set_unusable_password()
210 | user.save()
211 | form = UserChangeForm(instance=user)
212 | self.assertIn(_("No password set."), form.as_table())
213 |
214 | def test_bug_17944_empty_password(self):
215 | user = User.objects.get(username='empty_password')
216 | form = UserChangeForm(instance=user)
217 | self.assertIn(_("No password set."), form.as_table())
218 |
219 | def test_bug_17944_unmanageable_password(self):
220 | user = User.objects.get(username='unmanageable_password')
221 | form = UserChangeForm(instance=user)
222 | self.assertIn(_("Invalid password format or unknown hashing algorithm."),
223 | form.as_table())
224 |
225 | def test_bug_17944_unknown_password_algorithm(self):
226 | user = User.objects.get(username='unknown_password')
227 | form = UserChangeForm(instance=user)
228 | self.assertIn(_("Invalid password format or unknown hashing algorithm."),
229 | form.as_table())
230 |
231 | def test_bug_19133(self):
232 | "The change form does not return the password value"
233 | # Use the form to construct the POST data
234 | user = User.objects.get(username='testclient')
235 | form_for_data = UserChangeForm(instance=user)
236 | post_data = form_for_data.initial
237 |
238 | # The password field should be readonly, so anything
239 | # posted here should be ignored; the form will be
240 | # valid, and give back the 'initial' value for the
241 | # password field.
242 | post_data['password'] = 'new password'
243 | form = UserChangeForm(instance=user, data=post_data)
244 |
245 | self.assertTrue(form.is_valid())
246 | self.assertEqual(form.cleaned_data['password'], 'pbkdf2_sha256$600000$U3$rhq9d758wGq/JU5/+bkG8OqDVY05d04zKD4cuamR+Sk=')
247 |
248 | def test_bug_19349_bound_password_field(self):
249 | user = User.objects.get(username='testclient')
250 | form = UserChangeForm(data={}, instance=user)
251 | # When rendering the bound password field,
252 | # ReadOnlyPasswordHashWidget needs the initial
253 | # value to render correctly
254 | self.assertEqual(form.initial['password'], form['password'].value())
255 |
256 | def test_better_readonly_password_widget(self):
257 | user = User.objects.get(username='testclient')
258 | form = UserChangeForm(instance=user)
259 |
260 | self.assertIn(_('*************'), form.as_table())
261 |
262 | version = django.VERSION[0]
263 |
264 | if version < 4:
265 | self.assertIn('', form.as_table())
266 | elif version < 5:
267 | self.assertIn(''.format(user.id), form.as_table())
268 | else:
269 | self.assertIn('', form.as_table())
270 |
271 |
272 | class UserAdminTest(TestCase):
273 | def test_generated_fieldsets(self):
274 | if settings.AUTH_USER_MODEL == 'auth.User':
275 | fields = ('username', 'email', 'password')
276 | elif settings.AUTH_USER_MODEL == 'authtools.User':
277 | fields = ('email', 'name', 'password')
278 | elif settings.AUTH_USER_MODEL == 'tests.User':
279 | fields = ('email', 'full_name', 'preferred_name', 'password')
280 | else:
281 | assert False, "I don't know your user model"
282 |
283 | self.assertSequenceEqual(BASE_FIELDS[1]['fields'], fields)
284 |
285 |
286 | class UserManagerTest(TestCase):
287 | def test_create_user(self):
288 | u = User._default_manager.create_user(**{
289 | User.USERNAME_FIELD: 'newuser@example.com',
290 | 'password': 'test123',
291 | })
292 |
293 | self.assertEqual(getattr(u, User.USERNAME_FIELD), 'newuser@example.com')
294 | self.assertTrue(u.check_password('test123'))
295 | self.assertEqual(u, User._default_manager.get_by_natural_key('newuser@example.com'))
296 | self.assertTrue(u.is_active)
297 | self.assertFalse(u.is_staff)
298 | self.assertFalse(u.is_superuser)
299 |
300 | @skipIfNotCustomUser
301 | def test_create_superuser(self):
302 | u = User._default_manager.create_superuser(**{
303 | User.USERNAME_FIELD: 'newuser@example.com',
304 | 'password': 'test123',
305 | })
306 |
307 | self.assertTrue(u.is_staff)
308 | self.assertTrue(u.is_superuser)
309 |
310 |
311 | class UserModelTest(TestCase):
312 | @skipUnless(settings.AUTH_USER_MODEL == 'authtools.User',
313 | "only check authuser's ordering")
314 | def test_default_ordering(self):
315 | self.assertSequenceEqual(['name', 'email'], User._meta.ordering)
316 |
317 | def test_send_mail(self):
318 | abstract_user = User(email='foo@bar.com')
319 | abstract_user.email_user(subject="Subject here",
320 | message="This is a message", from_email="from@domain.com")
321 | # Test that one message has been sent.
322 | self.assertEqual(len(mail.outbox), 1)
323 | # Verify that test email contains the correct attributes:
324 | message = mail.outbox[0]
325 | self.assertEqual(message.subject, "Subject here")
326 | self.assertEqual(message.body, "This is a message")
327 | self.assertEqual(message.from_email, "from@domain.com")
328 | self.assertEqual(message.to, [abstract_user.email])
329 |
330 |
331 | @override_settings(AUTHENTICATION_BACKENDS=['authtools.backends.CaseInsensitiveUsernameFieldModelBackend'])
332 | class CaseInsensitiveTest(TestCase):
333 | form_class = CaseInsensitiveUsernameFieldCreationForm
334 |
335 | def get_form_data(self, data):
336 | base_data = {
337 | 'auth.User': {},
338 | 'authtools.User': {
339 | 'name': 'Test Name',
340 | },
341 | 'tests.User': {
342 | 'full_name': 'Francis Underwood',
343 | 'preferred_name': 'Frank',
344 | }
345 | }
346 | defaults = base_data[settings.AUTH_USER_MODEL]
347 | defaults.update(data)
348 | return defaults
349 |
350 | def test_case_insensitive_login_works(self):
351 | password = 'secret'
352 | form = self.form_class(self.get_form_data({
353 | User.USERNAME_FIELD: 'TEst@exAmPle.Com',
354 | 'password1': password,
355 | 'password2': password,
356 | }))
357 | self.assertTrue(form.is_valid(), form.errors)
358 | form.save()
359 |
360 | self.assertTrue(self.client.login(
361 | username='test@example.com',
362 | password=password,
363 | ))
364 |
365 | self.assertTrue(self.client.login(
366 | username='TEST@EXAMPLE.COM',
367 | password=password,
368 | ))
369 |
370 |
371 | @override_settings(AUTHENTICATION_BACKENDS=['authtools.backends.CaseInsensitiveEmailModelBackend'])
372 | class CaseInsensitiveAliasTest(TestCase):
373 | """Test that the aliases still work as well"""
374 | form_class = CaseInsensitiveEmailUserCreationForm
375 |
376 |
377 | @skipIfNotCustomUser
378 | class NormalizeEmailTestCase(TestCase):
379 | def setUp(self):
380 | self.password = 'secret'
381 | self.user = User.objects.create_user(
382 | email='test@Foo.com',
383 | password=self.password,
384 | )
385 |
386 | def test_create_user_normalizes_email(self):
387 | self.assertEqual(self.user.email, 'test@foo.com')
388 |
389 | def test_login_email_domain_is_case_insensitive(self):
390 | self.assertTrue(self.client.login(
391 | username='test@foo.com',
392 | password=self.password,
393 | ))
394 | self.assertTrue(self.client.login(
395 | username='test@Foo.com',
396 | password=self.password,
397 | ))
398 |
399 | def test_login_email_local_part_is_case_sensitive(self):
400 | self.assertFalse(self.client.login(
401 | username='Test@foo.com',
402 | password=self.password,
403 | ))
404 |
--------------------------------------------------------------------------------