From fc6ad83f0de3b34b45e770140bf6192f8caa9614 Mon Sep 17 00:00:00 2001 From: Huy Chau Date: Wed, 23 Sep 2020 09:06:11 +0700 Subject: [PATCH 01/14] Initial resources --- .coveragerc | 11 ++ .editorconfig | 20 ++ .gitignore | 78 ++++++++ MANIFEST.in | 6 + Makefile | 50 +++++ README.md | 139 +++++++++++++ coverage.png | Bin 0 -> 140272 bytes docs/apis.rst | 70 +++++++ docs/conf.py | 54 ++++++ docs/index.rst | 54 ++++++ docs/install.rst | 16 ++ docs/make.bat | 35 ++++ docs/quickstart.rst | 62 ++++++ docs/settings/change-password.rst | 22 +++ docs/settings/email.rst | 107 ++++++++++ docs/settings/index.rst | 115 +++++++++++ docs/settings/login.rst | 28 +++ docs/settings/profile.rst | 22 +++ docs/settings/register.rst | 57 ++++++ docs/settings/reset-password.rst | 66 +++++++ docs/settings/set-password.rst | 22 +++ docs/settings/social-login.rst | 20 ++ docs/settings/user.rst | 126 ++++++++++++ drf_registration/__init__.py | 1 + drf_registration/admin.py | 0 drf_registration/api/__init__.py | 7 + drf_registration/api/change_password.py | 65 +++++++ drf_registration/api/login.py | 149 ++++++++++++++ drf_registration/api/logout.py | 26 +++ drf_registration/api/profile.py | 66 +++++++ drf_registration/api/register.py | 116 +++++++++++ drf_registration/api/reset_password.py | 96 +++++++++ drf_registration/api/set_password.py | 54 ++++++ drf_registration/api/user.py | 42 ++++ drf_registration/apps.py | 12 ++ drf_registration/auth.py | 33 ++++ drf_registration/constants.py | 27 +++ drf_registration/exceptions.py | 75 ++++++++ drf_registration/settings.py | 115 +++++++++++ drf_registration/tokens.py | 36 ++++ drf_registration/urls.py | 23 +++ drf_registration/utils/__init__.py | 0 drf_registration/utils/common.py | 64 ++++++ drf_registration/utils/domain.py | 13 ++ drf_registration/utils/email.py | 134 +++++++++++++ drf_registration/utils/responses.py | 0 drf_registration/utils/socials.py | 102 ++++++++++ drf_registration/utils/users.py | 177 +++++++++++++++++ examples/testapp/README.md | 14 ++ examples/testapp/accounts/__init__.py | 0 examples/testapp/accounts/apps.py | 5 + .../accounts/migrations/0001_initial.py | 44 +++++ .../testapp/accounts/migrations/__init__.py | 0 examples/testapp/accounts/models.py | 14 ++ examples/testapp/config/__init__.py | 0 examples/testapp/config/settings.py | 104 ++++++++++ examples/testapp/config/urls.py | 30 +++ examples/testapp/config/wsgi.py | 16 ++ examples/testapp/manage.py | 25 +++ examples/testapp/requirements.txt | 1 + .../registration/activate_email.html | 8 + .../registration/activate_failed.html | 1 + .../registration/activate_success.html | 1 + .../templates/registration/welcome_email.html | 1 + pytest.ini | 9 + requirements.txt | 0 requirements/base.txt | 3 + requirements/dev.txt | 0 requirements/docs.txt | 3 + requirements/test.txt | 4 + setup.cfg | 14 ++ setup.py | 37 ++++ tests/__init__.py | 0 tests/settings.py | 72 +++++++ tests/src/__init__.py | 0 tests/src/api/test_change_password.py | 37 ++++ tests/src/api/test_login.py | 115 +++++++++++ tests/src/api/test_logout.py | 23 +++ tests/src/api/test_profile.py | 52 +++++ tests/src/api/test_register.py | 147 ++++++++++++++ tests/src/api/test_reset_password.py | 53 +++++ tests/src/api/test_set_password.py | 44 +++++ tests/src/test_models.py | 13 ++ tests/src/test_settings.py | 29 +++ tests/src/utils/test_common.py | 20 ++ tests/src/utils/test_domain.py | 16 ++ tests/src/utils/test_email.py | 67 +++++++ tests/src/utils/test_socials.py | 77 ++++++++ tests/src/utils/test_users.py | 119 ++++++++++++ tests/urls.py | 10 + tests/utils.py | 182 ++++++++++++++++++ tox.ini | 20 ++ 92 files changed, 4043 insertions(+) create mode 100644 .coveragerc create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 README.md create mode 100644 coverage.png create mode 100644 docs/apis.rst create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/install.rst create mode 100644 docs/make.bat create mode 100644 docs/quickstart.rst create mode 100644 docs/settings/change-password.rst create mode 100644 docs/settings/email.rst create mode 100644 docs/settings/index.rst create mode 100644 docs/settings/login.rst create mode 100644 docs/settings/profile.rst create mode 100644 docs/settings/register.rst create mode 100644 docs/settings/reset-password.rst create mode 100644 docs/settings/set-password.rst create mode 100644 docs/settings/social-login.rst create mode 100644 docs/settings/user.rst create mode 100644 drf_registration/__init__.py create mode 100644 drf_registration/admin.py create mode 100644 drf_registration/api/__init__.py create mode 100644 drf_registration/api/change_password.py create mode 100644 drf_registration/api/login.py create mode 100644 drf_registration/api/logout.py create mode 100644 drf_registration/api/profile.py create mode 100644 drf_registration/api/register.py create mode 100644 drf_registration/api/reset_password.py create mode 100644 drf_registration/api/set_password.py create mode 100644 drf_registration/api/user.py create mode 100644 drf_registration/apps.py create mode 100644 drf_registration/auth.py create mode 100644 drf_registration/constants.py create mode 100644 drf_registration/exceptions.py create mode 100644 drf_registration/settings.py create mode 100644 drf_registration/tokens.py create mode 100644 drf_registration/urls.py create mode 100644 drf_registration/utils/__init__.py create mode 100644 drf_registration/utils/common.py create mode 100644 drf_registration/utils/domain.py create mode 100644 drf_registration/utils/email.py create mode 100644 drf_registration/utils/responses.py create mode 100644 drf_registration/utils/socials.py create mode 100644 drf_registration/utils/users.py create mode 100644 examples/testapp/README.md create mode 100644 examples/testapp/accounts/__init__.py create mode 100644 examples/testapp/accounts/apps.py create mode 100644 examples/testapp/accounts/migrations/0001_initial.py create mode 100644 examples/testapp/accounts/migrations/__init__.py create mode 100644 examples/testapp/accounts/models.py create mode 100644 examples/testapp/config/__init__.py create mode 100644 examples/testapp/config/settings.py create mode 100644 examples/testapp/config/urls.py create mode 100644 examples/testapp/config/wsgi.py create mode 100644 examples/testapp/manage.py create mode 100644 examples/testapp/requirements.txt create mode 100644 examples/testapp/templates/registration/activate_email.html create mode 100644 examples/testapp/templates/registration/activate_failed.html create mode 100644 examples/testapp/templates/registration/activate_success.html create mode 100644 examples/testapp/templates/registration/welcome_email.html create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 requirements/base.txt create mode 100644 requirements/dev.txt create mode 100644 requirements/docs.txt create mode 100644 requirements/test.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/settings.py create mode 100644 tests/src/__init__.py create mode 100644 tests/src/api/test_change_password.py create mode 100644 tests/src/api/test_login.py create mode 100644 tests/src/api/test_logout.py create mode 100644 tests/src/api/test_profile.py create mode 100644 tests/src/api/test_register.py create mode 100644 tests/src/api/test_reset_password.py create mode 100644 tests/src/api/test_set_password.py create mode 100644 tests/src/test_models.py create mode 100644 tests/src/test_settings.py create mode 100644 tests/src/utils/test_common.py create mode 100644 tests/src/utils/test_domain.py create mode 100644 tests/src/utils/test_email.py create mode 100644 tests/src/utils/test_socials.py create mode 100644 tests/src/utils/test_users.py create mode 100644 tests/urls.py create mode 100644 tests/utils.py create mode 100644 tox.ini diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..8f6af94 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,11 @@ +[run] +omit = + */usr/local/lib* + *migrations* + *management* + */test_*.py + */apps.py + */tests/utils.py + */tests/src* + *.tox* + *setup.py* diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f232486 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +indent_style = space +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_size = 4 + +[*.{py,sh}] +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02cce9e --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +.python-version + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# MyPy +.mypy_cache/ + +# Unit test / coverage reports +htmlcov/ +.tox/ +.pytest_cache/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +TEST-*.xml +*,cover +/coverage_html_report/ +test_db.sqlite3 + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# VS Code +.vscode/ + +# Mac OS +.DS_Store + +# Virtualenv +py36/ + +# Docs +_build diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..bf5b5be --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include README.md +include requirements.txt +include requirements/base.txt +include tests/* +recursive-include rest_registration/templates *.txt *.html +recursive-include rest_registration/locale *.mo *.po diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2eaa6f7 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +PACKAGE_DIR := drf_registration +TEST_DIR := tests + +PYLINT := pylint +PYLINT_OPTS := --rcfile=setup.cfg + +TEST := tox -e test ${ARGS} +DOCS := tox -e docs + +# Build Docs +DOCS_SPHINXOPTS ?= +DOCS_SPHINXBUILD ?= sphinx-build +DOCS_SOURCEDIR = ./docs +DOCS_BUILDDIR = ${DOCS_SOURCEDIR}/_build + +# Serve the docs +DOCS_PORT := 8080 +DOCS_SPHINXAUTOBUILD := sphinx-autobuild +DOCS_SPHINXAUTOBUILD_OPTS := --watch ${PACKAGE_DIR} --port ${DOCS_PORT} + +# Build directories +BUILD_DIRS := ${DOCS_BUILDDIR} *.egg-info dist build .tox .pytest_cache htmlcov .coverage +# Docs +.PHONY: build_docs +build_docs: + ${DOCS_SPHINXBUILD} ${DOCS_SPHINXOPTS} ${DOCS_SOURCEDIR} ${DOCS_BUILDDIR}/html ${ARGS} + +.PHONY: serve_docs +serve_docs: + ${DOCS_SPHINXAUTOBUILD} ${DOCS_SPHINXAUTOBUILD_OPTS} ${DOCS_SOURCEDIR} ${DOCS_BUILDDIR}/html ${ARGS} + +.PHONY: docs +docs: + ${DOCS} + +# Linter +.PHONY: pylint +pylint: ## run pylint + ${PYLINT} --disable=missing-module-docstring --disable=too-few-public-methods --disable=protected-access ${PYLINT_OPTS} ${PACKAGE_DIR} ${ARGS} + +# Run test +.PHONY: test +test: + ${TEST} + +.PHONY: clean +clean: + @ find . -name '*.py[co]' -delete + @ find . -name '__pycache__' -delete + @ rm -rf ${BUILD_DIRS} diff --git a/README.md b/README.md new file mode 100644 index 0000000..d50e2e2 --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# Django Rest Framework Registration + +User registration base on Django Rest Framework. + +Check the document at https://drf-registration.readthedocs.io/ + +## Requirements +- Django (>=2.0) +- Django REST Framework (>=3.8.2) +- Python (>=3.6) + +## Features +- [x] Register +- [x] Verify/activate account by token sent to email +- [x] Login use token +- [x] Logout +- [x] User profile +- [x] Change password +- [x] Reset password + +## Future Features +- [x] Login by socials (Facebook, Google) +- [x] Set password when login by social +- [x] Sync user account with socials +- [x] HTML email configuration +- [x] Test coverage (98%) + +## Base APIs Design + +Assuming that base resource is `/api/v1/accounts/` + +### Regsiter +#### POST: `/register/` +Register new user + +### Verify account +#### POST: `/verify/` +Verify account by email + +### Login +#### POST: `/login/` +Login to the system use username/email and password + +### Logout +#### POST: `/logout/` +Logout of the system + +### Profile +#### GET: `/profile/` +Get user profile + +#### PUT: `/profile/` +Update user profile + +### Change password +#### PUT: `/change-password/` +Change user password + +### Set password +#### PUT: `/set-password/` +Set user password when login with social account + +## Installing +- Add `drf_registration` in `INSTALLED_APPS` +``` +INSTALLED_APPS = [ + ... + 'rest_framework', + 'rest_framework.authtoken', + 'drf_registration', + ... +] +``` + +- Include urls of `drf_registration` in `urls.py` +``` +urlpatterns = [ + ... + path('/api/accounts/', include('drf_registration.urls')), + ... +] +``` + +## Settings +- Set `AUTHENTICATION_BACKEND` for support login by multiple custom fields and check inactivate user when login: + +``` +AUTHENTICATION_BACKENDS = [ + 'drf_registration.auth.MultiFieldsModelBackend', +] +``` + +You can update login username fields by change `LOGIN_USERNAME_FIELDS` in `DRF_REGISTRATION` object. Default to `['username, email,]`. + +- Set `DEFAULT_AUTHENTICATION_CLASSES` in `REST_FRAMEWORK` configuration + +``` +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + ], +} +``` + +### Design settings +``` +DRF_REGISTRATION = { + +} +``` + +## Command line + +- Unit Test +``` +make test +``` +*You can add `ARGS="specific_folder/"` or `ARGS="specific_file.py"` to run specific test cases.* + +- Run pylint +``` +make pylint +``` + +- Build & run docs local server +``` +make docs +``` +Access docs server at http://localhost:8080 + +- Clean +``` +make clean +``` + +## Test coverage + +![Test coverage](coverage.png) + diff --git a/coverage.png b/coverage.png new file mode 100644 index 0000000000000000000000000000000000000000..90de44d893f6fc43013bbc66ce7453029eb53509 GIT binary patch literal 140272 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

