From 78d70663beacde4977a51024b9060a3eeddc865c Mon Sep 17 00:00:00 2001 From: mark sevelj <31756570+imAsparky@users.noreply.github.com> Date: Sun, 9 Jan 2022 11:23:26 +0800 Subject: [PATCH] feat(user): Add custom user and admin #207 (#230) * Django admin is missing some useful security features, e.g. read-only on critical fields. * A custom user model that extends the existing user model will allow some additional flexibility to customise the user. * Added django project custom user test suite and fixtures. * Added a custom user how-to. * Updated django-cookiecutter test suite. * Updated tutorial-create-django-project. * Updated README closes #207 closes #212 --- README.rst | 37 ++++-- cookiecutter.json | 4 - docs/source/how-tos/how-to-custom-user.rst | 80 ++++++++++++ docs/source/how-tos/index-how-to.rst | 1 + .../tutorials/new-django-project-options.txt | 4 - .../tutorial-create-django-project.rst | 122 ++++++++++++++++-- hooks/post_gen_project.py | 2 - tests/test_bake_django.py | 113 ++-------------- {{cookiecutter.git_project_name}}/README.rst | 1 + .../config/requirements/base.txt | 2 - .../config/requirements/production.txt | 1 - .../config/requirements/test.txt | 1 + .../config/settings/base.py | 23 ++-- {{cookiecutter.git_project_name}}/conftest.py | 81 ++++++++++++ {{cookiecutter.git_project_name}}/pytest.ini | 3 + .../tests/__init__.py | 0 .../tests/test_custom_user.py | 112 ++++++++++++++++ {{cookiecutter.git_project_name}}/tox.ini | 4 +- .../users/__init__.py | 0 .../users/admin.py | 66 ++++++++++ .../users/apps.py | 6 + .../users/forms.py | 26 ++++ .../users/migrations/__init__.py | 0 .../users/models.py | 91 +++++++++++++ .../users/views.py | 3 + .../{{cookiecutter.project_slug}}/urls.py | 11 +- 26 files changed, 639 insertions(+), 155 deletions(-) create mode 100644 docs/source/how-tos/how-to-custom-user.rst create mode 100644 {{cookiecutter.git_project_name}}/conftest.py create mode 100644 {{cookiecutter.git_project_name}}/tests/__init__.py create mode 100644 {{cookiecutter.git_project_name}}/tests/test_custom_user.py create mode 100644 {{cookiecutter.git_project_name}}/users/__init__.py create mode 100644 {{cookiecutter.git_project_name}}/users/admin.py create mode 100644 {{cookiecutter.git_project_name}}/users/apps.py create mode 100644 {{cookiecutter.git_project_name}}/users/forms.py create mode 100644 {{cookiecutter.git_project_name}}/users/migrations/__init__.py create mode 100644 {{cookiecutter.git_project_name}}/users/models.py create mode 100644 {{cookiecutter.git_project_name}}/users/views.py diff --git a/README.rst b/README.rst index 8627ae66..d4b4a8f0 100644 --- a/README.rst +++ b/README.rst @@ -38,9 +38,31 @@ delivery using GitHub actions. :target: https://python-semantic-release.readthedocs.io/en/latest/ :alt: Python Sementic Release +Django Project Features +----------------------- + +#. Easy for new users to learn Django with sensible defaults. Also, `local` + and `test` environments default to using `SQLite`_, Django's bundled + database. +#. More advanced users can change the test environment database with an + environment variable to match the production environment. +#. `Django-allauth`_ provides authentication. +#. A `CustomUser` model, complete with tests and custom user types. See + `How-to Custom User`_ for customisation options before your initial migration. +#. Improved Admin panel security requires an authorised user to be logged in. + The Admin panel now has the protections provided by django-allauth. + +.. _Django-allauth: https://django-allauth.readthedocs.io/en/latest/installation.html +.. _SQLite: https://www.sqlite.org/index.html +.. _How-to Custom User: + + Django Project Creation Options ------------------------------- +Customise your project with the following options when creating your +django-cookiecutter. + Django Settings ~~~~~~~~~~~~~~~ @@ -48,15 +70,6 @@ Django Settings .. _Django settings: https://docs.djangoproject.com/en/3.2/ref/settings/ - -Django Options -~~~~~~~~~~~~~~ - -#. Choose to use `Django-allauth`_ for authentication. - -.. _Django-allauth: https://django-allauth.readthedocs.io/en/latest/installation.html - - Docker ~~~~~~ @@ -68,10 +81,10 @@ Docker Documentation ~~~~~~~~~~~~~ -#. Choose to add documentation using Sphinx with: +#. Add documentation using Sphinx with: #. `Furo`_, a clean modern theme, with dark and light mode options. - #. A `Copy Button`_ to assist your users copy. + #. A `Copy Button`_ to assist your users copy text or code snippets. #. `Inline Tabs`_ to group similar items. #. Use markdown or restructured text. #. Deploy to `Read the Docs`_. @@ -118,7 +131,7 @@ Contributions are very welcome and appreciated! You can contribute in many ways. -See `How-To Contribute `_ to help you get started. Please take a moment to read our `Code of Conduct diff --git a/cookiecutter.json b/cookiecutter.json index 5bd2f3e1..2dad602d 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -18,10 +18,6 @@ "False" ], "SITE_ID": "1", - "use_django_allauth": [ - "y", - "n" - ], "deploy_with_docker": [ "n", "y", diff --git a/docs/source/how-tos/how-to-custom-user.rst b/docs/source/how-tos/how-to-custom-user.rst new file mode 100644 index 00000000..914005cc --- /dev/null +++ b/docs/source/how-tos/how-to-custom-user.rst @@ -0,0 +1,80 @@ +.. include:: /extras.rst.txt +.. highlight:: rst +.. index:: cust-user-how-to ; Index + +.. _cust-user-how-to: +=========== +Custom User +=========== + +By default, a Custom User employs django-allauth. + +The Custom User comes with custom user types. + +The CustomUser types have Admin panel filters and are sortable in the +Users view. + +All Django Admin security and features remain intact. + + +Options Before Initial Migration +-------------------------------- + +CustomUser types can be left as is, or if you wish to change the user types +to meet your needs, change the values in Users/models.py + + +.. code-block:: python + :caption: Customise these user types. + + class CustomUser(AbstractUser): + + class CustomUserType(models.TextChoices): + """Custom user type choices""" + + # Change these to suit your needs before initial migration. + FREE = "FREE", _("Free") + LEVEL_1 = "LEVEL 1", _("Level 1") + LEVEL_2 = "LEVEL 2", _("Level 2") + STAFF = "STAFF", _("Staff") + SUPERUSER = "SUPERUSER", _("Superuser") + + +Accessing Admin Panel +--------------------- + +Additional security with your Custom User comes by default. + +You can only access your new Django project Admin after a successful login. + +Placing the Admin panel behind the django-allauth login gives Admin all the +protections of django-allauth. + +If this behaviour does not suit your workflow, you can disable this feature by +commenting out the line `admin.site.login`. + +.. code-block:: python + :caption: Users/admin.py + + # Require login before Admin panel is available. Removes the opportunity for + # a brute force attack on Admin Login. Provided by django-allauth. + # https://django-allauth.readthedocs.io/en/latest/advanced.html + # Comment the following line to disable django-allauth protection + admin.site.login = login_required(admin.site.login) + + +Adding a New User +----------------- + +When creating a new user with the django-allauth Sign-up form, the Custom User +model is used. + +.. note:: + + **Adding a new user via the Admin panel** + + The email should be entered under the Accounts menu when creating a new + user in the Admin panel. + + Entering the email in this manner will ensure the email will be available to + django-allauth. A search button will help locate the correct user. diff --git a/docs/source/how-tos/index-how-to.rst b/docs/source/how-tos/index-how-to.rst index 3903d59f..2ecf0a9d 100644 --- a/docs/source/how-tos/index-how-to.rst +++ b/docs/source/how-tos/index-how-to.rst @@ -15,5 +15,6 @@ See below for a list of How-To for Django Cookiecutter. :titlesonly: how-to-quickstart + how-to-custom-user how-to-docker-linux-cheatsheet how-to-contribute diff --git a/docs/source/tutorials/new-django-project-options.txt b/docs/source/tutorials/new-django-project-options.txt index 754389a5..947d49e0 100644 --- a/docs/source/tutorials/new-django-project-options.txt +++ b/docs/source/tutorials/new-django-project-options.txt @@ -17,10 +17,6 @@ Select USE_I18N: 2 - False Choose from 1, 2 [1]: SITE_ID [1]: -Select use_django_allauth: -1 - y -2 - n -Choose from 1, 2 [1]: Select deploy_with_docker: 1 - n 2 - y diff --git a/docs/source/tutorials/tutorial-create-django-project.rst b/docs/source/tutorials/tutorial-create-django-project.rst index e8eaf515..9c38a456 100644 --- a/docs/source/tutorials/tutorial-create-django-project.rst +++ b/docs/source/tutorials/tutorial-create-django-project.rst @@ -4,9 +4,9 @@ .. _cookie-create-pkg: ============================ -Create a Django Cookiecutter -============================ +Create a Django Cookiecutterrk +============================ | @@ -59,14 +59,14 @@ Select the tab for your preferred Operating System. Python version in your Operating System. If you prefer another python version installed on your computer, you can - replace `python3` with `python3.n`, where n is the version number. + replace `python3.8` with `python3.n`, where n is the version number. .. tab:: Linux .. code-block:: bash :caption: **bash/zsh** - python3 -m venv my_venv + python3.8 -m venv my_venv You will have a folder structure similar to this. @@ -82,7 +82,7 @@ Select the tab for your preferred Operating System. .. code-block:: bash :caption: **bash/zsh** - python3 -m venv my_venv + python3.8 -m venv my_venv You will have a folder structure similar to this. @@ -98,14 +98,14 @@ Select the tab for your preferred Operating System. .. code-block:: bash :caption: **cmd/PowerShell** - python3 -m venv my_venv + python3.8 -m venv my_venv Otherwise use .. code-block:: bash :caption: **cmd/PowerShell** - c:\>c:\Python36\python -m venv c:\path\to\projects\my_env + c:\>c:\Python38\python -m venv c:\path\to\projects\my_env You will have a folder structure similar to this. @@ -317,10 +317,6 @@ An Example Django Project 2 - False Choose from 1, 2 [1]: SITE_ID [1]: - Select use_django_allauth: - 1 - y - 2 - n - Choose from 1, 2 [1]: Select deploy_with_docker: 1 - n 2 - y @@ -451,6 +447,15 @@ will look something similar to this. │ │ ├── asgi.py │ │ ├── urls.py │ │ └── wsgi.py + │ ├── users + │ │ ├── __init__.py + │ │ ├── admin.py + │ │ ├── apps.py + │ │ ├── forms.py + │ │ ├── migrations + │ │ │ └── __init__.py + │ │ ├── models.py + │ │ └── views.py │ ├── pytest.ini │ ├── requirements_dev.txt │ └── templates @@ -582,6 +587,21 @@ You will see something similar to this in your CLI. .. include:: tutorial-segment-create-env-variable.rst +Before Initial Migration +------------------------ + +.. important:: + + You may wish to make some changes to the Custom User model + before making your initial migration. + +For example, you can change the default user types to suit your application. + +See `How-to Custom User`_ for customisation options before your initial migration. + +.. _How-to Custom User: + + Final Project Setup ------------------- @@ -675,6 +695,86 @@ You will see something similar to this in your CLI. Bypass password validation and create user anyway? [y/N]: y Superuser created successfully. + +Run the Tests +------------- + +Your project comes complete with a test suite for the custom user. + +Tox, the test runner is configured to test locally with Python +3.8, 3.9 and 3.10. + +See the following commands for options. + +.. code-block:: bash + :caption: Test against all python versions. + + tox + +.. code-block:: bash + :caption: Test against a single python version. + + tox -e py38 + + or + + tox -e py39 + + or + + tox -e py3.10 + +You will see something similar to this in your CLI. + +.. code-block:: bash + + platform linux -- Python 3.8.10, pytest-6.2.5, py-1.11.0, pluggy-1.0.0 -- /projects/my-new-django/.tox/py38/bin/python + cachedir: .tox/py38/.pytest_cache + django: settings: config.settings.test (from ini) + rootdir: /projects/my-new-django, configfile: pytest.ini + plugins: reverse-1.3.0, forked-1.4.0, xdist-2.5.0, django-4.5.2 + [gw0] linux Python 3.8.10 cwd: /projects/my-new-django + [gw1] linux Python 3.8.10 cwd: /projects/my-new-django + [gw0] Python 3.8.10 (default, Nov 26 2021, 20:14:08) -- [GCC 9.3.0] + [gw1] Python 3.8.10 (default, Nov 26 2021, 20:14:08) -- [GCC 9.3.0] + gw0 [6] / gw1 [6] + scheduling tests via LoadScopeScheduling + + tests/test_custom_user.py::test_create_superuser_errors_raised_ok + [gw0] [ 16%] PASSED tests/test_custom_user.py::test_create_superuser_errors_raised_ok + tests/test_custom_user.py::test_create_superuser_ok + [gw0] [ 33%] PASSED tests/test_custom_user.py::test_create_superuser_ok + tests/test_custom_user.py::test_create_user_errors_raised_ok + [gw0] [ 50%] PASSED tests/test_custom_user.py::test_create_user_errors_raised_ok + tests/test_custom_user.py::test_create_user_is_superuser_ok + [gw0] [ 66%] PASSED tests/test_custom_user.py::test_create_user_is_superuser_ok + tests/test_custom_user.py::test_create_user_is_staff_ok + [gw0] [ 83%] PASSED tests/test_custom_user.py::test_create_user_is_staff_ok + tests/test_custom_user.py::test_create_user_ok + [gw0] [100%] PASSED tests/test_custom_user.py::test_create_user_ok + + ================================= PASSES ================================== + _________________ test_create_superuser_errors_raised_ok___________________ + [gw0] linux -- Python 3.8.10 projects/my-new-django/.tox/py38/bin/python + -------------------------- Captured stderr setup -------------------------- + Creating test database for alias 'default'... + ------------------------ Captured stderr teardown ------------------------- + Destroying test database for alias 'default'... + ============================ 6 passed in 1.19s ============================ + _________________________________ summary _________________________________ + py38: commands succeeded + congratulations :) + + +Run Your Project +---------------- + +Django comes with a development server. This server provides all the features +needed to view your Django project locally; however, it is not suitable for a +production environment. + +To view your project in the browser, type the following command. + .. code-block:: bash python3 manage.py runserver # In your browser 127.0.0.1/admin diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 304bd5a3..fb39b27c 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -53,8 +53,6 @@ compose {% endif %}', '{% if cookiecutter.deploy_with_docker == "n" %} \ docker-entrypoint.sh {% endif %}', - '{% if cookiecutter.use_django_allauth == "n" %} \ - templates/account {% endif %}', ] # Helper functions diff --git a/tests/test_bake_django.py b/tests/test_bake_django.py index 2740bb5d..27d40994 100644 --- a/tests/test_bake_django.py +++ b/tests/test_bake_django.py @@ -18,108 +18,6 @@ def test_django_bakes_ok_with_defaults(cookies): assert default_django.project_path.name == "django-boilerplate" -def test_baked_django_with_allauth_requirements_ok(cookies): - """Test Django allauth requirements_dev file entry has been generated.""" - default_django = cookies.bake() - - requirements_path = default_django.project_path / "config/requirements/base.txt" - requirements_file = str(requirements_path.read_text().splitlines()) - - assert "django-allauth==" in requirements_file - - -def test_baked_django_without_allauth_requirements_ok(cookies): - """Test Django allauth requirements_dev file entry has not been generated.""" - non_default_django = cookies.bake(extra_context={"use_django_allauth": "n"}) - - requirements_path = non_default_django.project_path / "config/requirements/base.txt" - requirements_file = str(requirements_path.read_text().splitlines()) - - assert "django-allauth==" not in requirements_file - - -def test_baked_django_with_allauth_settings_ok(cookies): - """Test Django allauth settings file has been generated correctly.""" - default_django = cookies.bake() - - settings_path = default_django.project_path / "config/settings/base.py" - settings_file = settings_path.read_text().splitlines() - - assert ' "allauth",' in settings_file - - assert ' "allauth.account",' in settings_file - assert ' "allauth.socialaccount",' in settings_file - assert ( - ' "django.template.context_processors.request",' in settings_file - ) - assert 'LOGIN_REDIRECT_URL = "/admin/"' in settings_file - assert "LOGOUT_REDIRECT_URL = '/accounts/login/'" in settings_file - assert ( - "ACCOUNT_UNIQUE_EMAIL = True # Default dj-allauth" - in settings_file - ) - - -def test_baked_django_without_allauth_settings_ok(cookies): - """Test Django allauth settings file has not been generated.""" - non_default_django = cookies.bake(extra_context={"use_django_allauth": "n"}) - - settings_path = non_default_django.project_path / "config/settings/base.py" - settings_file = settings_path.read_text().splitlines() - - assert ' "allauth",' not in settings_file - - assert ' "allauth.account",' not in settings_file - assert ' "allauth.socialaccount",' not in settings_file - assert ( - ' "django.template.context_processors.request",' in settings_file - ) - assert 'LOGIN_REDIRECT_URL = "/admin/"' not in settings_file - assert "LOGOUT_REDIRECT_URL = '/accounts/login/'" not in settings_file - assert ( - "ACCOUNT_UNIQUE_EMAIL = True # Default dj-allauth" - not in settings_file - ) - - -def test_baked_django_with_allauth_templates_ok(cookies): - """Test Django allauth HTML templates have been generated.""" - default_django = cookies.bake() - - templates_path = default_django.project_path / "templates/account" - - assert os.path.isdir(templates_path) - - -def test_baked_django_without_allauth_templates_ok(cookies): - """Test Django allauth HTML templates have not been generated.""" - non_default_django = cookies.bake(extra_context={"use_django_allauth": "n"}) - - templates_path = non_default_django.project_path / "templates/account" - - assert not os.path.isdir(templates_path) - - -def test_baked_django_with_allauth_url_ok(cookies): - """Test Django allauth url.py file entry has been generated.""" - default_django = cookies.bake() - - url_path = default_django.project_path / "django_boilerplate/urls.py" - url_file = url_path.read_text().splitlines() - - assert " path('accounts/', include('allauth.urls'))," in url_file - - -def test_baked_django_without_allauth_url_ok(cookies): - """Test Django allauth url.py file entry has not been generated.""" - non_default_django = cookies.bake(extra_context={"use_django_allauth": "n"}) - - url_path = non_default_django.project_path / "django_boilerplate/urls.py" - url_file = url_path.read_text().splitlines() - - assert " path('accounts/', include('allauth.urls'))," not in url_file - - def test_baked_django_asgi_file_ok(cookies): """Test Django asgy.py file has been generated correctly.""" default_django = cookies.bake() @@ -134,6 +32,17 @@ def test_baked_django_asgi_file_ok(cookies): ) +def test_baked_django_custom_admin_file_ok(cookies): + """Test Django custom users/admin.py file has been generated correctly.""" + default_django = cookies.bake() + + admin_path = default_django.project_path / "users/admin.py" + admin_file = str(admin_path.read_text().splitlines()) + + assert 'admin.site.site_title = "django-boilerplate"' in admin_file + assert 'admin.site.site_header = "django-boilerplate Admin"' in admin_file + + def test_baked_django_docs_with_code_of_conduct(cookies): """Test Django docs code of conduct file has been generated correctly.""" default_django = cookies.bake() diff --git a/{{cookiecutter.git_project_name}}/README.rst b/{{cookiecutter.git_project_name}}/README.rst index 0f090483..e3e506d1 100644 --- a/{{cookiecutter.git_project_name}}/README.rst +++ b/{{cookiecutter.git_project_name}}/README.rst @@ -58,5 +58,6 @@ + Built with `Django Cookiecutter `_ diff --git a/{{cookiecutter.git_project_name}}/config/requirements/base.txt b/{{cookiecutter.git_project_name}}/config/requirements/base.txt index f7b9440f..af7bcae5 100644 --- a/{{cookiecutter.git_project_name}}/config/requirements/base.txt +++ b/{{cookiecutter.git_project_name}}/config/requirements/base.txt @@ -1,5 +1,3 @@ Django==4.0.1 -{% if cookiecutter.use_django_allauth == "y" %} django-allauth==0.47.0 -{% endif %} django-environ==0.8.1 diff --git a/{{cookiecutter.git_project_name}}/config/requirements/production.txt b/{{cookiecutter.git_project_name}}/config/requirements/production.txt index 4c47abd2..778edcf7 100644 --- a/{{cookiecutter.git_project_name}}/config/requirements/production.txt +++ b/{{cookiecutter.git_project_name}}/config/requirements/production.txt @@ -1,5 +1,4 @@ -r base.txt psycopg2==2.9.3 - uWSGI==2.0.20 diff --git a/{{cookiecutter.git_project_name}}/config/requirements/test.txt b/{{cookiecutter.git_project_name}}/config/requirements/test.txt index aa249d9d..09cfee90 100644 --- a/{{cookiecutter.git_project_name}}/config/requirements/test.txt +++ b/{{cookiecutter.git_project_name}}/config/requirements/test.txt @@ -1,4 +1,5 @@ dj-inmemorystorage==2.1.0 +psycopg2==2.9.3 pytest==6.2.5 pytest-django==4.5.2 pytest-reverse==1.3.0 diff --git a/{{cookiecutter.git_project_name}}/config/settings/base.py b/{{cookiecutter.git_project_name}}/config/settings/base.py index 2ec93a83..3dbced9f 100644 --- a/{{cookiecutter.git_project_name}}/config/settings/base.py +++ b/{{cookiecutter.git_project_name}}/config/settings/base.py @@ -20,6 +20,8 @@ # Application definition INSTALLED_APPS = [ + "users", + "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -27,25 +29,27 @@ "django.contrib.sites", "django.contrib.messages", "django.contrib.staticfiles", -{% if cookiecutter.use_django_allauth == "y" %} + "allauth", "allauth.account", "allauth.socialaccount", 'allauth.socialaccount.providers.github', 'allauth.socialaccount.providers.google', -{% endif %} + ] + +AUTH_USER_MODEL = "users.CustomUser" {% if cookiecutter.SITE_ID == "1" %} SITE_ID = 1 {% else %} SITE_ID = {{cookiecutter.SITE_ID}} {% endif %} -{% if cookiecutter.use_django_allauth == "y" %} + # LOGIN_REDIRECT_URL For new project convenience, change to your project requirements. LOGIN_REDIRECT_URL = "/admin/" LOGOUT_REDIRECT_URL = '/accounts/login/' -{% endif %} + MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", @@ -71,14 +75,13 @@ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", - {% if cookiecutter.use_django_allauth == "y" %} "django.template.context_processors.request", - {% endif %} + ], }, }, ] -{% if cookiecutter.use_django_allauth == "y" %} + AUTHENTICATION_BACKENDS = [ # Needed to login by username in Django admin, regardless of `allauth` @@ -87,7 +90,7 @@ # `allauth` specific authentication methods, such as login by e-mail 'allauth.account.auth_backends.AuthenticationBackend', ] -{% endif %} + WSGI_APPLICATION = "{{ cookiecutter.project_slug}}.wsgi.application" # Password validation @@ -156,7 +159,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -{% if cookiecutter.use_django_allauth == "y" %} + # Django Allauth Settings # https://django-allauth.readthedocs.io/en/latest/configuration.html ACCOUNT_AUTHENTICATION_METHOD = "username_email" # Default dj-allauth == username @@ -168,4 +171,4 @@ ACCOUNT_USERNAME_REQUIRED = True # Default dj-allauth ACCOUNT_USERNAME_MIN_LENGTH = 3 # Default dj-allauth == 1 ACCOUNT_USERNAME_BLACKLIST = username_blacklist -{% endif %} + diff --git a/{{cookiecutter.git_project_name}}/conftest.py b/{{cookiecutter.git_project_name}}/conftest.py new file mode 100644 index 00000000..c5e97662 --- /dev/null +++ b/{{cookiecutter.git_project_name}}/conftest.py @@ -0,0 +1,81 @@ +"""{{cookiecutter.git_project_name}} Test Fixtures.""" + +import pytest +from users.models import CustomUser as User + +# Fixtures for create_user function. +@pytest.fixture +def new_user_factory(db): + """Factory for create_user function.""" + + def create_user( + username: str = "new_user", + password: str = "new_userpw", + email: str = "new_user@newuser.com", + first_name: str = "firstname", + last_name: str = "lastname", + is_staff: bool = False, + is_superuser: bool = False, + is_active: bool = True, + user_type: str = "FREE", + ): + """Return a new user with default values.""" + user = User.objects.create_user( + username=username, + password=password, + email=email, + first_name=first_name, + last_name=last_name, + is_staff=is_staff, + is_superuser=is_superuser, + is_active=is_active, + user_type=user_type, + ) + return user + + return create_user + + +@pytest.fixture +def new_user(db, new_user_factory): + """Return a default new user""" + return new_user_factory() + + +@pytest.fixture +def new_user_is_staff(db, new_user_factory): + """Return a new staff user""" + return new_user_factory(is_staff=True) + + +@pytest.fixture +def new_user_is_superuser(db, new_user_factory): + """Return a new superuser""" + return new_user_factory(is_staff=True, is_active=True, is_superuser=True) + + +# Fixtures for create_superuser function. +@pytest.fixture +def new_superuser_factory(db): + """Factory for create_superuser function.""" + + def create_superuser( + username: str = "new_user", + password: str = "new_userpw", + email: str = "new_user@newuser.com", + ): + """Return a new superuser with default values.""" + user = User.objects.create_superuser( + username=username, + password=password, + email=email, + ) + return user + + return create_superuser + + +@pytest.fixture +def new_superuser(db, new_superuser_factory): + """Return a new superuser""" + return new_superuser_factory() diff --git a/{{cookiecutter.git_project_name}}/pytest.ini b/{{cookiecutter.git_project_name}}/pytest.ini index 65411140..c5e945af 100644 --- a/{{cookiecutter.git_project_name}}/pytest.ini +++ b/{{cookiecutter.git_project_name}}/pytest.ini @@ -3,3 +3,6 @@ addopts = --maxfail=1 --reverse --numprocesses auto --dist loadscope DJANGO_SETTINGS_MODULE = config.settings.test # -- recommended but optional: python_files = tests.py test_*.py *_tests.py + +markers = + slow: slow running test diff --git a/{{cookiecutter.git_project_name}}/tests/__init__.py b/{{cookiecutter.git_project_name}}/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/{{cookiecutter.git_project_name}}/tests/test_custom_user.py b/{{cookiecutter.git_project_name}}/tests/test_custom_user.py new file mode 100644 index 00000000..e777cf4b --- /dev/null +++ b/{{cookiecutter.git_project_name}}/tests/test_custom_user.py @@ -0,0 +1,112 @@ +"""{{cookiecutter.git_project_name}} project CustomUser Tests.""" + +import pytest +from users.models import CustomUser as User + +# Tests for the create_user function +def test_create_user_ok(new_user): + """Test a default user is created.""" + + assert new_user.username == "new_user" + assert new_user.password != "new_userpw" # should be a hashed password + assert new_user.email == "new_user@newuser.com" + assert new_user.first_name == "firstname" + assert new_user.last_name == "lastname" + assert new_user.user_type == "FREE" + + assert new_user.is_active + assert not new_user.is_staff + assert not new_user.is_superuser + + +def test_create_user_is_staff_ok(new_user_is_staff): + """Test a staff user is created.""" + + assert new_user_is_staff.is_staff + + +def test_create_user_is_superuser_ok(new_user_is_superuser): + """Test a superuser is created.""" + + assert new_user_is_superuser.is_active + assert new_user_is_superuser.is_staff + assert new_user_is_superuser.is_superuser + + +@pytest.mark.django_db +def test_create_user_errors_raised_ok(): + """Test missing new user input raises an error.""" + + # Empty email. + with pytest.raises(ValueError): + User.objects.create_user(email="", password="new_userpw", username="new_user") + + # Empty password. + with pytest.raises(ValueError): + User.objects.create_user( + email="new_user@newuser.com", password="", username="new_user" + ) + + +# # Tests for the create_superuser function. +def test_create_superuser_ok(new_superuser): + """Test that a superuser is created. + + This tests the create_superuser function , used with manage.py + """ + # User input. + assert new_superuser.username == "new_user" + assert new_superuser.password != "new_userpw" # should be a hashed password + assert new_superuser.email == "new_user@newuser.com" + + # Automatically added defaults. + assert new_superuser.is_staff + assert new_superuser.is_superuser + assert new_superuser.is_active + assert new_superuser.user_type == "SUPERUSER" + + +@pytest.mark.django_db +def test_create_superuser_errors_raised_ok(): + """Test that missing input for a new superuser raises an error. + + This tests the create_superuser function , used with manage.py. + """ + # Empty email. + with pytest.raises(ValueError): + User.objects.create_superuser( + email="", password="new_userpw", username="new_user" + ) + + # Empty password. + with pytest.raises(ValueError): + User.objects.create_superuser( + email="new_user@newuser.com", password="", username="new_user" + ) + + # Is not staff. + with pytest.raises(ValueError): + User.objects.create_superuser( + email="new_user@newuser.com", + password="new_userpw", + username="new_user", + is_staff=False, + ) + + # Is not active. + with pytest.raises(ValueError): + User.objects.create_superuser( + email="new_user@newuser.com", + password="new_userpw", + username="new_user", + is_active=False, + ) + + # Is not superuser. + with pytest.raises(ValueError): + User.objects.create_superuser( + email="new_user@newuser.com", + password="new_userpw", + username="new_user", + is_superuser=False, + ) diff --git a/{{cookiecutter.git_project_name}}/tox.ini b/{{cookiecutter.git_project_name}}/tox.ini index 622463d8..cf7f361a 100644 --- a/{{cookiecutter.git_project_name}}/tox.ini +++ b/{{cookiecutter.git_project_name}}/tox.ini @@ -34,7 +34,9 @@ setenv = deps = -r{toxinidir}/requirements_dev.txt commands = - pytest -v {posargs:tests} + # -rP prints stdout to the terminal + # -v prints a verbose pytest output to the terminal + pytest -rP -v {posargs:tests} [pydocstyle] ignore = D213 diff --git a/{{cookiecutter.git_project_name}}/users/__init__.py b/{{cookiecutter.git_project_name}}/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/{{cookiecutter.git_project_name}}/users/admin.py b/{{cookiecutter.git_project_name}}/users/admin.py new file mode 100644 index 00000000..8a285fa3 --- /dev/null +++ b/{{cookiecutter.git_project_name}}/users/admin.py @@ -0,0 +1,66 @@ +"""{{cookiecutter.git_project_name}} project CustomUser Admin.""" + +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.decorators import login_required + +from .forms import CustomUserCreationForm, CustomUserChangeForm +from .models import CustomUser + +# Customise the Admin title and header. +admin.site.site_title = "{{cookiecutter.git_project_name}}" +admin.site.site_header = "{{cookiecutter.git_project_name}} Admin" + +# Require login before Admin panel is available. Removes the opportunity for +# a brute force attack on Admin Login. Provided by django-allauth. +# https://django-allauth.readthedocs.io/en/latest/advanced.html +# Comment the following line to disable django-allauth protection. +admin.site.login = login_required(admin.site.login) + + +class CustomUserAdmin(UserAdmin): + """Custom user admin.""" + + add_form = CustomUserCreationForm + form = CustomUserChangeForm + model = CustomUser + + fieldsets = UserAdmin.fieldsets + ( + ( + "User Type", + {"fields": ("user_type",)}, + ), + ) + + list_display = [ + "username", + "email", + "user_type", + "last_login", + "is_staff", + "is_superuser", + "is_active", + "date_joined", + ] + + # These fields, by default, are not readonly in Admin. + # Change to suit your needs. + readonly_fields = [ + "date_joined", + "last_login", + ] + + # The Admin screen's visible items and display order. + # Change to suit your needs. + list_filter = ("user_type", "is_active", "is_staff", "is_superuser") + + # The Admin screen default sort order. + # Change to suit your needs. + ordering = ( + "username", # Primary sort field. + "user_type", # Secondary sort field. + ) + + +# Register the custom user models. +admin.site.register(CustomUser, CustomUserAdmin) diff --git a/{{cookiecutter.git_project_name}}/users/apps.py b/{{cookiecutter.git_project_name}}/users/apps.py new file mode 100644 index 00000000..72b14010 --- /dev/null +++ b/{{cookiecutter.git_project_name}}/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'users' diff --git a/{{cookiecutter.git_project_name}}/users/forms.py b/{{cookiecutter.git_project_name}}/users/forms.py new file mode 100644 index 00000000..95bff763 --- /dev/null +++ b/{{cookiecutter.git_project_name}}/users/forms.py @@ -0,0 +1,26 @@ +"""{{cookiecutter.git_project_name}} project CustomUser Forms.""" + +from django.contrib.auth.forms import UserCreationForm, UserChangeForm +from django.utils.translation import gettext_lazy as _ +from .models import CustomUser + + +class CustomUserCreationForm(UserCreationForm): + """A basic custom user creation form.""" + + class Meta(UserCreationForm.Meta): + model = CustomUser + fields = UserCreationForm.Meta.fields + ("user_type",) + + error_messages = { + "username": { + "unique": _("That user name already exists, please choose another.") + } + } + + +class CustomUserChangeForm(UserChangeForm): + """A basic custom user change form.""" + + class Meta(UserChangeForm.Meta): + model = CustomUser diff --git a/{{cookiecutter.git_project_name}}/users/migrations/__init__.py b/{{cookiecutter.git_project_name}}/users/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/{{cookiecutter.git_project_name}}/users/models.py b/{{cookiecutter.git_project_name}}/users/models.py new file mode 100644 index 00000000..37eb3ef4 --- /dev/null +++ b/{{cookiecutter.git_project_name}}/users/models.py @@ -0,0 +1,91 @@ +"""{{cookiecutter.git_project_name}} project CustomUser Models.""" + +from django.contrib.auth.models import AbstractUser +from django.contrib.auth.base_user import BaseUserManager +from django.utils.translation import gettext_lazy as _ +from django.db import models +from django.utils import timezone + + +class CustomUserManager(BaseUserManager): + """Custom user manager.""" + + def create_user(self, email=None, password=None, **extra_fields): + """Create and save a User.""" + if not email: + raise ValueError(_("An email address must be supplied.")) + if not password: + raise ValueError(_("A password must be supplied.")) + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save() + return user + + def create_superuser(self, email=None, password=None, **extra_fields): + """Create and save a SuperUser.""" + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + extra_fields.setdefault("is_active", True) + extra_fields.setdefault("user_type", "SUPERUSER") + + if extra_fields.get("is_staff") is not True: + raise ValueError(_("Superuser must be staff.")) + if extra_fields.get("is_active") is not True: + raise ValueError(_("Superuser must be an active user.")) + if extra_fields.get("is_superuser") is not True: + raise ValueError(_("Superuser must be a superuser.")) + if extra_fields.get("user_type") != "SUPERUSER": + raise ValueError(_("Superuser must be type SUPERUSER.")) + + return self.create_user(email, password, **extra_fields) + + +class CustomUser(AbstractUser): + """Extends the standard User model""" + + objects = CustomUserManager() + + class CustomUserType(models.TextChoices): + """Custom user type choices""" + + # Change these to suit your needs before initial migration. + FREE = "FREE", _("Free") + LEVEL_1 = "LEVEL 1", _("Level 1") + LEVEL_2 = "LEVEL 2", _("Level 2") + STAFF = "STAFF", _("Staff") + SUPERUSER = "SUPERUSER", _("Superuser") + + is_staff = models.BooleanField( + _("Is Staff"), + default=False, + help_text="Displays if the user is currently a staff member.", + ) + is_active = models.BooleanField( + _("Is Active"), + default=True, + help_text="Displays if the user is currently an active user.", + ) + date_joined = models.DateTimeField( + _("Date Joined"), + default=timezone.now, + help_text="Displays the user join date.", + ) + email = models.EmailField( + _("Email Address"), + unique=True, + max_length=255, + help_text="Displays if the user email address", + ) + user_type = models.CharField( + _("Type"), + null=False, + blank=False, + max_length=50, + choices=CustomUserType.choices, + default=CustomUserType.FREE, + help_text="Displays the users current user type.", + ) + + def __str__(self): + return self.username diff --git a/{{cookiecutter.git_project_name}}/users/views.py b/{{cookiecutter.git_project_name}}/users/views.py new file mode 100644 index 00000000..fd0e0449 --- /dev/null +++ b/{{cookiecutter.git_project_name}}/users/views.py @@ -0,0 +1,3 @@ +# from django.shortcuts import render + +# Create your views here. diff --git a/{{cookiecutter.git_project_name}}/{{cookiecutter.project_slug}}/urls.py b/{{cookiecutter.git_project_name}}/{{cookiecutter.project_slug}}/urls.py index c9774145..024e583e 100644 --- a/{{cookiecutter.git_project_name}}/{{cookiecutter.project_slug}}/urls.py +++ b/{{cookiecutter.git_project_name}}/{{cookiecutter.project_slug}}/urls.py @@ -19,17 +19,16 @@ from allauth import account urlpatterns = [ - path('admin/', admin.site.urls), -{% if cookiecutter.use_django_allauth == "y" %} - path('accounts/', include('allauth.urls')), - path('', account.views.LoginView.as_view(), name="login") -{% endif %} + path("admin/", admin.site.urls), + path("accounts/", include("allauth.urls")), + path("", account.views.LoginView.as_view(), name="login"), ] # If we are in DEBUG mode add debug-toolbar to url_patterns. if settings.DEBUG: import debug_toolbar + urlpatterns += [ - path('__debug__/', include(debug_toolbar.urls)), + path("__debug__/", include(debug_toolbar.urls)), ]