├── .bumpversion.cfg ├── .coveragerc ├── .editorconfig ├── .flake8 ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── labels.yml └── workflows │ ├── define-labels.yml │ ├── publish-package.yml │ ├── run-project-tests.yml │ └── run-tests.yml ├── .gitignore ├── .ignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── .prettierignore ├── .prettierrc.toml ├── .pylintrc ├── .readthedocs.yaml ├── AUTHORS.rst ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── codecov.yml ├── doc-requirements.txt ├── docs ├── Makefile ├── admin_usage.rst ├── change_log.rst ├── conf.py ├── contributing.rst ├── create_custom_user_with_mixins.rst ├── data_migrations.rst ├── email_warning.rst ├── index.rst ├── integration.rst ├── make.bat ├── quickstart.rst ├── quickstart_contrib.rst ├── rationale.rst ├── select_configuration_method.rst └── source │ ├── admin.rst │ ├── factories.rst │ ├── forms.rst │ ├── managers.rst │ ├── model_mixins.rst │ ├── models.rst │ └── modules.rst ├── example_extension_project ├── .coveragerc ├── config │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py ├── requirements.txt └── user_extension │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── factories.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ └── tests.py ├── example_integration_project ├── .coveragerc ├── config │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py ├── requirements.txt └── user_integration │ ├── __init__.py │ ├── apps.py │ ├── migrations │ ├── 0001_add_user.py │ └── __init__.py │ ├── templates │ ├── base.html │ └── home.html │ └── tests.py ├── example_replacement_project ├── .coveragerc ├── config │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py ├── requirements.txt └── user_replacement │ ├── __init__.py │ ├── apps.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ └── tests.py ├── pyproject.toml ├── requirements.txt ├── runtests.py ├── setup.cfg ├── src └── improved_user │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── factories.py │ ├── forms.py │ ├── managers.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── model_mixins.py │ └── models.py ├── tests ├── __init__.py ├── test_admin.py ├── test_auth_backends.py ├── test_basic.py ├── test_factories.py ├── test_forms.py ├── test_management.py ├── test_managers.py ├── test_models.py ├── test_signals.py └── urls.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.1.0 3 | commit = False 4 | tag = False 5 | parse = (?P\d+)\.(?P\d+)((\.(?P\d+))|((?P(a|b|rc|final))(?P\d+))) 6 | serialize = 7 | {major}.{minor}{level}{prerelease} 8 | {major}.{minor}.{patch} 9 | 10 | [bumpversion:part:level] 11 | first_value = final 12 | values = 13 | final 14 | a 15 | b 16 | rc 17 | 18 | [bumpversion:part:prerelease] 19 | first_value = 1 20 | 21 | [bumpversion:file:docs/conf.py] 22 | 23 | [bumpversion:file:src/improved_user/__init__.py] 24 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | src/improved_user 4 | */site-packages/improved_user 5 | 6 | [run] 7 | branch = true 8 | omit = 9 | setup.py 10 | parallel = true 11 | source = improved_user 12 | 13 | [report] 14 | show_missing = true 15 | precision = 2 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 4 9 | indent_style = tab 10 | insert_final_newline = true 11 | max_line_length = 80 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | indent_style = space 16 | ; Robert-Bringhurst in The Elements of Typographic Style: 17 | ; Anything from 45 to 75 characters is widely regarded as a satisfactory 18 | ; length of line for a single-column page […] The 66-character line […] 19 | ; is widely regarded as ideal (25). 20 | ; 21 | ; PEP 8 limits long blocks of text to 72 characters. 22 | ; https://www.python.org/dev/peps/pep-0008/#maximum-line-length 23 | max_line_length = 72 24 | 25 | [*.py] 26 | indent_style = space 27 | max_line_length = 79 28 | 29 | [*.{yaml,yml}] 30 | indent_style = space 31 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # https://flake8.readthedocs.org/en/latest/ 3 | select = B, B9, C, D, E, F, N, W 4 | ignore = B950, D104, D105, D106, D202, D400, E203, E266, E501, N803, N806, N815, W503 5 | exclude= 6 | src/*/migrations/*, 7 | example_*_project/*/migrations/*, 8 | max-complexity = 10 9 | max-line-length = 79 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 23 | 24 | ## Specifications 25 | 26 | - Platform/System: 27 | - Python Version: 28 | - Django Version: 29 | - Improved User Version: 30 | 31 | ## Expected Behavior 32 | 33 | 34 | 35 | ## Actual Behavior 36 | 37 | 39 | 40 | ## Context 41 | 42 | 44 | 45 | ## Steps to Reproduce the Problem 46 | 47 | 49 | 50 | 1. 51 | 2. 52 | 3. 53 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "pip" 6 | directory: "/" 7 | schedule: 8 | interval: "daily" 9 | time: "07:50" 10 | timezone: "America/New_York" 11 | reviewers: 12 | - "jambonrose" 13 | assignees: 14 | - "jambonrose" 15 | commit-message: 16 | prefix: "⬆️ " 17 | labels: 18 | - "Status: Ready for Review" 19 | - "Type: Dependency Upgrade" 20 | ignore: 21 | - dependency-name: "django" 22 | 23 | - package-ecosystem: "github-actions" 24 | directory: "/" 25 | schedule: 26 | # Check for updates to GitHub Actions every weekday 27 | interval: "daily" 28 | time: "06:50" 29 | timezone: "America/New_York" 30 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | - name: "Status: Author Working" 2 | description: > 3 | PR author is working. If others may review, label may be used with Ready 4 | for Review. 5 | color: FFBF00 6 | - name: "Status: Blocked" 7 | description: > 8 | PR may not be merged. Label should be the only Status label on PR. 9 | color: FFA3A3 10 | - name: "Status: Ready for Review" 11 | description: > 12 | Reviewer(s) may review PR. May be used with other Status labels. 13 | color: A3DA8B 14 | - name: "Status: Revision Required" 15 | description: > 16 | PR Author has received feedback. If others may review, label may be used 17 | with Ready for Review. 18 | color: F5AE70 19 | - name: "Type: Bug Fix" 20 | description: "PR includes a bug fix." 21 | color: FFA3A3 22 | - name: "Type: Dependency Upgrade" 23 | description: "PR upgrades dependencies." 24 | color: E5C4F8 25 | - name: "Type: Feature Enhancement" 26 | description: "PR includes a feature enhancement." 27 | color: BFD4F2 28 | - name: "Type: Maintenance" 29 | description: "PR is maintenance on the code or repository." 30 | color: FFBF00 31 | - name: "Type: New Feature" 32 | description: "PR introduces a new feature." 33 | color: CCE9FF 34 | - name: "wontfix" 35 | description: "For issues that can/will not be fixed." 36 | color: FFFFFF 37 | -------------------------------------------------------------------------------- /.github/workflows/define-labels.yml: -------------------------------------------------------------------------------- 1 | name: Define Github labels 2 | on: 3 | push: 4 | branches: ["development"] 5 | paths: 6 | - .github/workflows/define-labels.yml 7 | - .github/labels.yml 8 | 9 | jobs: 10 | define: 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: micnncim/action-label-syncer@v1.3.0 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/publish-package.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | name: Build package 11 | runs-on: ubuntu-22.04 12 | environment: publish 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Build sdist 17 | run: pipx run build --sdist --wheel 18 | 19 | - uses: actions/upload-artifact@v4 20 | with: 21 | path: dist/* 22 | 23 | publish: 24 | name: Upload release to PyPI 25 | needs: [build] 26 | runs-on: ubuntu-22.04 27 | environment: 28 | name: publish 29 | url: https://pypi.org/p/django-improved-user/ 30 | permissions: 31 | id-token: write 32 | steps: 33 | - name: Download artifact from build step 34 | uses: actions/download-artifact@v4 35 | with: 36 | name: artifact 37 | path: dist 38 | 39 | - name: Publish to PyPI 40 | uses: pypa/gh-action-pypi-publish@release/v1 41 | -------------------------------------------------------------------------------- /.github/workflows/run-project-tests.yml: -------------------------------------------------------------------------------- 1 | name: Example Project Tests 2 | 3 | on: 4 | push: 5 | branches: [development] 6 | pull_request: 7 | branches: [development] 8 | paths: 9 | - .github/** 10 | - src/** 11 | - tests/** 12 | - example_extension_project/** 13 | - example_replacement_project/** 14 | 15 | env: 16 | PYTHONDONTWRITEBYTECODE: 1 17 | PYTHONWARNINGS: once 18 | 19 | jobs: 20 | build: 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | matrix: 24 | os: [ubuntu-22.04, windows-2022] 25 | django-version: ["4.2"] 26 | project: ["extension", "replacement"] 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: Set up Python 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: "3.12" 35 | 36 | - name: Upgrade pip 37 | run: python3 -m pip install -U distlib pip setuptools wheel 38 | 39 | - name: Get pip cache dir 40 | id: pip-cache 41 | run: echo "::set-output name=dir::$(pip cache dir)" 42 | 43 | - name: Cache dependencies 44 | uses: actions/cache@v4 45 | with: 46 | path: ${{ steps.pip-cache.outputs.dir }} 47 | key: 48 | ${{ runner.os }}-pip-${{ matrix.django-version }}-${{ 49 | hashFiles('**/requirements.txt') }} 50 | restore-keys: | 51 | ${{ runner.os }}-pip- 52 | 53 | - name: Install dependencies 54 | run: | 55 | python3 -m pip install -r ./requirements.txt 56 | python3 -m flit install --deps none 57 | python3 -m pip install -r ./example_${{ matrix.project }}_project/requirements.txt 58 | 59 | - name: Install Django 60 | run: python3 -m pip install "Django~=${{ matrix.django-version }}" 61 | 62 | - name: Python and Django versions 63 | run: | 64 | python3 --version 65 | echo "Django ${{ matrix.django-version }}: $(django-admin --version)" 66 | 67 | - name: Run tests 68 | working-directory: ./example_${{ matrix.project }}_project 69 | run: python3 manage.py test 70 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Package Tests 2 | 3 | on: 4 | push: 5 | branches: [development] 6 | pull_request: 7 | branches: [development] 8 | paths: [".github/**", "src/**", "tests/**"] 9 | 10 | 11 | env: 12 | PYTHONDONTWRITEBYTECODE: 1 13 | PYTHONWARNINGS: once 14 | 15 | jobs: 16 | build: 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | matrix: 20 | os: [ubuntu-22.04, windows-2022] 21 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 22 | django-version: ["4.2"] 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - name: Upgrade pip 33 | run: python3 -m pip install -U distlib pip setuptools wheel 34 | 35 | - name: Get pip cache dir 36 | id: pip-cache 37 | run: echo "::set-output name=dir::$(pip cache dir)" 38 | 39 | - name: Cache dependencies 40 | uses: actions/cache@v4 41 | with: 42 | path: ${{ steps.pip-cache.outputs.dir }} 43 | key: 44 | ${{ runner.os }}-pip-${{ matrix.django-version }}-${{ 45 | hashFiles('./requirements.txt') }} 46 | restore-keys: | 47 | ${{ runner.os }}-pip- 48 | 49 | - name: Install dependencies 50 | run: | 51 | python3 -m pip install -r ./requirements.txt 52 | python3 -m flit install --deps none 53 | 54 | - name: Install Django 55 | run: python3 -m pip install "Django~=${{ matrix.django-version }}" 56 | 57 | - name: Python and Django versions 58 | run: | 59 | echo "Python ${{ matrix.python-version }} & Django ${{ matrix.django-version }}" 60 | python3 --version 61 | echo "Django: $(django-admin --version)" 62 | 63 | - name: Run tests 64 | run: | 65 | coverage run ./runtests.py 66 | coverage combine --append 67 | coverage report 68 | coverage xml 69 | 70 | - name: Upload coverage reports to Codecov 71 | uses: codecov/codecov-action@v4 72 | with: 73 | token: ${{ secrets.CODECOV_TOKEN }} 74 | fail_ci_if_error: true 75 | verbose: true 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project Specific 2 | example_*_project/db.sqlite3 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # IPython Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | .venv/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | /.git 2 | example_integration_project/categories.json 3 | example_integration_project/confusables.json 4 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | balanced_wrapping=true 3 | combine_as_imports=true 4 | known_first_party=improved_user 5 | known_third_party= 6 | django 7 | registration 8 | line_length=79 9 | profile=black 10 | skip=migrations 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | default_language_version: 4 | python: "python3.9" 5 | repos: 6 | # - repo: meta 7 | # hooks: 8 | # - id: check-hooks-apply 9 | # - id: check-useless-excludes 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v4.6.0 12 | hooks: 13 | - id: check-added-large-files 14 | args: [--maxkb=500] 15 | - id: check-case-conflict 16 | - id: check-json 17 | - id: check-merge-conflict 18 | - id: check-symlinks 19 | - id: check-yaml 20 | - id: debug-statements 21 | - id: detect-private-key 22 | - id: end-of-file-fixer 23 | - id: fix-byte-order-marker 24 | - id: mixed-line-ending 25 | args: [--fix=lf] 26 | - id: requirements-txt-fixer 27 | - id: trailing-whitespace 28 | - repo: https://github.com/pycqa/isort 29 | rev: 5.13.2 30 | hooks: 31 | - id: isort 32 | - repo: https://github.com/psf/black 33 | rev: 24.8.0 34 | hooks: 35 | - id: black 36 | - repo: https://github.com/PyCQA/flake8 37 | rev: 7.1.1 38 | hooks: 39 | - id: flake8 40 | args: ["--config=.flake8"] 41 | additional_dependencies: 42 | [ 43 | flake8-blind-except, 44 | flake8-bugbear, 45 | flake8-builtins, 46 | flake8-docstrings, 47 | flake8-logging-format, 48 | flake8-rst-docstrings, 49 | pep8-naming, 50 | ] 51 | - repo: https://github.com/asottile/pyupgrade 52 | rev: v3.17.0 53 | hooks: 54 | - id: pyupgrade 55 | args: [--py38-plus] 56 | - repo: https://github.com/adamchainz/django-upgrade 57 | rev: "1.20.0" 58 | hooks: 59 | - id: django-upgrade 60 | args: [--target-version, "4.2"] 61 | - repo: https://github.com/mgedmin/check-manifest 62 | rev: "0.49" 63 | hooks: 64 | - id: check-manifest 65 | args: [--no-build-isolation] 66 | additional_dependencies: [flit] 67 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore all Django Template files: 2 | *.html 3 | 4 | # Security & Validation files need not be formatted 5 | # See Django Registration documentation for more details 6 | example_integration_project/categories.json 7 | example_integration_project/confusables.json 8 | -------------------------------------------------------------------------------- /.prettierrc.toml: -------------------------------------------------------------------------------- 1 | bracketSameLine = false 2 | bracketSpacing = true 3 | proseWrap = "always" 4 | semi = false 5 | singleQuote = false 6 | trailingComma = "all" 7 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # https://docs.readthedocs.io/en/stable/config-file/v2.html 3 | version: 2 4 | build: 5 | os: ubuntu-22.04 6 | tools: 7 | python: "3.12" 8 | sphinx: 9 | configuration: docs/conf.py 10 | formats: 11 | - pdf 12 | - epub 13 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 14 | python: 15 | install: 16 | - requirements: doc-requirements.txt 17 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | AUTHORS 3 | ======= 4 | 5 | - Andrew Pinkham http://andrewsforge.com 6 | - Jim Illback jimillback01@gmail.com 7 | - Josh Schneier https://github.com/jschneier 8 | - Kenneth Johnson https://github.com/securedirective 9 | - Russell Keith-Magee http://cecinestpasun.com 10 | - Lander McFall Kerbey https://github.com/landermkerbey 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our 7 | project and our community a harassment-free experience for everyone, 8 | regardless of age, body size, disability, ethnicity, gender identity and 9 | expression, level of experience, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual 26 | attention or advances 27 | - Trolling, insulting/derogatory comments, and personal or political 28 | attacks 29 | - Public or private harassment 30 | - Publishing others' private information, such as a physical or 31 | electronic address, without explicit permission 32 | - Other conduct which could reasonably be considered inappropriate in 33 | a professional setting 34 | 35 | ## Our Responsibilities 36 | 37 | Project maintainers are responsible for clarifying the standards of 38 | acceptable behavior and are expected to take appropriate and fair 39 | corrective action in response to any instances of unacceptable behavior. 40 | 41 | Project maintainers have the right and responsibility to remove, edit, 42 | or reject comments, commits, code, wiki edits, issues, and other 43 | contributions that are not aligned to this Code of Conduct, or to ban 44 | temporarily or permanently any contributor for other behaviors that they 45 | deem inappropriate, threatening, offensive, or harmful. 46 | 47 | ## Scope 48 | 49 | This Code of Conduct applies both within project spaces and in public 50 | spaces when an individual is representing the project or its community. 51 | Examples of representing a project or community include using an 52 | official project e-mail address, posting via an official social media 53 | account, or acting as an appointed representative at an online or 54 | offline event. Representation of a project may be further defined and 55 | clarified by project maintainers. 56 | 57 | ## Enforcement 58 | 59 | Instances of abusive, harassing, or otherwise unacceptable behavior may 60 | be reported by contacting the project team at hello@jambonsw.com. The 61 | project team will review and investigate all complaints, and will 62 | respond in a way that it deems appropriate to the circumstances. The 63 | project team is obligated to maintain confidentiality with regard to the 64 | reporter of an incident. Further details of specific enforcement 65 | policies may be posted separately. 66 | 67 | Project maintainers who do not follow or enforce the Code of Conduct in 68 | good faith may face temporary or permanent repercussions as determined 69 | by other members of the project's leadership. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the [Contributor 74 | Covenant][homepage], version 1.4, available at 75 | [http://contributor-covenant.org/version/1/4][version]. 76 | 77 | [homepage]: http://contributor-covenant.org 78 | [version]: http://contributor-covenant.org/version/1/4/ 79 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | How to Contribute 3 | ================= 4 | 5 | First off, thanks for taking the time to contribute! ✨🎉 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every 8 | little bit helps, and credit will always be given. The following is a 9 | set of guidelines for contributing to django-improved-user, hosted on 10 | `Github`_. These are mostly guidelines, not rules. Use your best 11 | judgment, and feel free to propose changes to this document in a pull 12 | request. 13 | 14 | Please remember that this is a volunteer-driven project. We will look at 15 | the issues and pull requests as soon as possible. 16 | 17 | .. contents:: 18 | :local: 19 | 20 | Code of Conduct 21 | --------------- 22 | 23 | This project is subject to a `Code of Conduct`_. By participating, you 24 | are expected to uphold this code. 25 | 26 | Please be respectful to other developers. 27 | 28 | .. _Code of Conduct: https://github.com/jambonrose/django-improved-user/blob/development/CODE_OF_CONDUCT.md 29 | 30 | Types of Contributions 31 | ---------------------- 32 | 33 | You can contribute in many ways: 34 | 35 | Report Bugs 36 | ~~~~~~~~~~~ 37 | 38 | Please report bugs on the `Github issue tracker`_. Search the tracker to 39 | make sure someone else hasn't already reported the issue. If you find 40 | your the problem has already been reported, feel free to add more 41 | information if appropriate. If you don't find the problem reported, 42 | please open a new issue, and follow the guidelines set forth in the text 43 | field. 44 | 45 | Fix Bugs 46 | ~~~~~~~~ 47 | 48 | Look through the `Github issue tracker`_ for bugs. Anything tagged with 49 | "bug" and "help wanted" is open to whoever wants to implement it. If 50 | someone has been assigned, or notes that it is claimed in the comments, 51 | please reach out to them to work together on the issue to avoid 52 | duplicating work. Note that, as volunteers, people sometime are unable 53 | to complete work they start, and that it is reasonable after a certain 54 | amount of time to assume they are no longer working on the issue. Use 55 | your best judgment to assess the situation. 56 | 57 | Write (or Request) Documentation 58 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 59 | 60 | The documentation aims to provide reference material, how-to guides, and 61 | a general tutorial for getting started with Django and 62 | django-improved-user. If you believe the documentation can be expanded 63 | or added to, your contribution would be welcomed. 64 | 65 | If you are running into a problem, and believe that some documentation 66 | could clarify the problem (or the solution!) please feel free to request 67 | documentation on the `Github issue tracker`_. 68 | 69 | For more about different kinds of documentations and how to think about 70 | the differences, please watch `Daniele Procida's PyCon US 2017 talk`_ on 71 | the subject. 72 | 73 | .. _Daniele Procida's PyCon US 2017 talk: https://www.youtube.com/watch?v=azf6yzuJt54 74 | 75 | Your First Contribution 76 | ---------------------------- 77 | 78 | Ready to contribute? Let's get django-improved-user working on your 79 | local machine. 80 | 81 | This package relies on Python, pip, and Django. Please make sure you 82 | have the first two installed. 83 | 84 | To get started, fork the git repository to your own account using the 85 | fork button on the top right of the Github interface. You now have your 86 | own fork of the project! Clone your fork of the repository using the 87 | command below, but with your own username. 88 | 89 | .. code:: console 90 | 91 | $ git clone git@github.com:YOUR_USERNAME/django-improved-user.git 92 | 93 | We recommend the use of virtual environments when developing 94 | (generally). If you are not familiar with virtual environments, take a 95 | look at `Python's venv documentation`_. `Virtualenvwrapper`_ is also a 96 | favorite. 97 | 98 | .. _Python's venv documentation: https://docs.python.org/3/library/venv.html#module-venv 99 | .. _Virtualenvwrapper: https://virtualenvwrapper.readthedocs.io/en/latest/ 100 | 101 | You can now install all of the dependencies required to develop the 102 | project. Use pip to install all dependencies, as demonstrated below. 103 | 104 | .. code:: console 105 | 106 | $ pip install -r requirements.txt 107 | 108 | If you are modifying code, keep reading. If you are changing 109 | documentation, skip to the next section. 110 | 111 | Your First Code Contribution 112 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 113 | 114 | Before making any changes, let's first make sure all the tests pass. To 115 | run the test suite on a single version of Django, you will need to 116 | install Django and the package (in development mode). Use the command 117 | below to do both. 118 | 119 | .. code:: console 120 | 121 | $ python setup.py develop 122 | 123 | Run the `runtests.py` script from the root of the project to test the 124 | django-improved-user project. 125 | 126 | .. code:: console 127 | 128 | $ python runtests.py 129 | 130 | You can limit the tests or pass paramaters as if you had called Django's 131 | `manage.py test`. 132 | 133 | .. code:: console 134 | 135 | $ ./runtests.py tests.test_basic -v 3 136 | 137 | If you have Python 3.7, 3.8, 3.9 and 3.10 installed on your system, you 138 | will be able to test the package under all required conditions. The 139 | project uses `tox` to make this easy. This will use all the linters and 140 | test the package with multiple Python and Django versions. 141 | 142 | .. code:: console 143 | 144 | $ tox 145 | 146 | Note that any change made to this project must meet the linting rules 147 | and tests run by tox. These rules are double-checked by TravisCI and 148 | AppVeyor. Furthermore, changes in code must maintain or increase 149 | code-coverage unless this is unreasonable. 150 | 151 | If your tests all pass, you are ready to make changes! If not, please 152 | open an issue in Github detailing the test failure you are seeing. 153 | 154 | Create a new branch in the repository. Name the branch descriptively, 155 | and reference the the github issue if applicable. Below are a few 156 | examples of what that command might look like. 157 | 158 | .. code:: console 159 | 160 | $ git checkout -b add_how_to_subclass_abstract_user_guide 161 | $ git checkout -b issue_45_allow_whitespace_in_passwords 162 | 163 | Please note that all pull requests that feature code changes are 164 | expected to reference github issues, as discussion is required for any 165 | change. 166 | 167 | Make your changes! We recommend a test-driven approach to development. 168 | Please remember to update any relevant documentation. Make your commits 169 | small, and target each commit to do a single thing. If you are 170 | comfortable rebasing git commits, please do so at the end - providing 171 | small, targeted, organized commits can make reviewing code radically 172 | easier, and we will be grateful for it. 173 | 174 | Once you are done, push your changes to github, and open a pull request 175 | via the interface. Please follow all of the instructions in the pull 176 | request textfield when doing so, as it will help us understand and 177 | review your code. 178 | 179 | Congratulations on opening a pull request! 🎉 180 | 181 | Your First Documentation Contribution 182 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 183 | 184 | If it isn't documented, it doesn't exist. 185 | 186 | — `Mike Pope`_ 187 | 188 | .. _Mike Pope: https://www.mikepope.com/blog/DisplayBlog.aspx?permalink=1680 189 | 190 | Documentation is crucial, and I am thrilled to get your help writing it! 191 | 192 | All of the documentation is written in `reStructuredText`_, sometimes 193 | called *rst*. Some of the documents (such as this one!) are in the root 194 | of the `Github`_ project, but the vast majority exist in the ``docs`` 195 | directory. The documents found in this directory are compiled to HTML by 196 | `Sphinx`_ (which has a `primer on rst`_). 197 | 198 | You may use the ``Makefile`` in the ``docs`` directory to run Sphinx. 199 | 200 | .. code:: console 201 | 202 | $ cd docs 203 | $ make clean && make html 204 | 205 | If you browse to ``_build/html`` (within the ``docs`` directory), you'll 206 | find a local build of all the documentation! Open any of the HTML files 207 | in a browser to read the documentation. 208 | 209 | Alternatively, you can use ``tox`` to build the documentation (requires 210 | that Python 3.6 be installed). This is more of a check, as navigating to 211 | the built files is less easy. 212 | 213 | .. code:: console 214 | 215 | $ tox -e docs 216 | 217 | The documentation automatically builds reference documentation for the 218 | project. To update these reference documents, you will need to change 219 | the Python docstrings in the code itself. Corrections and expansions to 220 | existing docs, as well as new tutorials and how-to guides are welcome 221 | additions. If you had a pain point while using this project, and you 222 | would like to add to an existing document or else to write a new one, 223 | you are encouraged to do it! 224 | 225 | If you run into an problems or have a question, please ask it on the 226 | `Github issue tracker`_ (after making sure someone hasn't already asked 227 | and answered the question!). 228 | 229 | Once you have made changes to the documents in question, you'll want to 230 | make sure that Sphinx builds the documentation without any errors. 231 | 232 | Commit your changes, and push them to your local branch. Using the 233 | Github interface, open a pull request to the development branch in the 234 | main repository! Please follow all of the instructions in the pull 235 | request textfield when doing so, as it will help us understand and 236 | review your code. 237 | 238 | Congratulations on opening a pull request! 🎉 239 | 240 | .. _Github issue tracker: https://github.com/jambonrose/django-improved-user/issues 241 | .. _Github: https://github.com/jambonrose/django-improved-user 242 | .. _primer on rst: http://www.sphinx-doc.org/en/stable/rest.html#rst-primer 243 | .. _reStructuredText: https://docutils.sourceforge.io/rst.html 244 | .. _Sphinx: http://www.sphinx-doc.org/ 245 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | Next Release 6 | ------------ 7 | 8 | - Nothing Yet! 9 | 10 | 2.0.0 (2024-08-05) 11 | ------------------- 12 | 13 | 14 | - Support: 15 | - Python 3.6, 3.7, 3.8, 3.9 16 | - Django 2.2, 3.1, 3.2, 4.0 17 | - Drop support for: 18 | - Python 3.4, 3.5 19 | - Django 1.8, 1.11, 2.0, 2.1 20 | - Update: ugettext_lazy() → gettext_lazy() (`#118`_, `#117`_) 21 | - Upgrade development/maintenance experience 22 | - Run tests via Github Actions (instead of TravisCI) 23 | - Upgrade development & documentation dependencies 24 | - Use flit to build/release sdist instead of setup.py 25 | 26 | .. _#117: https://github.com/jambonrose/django-improved-user/pull/117 27 | .. _#118: https://github.com/jambonrose/django-improved-user/issues/118 28 | 29 | 1.0.1 (2020-02-16) 30 | ------------------ 31 | 32 | - Add flexibility to admin panel usage; document usage 33 | 34 | 1.0.0 (2018-07-28) 35 | ------------------ 36 | 37 | - Django 1.8, 1.11, 2.0, 2.1 officially supported. 38 | - Django 1.9 and 1.10 are not tested against, as Django does not support 39 | them, but they likely work. 40 | - **Breaking change**: Model mix-in classes now exist in their own 41 | module! Import from `model_mixins` instead of `models`. (`#46`_, 42 | `#96`_) 43 | - Fix issue `#49`_: allow form classes to be imported without requiring 44 | project to be in ``INSTALLED_APPS`` (See `#36`_ and `#46`_ below for 45 | associated error and reasoning) (`#50`_) 46 | - Fix issue `#36`_: refactor package to allow for mix-in classes to be 47 | imported into third-party project without requiring project to be in 48 | ``INSTALLED_APPS`` (which would unnecessarily create unused tables in 49 | the project). Add documentation/tutorial on subject. (`#46`_) 50 | - Django 2.0, 2.1 compatibility. (`#43`_, `#93`_) 51 | 52 | .. _#36: https://github.com/jambonrose/django-improved-user/issues/36 53 | .. _#43: https://github.com/jambonrose/django-improved-user/pull/43 54 | .. _#46: https://github.com/jambonrose/django-improved-user/pull/46 55 | .. _#49: https://github.com/jambonrose/django-improved-user/issues/49 56 | .. _#50: https://github.com/jambonrose/django-improved-user/pull/50 57 | .. _#93: https://github.com/jambonrose/django-improved-user/pull/93 58 | .. _#96: https://github.com/jambonrose/django-improved-user/pull/96 59 | 60 | 61 | 0.5.3 (2017-08-29) 62 | ------------------ 63 | 64 | - Include history of changes in online documentation. (`#34`_) 65 | - Write documentation about why and how the project was built. (`#34`_) 66 | - Add section about contributing documentation. (`#34`_) 67 | 68 | .. _#34: https://github.com/jambonrose/django-improved-user/pull/34 69 | 70 | 0.5.2 (2017-08-27) 71 | ------------------ 72 | 73 | - Change `package PyPI`_ license identifier for better information on 74 | `djangopackages.org detail page`_. See 75 | `djangopackages/djangopackages#483`_ for more information. 76 | 77 | .. _package PyPI: https://pypi.org/project/django-improved-user/ 78 | .. _djangopackages.org detail page: https://djangopackages.org/packages/p/django-improved-user/ 79 | .. _djangopackages/djangopackages#483: https://github.com/djangopackages/djangopackages/issues/483 80 | 81 | 0.5.1 (2017-08-27) 82 | ------------------ 83 | 84 | - Docfix: Remove links to ReadTheDocs Stable version from ReadMe, as we 85 | are unable to build that version until v1.0.0 release. See 86 | `rtfd/readthedocs.org#2032`_ for more information. (`#31`_) 87 | 88 | .. _rtfd/readthedocs.org#2032: https://github.com/readthedocs/readthedocs.org/issues/2032 89 | .. _#31: https://github.com/jambonrose/django-improved-user/pull/31 90 | 91 | 0.5.0 (2017-08-26) 92 | ------------------ 93 | 94 | - Provide documentation for the package. This includes Sphinx 95 | documentation hosted on ReadTheDocs.org, (`#26`_, `#29`_), but also 96 | documents to help contribute to github more easily (`#26`_) as well as 97 | a code of conduct (`#26`_). The Read Me includes badges (`#26`_). 98 | - In the event the documentation isn't enough, the project now includes 99 | an example project demonstrating integration of django-improved-user 100 | with Django as well as django-registration. (`#28`_) This content is 101 | used to create some of the documentation (`#29`_). 102 | - Bugfix: The ``UserManager`` was setting the ``last_login`` attribute 103 | of new users at creation time. Reported in `#25`_, fixed in `#27`_ 104 | (``last_login`` is ``None`` until the user actually logs in). 105 | 106 | .. _#25: https://github.com/jambonrose/django-improved-user/issues/25 107 | .. _#26: https://github.com/jambonrose/django-improved-user/pull/26 108 | .. _#27: https://github.com/jambonrose/django-improved-user/pull/27 109 | .. _#28: https://github.com/jambonrose/django-improved-user/pull/28 110 | .. _#29: https://github.com/jambonrose/django-improved-user/pull/29 111 | 112 | 0.4.0 (2017-08-14) 113 | ------------------ 114 | 115 | **Warning**: This is a **breaking change**, and migrations will conflict 116 | with v0.3.0 due to PR `#23`_ 117 | 118 | - Add ``UserFactory`` to make testing easier for developers using the 119 | package; requires factory-boy (PR `#20`_) 120 | - Split the ``ImprovedIdentityMixin`` class into atomic parts: 121 | ``DjangoIntegrationMixin``, ``FullNameMixin``, ``ShortNameMixin``, 122 | ``EmailAuthMixin``. This allows developers to create their own custom 123 | ``AbstractUsers`` if needed. (PR `#22`_) 124 | - Change ``blank`` to ``True`` on ``short_name`` field of User model. 125 | (**Breaking change!** PR `#23`_). 126 | 127 | .. _#20: https://github.com/jambonrose/django-improved-user/pull/20 128 | .. _#22: https://github.com/jambonrose/django-improved-user/pull/22 129 | .. _#23: https://github.com/jambonrose/django-improved-user/pull/23 130 | 131 | 0.3.0 (2017-08-10) 132 | ------------------ 133 | 134 | - Integrate coverage and codecov service (PR `#16`_) 135 | - Make TravisCI test builds public (first seen in PR `#16`_) 136 | - Merge appropriate tests from Django master (1.11.3 is current release 137 | at time of writing). This increases test coverage across the board and 138 | updates the test suite to check for parity between Django's User API 139 | and Improved User's API as well as check for the same security issues. 140 | (PR `#18`_) 141 | - UserManager raises a friendly error if the developer tries to pass a 142 | username argument (PR `#18`_) 143 | - Password errors are shown above both password fields 144 | (PR `#18`_) 145 | - Bugfix: UserManager handles is_staff, is_active, and is_superuser 146 | correctly (PR `#18`_) 147 | - Bugfix: User has email normalized during Model.clean phase (PR `#18`_) 148 | - Bugfix: UserAdmin requires short_name in both add and change 149 | (previously only in change; PR `#18`_) 150 | - Bugfix: UserAdmin uses correct relative path URL for password change 151 | in all versions of Django (was not working in Django 1.9+) (PR `#18`_) 152 | - Bugfix: Runtests correctly handles test specification (PR `#18`_) 153 | 154 | .. _#16: https://github.com/jambonrose/django-improved-user/pull/16 155 | .. _#18: https://github.com/jambonrose/django-improved-user/pull/18 156 | 157 | 0.2.0 (2017-07-30) 158 | ------------------ 159 | 160 | - Reorganize project to follow best practices (PR `#9`_) 161 | - Allow setup.py to run tests by overriding test command (PR `#9`_) 162 | - Test locally with Tox (PR `#10`_) 163 | - Remove Django 1.9 from supported versions (PR `#10`_) 164 | - Enforce styleguide with flake8, isort, and pylint. 165 | Use flake8-commas and flake8-quotes to enhance flake8. 166 | Override default distutils check command to check package metadata. 167 | Use check-manifest to check contents of MANIFEST.in (PR `#11`_) 168 | - Integrate pyup.io into project (PR `#12`_) 169 | - Upgrade flake8 to version 3.4.1 (PR `#13`_) 170 | - Make release and distribution less painful with 171 | bumpversion package and a Makefile (PR `#15`_) 172 | - Add HISTORY.rst file to provide change log (PR `#15`_) 173 | 174 | .. _#9: https://github.com/jambonrose/django-improved-user/pull/9 175 | .. _#10: https://github.com/jambonrose/django-improved-user/pull/10 176 | .. _#11: https://github.com/jambonrose/django-improved-user/pull/11 177 | .. _#12: https://github.com/jambonrose/django-improved-user/pull/12 178 | .. _#13: https://github.com/jambonrose/django-improved-user/pull/13 179 | .. _#15: https://github.com/jambonrose/django-improved-user/pull/15 180 | 181 | 0.1.1 (2017-06-28) 182 | ------------------ 183 | 184 | - Fix metadata in setup.py for warehouse 185 | (see https://github.com/pypi/warehouse/issues/2155 and PR `#8`_) 186 | 187 | .. _#8: https://github.com/jambonrose/django-improved-user/pull/8 188 | 189 | 0.1.0 (2017-06-28) 190 | ------------------ 191 | 192 | - Add tests for Django 1.11 (PR `#5`_) 193 | - Allow for integration with UserAttributeSimilarityValidator 194 | (see https://code.djangoproject.com/ticket/28127, 195 | https://github.com/django/django/pull/8408, and PR `#5`_) 196 | - Rename project django-improved-user (from django-simple-user) 197 | - Make development default branch (PR `#6`_) 198 | - Initial public release (PR `#7`_) 199 | - Use Simplified BSD License instead of Revised BSD License (`#7`_) 200 | 201 | .. _#5: https://github.com/jambonrose/django-improved-user/pull/5 202 | .. _#6: https://github.com/jambonrose/django-improved-user/pull/6 203 | .. _#7: https://github.com/jambonrose/django-improved-user/pull/7 204 | 205 | 0.0.1 (2016-10-26) 206 | ------------------ 207 | 208 | - Simplified User model for better international handling. 209 | Includes forms and admin configuration (PR `#1`_) 210 | - All tests run on TravisCI (PR `#3`_) 211 | - Compatible with: 212 | - Python 3.4, 3.5, 3.6 213 | - Django 1.8 through 1.10 (PR `#3`_ and `#4`_) 214 | 215 | .. _#1: https://github.com/jambonrose/django-improved-user/pull/1 216 | .. _#3: https://github.com/jambonrose/django-improved-user/pull/3 217 | .. _#4: https://github.com/jambonrose/django-improved-user/pull/4 218 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2016-2024 Andrew Pinkham 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | exclude .editorconfig 2 | exclude .ignore 3 | exclude .pre-commit-config.yaml 4 | exclude .prettierignore 5 | exclude .prettierrc.toml 6 | exclude .readthedocs.yaml 7 | exclude codecov.yml 8 | exclude Makefile 9 | include .bumpversion.cfg 10 | include .coveragerc 11 | include .flake8 12 | include .isort.cfg 13 | include .pylintrc 14 | include *.md 15 | include *.rst 16 | include doc-requirements.txt 17 | include LICENSE 18 | include requirements.txt 19 | include runtests.py 20 | include setup.cfg 21 | include tox.ini 22 | prune .github 23 | prune docs 24 | prune example_extension_project 25 | prune example_integration_project 26 | prune example_replacement_project 27 | recursive-include tests *.json 28 | recursive-include tests *.py 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Please type "make help" in your terminal for a list of make targets. 2 | 3 | # DIU => Django Improved User 4 | # DIU_VENV is the name of directory to store the virtual environment 5 | DIU_VENV ?= .venv 6 | ROOT_PYTHON ?= python3 7 | DIU_PYTHON ?= $(DIU_VENV)/bin/python3 8 | DIU_COV ?= $(DIU_VENV)/bin/coverage 9 | 10 | .DEFAULT_GOAL:=help 11 | 12 | $(DIU_VENV)/bin/activate: 13 | mkdir -p $(DIU_VENV) 14 | $(ROOT_PYTHON) -m venv $(DIU_VENV) 15 | $(DIU_PYTHON) -m pip install --upgrade pip setuptools wheel 16 | $(DIU_PYTHON) -m pip install -r requirements.txt 17 | $(DIU_PYTHON) -m pip install -r doc-requirements.txt 18 | $(DIU_PYTHON) -m flit install --symlink 19 | 20 | .PHONY: build ## Build artifacts meant for distribution 21 | build: $(DIU_VENV)/bin/activate 22 | $(DIU_PYTHON) -m flit build 23 | 24 | .PHONY: release ## Upload build artifacts to PyPI 25 | release: $(DIU_VENV)/bin/activate 26 | git tag v`$(DIU_PYTHON) -m bumpversion --dry-run --list --new-version 0.0.0 patch | grep current | cut -d'=' -f 2` 27 | @echo "Verify new tag has been created. If the tag looks correct, push to git with: git push --tags ." 28 | 29 | .PHONY: test ## Run test suite for current environment 30 | test: $(DIU_VENV)/bin/activate 31 | $(DIU_PYTHON) -V 32 | $(DIU_PYTHON) -m pip -V 33 | $(DIU_COV) erase 34 | $(DIU_COV) run runtests.py 35 | $(DIU_COV) combine --append 36 | $(DIU_COV) report 37 | 38 | .PHONY: tox ## Run test suite in different envs via tox 39 | tox: $(DIU_VENV)/bin/activate 40 | $(DIU_VENV)/bin/tox 41 | 42 | .PHONY: clean ## Remove build, deploy, and test artifacts 43 | clean: 44 | rm -rf dist 45 | rm -rf example*_project/db.sqlite3 46 | rm -rf htmlcov 47 | rm -rf src/*.egg-info 48 | rm -rf src/*.eggs 49 | find -X . \( -path '*/.tox/*' -o -path '*/.git/*' -o -path '*/$(DIU_VENV)/*' \) -prune -o \( -name "*.py[co]" -type f -print0 \) | xargs -0 -I {} rm '{}' 50 | find -X . \( -path '*/.tox/*' -o -path '*/.git/*' -o -path '*/$(DIU_VENV)/*' \) -prune -o \( -name ".coverage" -type f -print0 \) | xargs -0 -I {} rm '{}' 51 | find -X . \( -path '*/.tox/*' -o -path '*/.git/*' -o -path '*/$(DIU_VENV)/*' \) -prune -o \( -name ".coverage.*" -type f -print0 \) | xargs -0 -I {} rm '{}' 52 | find -X . \( -path '*/.tox/*' -o -path '*/.git/*' -o -path '*/$(DIU_VENV)/*' \) -prune -o \( -name "__pycache__" -type d -print0 \) | xargs -0 -I {} rm -r '{}' 53 | 54 | .PHONY: purge ## Clean + remove virtual environment 55 | purge: clean 56 | rm -rf .tox 57 | rm -rf $(DIU_VENV) 58 | 59 | .PHONY: help ## List make targets with description 60 | help: 61 | @printf "\nUsage: make \nExample: make serve\n\nTargets:\n" 62 | @grep '^.PHONY: .* #' Makefile | sed 's/\.PHONY: \(.*\) ## \(.*\)/ \1 \2/' | expand -t12 63 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Latest Release: |Version| |Tag| 2 | 3 | Documentation: |StableDocs| 4 | 5 | Compatibility: |Python| |Django| |License| |Black| 6 | 7 | Tests: |Pre-commit| |Coverage| 8 | 9 | .. |Version| image:: https://img.shields.io/pypi/v/django-improved-user 10 | :target: https://pypi.org/project/django-improved-user/ 11 | :alt: PyPI Version 12 | 13 | .. |Tag| image:: https://img.shields.io/github/v/tag/jambonrose/django-improved-user 14 | :target: https://github.com/jambonrose/django-improved-user/releases 15 | :alt: Github Tag 16 | 17 | .. |StableDocs| image:: https://readthedocs.org/projects/django-improved-user/badge/?version=stable 18 | :target: https://django-improved-user.readthedocs.io/en/stable/?badge=stable 19 | :alt: Stable Documentation Status 20 | 21 | .. |Python| image:: https://img.shields.io/pypi/pyversions/django-improved-user 22 | :target: https://pypi.org/project/django-improved-user/ 23 | :alt: Python Support 24 | 25 | .. |Django| image:: https://img.shields.io/pypi/frameworkversions/django/django-improved-user 26 | :target: https://pypi.org/project/django-improved-user/ 27 | :alt: Django Support 28 | 29 | .. |License| image:: https://img.shields.io/pypi/l/django-improved-user 30 | :target: http://opensource.org/licenses/BSD-2-Clause 31 | :alt: License 32 | 33 | .. |Pre-commit| image:: https://results.pre-commit.ci/badge/github/jambonrose/django-improved-user/development.svg 34 | :target: https://results.pre-commit.ci/latest/github/jambonrose/django-improved-user/development 35 | :alt: pre-commit.ci status 36 | 37 | .. |Coverage| image:: https://codecov.io/gh/jambonrose/django-improved-user/branch/development/graph/badge.svg 38 | :target: https://codecov.io/gh/jambonrose/django-improved-user 39 | :alt: Coverage Status 40 | 41 | .. |Black| image:: https://img.shields.io/badge/code%20style-black-000000 42 | :target: https://github.com/psf/black 43 | 44 | .. end-badges 45 | 46 | Read Me 47 | ======= 48 | 49 | This project provides a custom user model that improves on Django's 50 | default by making a few modern and international changes. 51 | 52 | * Uses email as the username to simplify login for users 53 | * Replace :code:`first_name` and :code:`last_name` with international 54 | friendly :code:`short_name` and :code:`full_name` fields 55 | 56 | The project also provides mix-in classes to make building custom User 57 | models easier. 58 | 59 | For an explanation of why and how the project was built, please see the 60 | `Project Rationale`_. 61 | 62 | For information about getting started, please refer to the `quickstart 63 | documentation`_. 64 | 65 | For information about how to help with the project, please see the 66 | `contributing documentation`_. 67 | 68 | .. _contributing documentation: https://django-improved-user.readthedocs.io/en/latest/contributing.html 69 | .. _Project Rationale: https://django-improved-user.readthedocs.io/en/latest/rationale.html 70 | .. _quickstart documentation: https://django-improved-user.readthedocs.io/en/latest/quickstart.html 71 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 0 3 | round: down 4 | status: 5 | patch: 6 | default: 7 | target: 100% 8 | -------------------------------------------------------------------------------- /doc-requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=4.2,<4.3 2 | factory-boy==3.3.0 3 | Faker==26.1.0 4 | Pygments==2.18.0 5 | python-dateutil==2.9.0.post0 6 | Sphinx==7.4.7 7 | sphinx-rtd-theme==2.0.0 8 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = DjangoImprovedUser 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | LINKCHECKDIR = build/linkcheck 12 | 13 | .PHONY: checklinks 14 | checklinks: 15 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(LINKCHECKDIR) 16 | @echo 17 | @echo "Check finished. Report is in $(LINKCHECKDIR)." 18 | 19 | # Put it first so that "make" without argument is like "make help". 20 | help: 21 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 22 | 23 | .PHONY: help Makefile 24 | 25 | # Catch-all target: route all unknown targets to Sphinx using the new 26 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 27 | %: Makefile 28 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 29 | -------------------------------------------------------------------------------- /docs/admin_usage.rst: -------------------------------------------------------------------------------- 1 | ############################################### 2 | How To: Use the Django Admin with Improved User 3 | ############################################### 4 | 5 | Django Improved User defines an admin panel for the 6 | :py:class:`~improved_user.models.User` model provided by the package. 7 | 8 | The admin panel is used automatically if you are integrating directly 9 | with the package (see :doc:`select_configuration_method` for more 10 | information about different uses, and :doc:`integration` for 11 | instructions on direct integration). 12 | 13 | If you are extending the User model with **no changes** (as shown in the 14 | :doc:`quickstart`), you can simply import the existing admin panel and 15 | use it in your own project. 16 | 17 | .. literalinclude:: ../example_extension_project/user_extension/admin.py 18 | 19 | As noted in the comment in the file above, this method is not desirable 20 | in production contexts. Additionally, it will not work in the event you 21 | are replacing existing fields (as shown in 22 | :doc:`create_custom_user_with_mixins`). 23 | 24 | When using the extension method on a real/production site, or when 25 | replacing existing fields, you will need to build your own admin panel. 26 | Django doesn't supply mechanisms for simple inheritance of other admin 27 | panels, and the package maintainers don't know what fields you're using, 28 | so it's impossible for us to provide an easily extendable or re-usable 29 | admin panel in these scenarios. We encourage you to look at 30 | :py:class:`~improved_user.admin.UserAdmin` for guidance (printed below 31 | for your convenience). 32 | 33 | .. literalinclude:: ../src/improved_user/admin.py 34 | 35 | .. NOTE:: 36 | To allow the class above to be imported in demo situations, the 37 | module is lacking a call to register the :code:`UserAdmin` class. 38 | When you create your own class, you will need code similar to the 39 | snippet below. 40 | 41 | .. code:: python 42 | 43 | from django.contrib import admin 44 | from django.contrib.auth import get_user_model 45 | 46 | User = get_user_model() 47 | admin.site.register(User, NewUserAdmin) 48 | -------------------------------------------------------------------------------- /docs/change_log.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Documentation Build Configuration 3 | 4 | Django Improved User documentation build configuration file, created by 5 | sphinx-quickstart on Thu Aug 17 10:44:16 2017. 6 | 7 | This file is execfile()d with the current directory set to its 8 | containing dir. 9 | 10 | Note that not all possible configuration values are present in this 11 | autogenerated file. 12 | 13 | All configuration values have a default; values that are commented out 14 | serve to show the default. 15 | 16 | If extensions (or modules to document with autodoc) are in another directory, 17 | add these directories to sys.path here. If the directory is relative to the 18 | documentation root, use os.path.abspath to make it absolute, like shown here. 19 | """ 20 | 21 | import inspect 22 | import re 23 | import sys 24 | from operator import attrgetter 25 | from os.path import abspath, join 26 | 27 | import sphinx_rtd_theme # noqa: F401 28 | from django import setup as django_setup 29 | from django.conf import settings as django_settings 30 | from django.utils.encoding import force_str 31 | from django.utils.html import strip_tags 32 | 33 | sys.path.insert(0, abspath(join("..", "src"))) 34 | django_settings.configure( 35 | SECRET_KEY="documentation_secret", 36 | INSTALLED_APPS=[ 37 | "django.contrib.admin", 38 | "django.contrib.auth", 39 | "django.contrib.contenttypes", 40 | "django.contrib.sessions", 41 | "django.contrib.sites", 42 | "improved_user.apps.ImprovedUserConfig", 43 | ], 44 | AUTH_USER_MODEL="improved_user.User", 45 | ) 46 | django_setup() 47 | 48 | 49 | def annotate_field(lines, field, models): 50 | """Add documentation based on Django field data""" 51 | if not hasattr(field, "verbose_name") and not hasattr(field, "help_text"): 52 | return lines 53 | 54 | if field.help_text: 55 | # Decode and strip any html out of the field's help text 56 | help_text = strip_tags(force_str(field.help_text)) 57 | else: 58 | help_text = force_str(field.verbose_name).capitalize() 59 | # Add the model field to the end of the docstring as a param 60 | # using the verbose name as the description 61 | lines.append(f":param {field.attname}: {help_text}") 62 | # Add the field's type to the docstring 63 | if isinstance(field, models.ForeignKey): 64 | to = field.rel.to 65 | lines.append( 66 | ":type %s: %s to :class:`~%s.%s`" 67 | % (field.attname, type(field).__name__, to.__module__, to.__name__) 68 | ) 69 | else: 70 | lines.append(f":type {field.attname}: {type(field).__name__}") 71 | return lines 72 | 73 | 74 | def process_docstring(app, what, name, obj, options, lines): 75 | """Use Model or Form data to improve docstrings""" 76 | # https://djangosnippets.org/snippets/2533/ 77 | # https://gist.github.com/abulka/48b54ea4cbc7eb014308 78 | from django.db import models 79 | from django.forms import BaseForm 80 | 81 | if inspect.isclass(obj) and issubclass(obj, models.Model): 82 | sorted_fields = sorted(obj._meta.get_fields(), key=attrgetter("name")) 83 | primary_fields = [ 84 | field 85 | for field in sorted_fields 86 | if hasattr(field, "primary_key") and field.primary_key is True 87 | ] 88 | regular_fields = [ 89 | field 90 | for field in sorted_fields 91 | if hasattr(field, "primary_key") and field.primary_key is False 92 | ] 93 | 94 | for field in primary_fields: 95 | lines = annotate_field(lines, field, models) 96 | 97 | for field in regular_fields: 98 | lines = annotate_field(lines, field, models) 99 | elif inspect.isclass(obj) and issubclass(obj, BaseForm): 100 | form = obj 101 | for field_name in form.base_fields: 102 | field = form.base_fields[field_name] 103 | if field.help_text: 104 | # Decode and strip any html out of the field's help text 105 | help_text = strip_tags(force_str(field.help_text)) 106 | else: 107 | help_text = force_str(field.label).capitalize() 108 | lines.append(f":param {field_name}: {help_text}") 109 | if field.widget.is_hidden: 110 | lines.append( 111 | ":type %s: (Hidden) %s" 112 | % (field_name, type(field).__name__) 113 | ) 114 | else: 115 | lines.append(f":type {field_name}: {type(field).__name__}") 116 | 117 | # Return the extended docstring 118 | return lines 119 | 120 | 121 | def setup(app): 122 | """Register the docstring processor with sphinx""" 123 | app.connect("autodoc-process-docstring", process_docstring) 124 | app.add_crossref_type( 125 | directivename="setting", 126 | rolename="setting", 127 | indextemplate="pair: %s; setting", 128 | ) 129 | 130 | 131 | # -- General configuration ------------------------------------------------ 132 | 133 | # Add any Sphinx extension module names here, as strings. They can be 134 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 135 | # ones. 136 | extensions = [ 137 | "sphinx.ext.autodoc", 138 | "sphinx.ext.intersphinx", 139 | "sphinx.ext.viewcode", 140 | ] 141 | 142 | intersphinx_mapping = { 143 | "django": ( 144 | "http://docs.djangoproject.com/en/stable/", 145 | "http://docs.djangoproject.com/en/stable/_objects/", 146 | ), 147 | } 148 | 149 | # Add any paths that contain templates here, relative to this directory. 150 | templates_path = ["_templates"] 151 | 152 | # The suffix(es) of source filenames. 153 | # You can specify multiple suffix as a list of string: 154 | # 155 | # source_suffix = ['.rst', '.md'] 156 | source_suffix = ".rst" 157 | 158 | # The master toctree document. 159 | master_doc = "index" 160 | 161 | # General information about the project. 162 | project = "Django Improved User" 163 | copyright = "2016-2024 Andrew Pinkham" 164 | author = "Russell Keith-Magee, Andrew Pinkham" 165 | 166 | # The version info for the project you're documenting, acts as replacement for 167 | # |version| and |release|, also used in various other places throughout the 168 | # built documents. 169 | # 170 | # The short X.Y version. 171 | version = "2.0" 172 | # The full version, including alpha/beta/rc tags. 173 | release = "2.1.0" 174 | 175 | # The language for content autogenerated by Sphinx. Refer to documentation 176 | # for a list of supported languages. 177 | # 178 | # This is also used if you do content translation via gettext catalogs. 179 | # Usually you set "language" from the command line for these cases. 180 | language = "en" 181 | 182 | # List of patterns, relative to source directory, that match files and 183 | # directories to ignore when looking for source files. 184 | # This list of exclusions will affect the files found in the paths 185 | # specified in html_static_path and html_extra_path 186 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 187 | 188 | # The name of the Pygments (syntax highlighting) style to use. 189 | pygments_style = "sphinx" 190 | 191 | # If true, `todo` and `todoList` produce output, else they produce nothing. 192 | todo_include_todos = False 193 | 194 | 195 | # -- Options for HTML output ---------------------------------------------- 196 | 197 | # The theme to use for HTML and HTML Help pages. See the documentation for 198 | # a list of built-in themes. 199 | # 200 | html_theme = "sphinx_rtd_theme" 201 | 202 | # Theme options are theme-specific and customize the look and feel of a theme 203 | # further. For a list of options available for each theme, see the 204 | # documentation. 205 | # 206 | # html_theme_options = {} 207 | 208 | # Add any paths that contain custom static files (such as style sheets) here, 209 | # relative to this directory. They are copied after the built-in static files, 210 | # so a file named "default.css" will overwrite the built-in "default.css". 211 | # html_static_path = ['_static'] 212 | 213 | # Custom sidebar templates, must be a dictionary that maps document names 214 | # to template names. 215 | # 216 | # This is required for the alabaster theme 217 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 218 | html_sidebars = { 219 | "**": [ 220 | "about.html", 221 | "navigation.html", 222 | "relations.html", # needs 'show_related': True theme option to display 223 | "searchbox.html", 224 | "donate.html", 225 | ], 226 | } 227 | 228 | 229 | # -- Options for HTMLHelp output ------------------------------------------ 230 | 231 | # Output file base name for HTML help builder. 232 | htmlhelp_basename = "DjangoImprovedUserdoc" 233 | 234 | 235 | # -- Options for LaTeX output --------------------------------------------- 236 | 237 | latex_elements = { 238 | # The paper size ('letterpaper' or 'a4paper'). 239 | # 240 | # 'papersize': 'letterpaper', 241 | # The font size ('10pt', '11pt' or '12pt'). 242 | # 243 | # 'pointsize': '10pt', 244 | # Additional stuff for the LaTeX preamble. 245 | # 246 | # 'preamble': '', 247 | # Latex figure (float) alignment 248 | # 249 | # 'figure_align': 'htbp', 250 | } 251 | 252 | # Grouping the document tree into LaTeX files. List of tuples 253 | # (source start file, target name, title, 254 | # author, documentclass [howto, manual, or own class]). 255 | latex_documents = [ 256 | ( 257 | master_doc, 258 | "DjangoImprovedUser.tex", 259 | "Django Improved User Documentation", 260 | "Russell Keith-Magee, Andrew Pinkham", 261 | "manual", 262 | ), 263 | ] 264 | 265 | 266 | # -- Options for manual page output --------------------------------------- 267 | 268 | # One entry per manual page. List of tuples 269 | # (source start file, name, description, authors, manual section). 270 | man_pages = [ 271 | ( 272 | master_doc, 273 | "djangoimproveduser", 274 | "Django Improved User Documentation", 275 | [author], 276 | 1, 277 | ), 278 | ] 279 | 280 | 281 | # -- Options for Texinfo output ------------------------------------------- 282 | 283 | # Grouping the document tree into Texinfo files. List of tuples 284 | # (source start file, target name, title, author, 285 | # dir menu entry, description, category) 286 | texinfo_documents = [ 287 | ( 288 | master_doc, 289 | "DjangoImprovedUser", 290 | "Django Improved User Documentation", 291 | author, 292 | "DjangoImprovedUser", 293 | "A custom Django user that authenticates via email." # no comma! 294 | "Follows authentication best practices.", 295 | "Miscellaneous", 296 | ), 297 | ] 298 | 299 | # -- Linkcheck configuration ----------------------------------------------- 300 | 301 | linkcheck_ignore = [re.compile(r"https://djangopackages\.org/.*")] 302 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/create_custom_user_with_mixins.rst: -------------------------------------------------------------------------------- 1 | ######################################### 2 | How To: Create a Custom User using Mixins 3 | ######################################### 4 | 5 | .. WARNING:: 6 | This configuration method is but one of three, and may not make the 7 | most sense for your project. Please read 8 | :doc:`select_configuration_method` before continuing, or else follow 9 | the instructions in :doc:`quickstart`. 10 | 11 | The :py:class:`~improved_user.models.User` and 12 | :py:class:`~improved_user.model_mixins.AbstractUser` classes supplied by 13 | the package are not always what you want. In some cases, they may supply 14 | fields you do not need or wish for. This tutorial demonstrates how to 15 | create :code:`User` models using the provided mix-in classes, 16 | effectively building the model from scratch. 17 | 18 | In this tutorial, we will create a new custom User that has an email 19 | field and password, but which does not feature either the ``short_name`` 20 | or ``full_name`` fields. 21 | 22 | .. WARNING:: 23 | Not supplying methods for names on the User model will cause 24 | problems with Django's Admin. 25 | 26 | .. TIP:: 27 | If you're looking to extend the 28 | :py:class:`~improved_user.models.User` model, rather than replace 29 | it as shown in this tutorial, use the following steps: 30 | 31 | 1. inherit :py:class:`~improved_user.model_mixins.AbstractUser` 32 | (follow the instructions in :doc:`quickstart` to see how) 33 | 2. add new fields as desired 34 | 3. override 35 | :attr:`~django.contrib.auth.models.CustomUser.REQUIRED_FIELDS` 36 | if necessary (remembering to put ``'short_name', 37 | 'full_name'`` in the list) 38 | 39 | In an existing app, in the ``models.py`` file, we start by importing the 40 | tools we need to build the model. We first import classes from Django. 41 | 42 | .. literalinclude:: ../example_replacement_project/user_replacement/models.py 43 | :lines: 2-3 44 | 45 | :py:class:`~django.contrib.auth.models.AbstractBaseUser` and 46 | :py:class:`~django.contrib.auth.models.PermissionsMixin` will serve as a 47 | base for the User (click the classes in this sentence to see Django's 48 | official documentation on the subject). We also import 49 | :py:func:`~django.utils.translation.gettext_lazy` to enable translation 50 | of our strings. 51 | 52 | We then import mix-in classes from Improved User. 53 | 54 | .. literalinclude:: ../example_replacement_project/user_replacement/models.py 55 | :lines: 5-6 56 | 57 | The :py:class:`~improved_user.model_mixins.DjangoIntegrationMixin` class 58 | provides fields that allow the model to integrate with Django's default 59 | Authentication Backend as well as a field to allow for integration with 60 | Django's Admin. 61 | 62 | The :py:class:`~improved_user.model_mixins.EmailAuthMixin` creates an 63 | :py:class:`~django.db.models.EmailField` and sets the field to be used 64 | as the username during the authentication process. 65 | 66 | The :py:class:`~improved_user.managers.UserManager` is a custom model 67 | manager that provides the 68 | :py:meth:`~improved_user.managers.UserManager.create_user` and 69 | :py:meth:`~improved_user.managers.UserManager.create_superuser` methods 70 | used in Django. 71 | 72 | .. DANGER:: 73 | Improved Users' custom 74 | :py:class:`~improved_user.managers.UserManager` is intended to work 75 | with subclasses of :py:class:`~improved_user.model_mixins.EmailAuthMixin`, 76 | and will likely not work with your User subclass if you are using a 77 | different field for your username. You will, in that case, need to 78 | create your own ``UserManager``. The source code for Improved Users' 79 | :py:class:`~improved_user.managers.UserManager` as well as Django's 80 | :py:class:`~django.contrib.auth.models.BaseUserManager` and 81 | :class:`~django.contrib.auth.models.UserManager` would likely prove 82 | helpful. 83 | 84 | .. NOTE:: 85 | If you wanted to create a User model with a field other than email 86 | for username, you would set the 87 | :attr:`~django.contrib.auth.models.CustomUser.USERNAME_FIELD` on 88 | your User model to the name of the field that should serve as the 89 | username. Please take a look at the source of 90 | :py:class:`~improved_user.model_mixins.EmailAuthMixin` for an example of 91 | this. 92 | 93 | With all our tools in place, we can now create a User model. We start by 94 | creating a class that inherits all of the classes we have imported, and 95 | then we tie the :py:class:`~improved_user.managers.UserManager` to the 96 | new model. 97 | 98 | 99 | .. literalinclude:: ../example_replacement_project/user_replacement/models.py 100 | :lines: 9-15 101 | 102 | For good measure, we can specify the name and verbose name of the model, 103 | making sure to internationalize the strings. Our full and final 104 | ``models.py`` file is shown below. 105 | 106 | .. literalinclude:: ../example_replacement_project/user_replacement/models.py 107 | 108 | .. TIP:: 109 | Setting ``abstract = True`` in the ``Meta`` class would allow the 110 | class above to be an AbstractUser model similar to 111 | :py:class:`~improved_user.model_mixins.AbstractUser` 112 | 113 | For all of the classes you may use to create your own :code:`User` 114 | model, please see :mod:`~improved_user.model_mixins`. 115 | -------------------------------------------------------------------------------- /docs/data_migrations.rst: -------------------------------------------------------------------------------- 1 | ############################################ 2 | How To: Use Improved User in Data Migrations 3 | ############################################ 4 | 5 | Creating users in :ref:`data migrations ` is 6 | **discouraged** as doing so represents a potential **security risk**, as 7 | passwords are stored in plaintext in the migration. However, doing so in 8 | proof-of-concepts or in special cases may be necessary, and the steps 9 | below will demonstrate how to create and remove new users in a Django 10 | data migration. 11 | 12 | The ``django-improved-user`` package intentionally disallows use of 13 | :py:class:`~improved_user.managers.UserManager` in data migrations (we 14 | forgo the :ref:`use of model managers in migrations 15 | `). The 16 | :py:meth:`~improved_user.managers.UserManager.create_user` and 17 | :py:meth:`~improved_user.managers.UserManager.create_superuser` methods 18 | are thus both unavailable when using data migrations. Both of these 19 | methods rely on :py:class:`~improved_user.models.User` model methods 20 | which are unavailable in :ref:`django:historical-models`, so we could 21 | not use them even if we wanted to (short of refactoring large parts of 22 | code currently inherited by Django). 23 | 24 | We therefore rely on the standard 25 | :py:class:`~django:django.db.models.Manager`, and supplement the 26 | password creation behavior. 27 | 28 | In an existing Django project, you will start by creating a new and 29 | empty migration file. Replace :code:`APP_NAME` in the command below with the 30 | name of the app for which you wish to create a migration. 31 | 32 | .. code:: console 33 | 34 | $ python manage.py makemigrations --empty --name=add_user APP_NAME 35 | 36 | We start by importing the necessary tools 37 | 38 | .. literalinclude:: ../example_integration_project/user_integration/migrations/0001_add_user.py 39 | :lines: 1-3 40 | 41 | We will use :py:class:`~django.db.migrations.operations.RunPython` to 42 | run our code. :py:class:`~django.db.migrations.operations.RunPython` 43 | expects two functions with specific parameters. Our first function 44 | creates a new user. 45 | 46 | .. literalinclude:: ../example_integration_project/user_integration/migrations/0001_add_user.py 47 | :pyobject: add_user 48 | 49 | **NB**: Due to the lack of 50 | :py:class:`~improved_user.managers.UserManager` or 51 | :py:class:`~improved_user.models.User` methods, the :code:`email` field 52 | is not validated or normalized. What's more, the :code:`password` field 53 | is not validated against the project's password validators. **It is up 54 | to the developer coding the migration file to provide proper values.** 55 | 56 | The second function is technically optional, but providing one makes our 57 | lives easier and is considered best-practice. This function undoes the 58 | first, and deletes the user we created. 59 | 60 | .. literalinclude:: ../example_integration_project/user_integration/migrations/0001_add_user.py 61 | :pyobject: remove_user 62 | 63 | Finally, we use our migration functions via 64 | :py:class:`~django.db.migrations.operations.RunPython` in a 65 | ``django.db.migrations.Migration`` subclass. Please note the *addition* 66 | of the dependency below. If your file already had a dependency, please 67 | add the tuple below, but do not remove the existing tuple(s). 68 | 69 | .. literalinclude:: ../example_integration_project/user_integration/migrations/0001_add_user.py 70 | :pyobject: Migration 71 | 72 | The final migration file is printed in totality below. 73 | 74 | .. literalinclude:: ../example_integration_project/user_integration/migrations/0001_add_user.py 75 | :linenos: 76 | 77 | You may wish to read more about :ref:`Django Data Migrations 78 | ` and 79 | :py:class:`~django.db.migrations.operations.RunPython`. 80 | -------------------------------------------------------------------------------- /docs/email_warning.rst: -------------------------------------------------------------------------------- 1 | #################################### 2 | Warning about Email Case-Sensitivity 3 | #################################### 4 | 5 | `RFC 5321`_ states that the ``mailbox`` in ``mailbox@hostname`` of an 6 | email format is case-sensitive. ``ANDREW@example.com`` and 7 | ``andrew@example.com`` are therefore different email addresses (the 8 | domain is case-insensitive). 9 | 10 | Django's :class:`~django.db.models.EmailField` follows the RFC, and so, 11 | therefore, does Improved User. 12 | 13 | Today, many email providers have made their email systems 14 | case-insensitive. However, not all providers have done so. As such, if 15 | we were to provide a custom case-insensitive ``EmailField``, we may be 16 | alienating your users without you even knowing! 17 | 18 | What's more, we follow the RFC because not doing so can `cause obscure 19 | security issues`_. 20 | 21 | When creating your project's templates, we recommend reminding your 22 | users that their emails *may* be case-sensitive, and that the username 23 | on this site is definitely case-sensitive. 24 | 25 | Even if email case-sensitivity becomes a problem on your site, we 26 | recommend you continue to use case-sensitive email fields so that you 27 | retain case-sensitive data. Instead, rely on case-insensitive selection 28 | and filtering to find and authenticate users (lowercase database indexes 29 | can make this quite fast). These decisions and code are outside the 30 | scope of this project and we therefore do not provide any work on this 31 | front. 32 | 33 | .. _`RFC 5321`: https://www.rfc-editor.org/rfc/rfc5321.txt 34 | .. _`cause obscure security issues`: https://www.schneier.com/blog/archives/2018/04/obscure_e-mail_.html 35 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ====================================== 2 | Documentation for Django Improved User 3 | ====================================== 4 | 5 | Welcome! Below you will find the table of contents for Improved User. 6 | 7 | If you're in a rush, head over to :doc:`quickstart`. 8 | 9 | If you're new and want to see what your options are, please read 10 | :doc:`select_configuration_method`. 11 | 12 | The documentation you are viewing covers Django Improved User 2.1, 13 | compatible with Django 2.2, 3.0, 3.1, 3.2, 4.0, 4.1, 4.2. 14 | 15 | .. toctree:: 16 | :caption: Contents: 17 | :glob: 18 | :maxdepth: 3 19 | 20 | quickstart 21 | quickstart_contrib 22 | rationale 23 | select_configuration_method 24 | email_warning 25 | integration 26 | create_custom_user_with_mixins 27 | data_migrations 28 | admin_usage 29 | contributing 30 | source/modules 31 | change_log 32 | 33 | 34 | 35 | 36 | Indices and tables 37 | ================== 38 | 39 | * :ref:`genindex` 40 | * :ref:`modindex` 41 | * :ref:`search` 42 | -------------------------------------------------------------------------------- /docs/integration.rst: -------------------------------------------------------------------------------- 1 | ======================================== 2 | How To: Integrate Improved User Directly 3 | ======================================== 4 | 5 | .. WARNING:: 6 | This configuration method is but one of three, and may not make the 7 | most sense for your project. Please read 8 | :doc:`select_configuration_method` before continuing, or else follow 9 | the instructions in :doc:`quickstart`. 10 | 11 | In a new Django project, perform the following steps in the 12 | ``settings.py`` file or base settings file. 13 | 14 | 1. Add :code:`improved_user.apps.ImprovedUserConfig` 15 | to :code:`INSTALLED_APPS` 16 | 17 | 2. Define or replace :code:`AUTH_USER_MODEL` with the new model, as 18 | below. 19 | 20 | .. code:: python 21 | 22 | AUTH_USER_MODEL='improved_user.User' 23 | 24 | 3. Change :code:`UserAttributeSimilarityValidator` to match correct 25 | :code:`User` fields, as shown below. 26 | 27 | .. code:: python 28 | 29 | AUTH_PREFIX = 'django.contrib.auth.password_validation.' 30 | AUTH_PASSWORD_VALIDATORS = [ 31 | { 32 | 'NAME': AUTH_PREFIX + 'UserAttributeSimilarityValidator', 33 | 'OPTIONS': { 34 | 'user_attributes': ('email', 'full_name', 'short_name') 35 | }, 36 | }, 37 | # include other password validators here 38 | ] 39 | 40 | .. NOTE:: 41 | Improved user also comes with forms, test factories, and an admin panel. 42 | Take a look at the :doc:`source/modules` for more information. 43 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=DjangoImprovedUser 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | ############################### 2 | Quickstart: Using Improved User 3 | ############################### 4 | 5 | This document provides a quick tutorial for the recommended way to setup 6 | Improved User. 7 | 8 | See :doc:`select_configuration_method` for an overview of options and 9 | tradeoffs. 10 | 11 | .. contents:: 12 | :local: 13 | 14 | ************ 15 | Installation 16 | ************ 17 | 18 | In a Terminal, use :code:`pip` to install the package from `PyPI`_. 19 | To use the :class:`~improved_user.factories.UserFactory` provided 20 | by the package to allow for testing with |factory-boy|_, include it 21 | in the installation. 22 | 23 | .. code:: console 24 | 25 | $ pip install django-improved-user[factory] 26 | 27 | If |factory-boy|_ is unnecessary, it can be omitted by installing normally. 28 | 29 | .. code:: console 30 | 31 | $ pip install django-improved-user 32 | 33 | 34 | 35 | .. _PyPI: https://pypi.org/project/django-improved-user/ 36 | .. _factory-boy: https://github.com/FactoryBoy/factory_boy 37 | .. |factory-boy| replace:: :code:`factory-boy` 38 | 39 | *********************** 40 | Configuration and Usage 41 | *********************** 42 | 43 | 1. In a Django project, create a new app. For the purposes of this 44 | documentation, we will assume the name of your new app is 45 | ``user_app``, but you could name it whatever you wish. 46 | 47 | .. code:: console 48 | 49 | $ python3 manage.py startapp user_app 50 | 51 | 2. In your project's settings, add :code:`user_app.apps.UserAppConfig` to 52 | :code:`INSTALLED_APPS` (replace ``user_app`` and ``UserAppConfig`` 53 | as necessary). 54 | 55 | 3. In ``user_app/models.py``, import Improved User's 56 | :py:class:`~improved_user.model_mixins.AbstractUser`. 57 | 58 | .. literalinclude:: ../example_extension_project/user_extension/models.py 59 | :lines: 5 60 | 61 | 4. Create a new :code:`User` model. If you omit comments, you may need 62 | to add :code:`pass` to the line below the class. 63 | 64 | .. literalinclude:: ../example_extension_project/user_extension/models.py 65 | :lines: 9-10 66 | 67 | .. ATTENTION:: 68 | If you add your own fields to the model, you may wish to modify 69 | :attr:`~django.contrib.auth.models.CustomUser.REQUIRED_FIELDS`. 70 | 71 | 5. Define or replace :setting:`AUTH_USER_MODEL` in your project settings 72 | with the new model, as below (replace :code:`user_app` with the name 73 | of your own app). 74 | 75 | .. code:: python 76 | 77 | AUTH_USER_MODEL='user_app.User' 78 | 79 | .. TIP:: 80 | Remember to use :py:func:`~django.contrib.auth.get_user_model` to 81 | get your new model. Don't import it directly! 82 | 83 | 6. While still in settings, change 84 | :class:`UserAttributeSimilarityValidator` to match correct 85 | :py:class:`~improved_user.model_mixins.AbstractUser` fields, as 86 | shown below. 87 | 88 | .. code:: python 89 | 90 | AUTH_PREFIX = 'django.contrib.auth.password_validation.' 91 | AUTH_PASSWORD_VALIDATORS = [ 92 | { 93 | 'NAME': AUTH_PREFIX + 'UserAttributeSimilarityValidator', 94 | 'OPTIONS': { 95 | 'user_attributes': ('email', 'full_name', 'short_name') 96 | }, 97 | }, 98 | # include other password validators here 99 | ] 100 | 101 | 7. You're done! 🎉 Run migrations or go back to programming the rest 102 | of your project. 103 | 104 | .. NOTE:: 105 | Improved user also comes with forms, test factories, and an admin 106 | panel. Take a look at the :doc:`source/modules` for more 107 | information. 108 | -------------------------------------------------------------------------------- /docs/quickstart_contrib.rst: -------------------------------------------------------------------------------- 1 | ######################## 2 | Quickstart: Contributing 3 | ######################## 4 | 5 | First off, thanks for taking the time to contribute! ✨🎉 6 | 7 | This document assumes that you have forked and cloned the repository to 8 | work on the package locally. If you are unsure how to do this, please 9 | see the :doc:`contributing` documentation. 10 | 11 | To test the package, start by installing it locally. 12 | 13 | .. code:: console 14 | 15 | $ pip install -r requirements.txt 16 | $ python setup.py develop 17 | 18 | To run the test suite on a single version of Django (assuming you have a 19 | version of Django installed), run the ``runtests.py`` script from the 20 | root of the project. 21 | 22 | .. code:: console 23 | 24 | $ python runtests.py 25 | 26 | You can limit tests or pass paramaters as when using ``manage.py test``. 27 | 28 | .. code:: console 29 | 30 | $ ./runtests.py tests.test_basic -v 3 31 | 32 | If you have all of the supported Python versions installed, 33 | you may use ``tox`` to run all linters and test the 34 | package with multiple versions of Python and Django. 35 | 36 | .. code:: console 37 | 38 | $ tox 39 | 40 | You may also limit tests to specific environments or test suites with 41 | tox. For instance: 42 | 43 | .. code:: console 44 | 45 | $ tox -e py311-django42-unit tests.test_basic 46 | $ tox -e py311-django42-integration user_integration.tests.TestViews.test_home 47 | 48 | Any change to the code should first be discussed in an issue. 49 | 50 | For any changes, please create a new branch, make your changes, and open 51 | a pull request on github agains the ``development`` branch. Refer to the 52 | issue you are fixing or building. To make review of the PR easier, 53 | please commit small, targeted changes. Multiple small commits with 54 | clear messages make reviewing changes easier. Rebasing your 55 | branch to help clean up your changes is encouraged. Please remember that 56 | this is a volunteer-driven project; we will look at your PR as soon as 57 | possible. 58 | -------------------------------------------------------------------------------- /docs/rationale.rst: -------------------------------------------------------------------------------- 1 | ################# 2 | Project Rationale 3 | ################# 4 | 5 | While working together in late 2016, `Russell Keith-Magee`_ and `Andrew 6 | Pinkham`_— original authors of the project—discussed the repetitive 7 | nature of rebuilding a best-practices email-based User model in new 8 | Django projects. The two were tired of redoing the same work, and 9 | decided to open-source code based on what they'd learned previously. 10 | 11 | Russell's *Red User, Blue User, MyUser, auth.User* talk from DjangoCon 12 | US 2013 and PyCon AU 2017 (video below) provides a breakdown of the 13 | problems with Django's existing approach to identity-handling, as well 14 | as an introduction to using custom User models in Django. 15 | 16 | .. raw:: html 17 | 18 |
19 | 20 |
21 | 22 | In turn, Andrew wished for more modularity in Django's existing ``auth`` 23 | codebase. Having described the process of creating custom User models in 24 | `his book, Django Unleashed`_, Andrew felt that developers should be 25 | able to import and compose classes to properly integrate with Django's 26 | permissions and/or admin. The two set out to build a project that would: 27 | 28 | 1. provide a User model that authenticates via email (not username) 29 | 2. provide a User model with global identity name-fields (full name and short 30 | name, rather than the limited anglo-centric first and last name) 31 | 3. provide mix-in classes to allow developers to easily compose a new 32 | User model. 33 | 34 | The project is originally based on the app that Russell had built over a 35 | decade of working in Django. Andrew took what he had learned from 36 | `Django Unleashed`_ and his `consulting experience`_, and integrated it 37 | into the project. The result is a :class:`~improved_user.models.User` 38 | model that can be used out of the box (see :doc:`quickstart` for 39 | details) and a set of :doc:`mix-in classes ` to 40 | allow for creation of new User models (notably, the 41 | :class:`~improved_user.model_mixins.DjangoIntegrationMixin`). 42 | 43 | We hope you find our work useful! 44 | 45 | .. _Andrew Pinkham: https://andrewsforge.com 46 | .. _consulting experience: https://www.jambonsw.com 47 | .. _Django Unleashed: https://django-unleashed.com 48 | .. _his book, Django Unleashed: `Django Unleashed`_ 49 | .. _Russell Keith-Magee: https://cecinestpasun.com 50 | -------------------------------------------------------------------------------- /docs/select_configuration_method.rst: -------------------------------------------------------------------------------- 1 | ############################################### 2 | Select a Configuration Method for Improved User 3 | ############################################### 4 | 5 | The goal of this package is to improve your project's User model. To 6 | that end, Improved User may be used in three different ways. You may: 7 | 8 | 1. inherit :py:class:`~improved_user.model_mixins.AbstractUser` 9 | in your own :code:`User` model (**extension**); 10 | 2. use the supplied :py:class:`~improved_user.models.User` model 11 | directly (**integration**); 12 | 3. create your own User model using the supplied model mix-in classes 13 | (**replacement**). 14 | 15 | .. TIP:: 16 | It is generally considered a good idea to change the :code:`User` 17 | model as infrequently and as little as possible, given the 18 | possibility of security problems. Creating a :code:`Profile` 19 | model---which has a foreign key to the :code:`User` model---to store 20 | your users' information can help avoid changes to the :code:`User` 21 | model. 22 | 23 | **************** 24 | Extension Method 25 | **************** 26 | 27 | The extension method is the recommended method to use when configuring 28 | Improved User. Instructions for this method are found in 29 | :doc:`quickstart`. This method gives the developer the most control and 30 | flexibility, at the cost of having slightly extra code. This method is 31 | the least likely to cause you problems in the long run, as it grants you 32 | control of the model fields and migrations for your :code:`User` model, 33 | and gives you the opportunity of entirely removing Improved User in the 34 | future if you need to. 35 | 36 | ****************** 37 | Integration Method 38 | ****************** 39 | 40 | The integration option is the simplest, and uses the least code. 41 | However, it is also the least flexible, as it assumes that you will 42 | never change the structure of the :class:`~improved_user.models.User` 43 | model. While this method may work fine for many, the amount of work 44 | required to deal with any potential future change is very high. In many 45 | ways, it is the most similar to Django's own 46 | :class:`~.django.contrib.auth.models.User`: you gain all of the benefits 47 | of the class directly, but forgo the ability to control or remove the 48 | model in the future without serious work. You may refer to 49 | :doc:`integration` to use this method. 50 | 51 | .. WARNING:: 52 | It will always be possible to switch between the extension and 53 | replacement methods, but is difficult to migrate to or from the 54 | integration method. 55 | 56 | ****************** 57 | Replacement Method 58 | ****************** 59 | 60 | The replacement method comes with the same trade-offs as the extension 61 | method, but should be used in the event any of the fields included in 62 | the :py:class:`~improved_user.model_mixins.AbstractUser` are not 63 | desired. We recommend this method only to those very familiar with 64 | Django. For more information, please refer to 65 | :doc:`create_custom_user_with_mixins`. 66 | -------------------------------------------------------------------------------- /docs/source/admin.rst: -------------------------------------------------------------------------------- 1 | ################## 2 | Django Admin Panel 3 | ################## 4 | 5 | .. automodule:: improved_user.admin 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/source/factories.rst: -------------------------------------------------------------------------------- 1 | ############## 2 | Test Factories 3 | ############## 4 | 5 | .. automodule:: improved_user.factories 6 | :members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/forms.rst: -------------------------------------------------------------------------------- 1 | ##### 2 | Forms 3 | ##### 4 | 5 | .. py:module:: improved_user.forms 6 | 7 | Abstract forms meant to be inherited or concrete forms meant to be 8 | used direction in your views. 9 | 10 | .. NOTE:: 11 | These forms are unnecessary starting in Django 2.1, as Django now 12 | supports custom user models in its own forms. 13 | 14 | .. contents:: 15 | :local: 16 | 17 | **************** 18 | UserCreationForm 19 | **************** 20 | 21 | .. autoclass:: improved_user.forms.UserCreationForm 22 | :members: 23 | :private-members: 24 | :show-inheritance: 25 | 26 | ************** 27 | UserChangeForm 28 | ************** 29 | 30 | .. autoclass:: improved_user.forms.UserChangeForm 31 | :members: 32 | :private-members: 33 | :show-inheritance: 34 | 35 | ************************ 36 | AbstractUserCreationForm 37 | ************************ 38 | 39 | .. autoclass:: improved_user.forms.AbstractUserCreationForm 40 | :members: 41 | :private-members: 42 | :show-inheritance: 43 | 44 | ********************** 45 | AbstractUserChangeForm 46 | ********************** 47 | 48 | .. autoclass:: improved_user.forms.AbstractUserChangeForm 49 | :members: 50 | :private-members: 51 | :show-inheritance: 52 | -------------------------------------------------------------------------------- /docs/source/managers.rst: -------------------------------------------------------------------------------- 1 | ######## 2 | Managers 3 | ######## 4 | 5 | .. py:module:: improved_user.managers 6 | 7 | .. autoclass:: improved_user.managers.UserManager 8 | :members: 9 | :undoc-members: 10 | -------------------------------------------------------------------------------- /docs/source/model_mixins.rst: -------------------------------------------------------------------------------- 1 | #################### 2 | Mix-in Model Classes 3 | #################### 4 | 5 | .. py:module:: improved_user.model_mixins 6 | 7 | These classes are provided as tools to help build your own User models. 8 | 9 | .. contents:: 10 | :local: 11 | 12 | ************ 13 | AbstractUser 14 | ************ 15 | 16 | .. autoclass:: improved_user.model_mixins.AbstractUser 17 | :inherited-members: 18 | :members: 19 | :show-inheritance: 20 | 21 | ********************** 22 | DjangoIntegrationMixin 23 | ********************** 24 | 25 | .. autoclass:: improved_user.model_mixins.DjangoIntegrationMixin 26 | :members: 27 | 28 | ************** 29 | EmailAuthMixin 30 | ************** 31 | 32 | .. autoclass:: improved_user.model_mixins.EmailAuthMixin 33 | :members: 34 | 35 | ************* 36 | FullNameMixin 37 | ************* 38 | 39 | .. autoclass:: improved_user.model_mixins.FullNameMixin 40 | :members: 41 | 42 | ************** 43 | ShortNameMixin 44 | ************** 45 | 46 | .. autoclass:: improved_user.model_mixins.ShortNameMixin 47 | :members: 48 | -------------------------------------------------------------------------------- /docs/source/models.rst: -------------------------------------------------------------------------------- 1 | ################### 2 | Improved User Model 3 | ################### 4 | 5 | .. py:module:: improved_user.models 6 | 7 | .. autoclass:: improved_user.models.User(email, password, short_name=None, full_name=None) 8 | :members: check_password, clean, email_user, get_full_name, get_short_name, 9 | get_username, has_module_perms, has_perm, has_perms, 10 | is_anonymous, is_authenticated, refresh_from_db 11 | :show-inheritance: 12 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | ################# 2 | Package Reference 3 | ################# 4 | 5 | .. contents:: In this Document 6 | :local: 7 | 8 | .. py:module:: improved_user.apps 9 | 10 | ******** 11 | Overview 12 | ******** 13 | 14 | Django Improved User is organized like a regular Django app. 15 | 16 | .. py:class:: improved_user.apps.ImprovedUserConfig 17 | 18 | Reference this class in ``INSTALLED_APPS`` to use the package. 19 | 20 | The package provides both a concerete 21 | :class:`~improved_user.models.User` model, as well as mix-in and 22 | abstract model classes to be used to extend the model or replace it 23 | entirely. Please refer to :doc:`../select_configuration_method` for more 24 | information about how to configure these models to best suit your 25 | purposes. 26 | 27 | The package also provides forms, test factories, and an admin panel. Please 28 | see the reference documentation for these items below. 29 | 30 | Finally, the actual code on `Github`_ has three example projects that may 31 | be helpful if this documentation was not. 32 | 33 | .. _Github: https://github.com/jambonrose/django-improved-user 34 | 35 | ******************* 36 | Reference Documents 37 | ******************* 38 | 39 | .. toctree:: 40 | :glob: 41 | :maxdepth: 2 42 | 43 | models 44 | managers 45 | model_mixins 46 | forms 47 | factories 48 | admin 49 | -------------------------------------------------------------------------------- /example_extension_project/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | omit = 4 | *migrations*, 5 | *tests*, 6 | parallel = true 7 | source = user_extension 8 | plugins = 9 | django_coverage_plugin 10 | 11 | [report] 12 | show_missing = true 13 | precision = 2 14 | -------------------------------------------------------------------------------- /example_extension_project/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/django-improved-user/a943e4cd2edc3354d8ce8e4078d708ea1f61e891/example_extension_project/config/__init__.py -------------------------------------------------------------------------------- /example_extension_project/config/settings.py: -------------------------------------------------------------------------------- 1 | """Settings for example extension project of django-improved-user 2 | 3 | https://docs.djangoproject.com/en/stable/topics/settings/ 4 | https://docs.djangoproject.com/en/stable/ref/settings/ 5 | """ 6 | 7 | import os 8 | 9 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 10 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 11 | 12 | # SECURITY WARNING: keep the secret key used in production secret! 13 | SECRET_KEY = "!8_-4bh_ecnux#)2v%1w+zf+#0(9dm-h=jz0qhsu(qfz8tzh)c" 14 | 15 | # SECURITY WARNING: don't run with debug turned on in production! 16 | DEBUG = True 17 | 18 | ALLOWED_HOSTS = [] 19 | 20 | 21 | # Application definition 22 | 23 | INSTALLED_APPS = [ 24 | "django.contrib.admin", 25 | "django.contrib.auth", 26 | "django.contrib.contenttypes", 27 | "django.contrib.sessions", 28 | "django.contrib.messages", 29 | "django.contrib.staticfiles", 30 | "django_extensions", 31 | "user_extension.apps.UserExtensionConfig", 32 | ] 33 | 34 | MIDDLEWARE = [ 35 | "django.middleware.security.SecurityMiddleware", 36 | "django.contrib.sessions.middleware.SessionMiddleware", 37 | "django.middleware.common.CommonMiddleware", 38 | "django.middleware.csrf.CsrfViewMiddleware", 39 | "django.contrib.auth.middleware.AuthenticationMiddleware", 40 | "django.contrib.messages.middleware.MessageMiddleware", 41 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 42 | ] 43 | 44 | ROOT_URLCONF = "config.urls" 45 | 46 | TEMPLATES = [ 47 | { 48 | "BACKEND": "django.template.backends.django.DjangoTemplates", 49 | "DIRS": [], 50 | "APP_DIRS": True, 51 | "OPTIONS": { 52 | "context_processors": [ 53 | "django.template.context_processors.debug", 54 | "django.template.context_processors.request", 55 | "django.contrib.auth.context_processors.auth", 56 | "django.contrib.messages.context_processors.messages", 57 | ], 58 | "debug": True, 59 | }, 60 | }, 61 | ] 62 | 63 | WSGI_APPLICATION = "config.wsgi.application" 64 | 65 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 66 | 67 | # Database 68 | # https://docs.djangoproject.com/en/stable/ref/settings/#databases 69 | 70 | DATABASES = { 71 | "default": { 72 | "ENGINE": "django.db.backends.sqlite3", 73 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 74 | }, 75 | } 76 | 77 | 78 | # Password validation 79 | # https://docs.djangoproject.com/en/stable/ref/settings/#auth-password-validators 80 | 81 | AUTH_USER_MODEL = "user_extension.User" 82 | 83 | AUTH_PREFIX = "django.contrib.auth.password_validation." 84 | AUTH_PASSWORD_VALIDATORS = [ 85 | { 86 | "NAME": AUTH_PREFIX + "UserAttributeSimilarityValidator", 87 | "OPTIONS": { 88 | "user_attributes": ("email", "full_name", "short_name"), 89 | }, 90 | }, 91 | { 92 | "NAME": AUTH_PREFIX + "MinimumLengthValidator", 93 | "OPTIONS": { 94 | "min_length": 12, 95 | }, 96 | }, 97 | { 98 | "NAME": AUTH_PREFIX + "CommonPasswordValidator", 99 | }, 100 | { 101 | "NAME": AUTH_PREFIX + "NumericPasswordValidator", 102 | }, 103 | ] 104 | 105 | 106 | # Internationalization 107 | # https://docs.djangoproject.com/en/stable/topics/i18n/ 108 | 109 | LANGUAGE_CODE = "en-us" 110 | 111 | TIME_ZONE = "UTC" 112 | 113 | USE_I18N = True 114 | 115 | 116 | USE_TZ = True 117 | 118 | 119 | # Static files (CSS, JavaScript, Images) 120 | # https://docs.djangoproject.com/en/stable/howto/static-files/ 121 | 122 | STATIC_URL = "/static/" 123 | -------------------------------------------------------------------------------- /example_extension_project/config/urls.py: -------------------------------------------------------------------------------- 1 | """Extension project URL Configuration""" 2 | 3 | from django.contrib import admin 4 | 5 | try: 6 | from django.urls import re_path 7 | except ImportError: 8 | from django.conf.urls import url as re_path 9 | 10 | 11 | urlpatterns = [ 12 | re_path(r"^admin/", admin.site.urls), 13 | ] 14 | -------------------------------------------------------------------------------- /example_extension_project/config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for config project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/stable/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example_extension_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Script that allows developers to run Django commands""" 3 | import os 4 | import sys 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError as exc: 11 | raise ImportError( 12 | "Couldn't import Django. Are you sure it's installed and " 13 | "available on your PYTHONPATH environment variable? Did you " 14 | "forget to activate a virtual environment?", 15 | ) from exc 16 | execute_from_command_line(sys.argv) 17 | -------------------------------------------------------------------------------- /example_extension_project/requirements.txt: -------------------------------------------------------------------------------- 1 | django-coverage-plugin==2.0.2 2 | django-extensions==3.1.5 3 | factory-boy==3.2.1 4 | -------------------------------------------------------------------------------- /example_extension_project/user_extension/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/django-improved-user/a943e4cd2edc3354d8ce8e4078d708ea1f61e891/example_extension_project/user_extension/__init__.py -------------------------------------------------------------------------------- /example_extension_project/user_extension/admin.py: -------------------------------------------------------------------------------- 1 | """Demonstrate use of UserAdmin on extended User model""" 2 | 3 | from django.contrib import admin 4 | from django.contrib.auth import get_user_model 5 | 6 | from improved_user.admin import UserAdmin 7 | 8 | User = get_user_model() # pylint: disable=invalid-name 9 | # WARNING 10 | # This works, but note that any additional fields do not appear in the 11 | # Admin. For instance, the User model in this example has a verified 12 | # boolean field added to it, but this field will not appear in the 13 | # admin. Additionally, if the verified field did not have a default, 14 | # creating the User model via the admin panel would be impossible. As 15 | # such, do not use this method in production applications, and instead 16 | # define your own UserAdmin class. 17 | admin.site.register(User, UserAdmin) 18 | -------------------------------------------------------------------------------- /example_extension_project/user_extension/apps.py: -------------------------------------------------------------------------------- 1 | """Application Definition File""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class UserExtensionConfig(AppConfig): 7 | """AppConfig definition for user extension code""" 8 | 9 | name = "user_extension" 10 | default_auto_field = "django.db.models.AutoField" 11 | -------------------------------------------------------------------------------- /example_extension_project/user_extension/factories.py: -------------------------------------------------------------------------------- 1 | """Factories to make testing with Extended User easier 2 | 3 | Demonstrated here to ensure that Improved User Factory can be subclassed. 4 | 5 | """ 6 | 7 | from factory import Faker 8 | 9 | from improved_user.factories import UserFactory as BaseUserFactory 10 | 11 | 12 | # pylint: disable=too-few-public-methods 13 | class UserFactory(BaseUserFactory): 14 | """A subclass of Improved Users' UserFactory""" 15 | 16 | verified = Faker("pybool") # not strictly necessary due to False default 17 | -------------------------------------------------------------------------------- /example_extension_project/user_extension/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | import django.utils.timezone 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | initial = True 8 | 9 | dependencies = [ 10 | ("auth", "0006_require_contenttypes_0002"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="User", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ( 27 | "password", 28 | models.CharField(max_length=128, verbose_name="password"), 29 | ), 30 | ( 31 | "last_login", 32 | models.DateTimeField( 33 | blank=True, null=True, verbose_name="last login" 34 | ), 35 | ), 36 | ( 37 | "is_superuser", 38 | models.BooleanField( 39 | default=False, 40 | help_text=( 41 | "Designates that this user has all permissions " 42 | "without explicitly assigning them." 43 | ), 44 | verbose_name="superuser status", 45 | ), 46 | ), 47 | ( 48 | "is_staff", 49 | models.BooleanField( 50 | default=False, 51 | help_text=( 52 | "Designates whether the user can log into " 53 | "the admin site." 54 | ), 55 | verbose_name="staff status", 56 | ), 57 | ), 58 | ( 59 | "is_active", 60 | models.BooleanField( 61 | default=True, 62 | help_text=( 63 | "Designates whether this user should be treated as " 64 | "active. Unselect this instead of " 65 | "deleting accounts." 66 | ), 67 | verbose_name="active", 68 | ), 69 | ), 70 | ( 71 | "date_joined", 72 | models.DateTimeField( 73 | default=django.utils.timezone.now, 74 | verbose_name="date joined", 75 | ), 76 | ), 77 | ( 78 | "full_name", 79 | models.CharField( 80 | blank=True, max_length=200, verbose_name="full name" 81 | ), 82 | ), 83 | ( 84 | "short_name", 85 | models.CharField( 86 | blank=True, max_length=50, verbose_name="short name" 87 | ), 88 | ), 89 | ( 90 | "email", 91 | models.EmailField( 92 | max_length=254, 93 | unique=True, 94 | verbose_name="email address", 95 | ), 96 | ), 97 | ( 98 | "verified", 99 | models.BooleanField( 100 | default=False, 101 | help_text=( 102 | "Designates whether the user has " 103 | "verified their email." 104 | ), 105 | verbose_name="email verified", 106 | ), 107 | ), 108 | ( 109 | "groups", 110 | models.ManyToManyField( 111 | blank=True, 112 | help_text=( 113 | "The groups this user belongs to. A user will get all" 114 | " permissions granted to each of their groups." 115 | ), 116 | related_name="user_set", 117 | related_query_name="user", 118 | to="auth.Group", 119 | verbose_name="groups", 120 | ), 121 | ), 122 | ( 123 | "user_permissions", 124 | models.ManyToManyField( 125 | blank=True, 126 | help_text="Specific permissions for this user.", 127 | related_name="user_set", 128 | related_query_name="user", 129 | to="auth.Permission", 130 | verbose_name="user permissions", 131 | ), 132 | ), 133 | ], 134 | options={ 135 | "verbose_name": "user", 136 | "verbose_name_plural": "users", 137 | "abstract": False, 138 | }, 139 | ), 140 | ] 141 | -------------------------------------------------------------------------------- /example_extension_project/user_extension/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/django-improved-user/a943e4cd2edc3354d8ce8e4078d708ea1f61e891/example_extension_project/user_extension/migrations/__init__.py -------------------------------------------------------------------------------- /example_extension_project/user_extension/models.py: -------------------------------------------------------------------------------- 1 | """Demonstration of how to extend the Improved User model""" 2 | 3 | from django.db import models 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from improved_user.model_mixins import AbstractUser 7 | 8 | 9 | # pylint: disable=too-many-ancestors 10 | class User(AbstractUser): 11 | """A User model that extends the Improved User""" 12 | 13 | verified = models.BooleanField( 14 | _("email verified"), 15 | default=False, 16 | help_text=_("Designates whether the user has verified their email."), 17 | ) 18 | 19 | def is_verified(self): 20 | """Is the user properly verified?""" 21 | return self.is_active and self.verified 22 | -------------------------------------------------------------------------------- /example_extension_project/user_extension/tests.py: -------------------------------------------------------------------------------- 1 | """Tests to ensure proper subclassing of models, forms, and factories""" 2 | 3 | from unittest.mock import patch 4 | 5 | from django.contrib.auth import get_user_model 6 | from django.test import TestCase 7 | 8 | from improved_user.forms import UserChangeForm, UserCreationForm 9 | 10 | from .factories import UserFactory 11 | 12 | User = get_user_model() # pylint: disable=invalid-name 13 | 14 | 15 | class ExtensionTestCase(TestCase): 16 | """Test Custom Extended User""" 17 | 18 | def test_user_creation(self): 19 | """Users can be created and can set/modify their password""" 20 | email_lowercase = "test@example.com" 21 | password = "password1!" 22 | user = User.objects.create_user(email_lowercase, password) 23 | self.assertEqual(user.email, email_lowercase) 24 | self.assertTrue(user.has_usable_password()) 25 | self.assertFalse(user.check_password("wrong")) 26 | self.assertTrue(user.check_password(password)) 27 | 28 | # Check we can manually set an unusable password 29 | user.set_unusable_password() 30 | user.save() 31 | self.assertFalse(user.check_password(password)) 32 | self.assertFalse(user.has_usable_password()) 33 | user.set_password(password) 34 | self.assertTrue(user.check_password(password)) 35 | user.set_password(None) 36 | self.assertFalse(user.has_usable_password()) 37 | 38 | # can add short and full name 39 | user.full_name = "John Smith" 40 | user.short_name = "John" 41 | user.save() 42 | self.assertEqual(user.get_full_name(), "John Smith") 43 | self.assertEqual(user.get_short_name(), "John") 44 | 45 | def test_extra_boolean_field(self): 46 | """Verify that the current User has an extra boolean field""" 47 | email_lowercase = "test@example.com" 48 | password = "password1!" 49 | user = User.objects.create_user(email_lowercase, password) 50 | self.assertFalse(user.verified) 51 | self.assertFalse(user.is_verified()) 52 | user.verified = True 53 | user.save() 54 | user = User.objects.get(email=email_lowercase) 55 | self.assertTrue(user.verified) 56 | self.assertTrue(user.is_verified()) 57 | 58 | def test_basic_factory_build(self): 59 | """Test creation of User via factory""" 60 | user = UserFactory.build() 61 | self.assertIsInstance(user.verified, bool) 62 | self.assertEqual(User.objects.all().count(), 0) 63 | user.save() 64 | self.assertEqual(User.objects.all().count(), 1) 65 | 66 | def test_basic_factory_create(self): 67 | """Test creation of User via factory saves to DB""" 68 | user = UserFactory() 69 | self.assertIsInstance(user, User) 70 | self.assertEqual(User.objects.all().count(), 1) 71 | 72 | def test_verified_attribute(self): 73 | """Ensure that verified attribute may be overridden""" 74 | user = UserFactory(verified=True) 75 | self.assertTrue(user.verified) 76 | user = UserFactory(verified=False) 77 | self.assertFalse(user.verified) 78 | 79 | @patch("django.contrib.auth.password_validation.password_changed") 80 | def test_create_form_success(self, password_changed): 81 | """Successful submission of form data""" 82 | data = { 83 | "email": "jsmith@example.com", 84 | "full_name": "John Smith", # optional field 85 | "short_name": "John", # optional field 86 | "password1": "k4b3c14gl9077954", 87 | "password2": "k4b3c14gl9077954", 88 | } 89 | form = UserCreationForm(data) 90 | self.assertTrue(form.is_valid()) 91 | form.save(commit=False) 92 | self.assertEqual(password_changed.call_count, 0) 93 | user = form.save() 94 | self.assertEqual(password_changed.call_count, 1) 95 | self.assertEqual(repr(user), "") 96 | self.assertEqual(user.get_short_name(), "John") 97 | self.assertEqual(user.get_full_name(), "John Smith") 98 | self.assertTrue(user.check_password("k4b3c14gl9077954")) 99 | self.assertFalse(user.verified) 100 | 101 | def test_update_form_success(self): 102 | """Test successful submission of update form""" 103 | user = UserFactory() 104 | data = { 105 | "email": user.email, 106 | "date_joined": user.date_joined, 107 | } 108 | form = UserChangeForm(data, instance=user) 109 | self.assertTrue(form.is_valid()) 110 | -------------------------------------------------------------------------------- /example_integration_project/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | omit = 4 | *migrations*, 5 | *tests*, 6 | parallel = true 7 | source = user_integration 8 | plugins = 9 | django_coverage_plugin 10 | 11 | [report] 12 | show_missing = true 13 | precision = 2 14 | -------------------------------------------------------------------------------- /example_integration_project/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/django-improved-user/a943e4cd2edc3354d8ce8e4078d708ea1f61e891/example_integration_project/config/__init__.py -------------------------------------------------------------------------------- /example_integration_project/config/settings.py: -------------------------------------------------------------------------------- 1 | """Settings for example integration project of django-improved-user 2 | 3 | https://docs.djangoproject.com/en/stable/topics/settings/ 4 | https://docs.djangoproject.com/en/stable/ref/settings/ 5 | """ 6 | 7 | import os 8 | 9 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 10 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 11 | 12 | # SECURITY WARNING: keep the secret key used in production secret! 13 | SECRET_KEY = ")#-l63lltqso!-&dz3r)xb&p#mz*s=dti_l@=1&ynd_3$+sw85" 14 | 15 | # SECURITY WARNING: don't run with debug turned on in production! 16 | DEBUG = True 17 | 18 | ALLOWED_HOSTS = [] 19 | 20 | 21 | # Application definition 22 | 23 | INSTALLED_APPS = [ 24 | "django.contrib.admin", 25 | "django.contrib.auth", 26 | "django.contrib.contenttypes", 27 | "django.contrib.sessions", 28 | "django.contrib.messages", 29 | "django.contrib.staticfiles", 30 | "django_extensions", 31 | "improved_user.apps.ImprovedUserConfig", 32 | "user_integration.apps.UserIntegrationConfig", 33 | ] 34 | 35 | MIDDLEWARE = [ 36 | "django.middleware.security.SecurityMiddleware", 37 | "django.contrib.sessions.middleware.SessionMiddleware", 38 | "django.middleware.common.CommonMiddleware", 39 | "django.middleware.csrf.CsrfViewMiddleware", 40 | "django.contrib.auth.middleware.AuthenticationMiddleware", 41 | "django.contrib.messages.middleware.MessageMiddleware", 42 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 43 | ] 44 | 45 | ROOT_URLCONF = "config.urls" 46 | 47 | TEMPLATES = [ 48 | { 49 | "BACKEND": "django.template.backends.django.DjangoTemplates", 50 | "DIRS": [], 51 | "APP_DIRS": True, 52 | "OPTIONS": { 53 | "context_processors": [ 54 | "django.template.context_processors.debug", 55 | "django.template.context_processors.request", 56 | "django.contrib.auth.context_processors.auth", 57 | "django.contrib.messages.context_processors.messages", 58 | ], 59 | "debug": True, 60 | }, 61 | }, 62 | ] 63 | 64 | WSGI_APPLICATION = "config.wsgi.application" 65 | 66 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 67 | 68 | # Database 69 | # https://docs.djangoproject.com/en/stable/ref/settings/#databases 70 | 71 | DATABASES = { 72 | "default": { 73 | "ENGINE": "django.db.backends.sqlite3", 74 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 75 | }, 76 | } 77 | 78 | 79 | # Password validation 80 | # https://docs.djangoproject.com/en/stable/ref/settings/#auth-password-validators 81 | 82 | AUTH_USER_MODEL = "improved_user.User" 83 | 84 | LOGIN_REDIRECT_URL = "home" 85 | LOGIN_URL = LOGOUT_REDIRECT_URL = "login" 86 | 87 | AUTH_PREFIX = "django.contrib.auth.password_validation." 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | "NAME": AUTH_PREFIX + "UserAttributeSimilarityValidator", 91 | "OPTIONS": { 92 | "user_attributes": ("email", "full_name", "short_name"), 93 | }, 94 | }, 95 | { 96 | "NAME": AUTH_PREFIX + "MinimumLengthValidator", 97 | "OPTIONS": { 98 | "min_length": 12, 99 | }, 100 | }, 101 | { 102 | "NAME": AUTH_PREFIX + "CommonPasswordValidator", 103 | }, 104 | { 105 | "NAME": AUTH_PREFIX + "NumericPasswordValidator", 106 | }, 107 | ] 108 | 109 | ACCOUNT_ACTIVATION_DAYS = 3 110 | 111 | 112 | # Internationalization 113 | # https://docs.djangoproject.com/en/stable/topics/i18n/ 114 | 115 | LANGUAGE_CODE = "en-us" 116 | 117 | TIME_ZONE = "UTC" 118 | 119 | USE_I18N = True 120 | 121 | 122 | USE_TZ = True 123 | 124 | 125 | # Static files (CSS, JavaScript, Images) 126 | # https://docs.djangoproject.com/en/stable/howto/static-files/ 127 | 128 | STATIC_URL = "/static/" 129 | -------------------------------------------------------------------------------- /example_integration_project/config/urls.py: -------------------------------------------------------------------------------- 1 | """Integration project URL Configuration""" 2 | 3 | from django.contrib import admin 4 | from django.urls import path, re_path 5 | from django.views.generic import TemplateView 6 | 7 | urlpatterns = [ 8 | re_path(r"^admin/", admin.site.urls), 9 | path("", TemplateView.as_view(template_name="home.html"), name="home"), 10 | ] 11 | -------------------------------------------------------------------------------- /example_integration_project/config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for config project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/stable/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example_integration_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Script that allows developers to run Django commands""" 3 | import os 4 | import sys 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError as exc: 11 | raise ImportError( 12 | "Couldn't import Django. Are you sure it's installed and " 13 | "available on your PYTHONPATH environment variable? Did you " 14 | "forget to activate a virtual environment?", 15 | ) from exc 16 | execute_from_command_line(sys.argv) 17 | -------------------------------------------------------------------------------- /example_integration_project/requirements.txt: -------------------------------------------------------------------------------- 1 | django-coverage-plugin==2.0.2 2 | django-extensions==3.1.5 3 | -------------------------------------------------------------------------------- /example_integration_project/user_integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/django-improved-user/a943e4cd2edc3354d8ce8e4078d708ea1f61e891/example_integration_project/user_integration/__init__.py -------------------------------------------------------------------------------- /example_integration_project/user_integration/apps.py: -------------------------------------------------------------------------------- 1 | """Application Definition File""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class UserIntegrationConfig(AppConfig): 7 | """AppConfig definition for user integration code""" 8 | 9 | name = "user_integration" 10 | -------------------------------------------------------------------------------- /example_integration_project/user_integration/migrations/0001_add_user.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.hashers import make_password 3 | from django.db import migrations 4 | 5 | 6 | def add_user(apps, schema_editor): 7 | User = apps.get_model(*settings.AUTH_USER_MODEL.split(".")) 8 | User.objects.create( 9 | email="migrated@jambonsw.com", 10 | password=make_password("s3cr3tp4ssw0rd!"), 11 | short_name="Migrated", 12 | full_name="Migrated Improved User", 13 | ) 14 | 15 | 16 | def remove_user(apps, schema_editor): 17 | User = apps.get_model(*settings.AUTH_USER_MODEL.split(".")) 18 | User.objects.get(email="migrated@jambonsw.com").delete() 19 | 20 | 21 | class Migration(migrations.Migration): 22 | 23 | dependencies = [ 24 | ("improved_user", "0001_initial"), 25 | ] 26 | 27 | operations = [ 28 | migrations.RunPython(add_user, remove_user), 29 | ] 30 | -------------------------------------------------------------------------------- /example_integration_project/user_integration/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/django-improved-user/a943e4cd2edc3354d8ce8e4078d708ea1f61e891/example_integration_project/user_integration/migrations/__init__.py -------------------------------------------------------------------------------- /example_integration_project/user_integration/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Django Improved User Test Project 6 | 10 | 11 | 12 | 13 | {% block content %}{% endblock %} 14 | 15 | 16 | -------------------------------------------------------------------------------- /example_integration_project/user_integration/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends parent_template|default:"base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |

Legacy template; to be removed soon.

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /example_integration_project/user_integration/tests.py: -------------------------------------------------------------------------------- 1 | """Test integration of Improved User with Django 2 | 3 | This test suite is legacy, and will be replaced in the near future. 4 | """ 5 | 6 | from django.contrib.auth import get_user_model 7 | from django.test import TestCase 8 | from django.urls import reverse 9 | 10 | 11 | class TestDataMigration(TestCase): 12 | """Test that the Improved User may be used in data migrations""" 13 | 14 | def test_user_exists(self): 15 | """Check UserManager properly created user""" 16 | User = get_user_model() # pylint: disable=invalid-name 17 | self.assertTrue( 18 | User.objects.filter(email="migrated@jambonsw.com").exists() 19 | ) 20 | 21 | 22 | class TestViews(TestCase): 23 | """Test views to ensure User integration""" 24 | 25 | def test_home(self): 26 | """Test that homeview returns basic template""" 27 | get_response = self.client.get(reverse("home")) 28 | self.assertEqual(200, get_response.status_code) 29 | self.assertTemplateUsed(get_response, "home.html") 30 | self.assertTemplateUsed(get_response, "base.html") 31 | 32 | def test_tester(self): 33 | """Ensure that tests behave as expected""" 34 | email = "hello@jambonsw.com" 35 | password = "s4f3passw0rd!" 36 | User = get_user_model() # pylint: disable=invalid-name 37 | User.objects.create_user(email, password) 38 | self.assertTrue(self.client.login(username=email, password=password)) 39 | -------------------------------------------------------------------------------- /example_replacement_project/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | omit = 4 | *migrations*, 5 | *tests*, 6 | parallel = true 7 | source = user_replacement 8 | plugins = 9 | django_coverage_plugin 10 | 11 | [report] 12 | show_missing = true 13 | precision = 2 14 | -------------------------------------------------------------------------------- /example_replacement_project/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/django-improved-user/a943e4cd2edc3354d8ce8e4078d708ea1f61e891/example_replacement_project/config/__init__.py -------------------------------------------------------------------------------- /example_replacement_project/config/settings.py: -------------------------------------------------------------------------------- 1 | """Settings for example replacement project of django-improved-user 2 | 3 | The replacement project notably demonstrates the use of model mixins. 4 | 5 | https://docs.djangoproject.com/en/stable/topics/settings/ 6 | https://docs.djangoproject.com/en/stable/ref/settings/ 7 | """ 8 | 9 | import os 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 13 | 14 | # SECURITY WARNING: keep the secret key used in production secret! 15 | SECRET_KEY = "4u4m#*7xy764)@$%w#)%txv)ktqj1y!o17a23b63-6xcdye@e-" 16 | 17 | # SECURITY WARNING: don't run with debug turned on in production! 18 | DEBUG = True 19 | 20 | ALLOWED_HOSTS = [] 21 | 22 | 23 | # Application definition 24 | 25 | INSTALLED_APPS = [ 26 | "django.contrib.admin", 27 | "django.contrib.auth", 28 | "django.contrib.contenttypes", 29 | "django.contrib.sessions", 30 | "django.contrib.messages", 31 | "django.contrib.staticfiles", 32 | "django_extensions", 33 | "user_replacement.apps.UserReplacementConfig", 34 | ] 35 | 36 | MIDDLEWARE = [ 37 | "django.middleware.security.SecurityMiddleware", 38 | "django.contrib.sessions.middleware.SessionMiddleware", 39 | "django.middleware.common.CommonMiddleware", 40 | "django.middleware.csrf.CsrfViewMiddleware", 41 | "django.contrib.auth.middleware.AuthenticationMiddleware", 42 | "django.contrib.messages.middleware.MessageMiddleware", 43 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 44 | ] 45 | 46 | ROOT_URLCONF = "config.urls" 47 | 48 | TEMPLATES = [ 49 | { 50 | "BACKEND": "django.template.backends.django.DjangoTemplates", 51 | "DIRS": [], 52 | "APP_DIRS": True, 53 | "OPTIONS": { 54 | "context_processors": [ 55 | "django.template.context_processors.debug", 56 | "django.template.context_processors.request", 57 | "django.contrib.auth.context_processors.auth", 58 | "django.contrib.messages.context_processors.messages", 59 | ], 60 | "debug": True, 61 | }, 62 | }, 63 | ] 64 | 65 | WSGI_APPLICATION = "config.wsgi.application" 66 | 67 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 68 | 69 | # Database 70 | # https://docs.djangoproject.com/en/stable/ref/settings/#databases 71 | 72 | DATABASES = { 73 | "default": { 74 | "ENGINE": "django.db.backends.sqlite3", 75 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 76 | }, 77 | } 78 | 79 | 80 | # Password validation 81 | # https://docs.djangoproject.com/en/stable/ref/settings/#auth-password-validators 82 | 83 | AUTH_USER_MODEL = "user_replacement.User" 84 | 85 | AUTH_PREFIX = "django.contrib.auth.password_validation." 86 | AUTH_PASSWORD_VALIDATORS = [ 87 | { 88 | "NAME": AUTH_PREFIX + "UserAttributeSimilarityValidator", 89 | "OPTIONS": { 90 | "user_attributes": ("email",), 91 | }, 92 | }, 93 | { 94 | "NAME": AUTH_PREFIX + "MinimumLengthValidator", 95 | "OPTIONS": { 96 | "min_length": 12, 97 | }, 98 | }, 99 | { 100 | "NAME": AUTH_PREFIX + "CommonPasswordValidator", 101 | }, 102 | { 103 | "NAME": AUTH_PREFIX + "NumericPasswordValidator", 104 | }, 105 | ] 106 | 107 | 108 | # Internationalization 109 | # https://docs.djangoproject.com/en/stable/topics/i18n/ 110 | 111 | LANGUAGE_CODE = "en-us" 112 | 113 | TIME_ZONE = "UTC" 114 | 115 | USE_I18N = True 116 | 117 | 118 | USE_TZ = True 119 | 120 | 121 | # Static files (CSS, JavaScript, Images) 122 | # https://docs.djangoproject.com/en/stable/howto/static-files/ 123 | 124 | STATIC_URL = "/static/" 125 | -------------------------------------------------------------------------------- /example_replacement_project/config/urls.py: -------------------------------------------------------------------------------- 1 | """Replacement project URL Configuration""" 2 | 3 | from django.contrib import admin 4 | 5 | try: 6 | from django.urls import re_path 7 | except ImportError: 8 | from django.conf.urls import url as re_path 9 | 10 | 11 | urlpatterns = [ 12 | re_path(r"^admin/", admin.site.urls), 13 | ] 14 | -------------------------------------------------------------------------------- /example_replacement_project/config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for config project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/stable/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example_replacement_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Script that allows developers to run Django commands""" 3 | import os 4 | import sys 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError as exc: 11 | raise ImportError( 12 | "Couldn't import Django. Are you sure it's installed and " 13 | "available on your PYTHONPATH environment variable? Did you " 14 | "forget to activate a virtual environment?", 15 | ) from exc 16 | execute_from_command_line(sys.argv) 17 | -------------------------------------------------------------------------------- /example_replacement_project/requirements.txt: -------------------------------------------------------------------------------- 1 | django-coverage-plugin==2.0.2 2 | django-extensions==3.1.5 3 | -------------------------------------------------------------------------------- /example_replacement_project/user_replacement/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/django-improved-user/a943e4cd2edc3354d8ce8e4078d708ea1f61e891/example_replacement_project/user_replacement/__init__.py -------------------------------------------------------------------------------- /example_replacement_project/user_replacement/apps.py: -------------------------------------------------------------------------------- 1 | """Application Definition File""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class UserReplacementConfig(AppConfig): 7 | """AppConfig definition for user replacement code""" 8 | 9 | name = "user_replacement" 10 | default_auto_field = "django.db.models.AutoField" 11 | -------------------------------------------------------------------------------- /example_replacement_project/user_replacement/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | import django.utils.timezone 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | initial = True 8 | 9 | dependencies = [ 10 | ("auth", "0006_require_contenttypes_0002"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="User", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ( 27 | "password", 28 | models.CharField(max_length=128, verbose_name="password"), 29 | ), 30 | ( 31 | "last_login", 32 | models.DateTimeField( 33 | blank=True, null=True, verbose_name="last login" 34 | ), 35 | ), 36 | ( 37 | "is_superuser", 38 | models.BooleanField( 39 | default=False, 40 | help_text=( 41 | "Designates that this user has all permissions " 42 | "without explicitly assigning them." 43 | ), 44 | verbose_name="superuser status", 45 | ), 46 | ), 47 | ( 48 | "is_staff", 49 | models.BooleanField( 50 | default=False, 51 | help_text=( 52 | "Designates whether the user can " 53 | "log into the admin site." 54 | ), 55 | verbose_name="staff status", 56 | ), 57 | ), 58 | ( 59 | "is_active", 60 | models.BooleanField( 61 | default=True, 62 | help_text=( 63 | "Designates whether this user should be treated as " 64 | "active. Unselect this instead of deleting accounts." 65 | ), 66 | verbose_name="active", 67 | ), 68 | ), 69 | ( 70 | "date_joined", 71 | models.DateTimeField( 72 | default=django.utils.timezone.now, 73 | verbose_name="date joined", 74 | ), 75 | ), 76 | ( 77 | "email", 78 | models.EmailField( 79 | max_length=254, 80 | unique=True, 81 | verbose_name="email address", 82 | ), 83 | ), 84 | ( 85 | "groups", 86 | models.ManyToManyField( 87 | blank=True, 88 | help_text=( 89 | "The groups this user belongs to. A user will get all " 90 | "permissions granted to each of their groups." 91 | ), 92 | related_name="user_set", 93 | related_query_name="user", 94 | to="auth.Group", 95 | verbose_name="groups", 96 | ), 97 | ), 98 | ( 99 | "user_permissions", 100 | models.ManyToManyField( 101 | blank=True, 102 | help_text="Specific permissions for this user.", 103 | related_name="user_set", 104 | related_query_name="user", 105 | to="auth.Permission", 106 | verbose_name="user permissions", 107 | ), 108 | ), 109 | ], 110 | options={ 111 | "verbose_name": "user", 112 | "verbose_name_plural": "users", 113 | }, 114 | ), 115 | ] 116 | -------------------------------------------------------------------------------- /example_replacement_project/user_replacement/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/django-improved-user/a943e4cd2edc3354d8ce8e4078d708ea1f61e891/example_replacement_project/user_replacement/migrations/__init__.py -------------------------------------------------------------------------------- /example_replacement_project/user_replacement/models.py: -------------------------------------------------------------------------------- 1 | """A User model created by django-improved-user mixins""" 2 | 3 | from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from improved_user.managers import UserManager 7 | from improved_user.model_mixins import DjangoIntegrationMixin, EmailAuthMixin 8 | 9 | 10 | class User( 11 | DjangoIntegrationMixin, EmailAuthMixin, PermissionsMixin, AbstractBaseUser 12 | ): 13 | """A user created using mix-ins from Django and improved-user 14 | 15 | Note that the lack of name methods will cause errors in the Admin 16 | """ 17 | 18 | objects = UserManager() 19 | 20 | class Meta: 21 | verbose_name = _("user") 22 | verbose_name_plural = _("users") 23 | -------------------------------------------------------------------------------- /example_replacement_project/user_replacement/tests.py: -------------------------------------------------------------------------------- 1 | """Tests for the extended User model""" 2 | 3 | from types import MethodType 4 | 5 | from django.test import TestCase 6 | 7 | from .models import User 8 | 9 | 10 | class ExtendedUserModelTests(TestCase): 11 | """Tests for the extended User model""" 12 | 13 | def test_user_creation(self): 14 | """Users can be created and can set/modify their password""" 15 | email_lowercase = "test@example.com" 16 | password = "password1!" 17 | user = User.objects.create_user(email_lowercase, password) 18 | self.assertEqual(user.email, email_lowercase) 19 | self.assertTrue(user.has_usable_password()) 20 | self.assertFalse(user.check_password("wrong")) 21 | self.assertTrue(user.check_password(password)) 22 | 23 | # Check we can manually set an unusable password 24 | user.set_unusable_password() 25 | user.save() 26 | self.assertFalse(user.check_password(password)) 27 | self.assertFalse(user.has_usable_password()) 28 | user.set_password(password) 29 | self.assertTrue(user.check_password(password)) 30 | user.set_password(None) 31 | self.assertFalse(user.has_usable_password()) 32 | 33 | self.assertFalse(hasattr(user, "get_full_name")) 34 | self.assertFalse(hasattr(user, "get_short_name")) 35 | 36 | def test_fields_and_attributes(self): 37 | """Ensure the model has the fields and attributes we expect""" 38 | expected_fields = ( 39 | "id", 40 | "password", 41 | "last_login", 42 | "is_superuser", 43 | "is_staff", 44 | "is_active", 45 | "date_joined", 46 | "email", 47 | "groups", 48 | "user_permissions", 49 | ) 50 | excluded_fields = ( 51 | "full_name", 52 | "short_name", 53 | ) 54 | user_fields = [field.name for field in User._meta.get_fields()] 55 | for expected_field in expected_fields: 56 | with self.subTest(expected_field=expected_field): 57 | self.assertIn(expected_field, user_fields) 58 | for excluded_field in excluded_fields: 59 | with self.subTest(excluded_field=excluded_field): 60 | self.assertNotIn(excluded_field, user_fields) 61 | # Pre-empt Django check auth.E001 62 | self.assertTrue(isinstance(User.REQUIRED_FIELDS, (list, tuple))) 63 | # Pre-empt Django check auth.E002 64 | self.assertNotIn(User.USERNAME_FIELD, User.REQUIRED_FIELDS) 65 | # Pre-empt Django check auth.E003 66 | self.assertIs(User._meta.get_field(User.USERNAME_FIELD).unique, True) 67 | # Pre-empt Django check auth.C009 68 | self.assertFalse(isinstance(User.is_anonymous, MethodType)) 69 | # Pre-empt Django check auth.C010 70 | self.assertFalse(isinstance(User.is_authenticated, MethodType)) 71 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.4,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "django-improved-user" 7 | dynamic = ["version", "description"] 8 | authors = [ 9 | { name = "Russell Keith-Magee" }, 10 | { name = "Andrew Pinkham" }, 11 | ] 12 | readme = "README.rst" 13 | requires-python = ">=3.8" 14 | dependencies = ["django>=4.2"] 15 | license = { file = "LICENSE"} 16 | classifiers = [ 17 | "Development Status :: 5 - Production/Stable", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: BSD License", 20 | "Operating System :: OS Independent", 21 | "Topic :: Software Development :: Libraries", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3 :: Only", 29 | "Framework :: Django", 30 | "Framework :: Django :: 4.2", 31 | ] 32 | 33 | [project.urls] 34 | Documentation = "https://django-improved-user.rtfd.io" 35 | Source = "https://github.com/jambonrose/django-improved-user/" 36 | Tracker = "https://github.com/jambonrose/django-improved-user/issues" 37 | 38 | [project.optional-dependencies] 39 | factory = [ 40 | "factory-boy>=2.9", 41 | "Faker>=0.8", 42 | "python-dateutil>=2.6", 43 | ] 44 | 45 | [tool.flit.module] 46 | name = "improved_user" 47 | 48 | [tool.flit.sdist] 49 | include = [ 50 | "*.md", 51 | "*.rst", 52 | ".bumpversion.cfg", 53 | ".coveragerc", 54 | ".flake8", 55 | ".isort.cfg", 56 | ".pylintrc", 57 | "doc-requirements.txt", 58 | "LICENSE", 59 | "MANIFEST.in", 60 | "requirements.txt", 61 | "runtests.py", 62 | "setup.cfg", 63 | "tests/", 64 | "tox.ini", 65 | ] 66 | exclude = [ 67 | ".editorconfig", 68 | ".github/", 69 | ".ignore", 70 | ".pre-commit-config.yaml", 71 | ".prettierignore", 72 | ".prettierrc.toml", 73 | "docs/", 74 | "example_extension_project/", 75 | "example_integration_project/", 76 | "example_replacement_project/", 77 | "Makefile", 78 | ] 79 | 80 | [tool.black] 81 | line-length = 79 82 | target-version = ['py38'] 83 | include = '\.pyi?$' 84 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | astroid>=2.9.2 2 | bumpversion>=0.6.0 3 | check-manifest>=0.47 4 | coverage>=6.2 5 | docutils>=0.18.1 6 | factory-boy>=3.2.1 7 | Faker>=11.3.0 8 | flit>=3.6.0 9 | Pygments>=2.11.2 10 | pylint>=2.12.2 11 | pylint-django>=2.5.0 12 | python-dateutil>=2.8.2 13 | tox>=3.24.5 14 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Utility script to setup Django and run tests against package""" 3 | import sys 4 | from os.path import dirname, join 5 | 6 | try: 7 | from django import setup 8 | from django.apps import apps 9 | from django.conf import settings 10 | from django.core.management import execute_from_command_line 11 | except ImportError: 12 | print( 13 | "Could not load Django.\n" 14 | "Try running `flit install --symlink` before `./runtests.py`\n" 15 | "or run `make test` (or `make tox`) for an all in one solution", 16 | ) 17 | exit(-1) 18 | 19 | 20 | try: 21 | import improved_user # noqa: F401 pylint: disable=unused-import 22 | except ImportError: 23 | print( 24 | "Could not load improved_user!\n" 25 | "Try running `flit install --symlink` before `./runtests.py`\n" 26 | "or run `make test` (or `make tox`) for an all in one solution", 27 | ) 28 | exit(-1) 29 | 30 | 31 | def configure_django(): 32 | """Configure Django before tests""" 33 | settings.configure( 34 | SECRET_KEY="m-4Umd2!_nQQX.Ux6dYaiffmgRFpFxri!hqmffqBAhuAu*-!9n", 35 | DATABASES={ 36 | "default": { 37 | "ENGINE": "django.db.backends.sqlite3", 38 | "NAME": ":memory:", 39 | }, 40 | }, 41 | INSTALLED_APPS=[ 42 | "django.contrib.admin", 43 | "django.contrib.auth", 44 | "django.contrib.contenttypes", 45 | "django.contrib.messages", 46 | "django.contrib.sessions", 47 | "django.contrib.sites", 48 | "improved_user.apps.ImprovedUserConfig", 49 | ], 50 | SITE_ID=1, 51 | AUTH_USER_MODEL="improved_user.User", 52 | FIXTURE_DIRS=(join(dirname(__file__), "tests", "fixtures"),), 53 | TEMPLATES=[ 54 | { 55 | "BACKEND": "django.template.backends.django.DjangoTemplates", 56 | "DIRS": [], 57 | "APP_DIRS": True, 58 | "OPTIONS": { 59 | "context_processors": [ 60 | "django.contrib.auth.context_processors.auth", 61 | "django.contrib.messages.context_processors.messages", 62 | "django.template.context_processors.request", 63 | ], 64 | }, 65 | } 66 | ], 67 | MIDDLEWARE=[ 68 | "django.contrib.sessions.middleware.SessionMiddleware", 69 | "django.contrib.auth.middleware.AuthenticationMiddleware", 70 | "django.contrib.messages.middleware.MessageMiddleware", 71 | ], 72 | ) 73 | setup() 74 | 75 | 76 | def run_test_suite(*args): 77 | """Run the test suite""" 78 | test_args = list(args) or [] 79 | execute_from_command_line(["manage.py", "test"] + test_args) 80 | 81 | 82 | def check_missing_migrations(): 83 | """Check that user model and migration files are in sync""" 84 | from django.db.migrations.autodetector import MigrationAutodetector 85 | from django.db.migrations.loader import MigrationLoader 86 | from django.db.migrations.questioner import ( 87 | NonInteractiveMigrationQuestioner as Questioner, 88 | ) 89 | from django.db.migrations.state import ProjectState 90 | 91 | loader = MigrationLoader(None, ignore_no_migrations=True) 92 | conflicts = loader.detect_conflicts() 93 | if conflicts: 94 | raise Exception( 95 | "Migration conflicts detected. Please fix your migrations." 96 | ) 97 | questioner = Questioner(dry_run=True, specified_apps=None) 98 | autodetector = MigrationAutodetector( 99 | loader.project_state(), 100 | ProjectState.from_apps(apps), 101 | questioner, 102 | ) 103 | changes = autodetector.changes( 104 | graph=loader.graph, 105 | trim_to_apps=None, 106 | convert_apps=None, 107 | migration_name=None, 108 | ) 109 | if changes: 110 | raise Exception( 111 | "Migration changes detected. " 112 | "Please update or add to the migration file as appropriate" 113 | ) 114 | print("Migration-checker detected no problems.") 115 | 116 | 117 | if __name__ == "__main__": 118 | configure_django() 119 | check_missing_migrations() 120 | run_test_suite(*sys.argv[1:]) 121 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description_file = README.rst 3 | -------------------------------------------------------------------------------- /src/improved_user/__init__.py: -------------------------------------------------------------------------------- 1 | """Compose new Django User models that follow best-practices for international names and authenticate via email instead of username.""" 2 | 3 | # This file: 4 | # 1. define directory as module 5 | # 2. set default app config 6 | 7 | # pylint: disable=invalid-name 8 | __version__ = "2.1.0" 9 | # https://docs.djangoproject.com/en/stable/ref/applications/#configuring-applications 10 | # pylint: enable=invalid-name 11 | -------------------------------------------------------------------------------- /src/improved_user/admin.py: -------------------------------------------------------------------------------- 1 | """Admin Configuration for Improved User""" 2 | 3 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from .forms import UserChangeForm, UserCreationForm 7 | 8 | 9 | class UserAdmin(BaseUserAdmin): 10 | """Admin panel for Improved User, mimics Django's default""" 11 | 12 | fieldsets = ( 13 | (None, {"fields": ("email", "password")}), 14 | (_("Personal info"), {"fields": ("full_name", "short_name")}), 15 | ( 16 | _("Permissions"), 17 | { 18 | "fields": ( 19 | "is_active", 20 | "is_staff", 21 | "is_superuser", 22 | "groups", 23 | "user_permissions", 24 | ), 25 | }, 26 | ), 27 | (_("Important dates"), {"fields": ("last_login", "date_joined")}), 28 | ) 29 | add_fieldsets = ( 30 | ( 31 | None, 32 | { 33 | "classes": ("wide",), 34 | "fields": ("email", "short_name", "password1", "password2"), 35 | }, 36 | ), 37 | ) 38 | form = UserChangeForm 39 | add_form = UserCreationForm 40 | list_display = ("email", "full_name", "short_name", "is_staff") 41 | search_fields = ("email", "full_name", "short_name") 42 | ordering = ("email",) 43 | -------------------------------------------------------------------------------- /src/improved_user/apps.py: -------------------------------------------------------------------------------- 1 | """App Configuration for Improved User""" 2 | 3 | from django.apps import AppConfig 4 | from django.contrib import admin 5 | from django.contrib.auth import get_user_model 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | 9 | class ImprovedUserConfig(AppConfig): 10 | """App Config for Improved User""" 11 | 12 | name = "improved_user" 13 | verbose_name = _("Improved User") 14 | default_auto_field = "django.db.models.AutoField" 15 | 16 | def ready(self): 17 | """Register User model for Admin 18 | 19 | Ensure UserAdmin is only used when application is added to 20 | Installed Apps, and that UserAdmin can be imported if necessary 21 | (please note: not recommended. Please see the docs). 22 | 23 | https://django-improved-user.rtfd.io/en/latest/admin_usage.html 24 | """ 25 | from .admin import UserAdmin 26 | 27 | User = get_user_model() # pylint: disable=invalid-name 28 | admin.site.register(User, UserAdmin) 29 | -------------------------------------------------------------------------------- /src/improved_user/factories.py: -------------------------------------------------------------------------------- 1 | """Factories to make testing with Improved User easier""" 2 | 3 | from django.contrib.auth import get_user_model 4 | 5 | try: 6 | from factory import Faker, PostGenerationMethodCall 7 | from factory.django import DjangoModelFactory 8 | except ImportError: # pragma: no cover 9 | raise Exception( 10 | "Please install factory-boy to use Improved User's UserFactory.\n" 11 | "pip install factory-boy" 12 | ) from None 13 | 14 | User = get_user_model() # pylint: disable=invalid-name 15 | 16 | 17 | # pylint: disable=too-few-public-methods 18 | class UserFactory(DjangoModelFactory): 19 | """Factory Boy factory for Improved User 20 | 21 | Generates a user with a default password of ``password!``. 22 | The user is active, but is not staff or a superuser. 23 | Any value can be overridden by passing in a value, as shown below. 24 | 25 | .. code:: python 26 | 27 | UserFactory( 28 | password='mys3cr3tp4ssw0rd!', 29 | is_superuser=True, 30 | ) 31 | """ 32 | 33 | class Meta: 34 | """Configuration Options""" 35 | 36 | model = User 37 | 38 | email = Faker("email") 39 | password = PostGenerationMethodCall("set_password", "password!") 40 | full_name = Faker("name") 41 | short_name = Faker("first_name") 42 | is_active = True 43 | is_staff = False 44 | is_superuser = False 45 | 46 | 47 | # pylint: enable=too-few-public-methods 48 | -------------------------------------------------------------------------------- /src/improved_user/forms.py: -------------------------------------------------------------------------------- 1 | """Forms for Creating and Updating Improved Users""" 2 | 3 | from django import forms 4 | from django.contrib.auth import get_user_model, password_validation 5 | from django.contrib.auth.forms import ReadOnlyPasswordHashField 6 | from django.core.exceptions import ValidationError 7 | from django.utils.translation import gettext as _ 8 | 9 | User = get_user_model() # pylint: disable=invalid-name 10 | 11 | 12 | class AbstractUserCreationForm(forms.ModelForm): 13 | """Abstract Form to create an unprivileged user 14 | 15 | Create a User with no permissions based on username and password. 16 | """ 17 | 18 | error_messages = { 19 | "password_mismatch": _("The two password fields didn't match."), 20 | } 21 | 22 | password1 = forms.CharField( 23 | label=_("Password"), 24 | widget=forms.PasswordInput, 25 | help_text=password_validation.password_validators_help_text_html(), 26 | strip=False, 27 | ) 28 | password2 = forms.CharField( 29 | label=_("Password confirmation"), 30 | widget=forms.PasswordInput, 31 | help_text=_("Enter the same password as above, for verification."), 32 | strip=False, 33 | ) 34 | 35 | def clean_password2(self): 36 | """Check wether password 1 and password 2 are equivalent 37 | 38 | While ideally this would be done in clean, there is a chance a 39 | superclass could declare clean and forget to call super. We 40 | therefore opt to run this password mismatch check in password2 41 | clean, but to show the error above password1 (as we are unsure 42 | whether password 1 or password 2 contains the typo, and putting 43 | it above password 2 may lead some users to believe the typo is 44 | in just one). 45 | 46 | """ 47 | password1 = self.cleaned_data.get("password1") 48 | password2 = self.cleaned_data.get("password2") 49 | if password1 and password2 and password1 != password2: 50 | self.add_error( 51 | "password1", 52 | forms.ValidationError( 53 | self.error_messages["password_mismatch"], 54 | code="password_mismatch", 55 | ), 56 | ) 57 | return password2 58 | 59 | def _post_clean(self): 60 | """Run password validaton after clean methods 61 | 62 | When clean methods are run, the user instance does not yet 63 | exist. To properly compare model values agains the password (in 64 | the UserAttributeSimilarityValidator), we wait until we have an 65 | instance to compare against. 66 | 67 | https://code.djangoproject.com/ticket/28127 68 | https://github.com/django/django/pull/8408 69 | 70 | Has no effect in Django prior to 1.9 71 | May become unnecessary in Django 2.0 (if this superclass changes) 72 | 73 | """ 74 | super()._post_clean() # updates self.instance with form data 75 | password = self.cleaned_data.get("password1") 76 | if password: 77 | try: 78 | password_validation.validate_password(password, self.instance) 79 | except ValidationError as error: 80 | self.add_error("password1", error) 81 | 82 | def save(self, commit=True): 83 | """Save the user; use password hasher to set password""" 84 | user = super().save(commit=False) 85 | user.set_password(self.cleaned_data["password1"]) 86 | if commit: 87 | user.save() 88 | return user 89 | 90 | 91 | class UserCreationForm(AbstractUserCreationForm): 92 | """Form to create an unprivileged user 93 | 94 | A concrete implementation of AbstractUserCreationForm that uses an 95 | e-mail address as a user's identifier. 96 | """ 97 | 98 | error_messages = { 99 | **AbstractUserCreationForm.error_messages, 100 | "duplicate_email": _("A user with that email already exists."), 101 | } 102 | 103 | class Meta: 104 | model = User 105 | fields = ("email", "full_name", "short_name") 106 | 107 | def clean_email(self): 108 | """Clean email; set nice error message 109 | 110 | Since User.email is unique, this check is redundant, 111 | but it sets a nicer error message than the ORM. See #13147. 112 | 113 | https://code.djangoproject.com/ticket/13147 114 | """ 115 | email = self.cleaned_data["email"] 116 | try: 117 | # https://docs.djangoproject.com/en/stable/topics/db/managers/#default-managers 118 | # pylint: disable=protected-access 119 | User._default_manager.get(email=email) 120 | # pylint: enable=protected-access 121 | except User.DoesNotExist: 122 | return email 123 | raise forms.ValidationError( 124 | self.error_messages["duplicate_email"], 125 | code="duplicate_email", 126 | ) 127 | 128 | 129 | class AbstractUserChangeForm(forms.ModelForm): 130 | """Base form update User, but not their password""" 131 | 132 | password = ReadOnlyPasswordHashField( 133 | label=_("Password"), 134 | help_text=_( 135 | "Raw passwords are not stored, so there is no way to see this " 136 | "user's password, but you can change the password using " 137 | 'this form.' 138 | ), 139 | ) 140 | 141 | rel_password_url = None 142 | 143 | def __init__(self, *args, **kwargs): 144 | """Initialize form; optimize user permission queryset""" 145 | super().__init__(*args, **kwargs) 146 | self.fields["password"].help_text = self.fields[ 147 | "password" 148 | ].help_text.format(self.get_local_password_path()) 149 | permission_field = self.fields.get("user_permissions", None) 150 | if permission_field is not None: 151 | # pre-load content types associated with permissions 152 | permission_field.queryset = ( 153 | permission_field.queryset.select_related("content_type") 154 | ) 155 | 156 | def get_local_password_path(self): 157 | """Return relative path to password form 158 | 159 | Will return rel_password_url attribute on form 160 | or else '../password/'. If subclasses cannot simply replace 161 | rel_password_url, then they can override this method instead of 162 | __init__. 163 | 164 | """ 165 | if ( 166 | hasattr(self, "rel_password_url") 167 | and self.rel_password_url is not None 168 | ): 169 | return self.rel_password_url 170 | return "../password/" 171 | 172 | def clean_password(self): 173 | """Change user info; not the password 174 | 175 | We seek to change the user, but not the password. 176 | Regardless of what the user provides, return the initial value. 177 | This is done here, rather than on the field, because the 178 | field does not have access to the initial value 179 | """ 180 | return self.initial["password"] 181 | 182 | 183 | class UserChangeForm(AbstractUserChangeForm): 184 | """Form to update user, but not their password""" 185 | 186 | class Meta: 187 | model = User 188 | fields = "__all__" 189 | -------------------------------------------------------------------------------- /src/improved_user/managers.py: -------------------------------------------------------------------------------- 1 | """User Manager used by Improved User; may be extended""" 2 | 3 | from django.contrib.auth.models import BaseUserManager 4 | 5 | 6 | class UserManager(BaseUserManager): 7 | """Manager for Users; overrides create commands for new fields 8 | 9 | Meant to be interacted with via the user model. 10 | 11 | .. code:: python 12 | 13 | User.objects # the UserManager 14 | User.objects.all() # has normal Manager/UserManager methods 15 | User.objects.create_user # overrides methods for Improved User 16 | 17 | Set to :attr:`~django.db.models.Model.objects` by 18 | :attr:`~improved_user.models.AbstractUser` 19 | """ 20 | 21 | def _create_user( 22 | self, email, password, is_staff, is_superuser, **extra_fields 23 | ): 24 | """Save a User with improved user fields; helper method""" 25 | if not email: 26 | raise ValueError("An email address must be provided.") 27 | if "username" in extra_fields: 28 | raise ValueError( 29 | "The Improved User model does not have a username; " 30 | "it uses only email" 31 | ) 32 | user = self.model( 33 | email=self.normalize_email(email), 34 | is_staff=is_staff, 35 | is_superuser=is_superuser, 36 | **extra_fields, 37 | ) 38 | user.set_password(password) 39 | user.save(using=self._db) 40 | return user 41 | 42 | def create_user(self, email=None, password=None, **extra_fields): 43 | """Save new User with email and password""" 44 | extra_fields.setdefault("is_staff", False) 45 | extra_fields.setdefault("is_superuser", False) 46 | return self._create_user(email, password, **extra_fields) 47 | 48 | def create_superuser(self, email, password, **extra_fields): 49 | """Save new User with is_staff and is_superuser set to True""" 50 | extra_fields.setdefault("is_staff", True) 51 | extra_fields.setdefault("is_superuser", True) 52 | if extra_fields.get("is_staff") is not True: 53 | raise ValueError("Superuser must have is_staff=True.") 54 | if extra_fields.get("is_superuser") is not True: 55 | raise ValueError("Superuser must have is_superuser=True.") 56 | return self._create_user(email, password, **extra_fields) 57 | -------------------------------------------------------------------------------- /src/improved_user/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.10 on 2016-10-25 12:07 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ("auth", "0006_require_contenttypes_0002"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="User", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ( 29 | "password", 30 | models.CharField(max_length=128, verbose_name="password"), 31 | ), 32 | ( 33 | "last_login", 34 | models.DateTimeField( 35 | blank=True, null=True, verbose_name="last login" 36 | ), 37 | ), 38 | ( 39 | "is_superuser", 40 | models.BooleanField( 41 | default=False, 42 | help_text="Designates that this user has all permissions without explicitly assigning them.", 43 | verbose_name="superuser status", 44 | ), 45 | ), 46 | ( 47 | "full_name", 48 | models.CharField( 49 | blank=True, max_length=200, verbose_name="full name" 50 | ), 51 | ), 52 | ( 53 | "short_name", 54 | models.CharField( 55 | blank=True, max_length=50, verbose_name="short name" 56 | ), 57 | ), 58 | ( 59 | "is_staff", 60 | models.BooleanField( 61 | default=False, 62 | help_text="Designates whether the user can log into the admin site.", 63 | verbose_name="staff status", 64 | ), 65 | ), 66 | ( 67 | "is_active", 68 | models.BooleanField( 69 | default=True, 70 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", 71 | verbose_name="active", 72 | ), 73 | ), 74 | ( 75 | "date_joined", 76 | models.DateTimeField( 77 | default=django.utils.timezone.now, 78 | verbose_name="date joined", 79 | ), 80 | ), 81 | ( 82 | "email", 83 | models.EmailField( 84 | max_length=254, 85 | unique=True, 86 | verbose_name="email address", 87 | ), 88 | ), 89 | ( 90 | "groups", 91 | models.ManyToManyField( 92 | blank=True, 93 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", 94 | related_name="user_set", 95 | related_query_name="user", 96 | to="auth.Group", 97 | verbose_name="groups", 98 | ), 99 | ), 100 | ( 101 | "user_permissions", 102 | models.ManyToManyField( 103 | blank=True, 104 | help_text="Specific permissions for this user.", 105 | related_name="user_set", 106 | related_query_name="user", 107 | to="auth.Permission", 108 | verbose_name="user permissions", 109 | ), 110 | ), 111 | ], 112 | options={ 113 | "verbose_name": "user", 114 | "verbose_name_plural": "users", 115 | "abstract": False, 116 | }, 117 | ), 118 | ] 119 | -------------------------------------------------------------------------------- /src/improved_user/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/django-improved-user/a943e4cd2edc3354d8ce8e4078d708ea1f61e891/src/improved_user/migrations/__init__.py -------------------------------------------------------------------------------- /src/improved_user/model_mixins.py: -------------------------------------------------------------------------------- 1 | """Mix-in Classes intended for use with Django Models""" 2 | 3 | from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin 4 | from django.core.mail import send_mail 5 | from django.db import models 6 | from django.utils import timezone 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | from .managers import UserManager 10 | 11 | 12 | class DjangoIntegrationMixin(models.Model): 13 | """Mixin provides fields for Django integration to work correctly 14 | 15 | Provides permissions for Django Admin integration, as well as date 16 | field used by authentication code. 17 | """ 18 | 19 | is_staff = models.BooleanField( 20 | _("staff status"), 21 | default=False, 22 | help_text=_( 23 | "Designates whether the user can log into the admin site." 24 | ), 25 | ) 26 | is_active = models.BooleanField( 27 | _("active"), 28 | default=True, 29 | help_text=_( 30 | "Designates whether this user should be treated as " 31 | "active. Unselect this instead of deleting accounts." 32 | ), 33 | ) 34 | date_joined = models.DateTimeField(_("date joined"), default=timezone.now) 35 | 36 | class Meta: 37 | abstract = True 38 | 39 | 40 | class FullNameMixin(models.Model): 41 | """A mixin to provide an optional full name field""" 42 | 43 | full_name = models.CharField(_("full name"), max_length=200, blank=True) 44 | 45 | class Meta: 46 | abstract = True 47 | 48 | def get_full_name(self): 49 | """Return the full name of the user.""" 50 | return self.full_name 51 | 52 | 53 | class ShortNameMixin(models.Model): 54 | """A mixin to provide an optional short name field""" 55 | 56 | short_name = models.CharField(_("short name"), max_length=50, blank=True) 57 | 58 | class Meta: 59 | abstract = True 60 | 61 | def get_short_name(self): 62 | """Return the short name for the user.""" 63 | return self.short_name 64 | 65 | 66 | class EmailAuthMixin(models.Model): 67 | """A mixin to use email as the username""" 68 | 69 | email = models.EmailField(_("email address"), max_length=254, unique=True) 70 | 71 | class Meta: 72 | abstract = True 73 | 74 | EMAIL_FIELD = "email" 75 | USERNAME_FIELD = "email" 76 | 77 | def clean(self): 78 | """Override default clean method to normalize email. 79 | 80 | Call :code:`super().clean()` if overriding. 81 | 82 | """ 83 | super().clean() 84 | self.email = self.__class__.objects.normalize_email(self.email) 85 | 86 | def email_user(self, subject, message, from_email=None, **kwargs): 87 | """Send an email to this User.""" 88 | send_mail(subject, message, from_email, [self.email], **kwargs) 89 | 90 | 91 | # pylint: disable=too-many-ancestors 92 | class AbstractUser( 93 | DjangoIntegrationMixin, 94 | FullNameMixin, 95 | ShortNameMixin, 96 | EmailAuthMixin, 97 | PermissionsMixin, 98 | AbstractBaseUser, 99 | ): 100 | """Abstract User base class to be inherited. 101 | 102 | Do not instantiate this class directly. The class provides a fully 103 | featured User model with admin-compliant permissions. Differs from 104 | Django's :class:`~django.contrib.auth.models.AbstractUser`: 105 | 106 | 1. Login occurs with an email and password instead of username. 107 | 2. Provides short_name and full_name instead of first_name and 108 | last_name. 109 | 110 | All fields other than email and password are optional. 111 | 112 | Sets :attr:`~django.db.models.Model.objects` to 113 | :class:`~improved_user.managers.UserManager`. 114 | 115 | Documentation about Django's 116 | :class:`~django.contrib.auth.models.AbstractBaseUser` may be helpful 117 | in understanding this class. 118 | """ 119 | 120 | objects = UserManager() 121 | 122 | # misnomer; fields Dj prompts for when user calls createsuperuser 123 | # https://docs.djangoproject.com/en/stable/topics/auth/customizing/#django.contrib.auth.models.CustomUser.REQUIRED_FIELDS 124 | REQUIRED_FIELDS = ["full_name", "short_name"] 125 | 126 | class Meta: 127 | abstract = True 128 | verbose_name = _("user") 129 | verbose_name_plural = _("users") 130 | -------------------------------------------------------------------------------- /src/improved_user/models.py: -------------------------------------------------------------------------------- 1 | """The Improved User Model 2 | 3 | Mixin classes used to create this class may be found in mixins.py 4 | 5 | The UserManager is found in managers.py 6 | 7 | """ 8 | 9 | from .model_mixins import AbstractUser 10 | 11 | 12 | # pylint: disable=too-many-ancestors 13 | class User(AbstractUser): 14 | """The Improved User Model is intended to be used out-of-the-box. 15 | 16 | Do **not** import this model directly: use 17 | :py:func:`~django.contrib.auth.get_user_model`. 18 | """ 19 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jambonrose/django-improved-user/a943e4cd2edc3354d8ce8e4078d708ea1f61e891/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | """Test Admin interface provided by Improved User""" 2 | 3 | import os 4 | import re 5 | 6 | from django import VERSION as DJANGO_VERSION 7 | from django.contrib.admin.models import LogEntry 8 | from django.contrib.auth import SESSION_KEY 9 | from django.test import TestCase, override_settings 10 | from django.urls import reverse 11 | from django.utils.encoding import force_str 12 | 13 | from improved_user.admin import UserAdmin 14 | from improved_user.forms import UserChangeForm, UserCreationForm 15 | from improved_user.models import User 16 | 17 | 18 | # Redirect in test_user_change_password will fail if session auth hash 19 | # isn't updated after password change (#21649) 20 | @override_settings(ROOT_URLCONF="tests.urls") 21 | class UserAdminTests(TestCase): 22 | """Based off django.tests.auth_tests.test_views.ChangelistTests""" 23 | 24 | @classmethod 25 | def setUpTestData(cls): 26 | """Create users during TestCase initialization""" 27 | cls.user1 = User.objects.create_user( 28 | email="testclient@example.com", 29 | password="password", 30 | ) 31 | cls.user2 = User.objects.create_user( 32 | email="staffmember@example.com", 33 | password="password", 34 | ) 35 | 36 | def setUp(self): 37 | """Make user1 a superuser before logging in.""" 38 | User.objects.filter(email="testclient@example.com").update( 39 | is_staff=True, is_superuser=True 40 | ) 41 | self.login() 42 | self.admin = User.objects.get(pk=self.user1.pk) 43 | 44 | def login(self, username="testclient@example.com", password="password"): 45 | """Login the user (specified or default); helper function""" 46 | response = self.client.post( 47 | "/login/", 48 | { 49 | "username": username, 50 | "password": password, 51 | }, 52 | ) 53 | self.assertIn(SESSION_KEY, self.client.session) 54 | return response 55 | 56 | def logout(self): 57 | """Logout the user; helper function""" 58 | response = self.client.get("/admin/logout/") 59 | self.assertEqual(response.status_code, 200) 60 | self.assertNotIn(SESSION_KEY, self.client.session) 61 | 62 | def get_user_data(self, user): # pylint: disable=no-self-use 63 | """Generate dictionary of values to compare against""" 64 | return { 65 | "email": user.email, 66 | "password": user.password, 67 | "is_active": user.is_active, 68 | "is_staff": user.is_staff, 69 | "is_superuser": user.is_superuser, 70 | "last_login_0": user.last_login.strftime("%Y-%m-%d"), 71 | "last_login_1": user.last_login.strftime("%H:%M:%S"), 72 | "initial-last_login_0": user.last_login.strftime("%Y-%m-%d"), 73 | "initial-last_login_1": user.last_login.strftime("%H:%M:%S"), 74 | "date_joined_0": user.date_joined.strftime("%Y-%m-%d"), 75 | "date_joined_1": user.date_joined.strftime("%H:%M:%S"), 76 | "initial-date_joined_0": user.date_joined.strftime("%Y-%m-%d"), 77 | "initial-date_joined_1": user.date_joined.strftime("%H:%M:%S"), 78 | "full_name": user.full_name, 79 | "short_name": user.short_name, 80 | } 81 | 82 | def test_display_fields(self): 83 | """Test that admin shows all user fields""" 84 | excluded_model_fields = ["id", "logentry"] 85 | model_fields = { 86 | field.name 87 | for field in User._meta.get_fields() 88 | if field.name not in excluded_model_fields 89 | } 90 | admin_fieldset_fields = { 91 | fieldname 92 | for name, fieldset in UserAdmin.fieldsets 93 | for fieldname in fieldset["fields"] 94 | } 95 | self.assertEqual(model_fields, admin_fieldset_fields) 96 | 97 | def test_add_has_required_fields(self): 98 | """Test all required fields in Admin Add view""" 99 | excluded_model_fields = [ 100 | "date_joined", 101 | "is_active", 102 | "is_staff", 103 | "is_superuser", 104 | "password", 105 | ] 106 | required_model_fields = [ 107 | field.name 108 | for field in User._meta.get_fields() 109 | if ( 110 | field.name not in excluded_model_fields 111 | and hasattr(field, "null") 112 | and field.null is False 113 | and hasattr(field, "blank") 114 | and field.blank is False 115 | ) 116 | ] 117 | extra_form_fields = [ 118 | field_name 119 | for field_name in list( 120 | UserCreationForm.declared_fields, # pylint: disable=no-member 121 | ) 122 | ] 123 | admin_add_fields = [ 124 | fieldname 125 | for name, fieldset in UserAdmin.add_fieldsets 126 | for fieldname in fieldset["fields"] 127 | ] 128 | for field in required_model_fields + extra_form_fields: 129 | with self.subTest(field=field): 130 | self.assertIn(field, admin_add_fields) 131 | 132 | def test_correct_forms_used(self): 133 | """Test that UserAdmin uses the right forms""" 134 | self.assertIs(UserAdmin.add_form, UserCreationForm) 135 | self.assertIs(UserAdmin.form, UserChangeForm) 136 | 137 | def test_user_add(self): 138 | """Ensure the admin add view works correctly""" 139 | # we can get the form view 140 | get_response = self.client.get( 141 | reverse("auth_test_admin:improved_user_user_add") 142 | ) 143 | self.assertEqual(get_response.status_code, 200) 144 | # we can create new users in the form view 145 | post_response = self.client.post( 146 | reverse("auth_test_admin:improved_user_user_add"), 147 | { 148 | "email": "newuser@example.com", 149 | "password1": "passw0rd1!", 150 | "password2": "passw0rd1!", 151 | }, 152 | follow=True, 153 | ) 154 | self.assertEqual(post_response.status_code, 200) 155 | self.assertTrue( 156 | User.objects.filter(email="newuser@example.com").exists() 157 | ) 158 | new_user = User.objects.get(email="newuser@example.com") 159 | self.assertTrue(new_user.check_password("passw0rd1!")) 160 | 161 | def test_user_change_email(self): 162 | """Test that user can change email in Admin""" 163 | data = self.get_user_data(self.admin) 164 | data["email"] = "new_" + data["email"] 165 | response = self.client.post( 166 | reverse( 167 | "auth_test_admin:improved_user_user_change", 168 | args=(self.admin.pk,), 169 | ), 170 | data, 171 | ) 172 | self.assertRedirects( 173 | response, reverse("auth_test_admin:improved_user_user_changelist") 174 | ) 175 | row = LogEntry.objects.latest("id") 176 | if DJANGO_VERSION >= (3, 0): 177 | self.assertEqual( 178 | row.get_change_message(), "Changed Email address." 179 | ) 180 | else: 181 | self.assertEqual(row.get_change_message(), "Changed email.") 182 | 183 | def test_user_not_change(self): 184 | """Test that message is raised when form submitted unchanged""" 185 | response = self.client.post( 186 | reverse( 187 | "auth_test_admin:improved_user_user_change", 188 | args=(self.admin.pk,), 189 | ), 190 | self.get_user_data(self.admin), 191 | ) 192 | self.assertRedirects( 193 | response, reverse("auth_test_admin:improved_user_user_changelist") 194 | ) 195 | row = LogEntry.objects.latest("id") 196 | self.assertEqual(row.get_change_message(), "No fields changed.") 197 | 198 | def test_user_change_password(self): 199 | """Test that URL to change password form is correct""" 200 | user_change_url = reverse( 201 | "auth_test_admin:improved_user_user_change", args=(self.admin.pk,) 202 | ) 203 | password_change_url = reverse( 204 | "auth_test_admin:auth_user_password_change", args=(self.admin.pk,) 205 | ) 206 | 207 | response = self.client.get(user_change_url) 208 | # Test the link inside password field help_text. 209 | rel_link = re.search( 210 | r"you can change the password using " 211 | r'this form', 212 | force_str(response.content), 213 | ).groups()[0] 214 | self.assertEqual( 215 | os.path.normpath(user_change_url + rel_link), 216 | os.path.normpath(password_change_url), 217 | ) 218 | 219 | response = self.client.post( 220 | password_change_url, 221 | { 222 | "password1": "password1", 223 | "password2": "password1", 224 | }, 225 | ) 226 | self.assertRedirects(response, user_change_url) 227 | row = LogEntry.objects.latest("id") 228 | self.assertEqual(row.get_change_message(), "Changed password.") 229 | self.logout() 230 | self.login(password="password1") 231 | 232 | def test_user_change_password_subclass_path(self): 233 | """Test subclasses can override password URL""" 234 | 235 | class CustomChangeForm(UserChangeForm): 236 | """Subclass of UserChangeForm; uses rel_password_url""" 237 | 238 | rel_password_url = "moOps" 239 | 240 | form = CustomChangeForm() 241 | self.assertEqual(form.rel_password_url, "moOps") 242 | rel_link = re.search( 243 | r"you can change the password using " 244 | r'this form', 245 | form.fields["password"].help_text, 246 | ).groups()[0] 247 | self.assertEqual(rel_link, "moOps") 248 | 249 | def test_user_change_different_user_password(self): 250 | """Test that administrator can update other Users' passwords""" 251 | user = User.objects.get(email="staffmember@example.com") 252 | response = self.client.post( 253 | reverse( 254 | "auth_test_admin:auth_user_password_change", 255 | args=(user.pk,), 256 | ), 257 | { 258 | "password1": "password1", 259 | "password2": "password1", 260 | }, 261 | ) 262 | self.assertRedirects( 263 | response, 264 | reverse( 265 | "auth_test_admin:improved_user_user_change", args=(user.pk,) 266 | ), 267 | ) 268 | row = LogEntry.objects.latest("id") 269 | self.assertEqual(row.user_id, self.admin.pk) 270 | self.assertEqual(row.object_id, str(user.pk)) 271 | self.assertEqual(row.get_change_message(), "Changed password.") 272 | 273 | def test_changelist_disallows_password_lookups(self): 274 | """Users shouldn't be allowed to guess password 275 | 276 | Checks against repeated password__startswith queries 277 | https://code.djangoproject.com/ticket/20078 278 | 279 | """ 280 | # A lookup that tries to filter on password isn't OK 281 | with self.assertLogs( 282 | "django.security.DisallowedModelAdminLookup", 283 | "ERROR", 284 | ) as logger_calls: 285 | response = self.client.get( 286 | reverse("auth_test_admin:improved_user_user_changelist") 287 | + "?password__startswith=sha1$" 288 | ) 289 | self.assertEqual(response.status_code, 400) 290 | self.assertEqual(len(logger_calls.output), 1, logger_calls.output) 291 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | """Test basic functionality; test API used by a Django project developer""" 2 | 3 | from django.contrib.auth import get_user, get_user_model 4 | from django.http import HttpRequest 5 | from django.test import TestCase 6 | from django.utils import translation 7 | 8 | from improved_user.models import User 9 | 10 | 11 | class BasicTestCase(TestCase): 12 | """Test Improved User to mimic Django Auth User 13 | 14 | The goal is to provide a User model that can be used as a drop-in 15 | replacement for Django Auth User, all while providing mixin classes 16 | to allow developers to easily extend their own class. These tests 17 | focus on the first part. 18 | """ 19 | 20 | def test_user_creation(self): 21 | """Users can be created and can set/modify their password""" 22 | email_lowercase = "test@example.com" 23 | password = "password1!" 24 | user = User.objects.create_user(email_lowercase, password) 25 | self.assertEqual(user.email, email_lowercase) 26 | self.assertTrue(user.has_usable_password()) 27 | self.assertFalse(user.check_password("wrong")) 28 | self.assertTrue(user.check_password(password)) 29 | 30 | # Check we can manually set an unusable password 31 | user.set_unusable_password() 32 | user.save() 33 | self.assertFalse(user.check_password(password)) 34 | self.assertFalse(user.has_usable_password()) 35 | user.set_password(password) 36 | self.assertTrue(user.check_password(password)) 37 | user.set_password(None) 38 | self.assertFalse(user.has_usable_password()) 39 | 40 | # can add short and full name 41 | user.full_name = "John Smith" 42 | user.short_name = "John" 43 | user.save() 44 | self.assertEqual(user.get_full_name(), "John Smith") 45 | self.assertEqual(user.get_short_name(), "John") 46 | 47 | def test_user_creation_without_password(self): 48 | """Users can be created without password""" 49 | user = User.objects.create_user("test@example.com") 50 | self.assertFalse(user.has_usable_password()) 51 | 52 | def test_unicode_email(self): # pylint: disable=no-self-use 53 | """Unicode emails are allowed in Model 54 | 55 | Note that Django's Email field validator will error on 56 | the following, meaning that while the model accepts 57 | these values, the forms will not. Some work required. 58 | 59 | """ 60 | User.objects.create_user("Pelé@example.com") 61 | User.objects.create_user("δοκιμή@παράδειγμα.δοκιμή") 62 | User.objects.create_user("我買@屋企.香港") 63 | User.objects.create_user("甲斐@黒川.日本") 64 | User.objects.create_user("чебурашка@ящик-с-апельсинами.рф") 65 | User.objects.create_user("संपर्क@डाटामेल.भारत") 66 | # Unlike usernames, emails are not normalized, 67 | # identical glyphs with different codepoints are allowed 68 | omega_emails = "iamtheΩ@email.com" # U+03A9 GREEK CAPITAL LETTER OMEGA 69 | ohm_username = "iamtheΩ@email.com" # U+2126 OHM SIGN 70 | User.objects.create_user(omega_emails) 71 | User.objects.create_user(ohm_username) 72 | 73 | def test_user_permissions(self): 74 | """Test normal user's authentication permissions""" 75 | user = User.objects.create_user("test@example.com") 76 | # Check authentication/permissions 77 | self.assertFalse(user.is_anonymous) 78 | self.assertTrue(user.is_authenticated) 79 | self.assertFalse(user.is_staff) 80 | self.assertTrue(user.is_active) 81 | self.assertFalse(user.is_superuser) 82 | 83 | def test_superuser_permissions(self): 84 | """Test superuser's authentication permissions""" 85 | user = User.objects.create_superuser("test@example.com", "password1!") 86 | self.assertFalse(user.is_anonymous) 87 | self.assertTrue(user.is_authenticated) 88 | self.assertTrue(user.is_superuser) 89 | self.assertTrue(user.is_active) 90 | self.assertTrue(user.is_staff) 91 | 92 | def test_username_getter(self): 93 | """Check username getter method""" 94 | user = User.objects.create_user("test@example.com") 95 | self.assertEqual(user.get_username(), "test@example.com") 96 | 97 | def test_default_email_method(self): 98 | """Test correct email field used in method""" 99 | user = User() 100 | self.assertEqual(user.get_email_field_name(), "email") 101 | 102 | def test_default_email_field(self): 103 | """Test correct email field used""" 104 | self.assertEqual(User.EMAIL_FIELD, "email") 105 | 106 | def test_is_active(self): 107 | """Test that is_active can be modified""" 108 | user = User.objects.create(email="foo@bar.com") 109 | # is_active is true by default 110 | self.assertIs(user.is_active, True) 111 | user.is_active = False 112 | user.save() 113 | user_fetched = User.objects.get(pk=user.pk) 114 | # the is_active flag is saved 115 | self.assertIs(user_fetched.is_active, False) 116 | 117 | def test_is_staff(self): 118 | """Test that is_staff can be modified""" 119 | user = User.objects.create(email="foo@bar.com") 120 | # is_active is true by default 121 | self.assertIs(user.is_staff, False) 122 | user.is_staff = True 123 | user.save() 124 | user_fetched = User.objects.get(pk=user.pk) 125 | # the is_active flag is saved 126 | self.assertIs(user_fetched.is_staff, True) 127 | 128 | def test_is_superuser(self): 129 | """Test that is_superuser can be modified""" 130 | user = User.objects.create(email="foo@bar.com") 131 | # is_active is true by default 132 | self.assertIs(user.is_superuser, False) 133 | user.is_superuser = True 134 | user.save() 135 | user_fetched = User.objects.get(pk=user.pk) 136 | # the is_active flag is saved 137 | self.assertIs(user_fetched.is_superuser, True) 138 | 139 | def test_get_user_model(self): 140 | """The improved user model can be retrieved""" 141 | self.assertEqual(get_user_model(), User) 142 | 143 | from django.contrib.auth.models import User as DjangoUser 144 | 145 | with self.assertRaises(AttributeError): 146 | DjangoUser.objects.all() 147 | 148 | def test_user_verbose_names_translatable(self): 149 | """User model verbose names are translatable (#19945)""" 150 | with translation.override("en"): 151 | self.assertEqual(User._meta.verbose_name, "user") 152 | self.assertEqual(User._meta.verbose_name_plural, "users") 153 | with translation.override("es"): 154 | self.assertEqual(User._meta.verbose_name, "usuario") 155 | self.assertEqual(User._meta.verbose_name_plural, "usuarios") 156 | 157 | def test_get_user(self): 158 | """Improved User can be extracted from request""" 159 | created_user = User.objects.create_user("test@example.com", "testpw") 160 | self.client.login(username="test@example.com", password="testpw") 161 | request = HttpRequest() 162 | request.session = self.client.session 163 | user = get_user(request) 164 | self.assertIsInstance(user, User) 165 | self.assertEqual(user.email, created_user.email) 166 | -------------------------------------------------------------------------------- /tests/test_factories.py: -------------------------------------------------------------------------------- 1 | """Test model factories provided by Improved User""" 2 | 3 | from django.test import TestCase 4 | 5 | from improved_user.factories import UserFactory 6 | from improved_user.models import User 7 | 8 | 9 | class UserFactoryTests(TestCase): 10 | """Test for UserFactory used with Factory Boy""" 11 | 12 | def test_basic_build(self): 13 | """Test creation of User via factory""" 14 | user = UserFactory.build() 15 | self.assertIsInstance(user, User) 16 | self.assertIsInstance(user.email, str) 17 | self.assertIsInstance(user.short_name, str) 18 | self.assertIsInstance(user.full_name, str) 19 | self.assertGreater(len(user.email), 1) 20 | self.assertGreater(len(user.short_name), 1) 21 | self.assertGreater(len(user.full_name), 1) 22 | self.assertTrue(user.check_password("password!")) 23 | self.assertTrue(user.is_active) 24 | self.assertFalse(user.is_staff) 25 | self.assertFalse(user.is_superuser) 26 | self.assertEqual(User.objects.all().count(), 0) 27 | user.save() 28 | self.assertEqual(User.objects.all().count(), 1) 29 | 30 | def test_basic_create(self): 31 | """Test creation of User via factory saves to DB""" 32 | user = UserFactory() 33 | self.assertIsInstance(user, User) 34 | self.assertEqual(User.objects.all().count(), 1) 35 | 36 | def test_attributes_override_build(self): 37 | """Test that all model fields can be modified""" 38 | user = UserFactory.build( 39 | email="hello@jambonsw.com", 40 | password="my_secret_password87", 41 | short_name="René", 42 | full_name="René Magritte", 43 | is_active=False, 44 | is_staff=True, 45 | is_superuser=True, 46 | ) 47 | self.assertIsInstance(user, User) 48 | self.assertEqual(user.email, "hello@jambonsw.com") 49 | self.assertEqual(user.short_name, "René") 50 | self.assertEqual(user.full_name, "René Magritte") 51 | self.assertTrue(user.check_password("my_secret_password87")) 52 | self.assertFalse(user.is_active) 53 | self.assertTrue(user.is_staff) 54 | self.assertTrue(user.is_superuser) 55 | self.assertEqual(User.objects.all().count(), 0) 56 | user.save() 57 | self.assertEqual(User.objects.all().count(), 1) 58 | 59 | def test_attributes_override_create(self): 60 | """Test that all model fields can be modified during creation""" 61 | user = UserFactory( 62 | email="hello@jambonsw.com", 63 | password="my_secret_password87", 64 | short_name="René", 65 | full_name="René Magritte", 66 | is_active=False, 67 | is_staff=True, 68 | is_superuser=True, 69 | ) 70 | self.assertIsInstance(user, User) 71 | self.assertTrue(user.check_password("my_secret_password87")) 72 | self.assertEqual(User.objects.all().count(), 1) 73 | -------------------------------------------------------------------------------- /tests/test_management.py: -------------------------------------------------------------------------------- 1 | """Test User model management commands""" 2 | 3 | import builtins 4 | from io import StringIO 5 | 6 | from django.contrib.auth.management.commands import createsuperuser 7 | from django.core.management import call_command 8 | from django.core.management.base import CommandError 9 | from django.test import TestCase, override_settings 10 | 11 | from improved_user.models import User 12 | 13 | 14 | # pylint: disable=missing-docstring,too-few-public-methods 15 | def mock_inputs(inputs): 16 | """Replace input/getpass 17 | 18 | Use as decorator to allow interactive createsuperuser. 19 | """ 20 | 21 | def inner(test_func): 22 | def wrapped(*args): 23 | class MockGetPass: 24 | # pylint: disable=unused-argument 25 | @staticmethod 26 | def getpass(prompt=b"Password: ", stream=None): 27 | if callable(inputs["password"]): 28 | return inputs["password"]() 29 | return inputs["password"] 30 | 31 | # pylint: enable=unused-argument 32 | 33 | def mock_input(prompt): 34 | assert "__proxy__" not in prompt 35 | response = "" 36 | for key, val in inputs.items(): 37 | if key in prompt.lower(): 38 | if callable(val): 39 | response = val() 40 | else: 41 | response = val 42 | break 43 | return response 44 | 45 | old_getpass = createsuperuser.getpass 46 | old_input = builtins.input 47 | createsuperuser.getpass = MockGetPass 48 | builtins.input = mock_input 49 | try: 50 | test_func(*args) 51 | finally: 52 | createsuperuser.getpass = old_getpass 53 | builtins.input = old_input 54 | 55 | return wrapped 56 | 57 | return inner 58 | 59 | 60 | class MockTTY: 61 | """A fake stdin object 62 | 63 | Pretend to be a TTY; to be used in conjunction with mock_inputs. 64 | """ 65 | 66 | def isatty(self): # pylint: disable=no-self-use 67 | """Assert this is a TTY""" 68 | return True 69 | 70 | 71 | # pylint: enable=missing-docstring,too-few-public-methods 72 | 73 | 74 | @override_settings( 75 | AUTH_PASSWORD_VALIDATORS=[ 76 | { 77 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator" 78 | } 79 | ] 80 | ) 81 | class CreatesuperuserManagementCommandTestCase(TestCase): 82 | """Test createsuperuser management command""" 83 | 84 | def test_basic_usage(self): 85 | """Check the operation of the createsuperuser management command""" 86 | # We can use the management command to create a superuser 87 | new_io = StringIO() 88 | call_command( 89 | "createsuperuser", 90 | interactive=False, 91 | email="joe@somewhere.org", 92 | short_name="Joe", 93 | full_name="Joe Smith", 94 | stdout=new_io, 95 | ) 96 | command_output = new_io.getvalue().strip() 97 | self.assertEqual(command_output, "Superuser created successfully.") 98 | user = User.objects.get(email="joe@somewhere.org") 99 | self.assertEqual(user.short_name, "Joe") 100 | 101 | # created password should be unusable 102 | self.assertFalse(user.has_usable_password()) 103 | 104 | def test_no_email_argument(self): 105 | """Ensure that email is required""" 106 | new_io = StringIO() 107 | with self.assertRaisesMessage( 108 | CommandError, "You must use --email with --noinput." 109 | ): 110 | call_command( 111 | "createsuperuser", 112 | interactive=False, 113 | short_name="Joe", 114 | full_name="Joe Smith", 115 | stdout=new_io, 116 | ) 117 | 118 | def test_invalid_username(self): 119 | """Creation fails if the username fails validation.""" 120 | email_field = User._meta.get_field("email") 121 | new_io = StringIO() 122 | invalid_username = ("x" * email_field.max_length) + "y" 123 | 124 | expected_error = ( 125 | "Enter a valid email address.; " 126 | "Ensure this value has at most %d characters (it has %d)." 127 | % (email_field.max_length, len(invalid_username)) 128 | ) 129 | 130 | with self.assertRaisesMessage(CommandError, expected_error): 131 | call_command( 132 | "createsuperuser", 133 | interactive=False, 134 | email=invalid_username, 135 | short_name="Joe", 136 | full_name="Joe Smith", 137 | stdout=new_io, 138 | ) 139 | 140 | def test_password_validation(self): 141 | """Creation should fail if the password fails validation.""" 142 | new_io = StringIO() 143 | 144 | index = [0] 145 | 146 | # Returns '1234567890' the first two times it is called, then 147 | # 'password' subsequently. 148 | def bad_then_good_password(): 149 | """Simulate user input on terminal""" 150 | index[0] += 1 151 | if index[0] <= 2: 152 | return "1234567890" 153 | return "password" 154 | 155 | @mock_inputs( 156 | { 157 | "password": bad_then_good_password, 158 | } 159 | ) 160 | def test(self): 161 | """The actual test function; run with multiple inputs""" 162 | call_command( 163 | "createsuperuser", 164 | email="hello@jambonsw.com", 165 | full_name="Joe Smith", 166 | interactive=True, 167 | short_name="Joe", 168 | stderr=new_io, 169 | stdin=MockTTY(), 170 | stdout=new_io, 171 | ) 172 | expected_out = ( 173 | "This password is entirely numeric.\n" 174 | "Superuser created successfully." 175 | ) 176 | self.assertEqual(new_io.getvalue().strip(), expected_out) 177 | 178 | test(self) 179 | -------------------------------------------------------------------------------- /tests/test_managers.py: -------------------------------------------------------------------------------- 1 | """Test User model manager""" 2 | 3 | from datetime import datetime 4 | 5 | from django.test import TestCase 6 | 7 | from improved_user.managers import UserManager 8 | from improved_user.models import User 9 | 10 | 11 | class UserManagerTestCase(TestCase): 12 | """Test User model manager""" 13 | 14 | def test_create_user_email_domain_normalize_rfc3696(self): 15 | """Normalize email allows @ in email local section""" 16 | # According to http://tools.ietf.org/html/rfc3696#section-3 17 | # the "@" symbol can be part of the local part of an email address 18 | returned = UserManager.normalize_email(r"Abc\@DEF@EXAMPLE.com") 19 | self.assertEqual(returned, r"Abc\@DEF@example.com") 20 | 21 | def test_create_user_email_domain_normalize(self): 22 | """Normalize email lowercases domain""" 23 | returned = UserManager.normalize_email("normal@DOMAIN.COM") 24 | self.assertEqual(returned, "normal@domain.com") 25 | 26 | def test_create_user_email_domain_normalize_with_whitespace(self): 27 | """Normalize email allows whitespace in email local section""" 28 | # pylint: disable=anomalous-backslash-in-string 29 | returned = UserManager.normalize_email(r"email\ with_whitespace@D.COM") 30 | self.assertEqual(returned, r"email\ with_whitespace@d.com") 31 | # pylint: enable=anomalous-backslash-in-string 32 | 33 | def test_empty_username(self): 34 | """Manager raises error if email is missing""" 35 | self.assertRaisesMessage( 36 | ValueError, 37 | "An email address must be provided.", 38 | User.objects.create_user, 39 | email="", 40 | ) 41 | 42 | def test_create_user_is_staff(self): 43 | """Check is_staff attribute is respected""" 44 | email = "normal@normal.com" 45 | user = User.objects.create_user(email, is_staff=True) 46 | self.assertEqual(user.email, email) 47 | self.assertTrue(user.is_staff) 48 | 49 | def test_create_user_is_active(self): 50 | """Check is_active attribute is respected""" 51 | email = "normal@normal.com" 52 | user = User.objects.create_user(email, is_active=False) 53 | self.assertEqual(user.email, email) 54 | self.assertFalse(user.is_active) 55 | 56 | def test_username_keyword_raises_warning(self): 57 | """Remind dev that username doesn't exist on model""" 58 | error = ( 59 | "The Improved User model does not have a username; " 60 | "it uses only email" 61 | ) 62 | with self.assertRaisesMessage(ValueError, error): 63 | User.objects.create_user( 64 | username="whoops", 65 | email="test@test.com", 66 | password="test", 67 | ) 68 | with self.assertRaisesMessage(ValueError, error): 69 | User.objects.create_superuser( 70 | username="whoops", 71 | email="test@test.com", 72 | password="test", 73 | ) 74 | 75 | def test_create_super_user_raises_error_on_false_is_superuser(self): 76 | """Warn developer when creating superuse without is_superuser""" 77 | error = "Superuser must have is_superuser=True." 78 | with self.assertRaisesMessage(ValueError, error): 79 | User.objects.create_superuser( 80 | email="test@test.com", 81 | password="test", 82 | is_superuser=False, 83 | ) 84 | 85 | def test_create_superuser_raises_error_on_false_is_staff(self): 86 | """Warn developer when creating superuse without is_staff""" 87 | error = "Superuser must have is_staff=True." 88 | with self.assertRaisesMessage(ValueError, error): 89 | User.objects.create_superuser( 90 | email="test@test.com", 91 | password="test", 92 | is_staff=False, 93 | ) 94 | 95 | def test_make_random_password(self): 96 | """Test manager make_random_password method""" 97 | allowed_chars = "abcdefg" 98 | password = UserManager().make_random_password(5, allowed_chars) 99 | self.assertEqual(len(password), 5) 100 | for char in password: 101 | self.assertIn(char, allowed_chars) 102 | 103 | def test_last_login_is_none(self): 104 | """Check that last login is unset when created 105 | 106 | https://github.com/jambonsw/django-improved-user/issues/25 107 | """ 108 | user1 = User.objects.create_user("hello@jambonsw.com", "password1") 109 | self.assertIsNone(user1.last_login) 110 | 111 | user2 = User.objects.create_superuser("clark@kent.com", "password1") 112 | self.assertIsNone(user2.last_login) 113 | 114 | def test_date_joined_default(self): 115 | """Check date joined set upon creation""" 116 | user1 = User.objects.create_user("hello@jambonsw.com", "password1") 117 | self.assertIsNotNone(user1.date_joined) 118 | self.assertIsInstance(user1.date_joined, datetime) 119 | 120 | user2 = User.objects.create_superuser("clark@kent.com", "password1") 121 | self.assertIsNotNone(user2.date_joined) 122 | self.assertIsInstance(user2.date_joined, datetime) 123 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | """Test Improved User Model""" 2 | 3 | from datetime import datetime 4 | from types import MethodType 5 | from unittest.mock import patch 6 | 7 | from django.contrib.auth.hashers import get_hasher 8 | from django.core import mail 9 | from django.test import TestCase 10 | 11 | from improved_user.models import User 12 | 13 | 14 | class UserModelTestCase(TestCase): 15 | """Improve User Model Test Suite""" 16 | 17 | def test_fields_and_attributes(self): 18 | """Ensure the model has the fields and attributes we expect""" 19 | expected_fields = ( 20 | "id", 21 | "password", 22 | "last_login", 23 | "is_superuser", 24 | "full_name", 25 | "short_name", 26 | "is_staff", 27 | "is_active", 28 | "date_joined", 29 | "email", 30 | "groups", 31 | "user_permissions", 32 | ) 33 | user_fields = [field.name for field in User._meta.get_fields()] 34 | for field in expected_fields: 35 | with self.subTest(field=field): 36 | self.assertIn(field, user_fields) 37 | # Pre-empt Django check auth.E001 38 | self.assertTrue(isinstance(User.REQUIRED_FIELDS, (list, tuple))) 39 | # Pre-empt Django check auth.E002 40 | self.assertNotIn(User.USERNAME_FIELD, User.REQUIRED_FIELDS) 41 | # Pre-empt Django check auth.E003 42 | self.assertIs(User._meta.get_field(User.USERNAME_FIELD).unique, True) 43 | # Pre-empt Django check auth.C009 44 | self.assertFalse(isinstance(User.is_anonymous, MethodType)) 45 | # Pre-empt Django check auth.C010 46 | self.assertFalse(isinstance(User.is_authenticated, MethodType)) 47 | 48 | def test_email_user(self): 49 | """Send Email to User via method""" 50 | # valid send_mail parameters 51 | kwargs = { 52 | "fail_silently": False, 53 | "auth_user": None, 54 | "auth_password": None, 55 | "connection": None, 56 | "html_message": None, 57 | } 58 | user = User(email="foo@bar.com") 59 | user.email_user( 60 | subject="Subject here", 61 | message="This is a message", 62 | from_email="from@domain.com", 63 | **kwargs, 64 | ) 65 | self.assertEqual(len(mail.outbox), 1) 66 | message = mail.outbox[0] 67 | self.assertEqual(message.subject, "Subject here") 68 | self.assertEqual(message.body, "This is a message") 69 | self.assertEqual(message.from_email, "from@domain.com") 70 | self.assertEqual(message.to, [user.email]) 71 | 72 | def test_last_login_default(self): 73 | """Check last login not set upon creation""" 74 | user1 = User.objects.create(email="test1@example.com") 75 | self.assertIsNone(user1.last_login) 76 | 77 | user2 = User.objects.create(email="test2@example.com") 78 | self.assertIsNone(user2.last_login) 79 | 80 | def test_date_joined_default(self): 81 | """Check date joined set upon creation""" 82 | user1 = User.objects.create(email="test1@example.com") 83 | self.assertIsNotNone(user1.date_joined) 84 | self.assertIsInstance(user1.date_joined, datetime) 85 | 86 | user2 = User.objects.create(email="test2@example.com") 87 | self.assertIsNotNone(user2.date_joined) 88 | self.assertIsInstance(user2.date_joined, datetime) 89 | 90 | def test_user_clean_normalize_email(self): 91 | """User email/username is normalized upon creation""" 92 | user = User(email="foo@BAR.com", password="foo") 93 | user.clean() 94 | self.assertEqual(user.email, "foo@bar.com") 95 | 96 | def test_user_double_save(self): 97 | """Trigger password_changed() once if user.save() called twice""" 98 | user = User.objects.create_user( 99 | email="test@example.com", password="foo" 100 | ) 101 | user.set_password("bar") 102 | with patch( 103 | "django.contrib.auth.password_validation.password_changed", 104 | ) as pw_changed: 105 | user.save() 106 | self.assertEqual(pw_changed.call_count, 1) 107 | user.save() 108 | self.assertEqual(pw_changed.call_count, 1) 109 | 110 | def test_check_password_upgrade(self): 111 | """Don't update password if adjusting hash iteration 112 | 113 | password_changed() shouldn't be called if User.check_password() 114 | triggers a hash iteration upgrade. 115 | """ 116 | user = User.objects.create_user( 117 | email="test@example.com", password="foo" 118 | ) 119 | initial_password = user.password 120 | self.assertTrue(user.check_password("foo")) 121 | hasher = get_hasher("default") 122 | self.assertEqual("pbkdf2_sha256", hasher.algorithm) 123 | 124 | old_iterations = hasher.iterations 125 | try: 126 | # Upgrade the password iterations 127 | hasher.iterations = old_iterations + 1 128 | with patch( 129 | "django.contrib.auth.password_validation.password_changed", 130 | ) as pw_changed: 131 | user.check_password("foo") 132 | self.assertEqual(pw_changed.call_count, 0) 133 | self.assertNotEqual(initial_password, user.password) 134 | finally: 135 | hasher.iterations = old_iterations 136 | -------------------------------------------------------------------------------- /tests/test_signals.py: -------------------------------------------------------------------------------- 1 | """Test Signal Handling""" 2 | 3 | from django.db.models.signals import post_save 4 | from django.test import TestCase 5 | 6 | from improved_user.models import User 7 | 8 | 9 | class TestCreateSuperUserSignals(TestCase): 10 | """Simple test case for ticket #20541""" 11 | 12 | # pylint: disable=unused-argument 13 | def post_save_listener(self, *args, **kwargs): 14 | """Note when signal sent; helper function""" 15 | self.signals_count += 1 16 | 17 | # pylint: enable=unused-argument 18 | 19 | def setUp(self): 20 | """Connect function above to postsave User model signal""" 21 | self.signals_count = 0 22 | post_save.connect(self.post_save_listener, sender=User) 23 | 24 | def tearDown(self): 25 | """Connect utility function from postsave""" 26 | post_save.disconnect(self.post_save_listener, sender=User) 27 | 28 | def test_create_user(self): 29 | """Test User Creation""" 30 | User.objects.create_user("mail@example.com") 31 | self.assertEqual(self.signals_count, 1) 32 | 33 | def test_create_superuser(self): 34 | """Test Super User Creation""" 35 | User.objects.create_superuser("mail@example.com", "password") 36 | self.assertEqual(self.signals_count, 1) 37 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | """Test URLs for auth admins""" 2 | 3 | from django.contrib import admin 4 | from django.contrib.auth.admin import GroupAdmin 5 | from django.contrib.auth.models import Group 6 | from django.contrib.auth.urls import urlpatterns 7 | from django.urls import re_path 8 | 9 | from improved_user.admin import UserAdmin 10 | from improved_user.models import User 11 | 12 | # Create a silo'd admin site for just the user/group admins. 13 | SITE = admin.AdminSite(name="auth_test_admin") 14 | SITE.register(User, UserAdmin) 15 | SITE.register(Group, GroupAdmin) 16 | 17 | urlpatterns += [ 18 | re_path(r"^admin/", SITE.urls), 19 | ] 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | py312-django42-pkgcheck, 5 | py312-docs, 6 | py{38,39,310,311,312}-django42-unit, 7 | py312-django42-{extension,replacement} 8 | 9 | [testenv] 10 | changedir = 11 | docs: docs 12 | extension: example_extension_project 13 | replacement: example_replacement_project 14 | unit: {toxinidir} 15 | skip_install = 16 | pkgcheck: true 17 | extras = 18 | {extension,replacement,unit}: factory 19 | setenv = 20 | PYTHONDONTWRITEBYTECODE=1 21 | {extension,replacement,unit}: PYTHONWARNINGS=once 22 | deps = 23 | {extension,pkgcheck,replacement,unit}: -r{toxinidir}/requirements.txt 24 | docs: -r{toxinidir}/doc-requirements.txt 25 | extension: -r{toxinidir}/example_extension_project/requirements.txt 26 | replacement: -r{toxinidir}/example_replacement_project/requirements.txt 27 | django42: Django>=4.2,<4.3 28 | commands = 29 | docs: sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 30 | docs: python -msphinx -b linkcheck . build/linkcheck 31 | {extension,replacement}: coverage erase 32 | {extension,replacement}: coverage run manage.py test {posargs} 33 | {extension,replacement}: coverage combine --append 34 | {extension,replacement}: coverage report 35 | pkgcheck: check-manifest {toxinidir} 36 | unit: coverage erase 37 | unit: coverage run runtests.py {posargs} 38 | unit: coverage combine --append 39 | unit: coverage report 40 | --------------------------------------------------------------------------------