9sis-Ho|ba4V>iT?D_tkdIBU}C;1!Z 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 N1fH&bF6*2UngA*EbtV7+ literal 0 HcmV?d00001 diff --git a/docs/apis.rst b/docs/apis.rst new file mode 100644 index 0000000..1228d43 --- /dev/null +++ b/docs/apis.rst @@ -0,0 +1,70 @@ +.. _apis: + +APIs Design +=========== + +Assuming that base resource is ``/api/v1/accounts/`` + +Register +-------- + +.. data:: POST: /register/ + + Register new user + +.. data:: GET: /activate// + + Activate account by token sent to email + +Login +----- + +.. data:: POST: /login/ + + Login to the system use username/email and password + +Social Login +----- + +.. data:: POST: /login/social/ + + Login to the system use ``provider`` and ``access_token`` + +Logout +------ + +.. data:: POST: /logout/ + + Logout of the system + +Profile +------- + +.. data:: GET: /profile/ + + Get user profile + +.. data:: PUT: /profile/ + + Update user profile + +Change password +--------------- + +.. data:: PUT: /change-password/ + + Change user password + +Reset password +-------------- + +.. data:: POST: /reset-password/ + + Reset user password by email + +Set password +-------------- + +.. data:: PUT: /set-password/ + + Set use password when login by socials diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..ee1ec00 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,54 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'DRF Registration' +copyright = '2020, Huy Chau' +author = 'Huy Chau' + +# -- General configuration --------------------------------------------------- + +master_doc = 'index' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx_rtd_theme', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..d0a85fc --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,54 @@ +.. DRF Registration documentation master file, created by + sphinx-quickstart on Wed Jul 22 08:44:34 2020. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to DRF Registration's documentation! +============================================ + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + install + quickstart + settings/index + apis + +Requirements +------------ + +- Django (>=2.0) +- Django REST Framework (>=3.8.2) +- Python (>=3.6) + +Features +-------- + +- Register +- Verify/activate account by token sent to email +- Send welcome email when register is successful +- Login use token +- Check inactivate user when login +- Logout +- User profile +- Change password +- Reset password +- Custom serializers +- Custom templates + +Extended Features +--------------- + +- Simple login by Google, Facebook without database model +- Set password when login by socials +- Sync user account with socials +- Above 98% code coverage + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/install.rst b/docs/install.rst new file mode 100644 index 0000000..c7012f9 --- /dev/null +++ b/docs/install.rst @@ -0,0 +1,16 @@ +.. _installation: + +Installation +============ + +You can install DRF Registration latest version via pip: + +:: + + pip install drf-registration # Just todo + +Or install directly from source via Github: + +:: + + pip install git+https://github.com/huychau/drf-registration # Just todo diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..2119f51 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 0000000..e659814 --- /dev/null +++ b/docs/quickstart.rst @@ -0,0 +1,62 @@ +.. _quickstart: + +Quickstart +========== + +All configurations in your ``settings.py`` + +.. note:: + We use authentication scheme uses a simple token-based HTTP Authentication scheme. Token authentication is appropriate for client-server setups, such as native desktop and mobile clients. + + +Add ``drf_registration`` to ``INSTALLED_APPS``. You also have to add ``rest_framework`` and ``rest_framework.authtoken`` too. + +.. code:: python + + INSTALLED_APPS = [ + ... + 'rest_framework', + 'rest_framework.authtoken', + 'drf_registration', + ... + ] + +Configure the user model + +.. code:: python + + AUTH_USER_MODEL = 'accounts.User' # You can set valid value of current system + +Include urls of ``drf_registration`` in ``urls.py`` + +.. code:: python + + urlpatterns = [ + ... + path('/api/accounts/', include('drf_registration.urls')), + ... + ] + +.. note:: + Add ``path('admin/', admin.site.urls),`` to ``urlpatterns`` if ``RESET_PASSWORD_ENABLED`` is ``True`` and use default Django reset password templates. + + +Set ``AUTHENTICATION_BACKEND`` for support login by multiple custom fields and check inactivate user when login + +.. code:: python + + AUTHENTICATION_BACKENDS = [ + 'drf_registration.auth.MultiFieldsModelBackend', + ] + +You can update login username fields by change ``LOGIN_USERNAME_FIELDS`` in ``DRF_REGISTRATION`` object. Default to ``['username, email,]``. + +Set ``DEFAULT_AUTHENTICATION_CLASSES`` in ``REST_FRAMEWORK`` configuration + +.. code:: python + + REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + ], + } diff --git a/docs/settings/change-password.rst b/docs/settings/change-password.rst new file mode 100644 index 0000000..f498386 --- /dev/null +++ b/docs/settings/change-password.rst @@ -0,0 +1,22 @@ +.. _change-password: + +Change Password +=============== + +.. data:: CHANGE_PASSWORD_PERMISSION_CLASSES + + The change password permissions classes + + Default: + + .. code:: python + + [ + 'rest_framework.permissions.IsAuthenticated', + ] + +.. data:: CHANGE_PASSWORD_SERIALIZER + + The change password serializer + + Default: ``'drf_registration.api.change_password.ChangePasswordSerializer'`` diff --git a/docs/settings/email.rst b/docs/settings/email.rst new file mode 100644 index 0000000..8643d43 --- /dev/null +++ b/docs/settings/email.rst @@ -0,0 +1,107 @@ +.. email: + +Email +===== + +.. note:: + + We are using the ``django.core.mail`` module and default configurations of SMTP server. + + You just need config email if you enabled send email in the Registration flow such as ``USER_ACTIVATE_TOKEN_ENABLED``, ``REGISTER_SEND_WELCOME_EMAIL_ENABLED`` and ``RESET_PASSWORD_ENABLED``. + +Default settings +----------------- + +Add the SMTP configurations in your settings + +.. code:: python + + # Default configurations + EMAIL_HOST = 'smtp.mailserver.com' + EMAIL_PORT = 587 + EMAIL_HOST_USER = 'username' + EMAIL_HOST_PASSWORD = 'hostpassword' + EMAIL_USE_TLS = True + + # Default from email + DEFAULT_FROM_EMAIL = 'info@testingdomain.com' + +Template settings +----------------- + +The settings in ``DRF_REGISTRATION`` object, to custom activate email template, make sure you have set ``USER_ACTIVATE_TOKEN_ENABLED`` is ``True``. + +Context support: + - ``activate_link``: The activate link + - ``domain``: Current domain + +.. data:: USER_ACTIVATE_EMAIL_SUBJECT + + The activate email subject + + Default: ``'Activate your account'`` + + +.. data:: USER_ACTIVATE_EMAIL_TEMPLATE + + The activate email template path + + Default: ``None`` + + If not set, the default template message is + + .. code:: python + +

By clicking on the following link, you are activating your account

+ Activate Account + +Custom welcome email template, make sure you have set ``REGISTER_SEND_WELCOME_EMAIL_ENABLED`` is ``True``. + +Context support: + - ``user``: the user information object. + +.. data:: REGISTER_SEND_WELCOME_EMAIL_SUBJECT + + The welcome email subject + + Default: ``'Welcome to the system'`` + + +.. data:: REGISTER_SEND_WELCOME_EMAIL_TEMPLATE + + The welcome email template path + + Default: ``None`` + + If not set, the default template message is + + .. code:: python + +

Hi,

+

Welcome to the system!

+ +Custom reset password email template, make sure you have set ``RESET_PASSWORD_ENABLED``. + +Context support: + - ``reset_password_link``: The reset password link + - ``domain``: Current domain + +.. data:: RESET_PASSWORD_EMAIL_SUBJECT + + The welcome email subject + + Default: ``'Reset Password'`` + + +.. data:: RESET_PASSWORD_EMAIL_TEMPLATE + + The reset password email body template path + + Default: ``None`` + + If not set, the default template message is + + .. code:: python + +

Please go to the following page and choose a new password:

+ Reset Password diff --git a/docs/settings/index.rst b/docs/settings/index.rst new file mode 100644 index 0000000..d9dd360 --- /dev/null +++ b/docs/settings/index.rst @@ -0,0 +1,115 @@ +Settings +======== + +.. note:: + All setting properties in ``DRF_REGISTRATION`` object. + +.. toctree:: + :maxdepth: 2 + + email + user + register + login + profile + change-password + reset-password + set-password + social-login + +All default settings +-------------------- + +.. code:: python + + DRF_REGISTRATION = { + + # General settings + 'PROJECT_NAME': 'DRF Registration', + 'PROJECT_BASE_URL': '', + + # User fields to register and response to profile + 'USER_FIELDS': ( + 'id', + 'username', + 'email', + 'password', + 'first_name', + 'last_name', + 'is_active', + ), + 'USER_READ_ONLY_FIELDS': ( + 'is_superuser', + 'is_staff', + 'is_active', + ), + 'USER_WRITE_ONLY_FIELDS': ( + 'password', + ), + + 'USER_SERIALIZER': 'drf_registration.api.user.UserSerializer', + + # User verify field + 'USER_VERIFY_FIELD': 'is_active', + + # Activate user by toiken sent to email + 'USER_ACTIVATE_TOKEN_ENABLED': False, + 'USER_ACTIVATE_SUCSSESS_TEMPLATE': '', + 'USER_ACTIVATE_FAILED_TEMPLATE': '', + 'USER_ACTIVATE_EMAIL_SUBJECT': 'Activate your account', + 'USER_ACTIVATE_EMAIL_TEMPLATE': '', + + # Profile + 'PROFILE_SERIALIZER': 'drf_registration.api.profile.ProfileSerializer', + 'PROFILE_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + + # Register + 'REGISTER_SERIALIZER': 'drf_registration.api.register.RegisterSerializer', + 'REGISTER_PERMISSION_CLASSES': [ + 'rest_framework.permissions.AllowAny', + ], + 'REGISTER_SEND_WELCOME_EMAIL_ENABLED': False, + 'REGISTER_SEND_WELCOME_EMAIL_SUBJECT': 'Welcome to the system', + 'REGISTER_SEND_WELCOME_EMAIL_TEMPLATE': '', + + # Login + 'LOGIN_SERIALIZER': 'drf_registration.api.login.LoginSerializer', + 'LOGIN_PERMISSION_CLASSES': [ + 'rest_framework.permissions.AllowAny', + ], + + # For custom login username fields + 'LOGIN_USERNAME_FIELDS': ['username', 'email',], + + 'LOGOUT_REMOVE_TOKEN': False, + + # Change password + 'CHANGE_PASSWORD_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'CHANGE_PASSWORD_SERIALIZER': 'drf_registration.api.change_password.ChangePasswordSerializer', + + # Reset password + 'RESET_PASSWORD_ENABLED': True, + 'RESET_PASSWORD_PERMISSION_CLASSES': [ + 'rest_framework.permissions.AllowAny', + ], + 'RESET_PASSWORD_SERIALIZER': 'drf_registration.api.reset_password.ResetPasswordSerializer', + 'RESET_PASSWORD_EMAIL_SUBJECT': 'Reset Password', + 'RESET_PASSWORD_EMAIL_TEMPLATE': '', + 'RESET_PASSWORD_CONFIRM_TEMPLATE': '', + 'RESET_PASSWORD_SUCCESS_TEMPLATE': '', + + # Social register/login + 'FACEBOOK_LOGIN_ENABLED': False, + 'GOOGLE_LOGIN_ENABLED': False, + + # Set password in the case login by socials + 'SET_PASSWORD_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'SET_PASSWORD_SERIALIZER': 'drf_registration.api.set_password.SetPasswordSerializer', + } + diff --git a/docs/settings/login.rst b/docs/settings/login.rst new file mode 100644 index 0000000..777a8f9 --- /dev/null +++ b/docs/settings/login.rst @@ -0,0 +1,28 @@ +.. _login: + +Login +===== + +.. data:: LOGIN_SERIALIZER + + Login serializer dotted path + + Default: ``'drf_registration.api.login.LoginSerializer'`` + +.. data:: LOGIN_PERMISSION_CLASSES + + Login permission classes dotted paths + + Default: + + .. code:: python + + [ + 'rest_framework.permissions.AllowAny', + ], + +.. data:: LOGIN_USERNAME_FIELDS: + + Custom multiple login username fields. + + Default: ``['username', 'email',]`` diff --git a/docs/settings/profile.rst b/docs/settings/profile.rst new file mode 100644 index 0000000..145031c --- /dev/null +++ b/docs/settings/profile.rst @@ -0,0 +1,22 @@ +.. _profile: + +Profile +======= + +.. data:: PROFILE_SERIALIZER + + Profile serializer dotted path + + Default: ``'drf_registration.api.profile.ProfileSerializer'`` + +.. data:: LOGIN_PERMISSION_CLASSES + + Profile permission classes dotted paths + + Default: + + .. code:: python + + [ + 'rest_framework.permissions.IsAuthenticated', + ], diff --git a/docs/settings/register.rst b/docs/settings/register.rst new file mode 100644 index 0000000..4c13532 --- /dev/null +++ b/docs/settings/register.rst @@ -0,0 +1,57 @@ +.. _register: + +Register +======== + +You can check the :ref:`user` for thre Register flow configurations. + + +.. data:: REGISTER_SERIALIZER + + Register serializer dotted path + + Default: ``'drf_registration.api.register.RegisterSerializer'`` + + +.. data:: REGISTER_PERMISSION_CLASSES + + Register permission classes dotted paths + + Default: + + .. code:: python + + [ + 'rest_framework.permissions.AllowAny', + ] + +.. data:: REGISTER_SEND_WELCOME_EMAIL_ENABLED + + Send welcome email afer register successfully + + Default: ``False`` + +.. data:: REGISTER_SEND_WELCOME_EMAIL_SUBJECT + + The welcome email subject + + Default: ``'Welcome to the system'`` + + .. note:: + It only works with ``REGISTER_SEND_WELCOME_EMAIL_ENABLED`` is ``True`` + +.. data:: REGISTER_SEND_WELCOME_EMAIL_TEMPLATE + + The welcome email template path + + Default: ``None`` + + If not set, the default message is + + .. code:: python + +

Hi,

+

Welcome to the system!

+ + .. note:: + It only works with ``REGISTER_SEND_WELCOME_EMAIL_ENABLED`` is ``True`` diff --git a/docs/settings/reset-password.rst b/docs/settings/reset-password.rst new file mode 100644 index 0000000..a58ca6e --- /dev/null +++ b/docs/settings/reset-password.rst @@ -0,0 +1,66 @@ +.. reset-password: + +Reset Password +============== + +.. note:: + The reset password views use custom of ``PasswordResetConfirmView`` and ``PasswordResetCompleteView`` from ``django.contrib.auth.views``. The default templates from Django registration. All configurations just work if ``RESET_PASSWORD_ENABLED`` is ``True``. + +.. data:: RESET_PASSWORD_ENABLED + + Enable reset password API + + Default: ``True`` + +.. data:: RESET_PASSWORD_PERMISSION_CLASSES + + The reset password permissions classes + + Default: + + .. code:: python + + [ + 'rest_framework.permissions.AllowAny', + ] + +.. data:: RESET_PASSWORD_SERIALIZER + + The reset password serializer + + Default: ``'drf_registration.api.reset_password.ResetPasswordSerializer'`` + +.. data:: RESET_PASSWORD_EMAIL_SUBJECT + + The reset password email subject + + Default: ``'Reset Password'`` + +.. data:: RESET_PASSWORD_EMAIL_TEMPLATE + + The reset password email body template + + Default: ``None`` + + If not set, it will use default email template message: + + .. code:: python + +

Please go to the following page and choose a new password:

+ Reset Password + +.. data:: RESET_PASSWORD_CONFIRM_TEMPLATE + + The reset password confirm template + + Default: ``None`` + + If not set, it will use the Django default registration template + +.. data:: RESET_PASSWORD_SUCCESS_TEMPLATE + + The reset password success template + + Default: ``None`` + + If not set, it will use the Django default registration template diff --git a/docs/settings/set-password.rst b/docs/settings/set-password.rst new file mode 100644 index 0000000..5628cf7 --- /dev/null +++ b/docs/settings/set-password.rst @@ -0,0 +1,22 @@ +.. set-password: + +Set Password +============ + +.. data:: set_PASSWORD_PERMISSION_CLASSES + + The set password permissions classes + + Default: + + .. code:: python + + [ + 'rest_framework.permissions.IsAuthenticated', + ] + +.. data:: SET_PASSWORD_SERIALIZER + + The set password serializer + + Default: ``'drf_registration.api.set_password.SetPasswordSerializer'`` diff --git a/docs/settings/social-login.rst b/docs/settings/social-login.rst new file mode 100644 index 0000000..0cb4a3d --- /dev/null +++ b/docs/settings/social-login.rst @@ -0,0 +1,20 @@ +.. social-login: + +Social Login +============ + +.. note:: + + We are using the the simple way to use Facebook and Google to register/login to the system without database model. You can set password after logged in. + +.. data:: FACEBOOK_LOGIN_ENABLED + + Enable login by Facebook + + Default: ``False`` + +.. data:: GOOGLE_LOGIN_ENABLED + + Enable login by Google + + Default: ``False`` diff --git a/docs/settings/user.rst b/docs/settings/user.rst new file mode 100644 index 0000000..b4ebd54 --- /dev/null +++ b/docs/settings/user.rst @@ -0,0 +1,126 @@ +.. _user: + +User +==== + +.. note:: + The User model base on ``AUTH_USER_MODEL`` + +Field settings +-------------- + +.. data:: USER_FIELDS + + The fields of the User use for Register and Profile + + Default: + + .. code:: python + + ( + 'id', + 'username', + 'email', + 'password', + 'is_active', + ) + + Make sure your fields include ``username``, ``email``, and ``password``. + +.. data:: USER_READ_ONLY_FIELDS + + The read only fields for serializers + + Default: + + .. code:: python + + ( + 'is_superuser', + 'is_staff', + 'is_active', + ) + +.. data:: USER_WRITE_ONLY_FIELDS + + The write only fields for Profile serializers. Make sure those fields can not update after created. + + Default: + + .. code:: python + + ( + 'password', + 'username', + ) + +.. data:: USER_SERIALIZER + + The User Serializer use dotted path + + Default: ``'drf_registration.api.user.UserSerializer'`` + +Verify/Activate settings +------------------------ + +Those configurations for the Register flow. + +.. data:: USER_VERIFY_FIELD + + The User verify/activate field + + Default: ``'is_active'`` + +.. data:: USER_ACTIVATE_TOKEN_ENABLED + + Enable verify use by token sent to email + + Default: ``False`` + +.. data:: USER_ACTIVATE_EMAIL_SUBJECT + + The activate email subject + + Default: ``'Activate your account'`` + + .. note:: + It only works with ``USER_ACTIVATE_TOKEN_ENABLED`` is ``True`` + +.. data:: USER_ACTIVATE_EMAIL_TEMPLATE + + The activate email template path + + Default: ``None`` + + If not set, the default template message is + + .. code:: python + +

By clicking on the following link, you are activating your account

+ Activate Account + + .. note:: + It only works with ``USER_ACTIVATE_TOKEN_ENABLED`` is ``True`` + + +.. data:: USER_ACTIVATE_SUCSSESS_TEMPLATE + + The template path when activate user successfully. + + Default: ``None`` + + If not set, the system will show the default message is ``Your account has been activate successfully`` + + .. note:: + It only works with ``USER_ACTIVATE_TOKEN_ENABLED`` is ``True`` + +.. data:: USER_ACTIVATE_FAILED_TEMPLATE + + The template path when activate user failed. + + Default: ``None`` + + If not set, the system will show the default message is ``Either the provided activation token is invalid or this account has already been activated.`` + + .. note:: + It only works with ``USER_ACTIVATE_TOKEN_ENABLED`` is ``True`` diff --git a/drf_registration/__init__.py b/drf_registration/__init__.py new file mode 100644 index 0000000..b794fd4 --- /dev/null +++ b/drf_registration/__init__.py @@ -0,0 +1 @@ +__version__ = '0.1.0' diff --git a/drf_registration/admin.py b/drf_registration/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/drf_registration/api/__init__.py b/drf_registration/api/__init__.py new file mode 100644 index 0000000..2f88913 --- /dev/null +++ b/drf_registration/api/__init__.py @@ -0,0 +1,7 @@ +from .login import LoginView, SocialLoginView +from .logout import LogoutView +from .register import RegisterView, VerifyView, ActivateView +from .profile import ProfileView +from .change_password import ChangePasswordView +from .reset_password import ResetPasswordView, ResetPasswordConfirmView, ResetPasswordCompleteView +from .set_password import SetPasswordView diff --git a/drf_registration/api/change_password.py b/drf_registration/api/change_password.py new file mode 100644 index 0000000..3a7697b --- /dev/null +++ b/drf_registration/api/change_password.py @@ -0,0 +1,65 @@ +from django.utils.translation import gettext as _ +from django.contrib.auth import password_validation + +from rest_framework.generics import UpdateAPIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework import serializers + +from drf_registration.settings import drfr_settings +from drf_registration.utils.users import get_user_profile_data, remove_user_token +from drf_registration.utils.common import import_string, import_string_list + + +class ChangePasswordSerializer(serializers.Serializer): + """ + Change password serializer + """ + old_password = serializers.CharField() + new_password = serializers.CharField() + + def validate_old_password(self, old_password): + """ + Validate user password + """ + user = self.context['request'].user + + if not user.check_password(old_password): + raise serializers.ValidationError(_('Old password is not correct.')) + return old_password + + def validate_new_password(self, new_password): + """ + Validate user password + """ + user = self.context['request'].user + password_validation.validate_password(new_password, user) + return new_password + + def save(self, **kwargs): + password = self.validated_data['new_password'] + user = self.context['request'].user + user.set_password(password) + user.save() + return user + + +class ChangePasswordView(UpdateAPIView): + """ + Change user password + """ + permission_classes = import_string_list(drfr_settings.CHANGE_PASSWORD_PERMISSION_CLASSES) + serializer_class = import_string(drfr_settings.CHANGE_PASSWORD_SERIALIZER) + + def update(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + + # Reset old token + remove_user_token(user) + + # Response data include new token + data = get_user_profile_data(user) + + return Response(data, status=status.HTTP_200_OK) diff --git a/drf_registration/api/login.py b/drf_registration/api/login.py new file mode 100644 index 0000000..7523ed8 --- /dev/null +++ b/drf_registration/api/login.py @@ -0,0 +1,149 @@ +from django.contrib.auth.models import update_last_login +from django.contrib.auth import authenticate + +from rest_framework.generics import CreateAPIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework import serializers + +from drf_registration.settings import drfr_settings +from drf_registration.utils.common import import_string, import_string_list +from drf_registration.utils.users import ( + get_user_model, + get_user_profile_data, + has_user_verified, + set_user_verified, +) +from drf_registration.utils import socials +from drf_registration.exceptions import ( + NotActivated, + LoginFailed, + InvalidProvider, + MissingEmail, + InvalidAccessToken, +) + + +class LoginSerializer(serializers.ModelSerializer): + """ + User login serializer + """ + + username = serializers.CharField() + password = serializers.CharField() + + class Meta: + model = get_user_model() + fields = ('username', 'password') + + def validate(self, data): + user = authenticate(**data) + if user: + + # Check user is activated or not + if has_user_verified(user): + + # added user model to OrderedDict that serializer is validating + data['user'] = user + + return data + raise NotActivated() + raise LoginFailed() + + +class LoginView(CreateAPIView): + """ + This is used to Login into system. + """ + + permission_classes = import_string_list( + drfr_settings.LOGIN_PERMISSION_CLASSES) + serializer_class = import_string(drfr_settings.LOGIN_SERIALIZER) + + def post(self, request, *args, **kwargs): + """ + Override to check user login + + Args: + request (object): The request object + + """ + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + user = serializer.validated_data['user'] + + # Update last logged in + update_last_login(None, user) + data = get_user_profile_data(user) + + return Response(data, status=status.HTTP_200_OK) + + +class SocialLoginSerializer(serializers.Serializer): + """ + User social login serializer + """ + + provider = serializers.CharField() + access_token = serializers.CharField() + + class Meta: + fields = ('provider', 'access_token',) + + +class SocialLoginView(CreateAPIView): + """ + This is used to Social Login into system. + """ + + permission_classes = import_string_list( + drfr_settings.LOGIN_PERMISSION_CLASSES) + serializer_class = SocialLoginSerializer + + def post(self, request, *args, **kwargs): + """ + Authenticate user through the provider and access_token + """ + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + provider = serializer.data.get('provider', None) + + # Check is invalid provider + if not socials.is_valid_provider(provider): + raise InvalidProvider() + + # Check valid token + access_token = serializer.data.get('access_token', None) + + user_data = socials.get_user_info(provider, access_token) + + # None value mean the access token is not valid + if not user_data: + raise InvalidAccessToken() + + # Check the case can not get user email address + if not user_data.get('email'): + raise MissingEmail() + + # create user if not exist + User = get_user_model() + try: + user = User.objects.get(email=user_data['email']) + except User.DoesNotExist: + user = User.objects.create( + username=user_data['email'], + email=user_data['email'], + first_name=user_data.get('first_name'), + last_name=user_data.get('last_name'), + ) + + # Always verified user if they using Google or Facebook + set_user_verified(user) + + # Update last logged in + update_last_login(None, user) + data = get_user_profile_data(user) + + return Response(data, status=status.HTTP_200_OK) diff --git a/drf_registration/api/logout.py b/drf_registration/api/logout.py new file mode 100644 index 0000000..789f21b --- /dev/null +++ b/drf_registration/api/logout.py @@ -0,0 +1,26 @@ +from rest_framework.response import Response +from rest_framework import status +from rest_framework import permissions +from rest_framework.views import APIView + +from drf_registration.utils.users import remove_user_token +from drf_registration.settings import drfr_settings + + +class LogoutView(APIView): + """ + This is used to Logout system. + """ + + permission_classes = [permissions.IsAuthenticated] + + def post(self, request, *args, **kwargs): + """ + Override post method to remove token and custom response + """ + + # Remove user token + if drfr_settings.LOGOUT_REMOVE_TOKEN: + remove_user_token(self.request.user) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/drf_registration/api/profile.py b/drf_registration/api/profile.py new file mode 100644 index 0000000..4978753 --- /dev/null +++ b/drf_registration/api/profile.py @@ -0,0 +1,66 @@ +from rest_framework.generics import RetrieveUpdateAPIView + +from drf_registration.utils.common import import_string, import_string_list +from drf_registration.settings import drfr_settings +from drf_registration.utils.users import get_all_users, get_user_serializer + + +class ProfileSerializer(get_user_serializer()): + """ + Profile serializer + """ + + def __init__(self, *args, **kwargs): + """ + Custom to add partial=True to PUT method request to skip blank + validations + """ + + if kwargs.get('context'): + request = kwargs['context'].get('request', None) + + if request and getattr(request, 'method', None) == 'PUT': + kwargs['partial'] = True + + super(ProfileSerializer, self).__init__(*args, **kwargs) + + +class ProfileView(RetrieveUpdateAPIView): + """ + Get update user profile information + """ + + permission_classes = import_string_list(drfr_settings.PROFILE_PERMISSION_CLASSES) + serializer_class = import_string(drfr_settings.PROFILE_SERIALIZER) + queryset = get_all_users() + + def get_object(self): + return self.request.user + + def update(self, request, *args, **kwargs): + """ + Custom update user profile + """ + + # Remove write only fields when update profile + for field in drfr_settings.USER_WRITE_ONLY_FIELDS: + + if field in request.data.keys(): + + # Make it editable + request.data._mutable = True + + request.data.pop(field) + + # Disable editable + request.data._mutable = False + + # Support the case user can change password in profile if + # USER_WRITE_ONLY_FIELDS not contain password field + if 'password' in request.data.keys(): + request.data._mutable = True + self.request.user.set_password(request.data.pop('password')) + self.request.user.save() + request.data._mutable = False + + return super(ProfileView, self).update(request, *args, **kwargs) diff --git a/drf_registration/api/register.py b/drf_registration/api/register.py new file mode 100644 index 0000000..6548561 --- /dev/null +++ b/drf_registration/api/register.py @@ -0,0 +1,116 @@ +from django.utils.translation import gettext as _ +from django.contrib.auth import password_validation +from django.shortcuts import render +from django.http import HttpResponse +from django.views import View + +from rest_framework.generics import CreateAPIView +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + +from drf_registration.settings import drfr_settings +from drf_registration.tokens import activation_token +from drf_registration.utils.common import import_string, import_string_list +from drf_registration.utils.email import send_verify_email, send_email_welcome +from drf_registration.utils.users import ( + get_user_profile_data, + get_user_serializer, + has_user_activate_token, + has_user_verify_code, + set_user_verified, + get_user_from_uid, +) +from drf_registration.utils.domain import get_current_domain + + +class RegisterSerializer(get_user_serializer()): + """ + User register serializer + """ + + def validate_password(self, value): + """ + Validate user password + """ + password_validation.validate_password(value, self.instance) + return value + + def create(self, validated_data): + """ + Override create method to create user password + """ + user = super().create(validated_data) + user.set_password(validated_data['password']) + + # Disable veriried if enable verify user, else set it enabled + if has_user_activate_token() or has_user_verify_code(): + set_user_verified(user, False) + else: + set_user_verified(user, True) + user.save() + return user + + +class RegisterView(CreateAPIView): + """ + Register a new user to the system + """ + permission_classes = import_string_list( + drfr_settings.REGISTER_PERMISSION_CLASSES) + serializer_class = import_string(drfr_settings.REGISTER_SERIALIZER) + + def create(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + user = serializer.save() + data = get_user_profile_data(user) + + domain = get_current_domain(request) + + # Send email activation link + if has_user_activate_token() or has_user_verify_code(): + send_verify_email(user, domain) + else: + send_email_welcome(user) + + return Response(data, status=status.HTTP_201_CREATED) + + +class VerifyView(APIView): + """ + Activate account by use code sent to email + """ + + +class ActivateView(View): + """ + Activate account by use token sent to email + """ + + def get(self, request, uidb64, token): + """ + Override to get the activation uid and token + + Args: + request (object): Request object + uidb64 (string): The uid + token (string): The user token + + """ + user = get_user_from_uid(uidb64) + + if user and activation_token.check_token(user, token): + set_user_verified(user) + + send_email_welcome(user) + + if drfr_settings.USER_ACTIVATE_SUCSSESS_TEMPLATE: + return render(request, drfr_settings.USER_ACTIVATE_SUCSSESS_TEMPLATE) # pragma: no cover + return HttpResponse(_('Your account has been activate successfully.')) + + if drfr_settings.USER_ACTIVATE_FAILED_TEMPLATE: + return render(request, drfr_settings.USER_ACTIVATE_FAILED_TEMPLATE) # pragma: no cover + return HttpResponse(_('Either the provided activation token is ' + 'invalid or this account has already been activated.')) diff --git a/drf_registration/api/reset_password.py b/drf_registration/api/reset_password.py new file mode 100644 index 0000000..b908784 --- /dev/null +++ b/drf_registration/api/reset_password.py @@ -0,0 +1,96 @@ +from django.http import Http404 +from django.utils.translation import gettext as _ +from django.contrib.auth.views import PasswordResetConfirmView, PasswordResetCompleteView +from django.urls import reverse_lazy + +from rest_framework.views import APIView +from rest_framework import serializers +from rest_framework.response import Response +from rest_framework import status + +from drf_registration.exceptions import UserNotFound +from drf_registration.settings import drfr_settings +from drf_registration.utils.common import import_string, import_string_list +from drf_registration.utils.domain import get_current_domain +from drf_registration.utils.users import get_user_model +from drf_registration.utils.email import send_reset_password_token_email + + +class ResetPasswordSerializer(serializers.Serializer): + """ + Reset password serializer + + Raises: + UserNotFound: In the case can not found user email + """ + email = serializers.EmailField() + + def validate(self, data): + + try: + user = get_user_model().objects.get(email=data['email']) + except get_user_model().DoesNotExist: + raise UserNotFound() + # added user model to OrderedDict that serializer is validating + data['user'] = user + + return data + + +class ResetPasswordView(APIView): + """ + Reset user password by send the link to email + """ + + permission_classes = import_string_list(drfr_settings.RESET_PASSWORD_PERMISSION_CLASSES) + serializer_class = import_string(drfr_settings.RESET_PASSWORD_SERIALIZER) + + def post(self, request, *args, **kwargs): + """ + Override to check reset password request + + Args: + request (object): The request object + + Raises: + Http404: In the case RESET_PASSWORD_ENABLED is False + """ + + # Check in the case reset password is not supported + if not drfr_settings.RESET_PASSWORD_ENABLED: + raise Http404() + + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + # Get user from validated data + user = serializer.validated_data['user'] + + # Send reset password link to email + domain = get_current_domain(request) + send_reset_password_token_email(user, domain) + + return Response( + {'detail': _('Password reset e-mail has been sent.')}, + status=status.HTTP_200_OK) + +class ResetPasswordConfirmView(PasswordResetConfirmView): + """ + Custom reset password confirm view + """ + + success_url = reverse_lazy('reset_password_complete') + + # Check in the case custom template name + if drfr_settings.RESET_PASSWORD_CONFIRM_TEMPLATE: + template_name = drfr_settings.RESET_PASSWORD_CONFIRM_TEMPLATE # pragma: no cover + + +class ResetPasswordCompleteView(PasswordResetCompleteView): + """ + Custom reset password complete view + """ + + # Check in the case custom template name + if drfr_settings.RESET_PASSWORD_SUCCESS_TEMPLATE: + template_name = drfr_settings.RESET_PASSWORD_SUCCESS_TEMPLATE # pragma: no cover diff --git a/drf_registration/api/set_password.py b/drf_registration/api/set_password.py new file mode 100644 index 0000000..f02513f --- /dev/null +++ b/drf_registration/api/set_password.py @@ -0,0 +1,54 @@ +from django.utils.translation import gettext as _ +from django.contrib.auth import password_validation + +from rest_framework import status +from rest_framework import serializers +from rest_framework.generics import UpdateAPIView +from rest_framework.response import Response + + +from drf_registration.settings import drfr_settings +from drf_registration.utils.users import get_user_profile_data +from drf_registration.utils.common import import_string, import_string_list + + +class SetPasswordSerializer(serializers.Serializer): + """ + Set password serializer + """ + password = serializers.CharField() + + def validate_password(self, password): + """ + Validate user password + """ + user = self.context['request'].user + if user.password: + raise serializers.ValidationError(_('Your password is already existed.')) + password_validation.validate_password(password, user) + return password + + def save(self, **kwargs): + password = self.validated_data['password'] + user = self.context['request'].user + user.set_password(password) + user.save() + return user + + +class SetPasswordView(UpdateAPIView): + """ + Set user password + """ + permission_classes = import_string_list(drfr_settings.SET_PASSWORD_PERMISSION_CLASSES) + serializer_class = import_string(drfr_settings.SET_PASSWORD_SERIALIZER) + + def update(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + + # Response data include new tokens + data = get_user_profile_data(user) + + return Response(data, status=status.HTTP_200_OK) diff --git a/drf_registration/api/user.py b/drf_registration/api/user.py new file mode 100644 index 0000000..1b7dafc --- /dev/null +++ b/drf_registration/api/user.py @@ -0,0 +1,42 @@ +from django.utils.translation import gettext as _ + +from rest_framework.validators import UniqueValidator +from rest_framework import serializers + +from drf_registration.settings import drfr_settings +from drf_registration.utils.users import get_user_model, get_all_users +from drf_registration.utils.socials import enable_has_password + + +class UserSerializer(serializers.ModelSerializer): + """ + User serializer + """ + + email = serializers.EmailField( + validators=[UniqueValidator(queryset=get_all_users(), + message=_('User with this email already exists.'))]) + username = serializers.CharField(validators=[UniqueValidator( + queryset=get_all_users(), + message=_('User with this username already exists.') + )]) + + if enable_has_password(): + has_password = serializers.SerializerMethodField() + + class Meta: + model = get_user_model() + + # Check to reponse has password field in the case enable + # Facebook or Google login + + fields = drfr_settings.USER_FIELDS + \ + ('has_password',) if enable_has_password() else drfr_settings.USER_FIELDS + read_only_fields = drfr_settings.USER_READ_ONLY_FIELDS + extra_kwargs = {'password': {'write_only': True}} + + def get_has_password(self, user): + """ + Custom reponse field to check user has password or not + """ + return True if user.password else False diff --git a/drf_registration/apps.py b/drf_registration/apps.py new file mode 100644 index 0000000..dd8a301 --- /dev/null +++ b/drf_registration/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class DrfRegistrationConfig(AppConfig): + """ + The app config + + Args: + AppConfig (class): Class representing a Django application and its + configuration + """ + name = 'drf_registration' diff --git a/drf_registration/auth.py b/drf_registration/auth.py new file mode 100644 index 0000000..399b3d9 --- /dev/null +++ b/drf_registration/auth.py @@ -0,0 +1,33 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from django.db.models import Q + +from drf_registration.settings import drfr_settings + + +class MultiFieldsModelBackend(ModelBackend): + """ + This is a ModelBacked that allows authentication with either any username fileds from config. + Login by username (or email) and password by default. + The username fields must unique value. + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + try: + + assert drfr_settings.LOGIN_USERNAME_FIELDS + + filters = Q() + + # Build filters with OR condition + for login_field in drfr_settings.LOGIN_USERNAME_FIELDS: + filters |= Q(**{login_field:username}) + + user = get_user_model().objects.get(filters) + + # If user not found or more than one user, will return None + except (get_user_model().DoesNotExist, get_user_model().MultipleObjectsReturned): + return None + else: + if user.check_password(password): + return user diff --git a/drf_registration/constants.py b/drf_registration/constants.py new file mode 100644 index 0000000..a794791 --- /dev/null +++ b/drf_registration/constants.py @@ -0,0 +1,27 @@ +from django.utils.translation import gettext as _ + +# Email constants +DEFAULT_EMAIL_BODY = { + 'WELCOME': _(''' +

Hi,

+

Welcome to the system!

+ '''), + + 'ACTIVATE': _(''' +

By clicking on the following link, you are activating your account

+ Activate Account + '''), + + 'RESET_PASSWORD': _(''' +

Please go to the following page and choose a new password:

+ Reset Password + ''') +} + +# Social login +FACEBOOK_PROVIDER = 'facebook' +FACEBOOK_AUTH_URL = 'https://graph.facebook.com/v2.4/me' +FACEBOOK_FIELDS = 'email,first_name,last_name,gender,birthday' + +GOOGLE_PROVIDER = 'google' +GOOGLE_AUTH_URL = 'https://oauth2.googleapis.com/tokeninfo' diff --git a/drf_registration/exceptions.py b/drf_registration/exceptions.py new file mode 100644 index 0000000..d9ad7a8 --- /dev/null +++ b/drf_registration/exceptions.py @@ -0,0 +1,75 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework.exceptions import APIException +from rest_framework import status + + +class NotActivated(APIException): + """ + Custom not acitvated exception when user is not activated + + Args: + APIException (class): Base class for exceptions + """ + status_code = status.HTTP_401_UNAUTHORIZED + default_detail = _('Account is not activated.') + default_code = 'not-activated' + + +class LoginFailed(APIException): + """ + Custom login failure exception when the credentials are invalid + + Args: + APIException (class): Base class for exceptions + """ + status_code = status.HTTP_401_UNAUTHORIZED + default_detail = _('Login failed wrong user credentials.') + default_code = 'login-failed' + + +class UserNotFound(APIException): + """ + Custom user not found exception when user is not found + + Args: + APIException (class): Base class for exceptions + """ + status_code = status.HTTP_404_NOT_FOUND + default_detail = _('User not found.') + default_code = 'user-not-found' + + +class InvalidProvider(APIException): + """ + Custom provider invalid exception + + Args: + APIException (class): Base class for exceptions + """ + status_code = status.HTTP_400_BAD_REQUEST + default_detail = _('Provider is invalid or you forgot enable social login.') + default_code = 'invalid-provider' + + +class InvalidAccessToken(APIException): + """ + Custom invalid access token exception + + Args: + APIException (class): Base class for exceptions + """ + status_code = status.HTTP_400_BAD_REQUEST + default_detail = _('This access token is invalid or is already expired.') + default_code = 'invalid-access-token' + + +class MissingEmail(APIException): + """ + Custom missing email exception + + Args: + APIException (class): Base class for exceptions + """ + status_code = status.HTTP_400_BAD_REQUEST + default_detail = _('Missing email address.') + default_code = 'missing-email' diff --git a/drf_registration/settings.py b/drf_registration/settings.py new file mode 100644 index 0000000..3c8a90a --- /dev/null +++ b/drf_registration/settings.py @@ -0,0 +1,115 @@ +from django.test.signals import setting_changed +from drf_registration.utils.common import generate_settings, get_django_settings + +PACKAGE_NAME = 'drf_registration' +PACKAGE_OBJECT_NAME = 'DRF_REGISTRATION' + +# DEFAULT CONFIGURATIONS +DEFAULT_SETTINGS = { + + # General settings + 'PROJECT_NAME': 'DRF Registration', + 'PROJECT_BASE_URL': '', + + # User fields to register and response to profile + 'USER_FIELDS': ( + 'id', + 'username', + 'email', + 'password', + 'first_name', + 'last_name', + 'is_active', + ), + 'USER_READ_ONLY_FIELDS': ( + 'is_superuser', + 'is_staff', + 'is_active', + ), + 'USER_WRITE_ONLY_FIELDS': ( + 'password', + ), + + 'USER_SERIALIZER': 'drf_registration.api.user.UserSerializer', + + # Activate user by verify code sent to email + 'USER_VERIFY_CODE_ENABLED': False, + 'USER_VERIFY_FIELD': 'is_active', + + # Activate user by toiken sent to email + 'USER_ACTIVATE_TOKEN_ENABLED': False, + 'USER_ACTIVATE_SUCSSESS_TEMPLATE': '', + 'USER_ACTIVATE_FAILED_TEMPLATE': '', + 'USER_ACTIVATE_EMAIL_SUBJECT': 'Activate your account', + 'USER_ACTIVATE_EMAIL_TEMPLATE': '', + + # Profile + 'PROFILE_SERIALIZER': 'drf_registration.api.profile.ProfileSerializer', + 'PROFILE_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + + # Register + 'REGISTER_SERIALIZER': 'drf_registration.api.register.RegisterSerializer', + 'REGISTER_PERMISSION_CLASSES': [ + 'rest_framework.permissions.AllowAny', + ], + 'REGISTER_SEND_WELCOME_EMAIL_ENABLED': False, + 'REGISTER_SEND_WELCOME_EMAIL_SUBJECT': 'Welcome to the system', + 'REGISTER_SEND_WELCOME_EMAIL_TEMPLATE': '', + + # Login + 'LOGIN_SERIALIZER': 'drf_registration.api.login.LoginSerializer', + 'LOGIN_PERMISSION_CLASSES': [ + 'rest_framework.permissions.AllowAny', + ], + + # For custom login username fields + 'LOGIN_USERNAME_FIELDS': ['username', 'email',], + + 'LOGOUT_REMOVE_TOKEN': False, + + # Change password + 'CHANGE_PASSWORD_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'CHANGE_PASSWORD_SERIALIZER': 'drf_registration.api.change_password.ChangePasswordSerializer', + + # Reset password + 'RESET_PASSWORD_ENABLED': True, + 'RESET_PASSWORD_PERMISSION_CLASSES': [ + 'rest_framework.permissions.AllowAny', + ], + 'RESET_PASSWORD_SERIALIZER': 'drf_registration.api.reset_password.ResetPasswordSerializer', + 'RESET_PASSWORD_EMAIL_SUBJECT': 'Reset Password', + 'RESET_PASSWORD_EMAIL_TEMPLATE': '', + 'RESET_PASSWORD_CONFIRM_TEMPLATE': '', + 'RESET_PASSWORD_SUCCESS_TEMPLATE': '', + + # Social register/login + 'FACEBOOK_LOGIN_ENABLED': False, + 'GOOGLE_LOGIN_ENABLED': False, + + # Set password in the case login by socials + 'SET_PASSWORD_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'SET_PASSWORD_SERIALIZER': 'drf_registration.api.set_password.SetPasswordSerializer', +} + +drfr_settings = generate_settings(get_django_settings(), DEFAULT_SETTINGS) + +def settings_changed_handler(*args, **kwargs): + """ + Listen user settings changed and update the defr_seetings properties values + """ + # Get the user setting values + setting_values = kwargs['value'] + setting_key = kwargs['setting'] + + # Check and update current serf settings + if setting_values and setting_key == PACKAGE_OBJECT_NAME: + for prop in setting_values: + drfr_settings[prop] = setting_values[prop] + +setting_changed.connect(settings_changed_handler) diff --git a/drf_registration/tokens.py b/drf_registration/tokens.py new file mode 100644 index 0000000..894d53c --- /dev/null +++ b/drf_registration/tokens.py @@ -0,0 +1,36 @@ +from django.contrib.auth.tokens import PasswordResetTokenGenerator +try: + from django.utils import six +except: + import six + +from drf_registration.utils.users import get_user_verified + + +class CustomAccountActivationTokenGenerator(PasswordResetTokenGenerator): + """ + Custom account activation token generator + + Args: + PasswordResetTokenGenerator (class): Strategy object used to generate + and check tokens for the password reset mechanism. + """ + def _make_hash_value(self, user, timestamp): + return ( + six.text_type(user.pk) + six.text_type(timestamp) + + six.text_type(get_user_verified(user)) + ) + +class CustomPasswordResetTokenGenerator(PasswordResetTokenGenerator): + """ + Custom password reset token generator + + Args: + Args: + PasswordResetTokenGenerator (class): Strategy object used to generate + and check tokens for the password reset mechanism. + """ + + +activation_token = CustomAccountActivationTokenGenerator() +reset_password_token = CustomPasswordResetTokenGenerator() diff --git a/drf_registration/urls.py b/drf_registration/urls.py new file mode 100644 index 0000000..b350368 --- /dev/null +++ b/drf_registration/urls.py @@ -0,0 +1,23 @@ +from django.urls import path + +from drf_registration import api + + +urlpatterns = [ + path('login/', api.LoginView.as_view(), name='login'), + path('login/social/', api.SocialLoginView.as_view(), name='login_social'), + path('logout/', api.LogoutView.as_view(), name='logout'), + path('register/', api.RegisterView.as_view(), name='register'), + path('activate///', api.ActivateView.as_view(), name='activate'), + path('verify/', api.VerifyView.as_view(), name='verify'), + path('profile/', api.ProfileView.as_view(), name='profile'), + path('change-password/', api.ChangePasswordView.as_view(), name='change_password'), + path('reset-password/', api.ResetPasswordView.as_view(), name='reset_password'), + path('reset-password///', \ + api.ResetPasswordConfirmView.as_view(), \ + name='reset_password_confirm'), + path('reset-password/complete/', \ + api.ResetPasswordCompleteView.as_view(), \ + name='reset_password_complete'), + path('set-password/', api.SetPasswordView.as_view(), name='set_password'), +] diff --git a/drf_registration/utils/__init__.py b/drf_registration/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/drf_registration/utils/common.py b/drf_registration/utils/common.py new file mode 100644 index 0000000..4f43e9f --- /dev/null +++ b/drf_registration/utils/common.py @@ -0,0 +1,64 @@ +from django.conf import settings +from django.utils.module_loading import import_string as django_import_string + + +class AttributeDict(dict): + """ + Access to dictionary attributes + """ + __getattr__ = dict.get + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ + + +def get_django_settings(settings_name='DRF_REGISTRATION'): + """ + Get settings from django settings + + Args: + settings_name (string): The name of object to get settings + """ + + return getattr(settings, settings_name, {}) + + +def generate_settings(user_settings, default_settings): + """ + Generate settings from user configuration or get default values + + Args: + user_settings (dict): user settings + default_settings (list, optional): The list of sertting properties. Defaults to []. + """ + result = {} + + for prop in default_settings: + result[prop] = user_settings.get(prop, default_settings[prop]) + + return AttributeDict(result) + + +def import_string(dotted_path): + """ + Import a dotted module path and return the attribute/class designated + by the last name in the path. Raise ImportError if the import failed. + + Args: + dotted_path (string): The dotted module path + """ + + return django_import_string(dotted_path) + + +def import_string_list(dotted_paths): + """ + Import list of module paths + + Args: + dotted_paths (list): The list of dotted paths to import + + Returns: + [list]: The list of attributes/classes + """ + + return [import_string(dotted_path) for dotted_path in dotted_paths] diff --git a/drf_registration/utils/domain.py b/drf_registration/utils/domain.py new file mode 100644 index 0000000..7071515 --- /dev/null +++ b/drf_registration/utils/domain.py @@ -0,0 +1,13 @@ +from drf_registration.settings import drfr_settings + + +def get_current_domain(request): + """ + Get current domain + + Args: + request (object): The request object from user + Returns: + [string]: Current domain name + """ + return drfr_settings.PROJECT_BASE_URL or f'{request.scheme}://{request.get_host()}' diff --git a/drf_registration/utils/email.py b/drf_registration/utils/email.py new file mode 100644 index 0000000..f7fc7e2 --- /dev/null +++ b/drf_registration/utils/email.py @@ -0,0 +1,134 @@ +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.conf import settings +from django.urls import reverse + +from drf_registration.constants import DEFAULT_EMAIL_BODY +from drf_registration.tokens import activation_token, reset_password_token +from drf_registration.utils.users import ( + has_user_activate_token, + has_user_verify_code, + has_user_verified, + generate_uid_and_token, +) +from drf_registration.settings import drfr_settings + + +def send_verify_email(user, domain=''): + """ + Send verify email to user's valid email + + Args: + user (object): The user instance + """ + + if has_user_activate_token(): + send_activate_token_email(user, domain) + + if has_user_verify_code(): + send_verify_code_email(user) + +def send_activate_token_email(user, domain): + """ + Send activate token to user email + + Args: + user (object): The user instance + domain (string): The current domain + """ + + # Get activate link + activate_link = domain + \ + reverse('activate', kwargs=generate_uid_and_token(user, activation_token)) + + # Default template message + default_message = DEFAULT_EMAIL_BODY['ACTIVATE'].format(activate_link=activate_link) + + html_template = drfr_settings.USER_ACTIVATE_EMAIL_TEMPLATE + + html_message = render_to_string( + html_template, { + 'activate_link': activate_link, + 'domain': domain + } + ) if html_template else None + + send_mail( + subject=drfr_settings.USER_ACTIVATE_EMAIL_SUBJECT, + message='', + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email,], + html_message=html_message or default_message + ) + +def send_verify_code_email(user): + """ + Send verify code to email + + Args: + user (object): The user object + """ + +def send_email_welcome(user): + """ + Send welcome email to verified user if REGISTER_SEND_WELCOME_EMAIL_ENABLED is True + + Args: + user (object): The user instance + """ + + # Check to send welcome email to verified user + if has_user_verified(user) and drfr_settings.REGISTER_SEND_WELCOME_EMAIL_ENABLED: + + # Default template message + default_message = DEFAULT_EMAIL_BODY['WELCOME'] + + html_template = drfr_settings.REGISTER_SEND_WELCOME_EMAIL_TEMPLATE + + html_message = render_to_string( + html_template, {'user': user} + ) if html_template else None + + send_mail( + subject=drfr_settings.REGISTER_SEND_WELCOME_EMAIL_SUBJECT, + message='', + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email,], + html_message=html_message or default_message + ) + + +def send_reset_password_token_email(user, domain): + """ + Send reset password token to user email + + Args: + user (object): The user instance + domain (string): The current domain + """ + + # Get activate link + reset_password_link = domain + \ + reverse('reset_password_confirm', \ + kwargs=generate_uid_and_token(user, reset_password_token)) + + # Default template message + default_message = \ + DEFAULT_EMAIL_BODY['RESET_PASSWORD'].format(reset_password_link=reset_password_link) + + html_template = drfr_settings.RESET_PASSWORD_EMAIL_TEMPLATE + + html_message = render_to_string( + html_template, { + 'reset_password_link': reset_password_link, + 'domain': domain + } + ) if html_template else None + + send_mail( + subject=drfr_settings.RESET_PASSWORD_EMAIL_SUBJECT, + message='', + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email,], + html_message=html_message or default_message + ) diff --git a/drf_registration/utils/responses.py b/drf_registration/utils/responses.py new file mode 100644 index 0000000..e69de29 diff --git a/drf_registration/utils/socials.py b/drf_registration/utils/socials.py new file mode 100644 index 0000000..d4617ec --- /dev/null +++ b/drf_registration/utils/socials.py @@ -0,0 +1,102 @@ +import requests +from rest_framework.utils import json + +from drf_registration.settings import drfr_settings +from drf_registration.constants import ( + FACEBOOK_PROVIDER, + FACEBOOK_AUTH_URL, + FACEBOOK_FIELDS, + GOOGLE_AUTH_URL, + GOOGLE_PROVIDER, +) + + +def is_valid_provider(provider): + """ + Check is valid provider if enabled, if not raise 404 error + + Args: + provider (string): The providor name + + Returns: + [boolean]: Is valid provider + """ + + # Check in the case enable Login using Facebook + if is_facebook_provider(provider) and drfr_settings.FACEBOOK_LOGIN_ENABLED: + return True + + # Check in the case enable Login using Google + if is_google_provider(provider) and drfr_settings.GOOGLE_LOGIN_ENABLED: + return True + + return False + +def is_facebook_provider(provider): + """ + Check is Facebook provider + + Args: + provider ([string]): The provider name + + Returns: + [type]: Is Facebook provider + """ + return provider == FACEBOOK_PROVIDER + +def is_google_provider(provider): + """ + Check is Google provider + + Args: + provider ([string]): The provider name + + Returns: + [type]: Is Google provider + """ + return provider == GOOGLE_PROVIDER + +def get_user_info(provider, access_token): + """ + Get user information by use valid access token + and request to sicoal APIs + + Args: + provider ([string]): The provider name + access_token (string): Access token + + Returns: + [object]: The user information + """ + + # Check is Facebook provider + # Ref: https://developers.facebook.com/docs/graph-api/using-graph-api/ + if is_facebook_provider(provider): + request_api = FACEBOOK_AUTH_URL + params = { + 'access_token': access_token, + 'fields': FACEBOOK_FIELDS + } + + # Check is Google provider + # Ref: https://developers.google.com/identity/sign-in/web/backend-auth#calling-the-tokeninfo-endpoint + if is_google_provider(provider): + request_api = GOOGLE_AUTH_URL + params = { + 'id_token': access_token + } + + req = requests.get(request_api, params=params) + data = json.loads(req.text) + + # Check error + return None if 'error' in data else data + +def enable_has_password(): + """ + Check to show has password field + + Returns: + [boolean]: Enable has_password field or not + """ + return drfr_settings.FACEBOOK_LOGIN_ENABLED or drfr_settings.GOOGLE_LOGIN_ENABLED diff --git a/drf_registration/utils/users.py b/drf_registration/utils/users.py new file mode 100644 index 0000000..c380803 --- /dev/null +++ b/drf_registration/utils/users.py @@ -0,0 +1,177 @@ +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from django.utils.encoding import force_bytes, force_str +from django.contrib.auth.tokens import default_token_generator +from django.contrib.auth import get_user_model as django_get_user_model + +from rest_framework.authtoken.models import Token + +from drf_registration.settings import drfr_settings +from drf_registration.utils.common import import_string + + +def get_user_model(): + """ + Get user model base on AUTH_USER_MODEL + """ + return django_get_user_model() + +def get_all_users(): + """ + Get all users queryset + """ + return get_user_model().objects.all() + +def get_user_serializer(): + """ + Get user serializer from settings + """ + + return import_string(drfr_settings.USER_SERIALIZER) + +def get_user_token(user): + """ + Get or create token for user + + Args: + user (dict): The user instance + + Returns: + [string]: The user token + """ + + token, created = Token.objects.get_or_create(user=user) + + return token + +def remove_user_token(user): + """ + Remove user token + + Args: + user (dict): The user instance + + """ + + # Remove old token + Token.objects.filter(user=user).delete() + +def get_user_profile_data(user): + """ + Get user refresh token and access token + + Args: + user (dict): The user instance + + Returns: + [dict]: The data response include the token + """ + serializer = import_string(drfr_settings.USER_SERIALIZER) + data = serializer(user).data + + # Add tokens to data + if has_user_verified(user): + data['token'] = get_user_token(user).key + + return data + +def has_user_activate_token(): + """ + Check to has user verify token from settings + + Returns: + [boolean]: True if USER_ACTIVATE_TOKEN_ENABLED is True, else is False + """ + return drfr_settings.USER_ACTIVATE_TOKEN_ENABLED + +def has_user_verify_code(): + """ + Check to has user verify code from settings + + Returns: + [boolean]: True if USER_VERIFY_CODE_ENABLED is True, else is False + """ + return drfr_settings.USER_VERIFY_CODE_ENABLED + +def has_user_verified(user): + """ + Check user verify or not + + Args: + user (object): The user instance + + Returns: + [boolean]: The verified value + """ + return get_user_verified(user) + +def get_user_verified(user): + """ + Get user verify value + + Args: + user (object): The user instance + + Returns: + [boolean]: The verified value + """ + return getattr(user, drfr_settings.USER_VERIFY_FIELD) + +def set_user_verified(user, verified=True): + """ + Set user verified + + Args: + user (object): The user instance + """ + setattr(user, drfr_settings.USER_VERIFY_FIELD, verified) + user.save() + +def generate_user_uid(user): + """ + Generate user UID from user pk + + Args: + user (object): The user object + + Returns: + [string]: The UID + """ + + return urlsafe_base64_encode(force_bytes(user.pk)) + +def generate_uid_and_token(user, token_generator=None): + """ + Generate UID and token from user information + + Args: + user (object): The user object + token_generator (optional): The token generator class. Defaults to None. + + Returns: + [object]: The object of uid and token + """ + + token_generator = token_generator or default_token_generator + + return { + 'uidb64': generate_user_uid(user), + 'token': token_generator.make_token(user) + } + +def get_user_from_uid(uidb64): + """ + Get user from uidb64 + + Args: + uidb64 (string): The uidb64 + + Returns: + [optional]: The user object or None + """ + + try: + uid = force_str(urlsafe_base64_decode(uidb64)) + user = get_user_model().objects.get(pk=uid) + return user + except: + return None diff --git a/examples/testapp/README.md b/examples/testapp/README.md new file mode 100644 index 0000000..9f8adb0 --- /dev/null +++ b/examples/testapp/README.md @@ -0,0 +1,14 @@ +# DRF Registration Example + +## Installing + +At `testapp` root directory run: + +- Install requirements: `pip install -r requirements.txt` +- Install drf_registration package locally: `pip install ../..` + +## Start server + +Run `python manage.py runserver`. + +Access at http://localhost:8000/ diff --git a/examples/testapp/accounts/__init__.py b/examples/testapp/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/testapp/accounts/apps.py b/examples/testapp/accounts/apps.py new file mode 100644 index 0000000..9b3fc5a --- /dev/null +++ b/examples/testapp/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' diff --git a/examples/testapp/accounts/migrations/0001_initial.py b/examples/testapp/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..c42796d --- /dev/null +++ b/examples/testapp/accounts/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 3.0.8 on 2020-07-20 04:17 + +import django.contrib.auth.models +import django.core.validators +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('username', models.CharField(max_length=50, unique=True, validators=[django.core.validators.MinLengthValidator(2)], verbose_name='username')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')), + ('first_name', models.CharField(blank=True, max_length=50)), + ('last_name', models.CharField(blank=True, max_length=50)), + ('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')), + ('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')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/examples/testapp/accounts/migrations/__init__.py b/examples/testapp/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/testapp/accounts/models.py b/examples/testapp/accounts/models.py new file mode 100644 index 0000000..c37e80b --- /dev/null +++ b/examples/testapp/accounts/models.py @@ -0,0 +1,14 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser +from django.core.validators import MinLengthValidator +from django.utils.translation import ugettext_lazy as _ + + +class User(AbstractUser): + username = models.CharField( + _('username'), unique=True, max_length=50, + validators=[MinLengthValidator(2),]) + password = models.CharField(_('password'), max_length=128) + email = models.EmailField(_('email address'), unique=True) + first_name = models.CharField(max_length=50, blank=True) + last_name = models.CharField(max_length=50, blank=True) diff --git a/examples/testapp/config/__init__.py b/examples/testapp/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/testapp/config/settings.py b/examples/testapp/config/settings.py new file mode 100644 index 0000000..efa5515 --- /dev/null +++ b/examples/testapp/config/settings.py @@ -0,0 +1,104 @@ +import os.path + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +SECRET_KEY = '5rnjl(8n64k%qt36c0vw)71n$3fflnrjfs!16^e1c!jkbksrot' + +ALLOWED_HOSTS = '*' +DEBUG = True +ROOT_URLCONF = 'config.urls' + +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework.authtoken', + 'drf_yasg', + 'drf_registration', + + 'accounts', +) + + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +TEMPLATES = ( + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +) + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa + }, +] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'test_db.sqlite3'), + } +} + +AUTHENTICATION_BACKENDS = [ + 'drf_registration.auth.MultiFieldsModelBackend', +] + +AUTH_USER_MODEL = 'accounts.User' + +STATIC_URL = '/static/' + +EMAIL_HOST = 'smtp.mailgun.org' +EMAIL_PORT = 587 +EMAIL_HOST_USER = '' +EMAIL_HOST_PASSWORD = '' +EMAIL_USE_TLS = True +DEFAULT_FROM_EMAIL = 'huychau.dev@gmail.com' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + ], +} + +# DRF REGISTRATION configurations +DRF_REGISTRATION = { + 'USER_ACTIVATE_TOKEN_ENABLED': False, + 'REGISTER_SEND_WELCOME_EMAIL_ENABLED': False, + 'FACEBOOK_LOGIN_ENABLED': False, + 'GOOGLE_LOGIN_ENABLED': True, + + 'LOGIN_USERNAME_FIELDS': ['email', 'username', 'first_name'], +} diff --git a/examples/testapp/config/urls.py b/examples/testapp/config/urls.py new file mode 100644 index 0000000..482ab20 --- /dev/null +++ b/examples/testapp/config/urls.py @@ -0,0 +1,30 @@ +from django.conf.urls import include +from django.urls import path +from django.contrib import admin + +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi + + +schema_view = get_schema_view( + openapi.Info( + title="DRF Registration API", + default_version='v1', + description="DRF Registration", + terms_of_service="https://www.google.com/policies/terms/", + contact=openapi.Contact(email="contact@snippets.local"), + license=openapi.License(name="BSD License"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) + +urlpatterns = [ + path('admin/', admin.site.urls), + path('api/accounts/', include('drf_registration.urls')), + + path('docs/redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), + path('docs/swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), +] + diff --git a/examples/testapp/config/wsgi.py b/examples/testapp/config/wsgi.py new file mode 100644 index 0000000..955cd2f --- /dev/null +++ b/examples/testapp/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/examples/testapp/manage.py b/examples/testapp/manage.py new file mode 100644 index 0000000..23b1ef0 --- /dev/null +++ b/examples/testapp/manage.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +import os +import sys + + +if __name__ == "__main__": + + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) diff --git a/examples/testapp/requirements.txt b/examples/testapp/requirements.txt new file mode 100644 index 0000000..ae41fb7 --- /dev/null +++ b/examples/testapp/requirements.txt @@ -0,0 +1 @@ +drf-yasg diff --git a/examples/testapp/templates/registration/activate_email.html b/examples/testapp/templates/registration/activate_email.html new file mode 100644 index 0000000..a01acd9 --- /dev/null +++ b/examples/testapp/templates/registration/activate_email.html @@ -0,0 +1,8 @@ + + + + + This is link: {{ activate_link }} + This is domain: {{ domain }} + + diff --git a/examples/testapp/templates/registration/activate_failed.html b/examples/testapp/templates/registration/activate_failed.html new file mode 100644 index 0000000..bbc6a34 --- /dev/null +++ b/examples/testapp/templates/registration/activate_failed.html @@ -0,0 +1 @@ +

Activate Failed

diff --git a/examples/testapp/templates/registration/activate_success.html b/examples/testapp/templates/registration/activate_success.html new file mode 100644 index 0000000..f7e0a51 --- /dev/null +++ b/examples/testapp/templates/registration/activate_success.html @@ -0,0 +1 @@ +

Activate Successfully

diff --git a/examples/testapp/templates/registration/welcome_email.html b/examples/testapp/templates/registration/welcome_email.html new file mode 100644 index 0000000..c650a2e --- /dev/null +++ b/examples/testapp/templates/registration/welcome_email.html @@ -0,0 +1 @@ +

Hi man! Welcome to the system ^^

diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c8a5fe5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,9 @@ +[pytest] +DJANGO_SETTINGS_MODULE = tests.settings +addopts = + --reuse-db + --no-migrations + --trace + --capture=fd + -rA + --cov=. --cov-report=html diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..2287d9b --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,3 @@ +requests +Django>=2.0 +djangorestframework>=3.8.2 diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..e69de29 diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 0000000..7f01784 --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1,3 @@ +sphinx +sphinx_rtd_theme +sphinx-autobuild diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 0000000..f5a34e5 --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,4 @@ +-r base.txt +pytest +pytest-django +pytest-cov diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..954f6a2 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,14 @@ + +[metadata] +name = drf-registration +author = Huy Chau +author-email = huy.chau@asnet.com.vn +description = User registration base on Django Rest Framework +description-file = README.md +long-description = file:README.md +long-description-content-type = text/markdown; charset=UTF-8 +url = https://github.com/huychau/drf-registration + +[options] +include_package_data = True +python_requires = >=3.6 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..624e15a --- /dev/null +++ b/setup.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +import os.path +import re + +from setuptools import find_packages, setup + +ROOT_DIR = os.path.dirname(__file__) +PACKAGE_NAME = 'drf_registration' + + +def get_requirements(local_filepath): + """ + Return list of this package requirements via local filepath. + """ + requirements = [] + with open(local_filepath) as f: + requirements = f.read().splitlines() + + return requirements + + +def get_version(package): + """ + Return package version as listed in `__version__` in package `__init__.py`. + """ + init_path = os.path.join(ROOT_DIR, package, '__init__.py') + with open(init_path, 'rt') as init_file: + init_contents = init_file.read() + return re.search( + "__version__ = ['\"]([^'\"]+)['\"]", init_contents).group(1) + + +setup( + version=get_version(PACKAGE_NAME), + packages=find_packages(exclude=['tests.*', 'tests']), + install_requires=get_requirements('requirements/base.txt'), +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..c11706a --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,72 @@ +import os.path + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +SECRET_KEY = '5rnjl(8n64k%qt36c0vw)71n$3fflnrjfs!16^e1c!jkbksrot' + +ALLOWED_HOSTS = '*' +DEBUG = True +ROOT_URLCONF = 'tests.urls' + +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework.authtoken', + 'drf_registration', +) + + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa + }, +] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'test_db.sqlite3'), + } +} + +AUTHENTICATION_BACKENDS = [ + 'drf_registration.auth.MultiFieldsModelBackend', +] + + +STATIC_URL = '/static/' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + ], +} + +# DRF REGISTRATION configurations +DRF_REGISTRATION = { + 'FACEBOOK_LOGIN_ENABLED': True, +} diff --git a/tests/src/__init__.py b/tests/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/src/api/test_change_password.py b/tests/src/api/test_change_password.py new file mode 100644 index 0000000..3d91063 --- /dev/null +++ b/tests/src/api/test_change_password.py @@ -0,0 +1,37 @@ +from django.test.utils import override_settings +from tests.utils import BaseAPITestCase + + +class ChangePasswordAPITestCase(BaseAPITestCase): + + def test_change_password_unauthorized(self): + params = {} + self.put_json_unauthorized('change-password/', params) + + def test_change_password_invalid_old_password(self): + self.client.force_authenticate(user=self.user) + params = { + 'old_password': 'invalid', + 'new_password': 'abcABC@123' + } + resp = self.put_json_bad_request('change-password/', params) + self.assertHasErrorDetail(resp.data['old_password'], 'Old password is not correct.') + + def test_change_password_invalid_new_password(self): + self.client.force_authenticate(user=self.user) + params = { + 'old_password': '123456', + 'new_password': 'short' + } + resp = self.put_json_bad_request('change-password/', params) + self.assertHasErrorDetail(resp.data['new_password'], 'This password is too short. It must contain at least 8 characters.') + + def test_change_password_ok(self): + self.client.force_authenticate(user=self.user) + old_password_hash = self.user.password + params = { + 'old_password': '123456', + 'new_password': 'abcABC@123' + } + self.put_json_ok('change-password/', params) + self.assertNotEqual(old_password_hash, self.user.password) diff --git a/tests/src/api/test_login.py b/tests/src/api/test_login.py new file mode 100644 index 0000000..2ca3dba --- /dev/null +++ b/tests/src/api/test_login.py @@ -0,0 +1,115 @@ +from django.test import override_settings +from tests.utils import BaseAPITestCase +from drf_registration.utils.users import set_user_verified + + +class LoginAPITestCase(BaseAPITestCase): + + def setUp(self): + super().setUp() + self.auth = False + + def test_login_empty_field(self): + params = {} + resp = self.post_json_bad_request('login/', params) + self.assertHasProps(resp.data, ['username', 'password']) + self.assertHasErrorDetail(resp.data['username'], 'This field is required.') + self.assertHasErrorDetail(resp.data['password'], 'This field is required.') + + def test_login_invalid_credentials(self): + params = { + 'username': 'invalid', + 'password': 'invalid' + } + self.post_json_unauthorized('login/', params) + + def test_login_inactivated_user(self): + set_user_verified(self.user, False) + + params = { + 'username': self.user.username, + 'password': '123456' + } + resp = self.post_json_unauthorized('login/', params) + self.assertEqual(resp.data['detail'], 'Account is not activated.') + + def test_login_ok(self): + set_user_verified(self.user, True) + params = { + 'username': self.user.username, + 'password': '123456', + } + resp = self.post_json_ok('login/', params) + + self.assertHasProps(resp.data, ['id', 'username', 'email', 'is_active', 'token']) + + +class SocialLoginAPITestCase(BaseAPITestCase): + + def test_login_empty_field(self): + params = {} + resp = self.post_json_bad_request('login/social/', params) + self.assertHasProps(resp.data, ['provider', 'access_token']) + self.assertHasErrorDetail(resp.data['provider'], 'This field is required.') + self.assertHasErrorDetail(resp.data['access_token'], 'This field is required.') + + @override_settings( + DRF_REGISTRATION={ + 'FACEBOOK_LOGIN_ENABLED': False + } + ) + def test_invalid_provider(self): + params = { + 'provider': 'invalid', + 'access_token': 'token' + } + self.post_json_bad_request('login/social/', params) + + @override_settings( + DRF_REGISTRATION={ + 'FACEBOOK_LOGIN_ENABLED': False + } + ) + def test_not_enable_facebook_login(self): + params = { + 'provider': 'facebook', + 'access_token': 'token' + } + self.post_json_bad_request('login/social/', params) + + @override_settings( + DRF_REGISTRATION={ + 'FACEBOOK_LOGIN_ENABLED': True + } + ) + def test_facebook_login_invalid_access_token(self): + params = { + 'provider': 'facebook', + 'access_token': 'invalid' + } + self.post_json_bad_request('login/social/', params) + + @override_settings( + DRF_REGISTRATION={ + 'GOOGLE_LOGIN_ENABLED': False + } + ) + def test_not_enable_google_login(self): + params = { + 'provider': 'google', + 'access_token': 'token' + } + self.post_json_bad_request('login/social/', params) + + @override_settings( + DRF_REGISTRATION={ + 'GOOGLE_LOGIN_ENABLED': True + } + ) + def test_google_login_invalid_access_token(self): + params = { + 'provider': 'google', + 'access_token': 'invalid' + } + self.post_json_bad_request('login/social/', params) + diff --git a/tests/src/api/test_logout.py b/tests/src/api/test_logout.py new file mode 100644 index 0000000..e6b7ee0 --- /dev/null +++ b/tests/src/api/test_logout.py @@ -0,0 +1,23 @@ +from django.test import override_settings +from rest_framework.authtoken.models import Token + +from tests.utils import BaseAPITestCase + + +class LogoutAPITestCase(BaseAPITestCase): + def test_logout_unauthorized(self): + self.post_json_unauthorized('logout/') + + def test_logout_ok(self): + self.client.force_authenticate(user=self.user) + self.post_json_no_content('logout/') + + @override_settings( + DRF_REGISTRATION={ + 'LOGOUT_REMOVE_TOKEN': True + } + ) + def test_logout_remove_token_ok(self): + self.client.force_authenticate(user=self.user) + self.post_json_no_content('logout/') + self.assertEqual(Token.objects.filter(user=self.user).count(), 0) diff --git a/tests/src/api/test_profile.py b/tests/src/api/test_profile.py new file mode 100644 index 0000000..240b978 --- /dev/null +++ b/tests/src/api/test_profile.py @@ -0,0 +1,52 @@ +from django.test.utils import override_settings +from tests.utils import BaseAPITestCase + + +class ProfileAPITestCase(BaseAPITestCase): + + def test_get_profile_unauthorized(self): + self.get_json_unauthorized('profile/') + + def test_get_profile_ok(self): + self.client.force_authenticate(user=self.user) + resp = self.get_json_ok('profile/') + self.assertHasProps(resp.data, ['id', 'username', 'email', 'is_active']) + + def test_update_profile_unauthorized(self): + self.put_json_unauthorized('profile/') + + @override_settings( + DRF_REGISTRATION={ + 'USER_WRITE_ONLY_FIELDS': ( + 'password', + 'username', + ) + } + ) + def test_update_profile_ok(self): + self.client.force_authenticate(user=self.user) + params = { + 'first_name': 'Hello', + 'last_name': 'World', + 'username': 'new_username' + } + resp = self.put_json_ok('profile/', params) + + # Can not update write only field + self.assertNotEqual(resp.data['username'], 'new_username') + + @override_settings( + DRF_REGISTRATION={ + 'USER_WRITE_ONLY_FIELDS': ( + 'username', + ) + } + ) + def test_update_profile_pasword_ok(self): + self.client.force_authenticate(user=self.user) + old_password_hash = self.user.password + params = { + 'password': 'abcABC@123' + } + self.put_json_ok('profile/', params) + self.assertNotEqual(old_password_hash, self.user.password) diff --git a/tests/src/api/test_register.py b/tests/src/api/test_register.py new file mode 100644 index 0000000..a56aa54 --- /dev/null +++ b/tests/src/api/test_register.py @@ -0,0 +1,147 @@ +from django.test import override_settings +from django.core import mail +from tests.utils import BaseAPITestCase +from drf_registration.utils.users import generate_uid_and_token, set_user_verified +from drf_registration.tokens import activation_token + + +class RegisterAPITestCase(BaseAPITestCase): + + def setUp(self): + super().setUp() + self.auth = False + + def test_register_empty_params(self): + params = {} + resp = self.post_json_bad_request('register/', params) + + self.assertHasProps(resp.data, ['username', 'password', 'email']) + + def test_register_existed_username(self): + params = { + 'username': self.user.username, + 'email': 'test+1@domain.com', + 'password': '123456', + } + resp = self.post_json_bad_request('register/', params) + self.assertHasProps(resp.data, ['username',]) + self.assertHasErrorDetail(resp.data['username'], 'User with this username already exists.') + + def test_register_existed_email(self): + params = { + 'username': 'testusername', + 'email': self.user.email, + 'password': '123456', + } + resp = self.post_json_bad_request('register/', params) + + self.assertHasProps(resp.data, ['email',]) + self.assertHasErrorDetail(resp.data['email'], 'User with this email already exists.') + + def test_register_short_password(self): + params = { + 'username': 'testusername', + 'email': 'testuser@domain.com', + 'password': '123456', + } + resp = self.post_json_bad_request('register/', params) + + self.assertHasErrorDetail(resp.data['password'], 'This password is too short. It must contain at least 8 characters.') + + @override_settings( + DRF_REGISTRATION={ + 'USER_ACTIVATE_TOKEN_ENABLED': False, + 'REGISTER_SEND_WELCOME_EMAIL_ENABLED': False, + 'USER_VERIFY_CODE_ENABLED': False + } + ) + def test_register_normal_ok(self): + params = { + 'username': 'testusername', + 'email': 'testuser@domain.com', + 'password': 'abcABC@123', + } + resp = self.post_json_created('register/', params) + self.assertHasProps(resp.data, ['id', 'username', 'email', 'is_active', 'token']) + + self.assertTrue(resp.data['is_active']) + + @override_settings( + DRF_REGISTRATION={ + 'DEFAULT_FROM_EMAIL': 'info@testdomain.com', + 'USER_ACTIVATE_EMAIL_SUBJECT': 'Activate subject', + 'USER_ACTIVATE_TOKEN_ENABLED': True, + 'USER_VERIFY_CODE_ENABLED': False, + 'REGISTER_SEND_WELCOME_EMAIL_ENABLED': False, + } + ) + def test_register_activate_enabled(self): + params = { + 'username': 'testusername', + 'email': 'testuser@domain.com', + 'password': 'abcABC@123', + } + resp = self.post_json_created('register/', params) + + self.assertHasProps(resp.data, ['id', 'username', 'email', 'is_active']) + self.assertFalse(resp.data['is_active']) + + # Test email + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, 'Activate subject') + self.assertEqual(mail.outbox[0].to, ['testuser@domain.com']) + + @override_settings( + DRF_REGISTRATION={ + 'USER_ACTIVATE_TOKEN_ENABLED': False, + 'USER_VERIFY_CODE_ENABLED': True, + 'REGISTER_SEND_WELCOME_EMAIL_ENABLED': False, + } + ) + def test_register_verify_enabled(self): + params = { + 'username': 'testusername', + 'email': 'testuser@domain.com', + 'password': 'abcABC@123', + } + resp = self.post_json_created('register/', params) + + self.assertHasProps(resp.data, ['id', 'username', 'email', 'is_active']) + self.assertFalse(resp.data['is_active']) + + @override_settings( + DRF_REGISTRATION={ + 'USER_ACTIVATE_TOKEN_ENABLED': False, + 'USER_VERIFY_CODE_ENABLED': False, + 'REGISTER_SEND_WELCOME_EMAIL_ENABLED': True, + 'DEFAULT_FROM_EMAIL': 'info@testdomain.com', + 'REGISTER_SEND_WELCOME_EMAIL_SUBJECT': 'Welcome subject' + } + ) + def test_register_send_welcome_email_enabled(self): + params = { + 'username': 'testusername', + 'email': 'testuser@domain.com', + 'password': 'abcABC@123', + } + resp = self.post_json_created('register/', params) + + self.assertHasProps(resp.data, ['id', 'username', 'email', 'is_active', 'token']) + + self.assertTrue(resp.data['is_active']) + + # Test email + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, 'Welcome subject') + self.assertEqual(mail.outbox[0].to, ['testuser@domain.com']) + + def test_register_activate_token_failed_views(self): + resp = self.get_json_ok('activate/xxx/yyy/') + self.assertContains(resp, 'Either the provided activation token is invalid or this account has already been activated.') + + def test_register_activate_token_success_views(self): + uid_token = generate_uid_and_token(self.user, activation_token) + uid = uid_token['uidb64'] + token = uid_token['token'] + resp = self.get_json_ok(f'activate/{uid}/{token}/') + self.assertContains(resp, 'Your account has been activate successfully.') diff --git a/tests/src/api/test_reset_password.py b/tests/src/api/test_reset_password.py new file mode 100644 index 0000000..b1a5bbf --- /dev/null +++ b/tests/src/api/test_reset_password.py @@ -0,0 +1,53 @@ +from django.core import mail +from django.test.utils import override_settings +from tests.utils import BaseAPITestCase + + +class ResetPasswordAPITestCase(BaseAPITestCase): + + @override_settings( + DRF_REGISTRATION={ + 'RESET_PASSWORD_ENABLED': False + } + ) + def test_reset_password_is_not_enabled(self): + self.post_json_not_found('reset-password/') + + @override_settings( + DRF_REGISTRATION={ + 'RESET_PASSWORD_ENABLED': True + } + ) + def test_reset_password_invalid_email(self): + params = { + 'email': 'invalid' + } + self.post_json_bad_request('reset-password/', params) + + @override_settings( + DRF_REGISTRATION={ + 'RESET_PASSWORD_ENABLED': True + } + ) + def test_reset_password_not_found_email(self): + params = { + 'email': 'notfoundemail@domain.com' + } + self.post_json_not_found('reset-password/', params) + + @override_settings( + DRF_REGISTRATION={ + 'RESET_PASSWORD_ENABLED': True, + 'RESET_PASSWORD_EMAIL_SUBJECT': 'Reset Password Subject' + } + ) + def test_reset_password_send_email_ok(self): + params = { + 'email': self.user.email + } + self.post_json_ok('reset-password/', params) + + # Test email + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, 'Reset Password Subject') + self.assertEqual(mail.outbox[0].to, [self.user.email]) diff --git a/tests/src/api/test_set_password.py b/tests/src/api/test_set_password.py new file mode 100644 index 0000000..7510394 --- /dev/null +++ b/tests/src/api/test_set_password.py @@ -0,0 +1,44 @@ +from django.test.utils import override_settings +from drf_registration.utils.users import get_user_model +from tests.utils import BaseAPITestCase + + +class SetPasswordAPITestCase(BaseAPITestCase): + + def setUp(self): + super().setUp() + + # Assuming that no user password created by social + self.user_1 = get_user_model().objects.create( + username='user1', + email='user1@example.com' + ) + + def test_set_password_unauthorized(self): + params = {} + self.put_json_unauthorized('set-password/', params) + + def test_set_password_invalid_new_password(self): + self.client.force_authenticate(user=self.user_1) + params = { + 'password': 'short' + } + resp = self.put_json_bad_request('set-password/', params) + self.assertHasErrorDetail(resp.data['password'], 'This password is too short. It must contain at least 8 characters.') + + def test_set_password_existed_password(self): + + # Use user has a password + self.client.force_authenticate(user=self.user) + params = { + 'password': 'abcABC@123' + } + resp = self.put_json_bad_request('set-password/', params) + self.assertHasErrorDetail(resp.data['password'], 'Your password is already existed.') + + def test_set_password_ok(self): + self.client.force_authenticate(user=self.user_1) + params = { + 'password': 'abcABC@123' + } + resp = self.put_json_ok('set-password/', params) diff --git a/tests/src/test_models.py b/tests/src/test_models.py new file mode 100644 index 0000000..ddd83eb --- /dev/null +++ b/tests/src/test_models.py @@ -0,0 +1,13 @@ +from django.test.utils import override_settings +from tests.utils import BaseModelTestCase +from drf_registration.utils.users import get_user_model + + +class UserModelTestCases(BaseModelTestCase): + def setUp(self): + self.user_model = get_user_model() + + def test_get_user_model(self): + user_model = get_user_model() + + self.assertHasModelFields(user_model, ['username', 'email']) diff --git a/tests/src/test_settings.py b/tests/src/test_settings.py new file mode 100644 index 0000000..028c8c5 --- /dev/null +++ b/tests/src/test_settings.py @@ -0,0 +1,29 @@ +from django.test import TestCase +from django.conf import settings +from django.test.utils import override_settings + +from drf_registration.utils.common import generate_settings, get_django_settings + + +class SettingsTestCase(TestCase): + + def setUp(self): + self.defaults = { + 'USER_VERIFY_CODE_ENABLED': False, + } + + def test_user_settings(self): + user_settings = { + 'USER_VERIFY_CODE_ENABLED': True, + } + settings = generate_settings(user_settings, self.defaults) + self.assertEqual(settings.USER_VERIFY_CODE_ENABLED, True) + + @override_settings( + DRF_REGISTRATION={ + 'USER_VERIFY_CODE_ENABLED': False, + } + ) + def test_django_settings(self): + settings = generate_settings(get_django_settings(), self.defaults) + self.assertEqual(settings.USER_VERIFY_CODE_ENABLED, False) diff --git a/tests/src/utils/test_common.py b/tests/src/utils/test_common.py new file mode 100644 index 0000000..7ad9dda --- /dev/null +++ b/tests/src/utils/test_common.py @@ -0,0 +1,20 @@ +from tests.utils import BaseTestCase +from rest_framework.permissions import AllowAny +from drf_registration.utils.common import import_string, import_string_list +from drf_registration.api.user import UserSerializer + + +class UtilCommonTestCases(BaseTestCase): + + def test_import_string(self): + + user_serializer = import_string('drf_registration.api.user.UserSerializer') + + self.assertTrue(user_serializer, UserSerializer) + + def test_import_string_list(self): + permission_classes = import_string_list([ + 'rest_framework.permissions.AllowAny', + ]) + + self.assertEqual(permission_classes, [AllowAny,]) diff --git a/tests/src/utils/test_domain.py b/tests/src/utils/test_domain.py new file mode 100644 index 0000000..1bb4795 --- /dev/null +++ b/tests/src/utils/test_domain.py @@ -0,0 +1,16 @@ +from tests.utils import BaseTestCase +from django.test.utils import override_settings +from drf_registration.utils.domain import get_current_domain + + +class UtilDomainTestCases(BaseTestCase): + + @override_settings( + DRF_REGISTRATION={ + 'PROJECT_BASE_URL': 'https://testdomain.com' + } + ) + def test_get_current_domain_from_settings(self): + domain = get_current_domain({}) + + self.assertEqual(domain, 'https://testdomain.com') diff --git a/tests/src/utils/test_email.py b/tests/src/utils/test_email.py new file mode 100644 index 0000000..519af74 --- /dev/null +++ b/tests/src/utils/test_email.py @@ -0,0 +1,67 @@ +from django.core import mail +from django.test import override_settings + +from tests.utils import BaseTestCase +from drf_registration.utils.users import get_user_model +from drf_registration.utils import email + + + +class UtilEmailTestCases(BaseTestCase): + + def setUp(self): + self.user_model = get_user_model() + self.user = self.user_model.objects.create( + username='username', + email='test@example.com', + password='123456', + ) + + @override_settings( + DRF_REGISTRATION={ + 'USER_ACTIVATE_TOKEN_ENABLED': True, + 'DEFAULT_FROM_EMAIL': 'info@testdomain.com', + 'USER_ACTIVATE_EMAIL_SUBJECT': 'Activate subject' + } + ) + def test_send_activate_token_email(self): + email.send_verify_email(self.user, 'http://testdomain.com') + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, 'Activate subject') + self.assertEqual(mail.outbox[0].to, [self.user.email]) + + @override_settings( + DRF_REGISTRATION={ + 'USER_VERIFY_CODE_ENABLED': True, + } + ) + def test_send_verify_code_email(self): + + # TODO: Need to implement later + email.send_verify_email(self.user, '') + + @override_settings( + DRF_REGISTRATION={ + 'REGISTER_SEND_WELCOME_EMAIL_ENABLED': True, + 'DEFAULT_FROM_EMAIL': 'info@testdomain.com', + 'REGISTER_SEND_WELCOME_EMAIL_SUBJECT': 'Welcome subject' + } + ) + def test_send_welcome_email(self): + email.send_email_welcome(self.user) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, 'Welcome subject') + self.assertEqual(mail.outbox[0].to, [self.user.email]) + + @override_settings( + DRF_REGISTRATION={ + 'RESET_PASSWORD_ENABLED': True, + 'DEFAULT_FROM_EMAIL': 'info@testdomain.com', + 'RESET_PASSWORD_EMAIL_SUBJECT': 'Reset password subject' + } + ) + def test_send_reset_password_email(self): + email.send_reset_password_token_email(self.user, 'http://testdomain.com') + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, 'Reset password subject') + self.assertEqual(mail.outbox[0].to, [self.user.email]) diff --git a/tests/src/utils/test_socials.py b/tests/src/utils/test_socials.py new file mode 100644 index 0000000..92f4115 --- /dev/null +++ b/tests/src/utils/test_socials.py @@ -0,0 +1,77 @@ +from django.test import override_settings +from django.http.response import Http404 +from tests.utils import BaseTestCase +from drf_registration.utils import socials +from drf_registration import constants + + +class UtilSocialsTestCases(BaseTestCase): + + def test_is_facebook_provider(self): + self.assertTrue(socials.is_facebook_provider(constants.FACEBOOK_PROVIDER)) + self.assertFalse(socials.is_facebook_provider('something')) + + def test_is_google_provider(self): + self.assertTrue(socials.is_google_provider(constants.GOOGLE_PROVIDER)) + self.assertFalse(socials.is_google_provider('something')) + + def test_in_valid_provider(self): + self.assertFalse(socials.is_valid_provider('something')) + + @override_settings( + DRF_REGISTRATION={ + 'FACEBOOK_LOGIN_ENABLED': False + } + ) + def test_not_enable_facebook_login(self): + self.assertFalse(socials.is_valid_provider(constants.FACEBOOK_PROVIDER)) + + @override_settings( + DRF_REGISTRATION={ + 'FACEBOOK_LOGIN_ENABLED': True + } + ) + def test_enable_facebook_login(self): + self.assertTrue(socials.is_valid_provider(constants.FACEBOOK_PROVIDER)) + + @override_settings( + DRF_REGISTRATION={ + 'FACEBOOK_LOGIN_ENABLED': True + } + ) + def test_get_facebook_user_info_invalid_access_token(self): + self.assertEqual(socials.get_user_info(constants.FACEBOOK_PROVIDER, 'invalid-token'), None) + + @override_settings( + DRF_REGISTRATION={ + 'GOOGLE_LOGIN_ENABLED': False + } + ) + def test_not_enable_google_login(self): + self.assertFalse(socials.is_valid_provider(constants.GOOGLE_PROVIDER)) + + @override_settings( + DRF_REGISTRATION={ + 'GOOGLE_LOGIN_ENABLED': True + } + ) + def test_enable_google_login(self): + self.assertTrue(socials.is_valid_provider(constants.GOOGLE_PROVIDER)) + + @override_settings( + DRF_REGISTRATION={ + 'GOOGLE_LOGIN_ENABLED': False, + 'FACEBOOK_LOGIN_ENABLED': False, + } + ) + def test_not_enable_has_password(self): + self.assertFalse(socials.enable_has_password()) + + @override_settings( + DRF_REGISTRATION={ + 'GOOGLE_LOGIN_ENABLED': True, + 'FACEBOOK_LOGIN_ENABLED': False, + } + ) + def test_enable_has_password(self): + self.assertTrue(socials.enable_has_password()) diff --git a/tests/src/utils/test_users.py b/tests/src/utils/test_users.py new file mode 100644 index 0000000..a2d9ada --- /dev/null +++ b/tests/src/utils/test_users.py @@ -0,0 +1,119 @@ +from tests.utils import BaseTestCase +from django.contrib.auth import get_user_model +from django.test.utils import override_settings +from rest_framework.authtoken.models import Token + +from django.test.utils import override_settings +from drf_registration.utils import users +from drf_registration.api.user import UserSerializer + + +class UtilUsersTestCases(BaseTestCase): + + def setUp(self): + self.user_model = users.get_user_model() + self.user = self.user_model.objects.create( + username='username', + email='test@example.com', + password='123456', + ) + + def test_get_user_model(self): + self.assertEqual(users.get_user_model(), get_user_model()) + + @override_settings( + DRF_REGISTRATION={ + 'USER_SERIALIZER': 'drf_registration.api.user.UserSerializer' + } + ) + def test_get_user_serializer(self): + self.assertEqual(users.get_user_serializer(), UserSerializer) + + def test_get_all_users(self): + all_users = users.get_all_users() + self.assertEqual(all_users.count(), 1) + self.assertEqual(all_users.first(), self.user) + + def test_get_user_token(self): + token, created = Token.objects.get_or_create(user=self.user) + + self.assertEqual(token, users.get_user_token(self.user)) + + def test_remove_user_token(self): + Token.objects.get_or_create(user=self.user) + self.assertEqual(Token.objects.filter(user=self.user).count(), 1) + + users.remove_user_token(self.user) + + self.assertEqual(Token.objects.filter(user=self.user).count(), 0) + + def test_get_user_profile_data(self): + + data = users.get_user_profile_data(self.user) + self.assertEqual(data['id'], self.user.id) + self.assertEqual(data['username'], self.user.username) + self.assertEqual(data['email'], self.user.email) + + @override_settings( + DRF_REGISTRATION={ + 'USER_VERIFY_CODE_ENABLED': False + } + ) + def test_no_has_user_verify_code_enabled(self): + self.assertFalse(users.has_user_verify_code()) + + @override_settings( + DRF_REGISTRATION={ + 'USER_VERIFY_CODE_ENABLED': True + } + ) + def test_has_user_verify_code_enabled(self): + self.assertTrue(users.has_user_verify_code()) + + @override_settings( + DRF_REGISTRATION={ + 'USER_ACTIVATE_TOKEN_ENABLED': False + } + ) + def test_no_has_user_activate_token_enabled(self): + self.assertFalse(users.has_user_activate_token()) + + @override_settings( + DRF_REGISTRATION={ + 'USER_ACTIVATE_TOKEN_ENABLED': True + } + ) + def test_has_user_activate_token_enabled(self): + self.assertTrue(users.has_user_activate_token()) + + @override_settings( + DRF_REGISTRATION={ + 'USER_VERIFY_FIELD': 'is_active' + } + ) + def test_not_has_user_verified(self): + users.set_user_verified(self.user, False) + self.assertFalse(users.has_user_verified(self.user)) + + @override_settings( + DRF_REGISTRATION={ + 'USER_VERIFY_FIELD': 'is_active' + } + ) + def test_has_user_verified(self): + users.set_user_verified(self.user, True) + + self.assertTrue(users.has_user_verified(self.user)) + + def test_generate_uid_and_token(self): + uid_token = users.generate_uid_and_token(self.user) + self.assertHasProps(uid_token, ['uidb64', 'token']) + + def test_get_user_from_uid_not_found(self): + user = users.get_user_from_uid('invalid-uid') + self.assertEqual(user, None) + + def test_get_user_from_uid_ok(self): + uid = users.generate_user_uid(self.user) + user = users.get_user_from_uid(uid) + self.assertEqual(user, self.user) diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..b4ac086 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import include +from django.urls import path +from django.contrib import admin + + +urlpatterns = [ + path('admin/', admin.site.urls), + path('api/accounts/', include('drf_registration.urls')), +] + diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..8cfe092 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,182 @@ +from django.test import TestCase +from rest_framework.test import APIClient +from drf_registration.utils.users import get_user_model + + +class BaseTestCase(TestCase): + + def assertHasProp(self, obj, prop): + self.assertTrue(True if prop in obj else False) + + def assertHasProps(self, obj, props): + for prop in props: + self.assertHasProp(obj, prop) + + +class BaseModelTestCase(BaseTestCase): + + def hasModelField(self, model, field): + """ + Check model has field or not + + Args: + model (class): The model instance + field (string): The field in model + + Returns: + [boolean]: Has field in model or not + """ + try: + model._meta.get_field(field) + return True + except FieldDoesNotExist: + return False + + def assertHasModelFields(self, model, fields=[]): + + for field in fields: + self.assertTrue(self.hasModelField(model, field)) + + +class BaseAPITestCase(BaseTestCase): + + def setUp(self): + super().setUp() + self.client = APIClient() + self.auth = False + self.resource = 'accounts' + self.endpoint = '/api' + + self.user_model = get_user_model() + self.user = self.user_model.objects.create( + username='username', + email='test@example.com', + ) + + self.user.set_password('123456') + self.user.save() + + def request_config(self): + if self.auth: + self.client.force_authenticate(user=self.user) + + self.client.login(user=self.user) + + def assertHttpOK(self, resp): + self.assertEqual(resp.status_code, 200) + + def assertHttpCreated(self, resp): + self.assertEqual(resp.status_code, 201) + + def assertHttpNoContent(self, resp): + self.assertEqual(resp.status_code, 204) + + def assertHttpBadRequest(self, resp): + self.assertEqual(resp.status_code, 400) + + def assertHttpUnauthorized(self, resp): + self.assertEqual(resp.status_code, 401) + + def assertHttpNotFound(self, resp): + self.assertEqual(resp.status_code, 404) + + def assertHasErrorDetail(self, element, msg): + self.assertEqual(element[0], msg) + + def build_api_url(self, fragment='', **params): + uri = '' + + if self.resource: + uri = f'{uri}/{self.resource}' + + if fragment: + uri = f'{uri}/{fragment}' + + # TODO: Create url from params + if params: + pass + + return f'{self.endpoint}{uri}' + + # GET JSON + #---------------------------------------# + def get_json(self, fragment='', **params): + self.request_config() + + url = self.build_api_url(fragment) + + return self.client.get(url, **params) + + def get_json_ok(self, fragment='', **params): + resp = self.get_json(fragment, **params) + self.assertHttpOK(resp) + return resp + + def get_json_unauthorized(self, fragment='', **params): + resp = self.get_json(fragment, **params) + self.assertHttpUnauthorized(resp) + return resp + + # POST JSON + #---------------------------------------# + def post_json(self, fragment='', data=None, **params): + self.request_config() + + url = self.build_api_url(fragment) + return self.client.post(url, data, **params) + + def post_json_ok(self, fragment='', data=None, **params): + resp = self.post_json(fragment, data, **params) + self.assertHttpOK(resp) + return resp + + def post_json_created(self, fragment='', data=None, **params): + resp = self.post_json(fragment, data, **params) + self.assertHttpCreated(resp) + return resp + + def post_json_no_content(self, fragment='', data=None, **params): + resp = self.post_json(fragment, data, **params) + self.assertHttpNoContent(resp) + return resp + + def post_json_bad_request(self, fragment='', data=None, **params): + resp = self.post_json(fragment, data, **params) + self.assertHttpBadRequest(resp) + return resp + + def post_json_unauthorized(self, fragment='', data=None, **params): + resp = self.post_json(fragment, data, **params) + self.assertHttpUnauthorized(resp) + return resp + + def post_json_not_found(self, fragment='', data=None, **params): + resp = self.post_json(fragment, data, **params) + self.assertHttpNotFound(resp) + + # PUT JSON + #---------------------------------------# + def put_json(self, fragment='', data=None, **params): + self.request_config() + + url = self.build_api_url(fragment) + return self.client.put(url, data, **params) + + def put_json_ok(self, fragment='', data=None, **params): + resp = self.put_json(fragment, data, **params) + self.assertHttpOK(resp) + return resp + + def put_json_bad_request(self, fragment='', data=None, **params): + resp = self.put_json(fragment, data, **params) + self.assertHttpBadRequest(resp) + return resp + + def put_json_unauthorized(self, fragment='', data=None, **params): + resp = self.put_json(fragment, data, **params) + self.assertHttpUnauthorized(resp) + return resp + + def put_json_not_found(self, fragment='', data=None, **params): + resp = self.put_json(fragment, data, **params) + self.assertHttpNotFound(resp) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..319c8a6 --- /dev/null +++ b/tox.ini @@ -0,0 +1,20 @@ +# content of: tox.ini , put in same dir as setup.py +[tox] +envlist = py36 + +[testenv:test] +# install pytest in the virtualenv where commands will be executed +deps = + -rrequirements/test.txt +commands = + pytest {posargs} +setenv = + PYTHONPATH = {toxinidir} + +[testenv:docs] +whitelist_externals = + make +basepython = python3.6 +commands = make serve_docs +deps = + -rrequirements/docs.txt From 34a104f69a98dc8a7c8768b4b91dd2fa4e401cb3 Mon Sep 17 00:00:00 2001 From: Huy Chau Date: Wed, 23 Sep 2020 09:30:37 +0700 Subject: [PATCH 02/14] Add license --- LICENSE.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 LICENSE.txt diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..3a36c4f --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,17 @@ +MIT License +Copyright (c) 2020 HUY CHAU +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From a372b18e88900207be0392ba3f4b28f72950195b Mon Sep 17 00:00:00 2001 From: Huy Chau Date: Wed, 23 Sep 2020 14:54:09 +0700 Subject: [PATCH 03/14] Update README.md --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d50e2e2..7fc851a 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,12 @@ Change user password #### PUT: `/set-password/` Set user password when login with social account -## Installing +## Installation & Configuration +- Install by use `pip`: +``` +pip install drf-regisration +``` + - Add `drf_registration` in `INSTALLED_APPS` ``` INSTALLED_APPS = [ From 2a9e5ffbffa23bdc787c8363bdd0ffd170cf6bb6 Mon Sep 17 00:00:00 2001 From: Huy Chau Date: Wed, 23 Sep 2020 14:54:51 +0700 Subject: [PATCH 04/14] Update install.rst --- docs/install.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index c7012f9..846e4bb 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -7,10 +7,10 @@ You can install DRF Registration latest version via pip: :: - pip install drf-registration # Just todo + pip install drf-registration Or install directly from source via Github: :: - pip install git+https://github.com/huychau/drf-registration # Just todo + pip install git+https://github.com/huychau/drf-registration From f0e20c2496eeda991f96340700cf272b39e22e85 Mon Sep 17 00:00:00 2001 From: Huy Chau Date: Tue, 20 Oct 2020 17:27:46 +0700 Subject: [PATCH 05/14] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7fc851a..327aa39 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Django Rest Framework Registration +# DRF Registration User registration base on Django Rest Framework. @@ -95,7 +95,7 @@ AUTHENTICATION_BACKENDS = [ ] ``` -You can update login username fields by change `LOGIN_USERNAME_FIELDS` in `DRF_REGISTRATION` object. Default to `['username, email,]`. +You can update login username fields by change `LOGIN_USERNAME_FIELDS` in `DRF_REGISTRATION` object. Default to `['username', 'email',]`. - Set `DEFAULT_AUTHENTICATION_CLASSES` in `REST_FRAMEWORK` configuration From f47b9cb739e65a302bd287a52fa1d59fecaf67fd Mon Sep 17 00:00:00 2001 From: Huy Chau Date: Fri, 30 Oct 2020 08:38:45 +0700 Subject: [PATCH 06/14] Add total downloads --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 327aa39..b248e0c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # DRF Registration +[![Downloads](https://static.pepy.tech/personalized-badge/drf-registration?period=total&units=international_system&left_color=black&right_color=brightgreen&left_text=Downloads)](https://pepy.tech/project/drf-registration) + User registration base on Django Rest Framework. Check the document at https://drf-registration.readthedocs.io/ From 259c54fb61ed1489c9dab657ee157ae083bf4c8c Mon Sep 17 00:00:00 2001 From: Huy Chau Date: Tue, 12 Jan 2021 09:59:23 +0700 Subject: [PATCH 07/14] Add drf-registration package to requirements --- examples/testapp/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/testapp/requirements.txt b/examples/testapp/requirements.txt index ae41fb7..d43aa2a 100644 --- a/examples/testapp/requirements.txt +++ b/examples/testapp/requirements.txt @@ -1 +1,2 @@ drf-yasg +drf-resitration From 163da25d694d9c859f8979c2f155a2262fc2cf65 Mon Sep 17 00:00:00 2001 From: Huy Chau Date: Tue, 12 Jan 2021 10:00:29 +0700 Subject: [PATCH 08/14] Update README.md --- examples/testapp/README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/examples/testapp/README.md b/examples/testapp/README.md index 9f8adb0..9613a8e 100644 --- a/examples/testapp/README.md +++ b/examples/testapp/README.md @@ -2,10 +2,7 @@ ## Installing -At `testapp` root directory run: - -- Install requirements: `pip install -r requirements.txt` -- Install drf_registration package locally: `pip install ../..` +At `testapp` root directory run `pip install -r requirements.txt` to install packages ## Start server From 04d027514ca85a41e14eaec83a1b08d92807fe5b Mon Sep 17 00:00:00 2001 From: Huy Chau Date: Sun, 31 Jan 2021 14:49:21 +0700 Subject: [PATCH 09/14] Add missing package --- examples/testapp/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/testapp/requirements.txt b/examples/testapp/requirements.txt index ae41fb7..1693475 100644 --- a/examples/testapp/requirements.txt +++ b/examples/testapp/requirements.txt @@ -1 +1,2 @@ drf-yasg +drf-registration From 5fe9b11f375305bb10804973c2997e3c8547ee89 Mon Sep 17 00:00:00 2001 From: Robert Timm Date: Tue, 9 Mar 2021 22:00:59 +0100 Subject: [PATCH 10/14] add test dependency six --- requirements/test.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/test.txt b/requirements/test.txt index f5a34e5..34ef71e 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -2,3 +2,4 @@ pytest pytest-django pytest-cov +six From 83912602e29ea25e212c7d3e50079ccced8df140 Mon Sep 17 00:00:00 2001 From: Robert Timm Date: Tue, 9 Mar 2021 22:01:41 +0100 Subject: [PATCH 11/14] fix a typo --- tests/src/api/test_profile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/src/api/test_profile.py b/tests/src/api/test_profile.py index 240b978..674c03d 100644 --- a/tests/src/api/test_profile.py +++ b/tests/src/api/test_profile.py @@ -42,7 +42,7 @@ def test_update_profile_ok(self): ) } ) - def test_update_profile_pasword_ok(self): + def test_update_profile_password_ok(self): self.client.force_authenticate(user=self.user) old_password_hash = self.user.password params = { From 95bb48f3110dfa8b17e97228667c210eabee7040 Mon Sep 17 00:00:00 2001 From: Robert Timm Date: Tue, 9 Mar 2021 22:05:04 +0100 Subject: [PATCH 12/14] fix ProfileView set_password django.http.QueryDict.pop returns a list, not a string https://docs.djangoproject.com/en/3.1/ref/request-response/\#django.http.QueryDict.pop --- drf_registration/api/profile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drf_registration/api/profile.py b/drf_registration/api/profile.py index 4978753..ff1c3f9 100644 --- a/drf_registration/api/profile.py +++ b/drf_registration/api/profile.py @@ -59,7 +59,7 @@ def update(self, request, *args, **kwargs): # USER_WRITE_ONLY_FIELDS not contain password field if 'password' in request.data.keys(): request.data._mutable = True - self.request.user.set_password(request.data.pop('password')) + self.request.user.set_password(request.data.pop('password')[0]) self.request.user.save() request.data._mutable = False From ff1db962e262ca4073b978a2cbd369b4310feb2f Mon Sep 17 00:00:00 2001 From: Robert Timm Date: Thu, 11 Mar 2021 23:12:18 +0100 Subject: [PATCH 13/14] move six dependency to base.txt --- requirements/base.txt | 1 + requirements/test.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/base.txt b/requirements/base.txt index 2287d9b..4bbad4e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,3 +1,4 @@ requests Django>=2.0 djangorestframework>=3.8.2 +six diff --git a/requirements/test.txt b/requirements/test.txt index 34ef71e..f5a34e5 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -2,4 +2,3 @@ pytest pytest-django pytest-cov -six From ca6407a07fd0d29e8d564863826ea7a93d508074 Mon Sep 17 00:00:00 2001 From: Huy Chau Date: Fri, 12 Mar 2021 11:14:37 +0700 Subject: [PATCH 14/14] Upgrade the fix version --- drf_registration/__init__.py | 2 +- examples/testapp/requirements.txt | 4 ++-- setup.cfg | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/drf_registration/__init__.py b/drf_registration/__init__.py index b794fd4..df9144c 100644 --- a/drf_registration/__init__.py +++ b/drf_registration/__init__.py @@ -1 +1 @@ -__version__ = '0.1.0' +__version__ = '0.1.1' diff --git a/examples/testapp/requirements.txt b/examples/testapp/requirements.txt index 1693475..ab1b816 100644 --- a/examples/testapp/requirements.txt +++ b/examples/testapp/requirements.txt @@ -1,2 +1,2 @@ -drf-yasg -drf-registration +drf-yasg==1.17.1 +drf-registration>=0.1.0 diff --git a/setup.cfg b/setup.cfg index 954f6a2..cad400a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,7 @@ [metadata] name = drf-registration author = Huy Chau -author-email = huy.chau@asnet.com.vn +author-email = huychau.dev@gmail.com description = User registration base on Django Rest Framework description-file = README.md long-description = file:README.md