diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 14a69c4e..00000000 --- a/.coveragerc +++ /dev/null @@ -1,7 +0,0 @@ -[run] -source = account -omit = account/tests/* -branch = 1 - -[report] -omit = account/tests/* diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 00000000..9e84a0db --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,16 @@ +version = 1 + +exclude_patterns = [ + "makemigrations.py", + "runtests.py", + "account/tests/**", + "account/tests/test_*.py", +] + +[[analyzers]] +name = "python" +enabled = true + + [analyzers.meta] + max_line_length = 120 + runtime_version = "3.x.x" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..0cc1bb37 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,56 @@ +name: Lints and Tests +on: [push] +jobs: + lint: + name: Linting + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install lint dependencies + run: | + python -m pip install --upgrade pip + pip install ruff + + - name: Lint with ruff + run: | + ruff --format=github --target-version=py311 account + + test: + name: Testing + runs-on: ubuntu-latest + strategy: + matrix: + python: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + django: + - "3.2.*" + - "4.2.*" + exclude: + - python: "3.11" + django: "3.2.*" + + steps: + - uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + + - name: Install Django + shell: bash + run: pip install Django==${{ matrix.django }} 'django-appconf>=1.0.4' 'pytz>=2020.4' + + - name: Running Python Tests + shell: bash + run: python3 runtests.py diff --git a/.gitignore b/.gitignore index 46f1e5d1..3ea1898a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,44 @@ -*.pyc +MANIFEST +.DS_Store + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +docs/_build/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +*.eggs +.python-version + +# Pipfile +Pipfile +Pipfile.lock + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ .coverage -*.egg-info +.cache +nosetests.xml +coverage.xml + +# IDEs +.idea/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1741e05f..00000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -sudo: false -language: python -python: - - "2.7" - - "3.3" - - "3.4" - - "3.5" -env: - - DJANGO=1.8 - - DJANGO=1.9 - - DJANGO=master -matrix: - exclude: - - python: "3.3" - env: DJANGO=1.9 - - python: "3.3" - env: DJANGO=master -install: - - pip install tox coveralls -script: - - tox -e py${TRAVIS_PYTHON_VERSION//[.]/}-$DJANGO -after_success: - - coveralls -notifications: - slack: pinax:7G2T4nTnSuv4ZhmJJ3StMM3m diff --git a/CHANGELOG.md b/CHANGELOG.md index f704c450..b68b889d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,91 @@ -# ChangeLog +# Change Log + +BI indicates a backward incompatible change. Take caution when upgrading to a +version with these. Your code will need to be updated to continue working. + +## 3.3.2 + +* #375 - Include migration for `SignupCode.max_uses` (closes #374) +* #376 - Static analysis fixups + +## 3.3.1 + +* #373 - Re-include migrations in distribution + +## 3.3.0 + +* #370 Drop Django 2.2, fix timezone-aware comparison, packaging tweaks + +## 3.2.1 + +* #364 - Performance fix to admin classes + +## 3.2.0 + +* #363 - Django 4.0 compat: `ugettext_lazy` -> `gettext_lazy` + +## 3.1.0 + +* #205 - Bug fix on checking email against email not signup code +* #225 - Fix case sensitivity mismatch on email addresses +* #233 - Fix link to languages in docs +* #247 - Update Spanish translations +* #273 - Update German translations +* #135 - Update Russian translations +* #242 - Fix callbacks/hooks for account deletion +* #251 (#249) - Allow overriding the password reset token url +* #280 - Raise improper config error if signup view can't login +* #348 (#337) - Make https the default protocol +* #351 (#332) - Reduction in queries +* #360 (#210) - Updates to docs +* #361 (#141) - Added ability to override clean passwords +* #362 - Updated CI to use Pinax Actions +* Updates to packaging +* Dropped Python 3.5 and Django 3.1 from test matrix +* Added Python 3.10 to test matrix + + +## 3.0.3 + +* Fix deprecated urls +* Update template context processors docs +* Fix deprecrated argument in signals +* Update decorators for Django 3 +* Fix issue with lazy string +* Drop deprecated `force_text()` + +## 3.0.2 + +* Drop Django 2.0 and Python 2,7, 3.4, and 3.5 support +* Add Django 2.1, 2.2 and 3.0, and Python 3.7 and 3.8 support +* Update packaging configs + +## 2.0.3 + + * fixed breaking change in 2.0.2 where context did not have uidb36 and token + * improved documentation + +## 2.0.2 + + * fixed potentional security issue with leaking password reset tokens through HTTP Referer header + * added `never_cache`, `csrf_protect` and `sensitive_post_parameters` to appropriate views + +## 2.0.1 + +@@@ todo + +## 2.0.0 + + * BI: moved account deletion callbacks to hooksets + * BI: dropped Django 1.7 support + * BI: dropped Python 3.2 support + * BI: removed deprecated `ACCOUNT_USE_AUTH_AUTHENTICATE` setting with behavior matching its `True` value + * added Django 1.10 support + * added Turkish translations + * fixed migration with language codes to dynamically set + * added password expiration + * added password stripping by default + * added `ACCOUNT_EMAIL_CONFIRMATION_AUTO_LOGIN` feature (default is `False`) ## 1.3.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index e807a229..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,172 +0,0 @@ -# How to Contribute - -There are many ways you can help contribute to django-user-accounts. -Contributing code, writing documentation, translations, reporting bugs, as well -as reading and providing feedback on issues and pull requests, all are valid and -necessary ways to help. - -## Committing Code - -The great thing about using a distributed versioning control system like git -is that everyone becomes a committer. When other people write good patches -it makes it very easy to include their fixes/features and give them proper -credit for the work. - -We recommend that you do all your work in a separate branch. When you -are ready to work on a bug or a new feature create yourself a new branch. The -reason why this is important is you can commit as often you like. When you are -ready you can merge in the change. Let's take a look at a common workflow: - - git checkout -b task-566 - ... fix and git commit often ... - git push origin task-566 - -The reason we have created two new branches is to stay off of `master`. -Keeping master clean of only upstream changes makes yours and ours lives -easier. You can then send us a pull request for the fix/feature. Then we can -easily review it and merge it when ready. - - -### Writing Commit Messages - -Writing a good commit message makes it simple for us to identify what your -commit does from a high-level. There are some basic guidelines we'd like to -ask you to follow. - -A critical part is that you keep the **first** line as short and sweet -as possible. This line is important because when git shows commits and it has -limited space or a different formatting option is used the first line becomes -all someone might see. If your change isn't something non-trivial or there -reasoning behind the change is not obvious, then please write up an extended -message explaining the fix, your rationale, and anything else relevant for -someone else that might be reviewing the change. Lastly, if there is a -corresponding issue in Github issues for it, use the final line to provide -a message that will link the commit message to the issue and auto-close it -if appropriate. - - Add ability to travel back in time - - You need to be driving 88 miles per hour to generate 1.21 gigawatts of - power to properly use this feature. - - Fixes #88 - - -## Coding style - -When writing code to be included in django-user-accounts keep our style in mind: - -* Follow [PEP8](http://www.python.org/dev/peps/pep-0008/) there are some - cases where we do not follow PEP8. It is an excellent starting point. -* Follow [Django's coding style](http://docs.djangoproject.com/en/dev/internals/contributing/#coding-style) - we're pretty much in agreement on Django style outlined there. - -We would like to enforce a few more strict guides not outlined by PEP8 or -Django's coding style: - -* PEP8 tries to keep line length at 80 characters. We follow it when we can, - but not when it makes a line harder to read. It is okay to go a little bit - over 80 characters if not breaking the line improves readability. -* Use double quotes not single quotes. Single quotes are allowed in cases - where a double quote is needed in the string. This makes the code read - cleaner in those cases. -* Blank lines should contain no whitespace. -* Docstrings always use three double quotes on a line of their own, so, for - example, a single line docstring should take up three lines not one. -* Imports are grouped specifically and ordered alphabetically. This is shown - in the example below. -* Always use `reverse` and never `@models.permalink`. -* Tuples should be reserved for positional data structures and not used - where a list is more appropriate. -* URL patterns should use the `url()` function rather than a tuple. - -Here is an example of these rules applied: - - # first set of imports are stdlib imports - # non-from imports go first then from style import in their own group - import csv - - # second set of imports are Django imports with contrib in their own - # group. - from django.core.urlresolvers import reverse - from django.db import models - from django.utils import timezone - from django.utils.translation import ugettext_lazy as _ - - from django.contrib.auth.models import User - - # third set of imports are external apps (if applicable) - from tagging.fields import TagField - - # fourth set of imports are local apps - from .fields import MarkupField - - - class Task(models.Model): - """ - A model for storing a task. - """ - - creator = models.ForeignKey(User) - created = models.DateTimeField(default=timezone.now) - modified = models.DateTimeField(default=timezone.now) - - objects = models.Manager() - - class Meta: - verbose_name = _("task") - verbose_name_plural = _("tasks") - - def __unicode__(self): - return self.summary - - def save(self, **kwargs): - self.modified = datetime.now() - super(Task, self).save(**kwargs) - - def get_absolute_url(self): - return reverse("task_detail", kwargs={"task_id": self.pk}) - - # custom methods - - - class TaskComment(models.Model): - # ... you get the point ... - pass - - -## Pull Requests - -Please keep your pull requests focused on one specific thing only. If you -have a number of contributions to make, then please send seperate pull -requests. It is much easier on maintainers to receive small, well defined, -pull requests, than it is to have a single large one that batches up a -lot of unrelated commits. - -If you ended up making multiple commits for one logical change, please -rebase into a single commit. - - git rebase -i HEAD~10 # where 10 is the number of commits back you need - -This will pop up an editor with your commits and some instructions you want -to squash commits down by replacing 'pick' with 's' to have it combined with -the commit before it. You can squash multiple ones at the same time. - -When you save and exit the text editor where you were squashing commits, git -will squash them down and then present you with another editor with commit -messages. Choose the one to apply to the squashed commit (or write a new -one entirely.) Save and exit will complete the rebase. Use a forced push to -your fork. - - git push -f - - -## Translations - -We use [Transifex](https://www.transifex.com/) to handle translations. We -discourage pull requests with changes to translations. Transifex handles -translations better than dealing them through the pull request system. - -Head over to [django-user-accounts on Transifex](https://www.transifex.com/projects/p/django-user-accounts/) -and find the language you would like to contribute. If you do not find your -language then please submit an issue and we will get it setup. diff --git a/LICENSE b/LICENSE index 82ec3974..c9d23959 100644 --- a/LICENSE +++ b/LICENSE @@ -1,19 +1,21 @@ -# Copyright (c) 2012-2014 James Tauber and contributors -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +The MIT License (MIT) + +Copyright (c) 2012-present James Tauber and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in index f1b8dd38..79e1f602 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,13 @@ -include README.rst -include runtests.py +global-exclude *.py[cod] +include CHANGELOG.md +include LICENSE +include README.md +recursive-include account *.html +recursive-include account *.txt recursive-include account/locale * -recursive-include docs Makefile conf.py *.rst +recursive-include account/migrations *.py +recursive-include docs *.rst +exclude tox.ini +recursive-exclude django_user_accounts.egg-info * +exclude docs/conf.py +exclude docs/Makefile diff --git a/README.md b/README.md new file mode 100644 index 00000000..1776e548 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +![](https://pinaxproject.com/pinax-design/social-banners/DUA.png) + +[![](https://img.shields.io/pypi/v/django-user-accounts.svg)](https://pypi.python.org/pypi/django-user-accounts/) + +[![Build](https://github.com/pinax/django-user-accounts/actions/workflows/ci.yaml/badge.svg)](https://github.com/pinax/django-user-accounts/actions) +[![Codecov](https://img.shields.io/codecov/c/github/pinax/django-user-accounts.svg)](https://codecov.io/gh/pinax/django-user-accounts) +[![](https://img.shields.io/github/contributors/pinax/django-user-accounts.svg)](https://github.com/pinax/django-user-accounts/graphs/contributors) +[![](https://img.shields.io/github/issues-pr/pinax/django-user-accounts.svg)](https://github.com/pinax/django-user-accounts/pulls) +[![](https://img.shields.io/github/issues-pr-closed/pinax/django-user-accounts.svg)](https://github.com/pinax/django-user-accounts/pulls?q=is%3Apr+is%3Aclosed) + +[![](http://slack.pinaxproject.com/badge.svg)](http://slack.pinaxproject.com/) +[![](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) + + +# Table of Contents + +* [About Pinax](#about-pinax) +* [Overview](#overview) + * [Features](#features) + * [Supported Django and Python versions](#supported-django-and-python-versions) +* [Requirements](#requirements) +* [Documentation](#documentation) +* [Templates](#templates) +* [Contribute](#contribute) +* [Code of Conduct](#code-of-conduct) +* [Connect with Pinax](#connect-with-pinax) +* [License](#license) + + +## About Pinax + +Pinax is an open-source platform built on the Django Web Framework. It is an ecosystem of reusable Django apps, themes, and starter project templates. This collection can be found at http://pinaxproject.com. + + +## django-user-accounts + +### Overview + +`django-user-accounts` provides a Django project with a very extensible infrastructure for dealing with user accounts. + +#### Features + +* Functionality for: + * Log in (email or username authentication) + * Sign up + * Email confirmation + * Signup tokens for private betas + * Password reset + * Password expiration + * Account management (update account settings and change password) + * Account deletion +* Extensible class-based views and hooksets +* Custom `User` model support + +#### Supported Django and Python versions + +Django / Python | 3.8 | 3.9 | 3.10 | 3.11 +--------------- | --- | --- | ---- | ---- +3.2 | * | * | * | +4.2 | * | * | * | * + + +## Requirements + +* Django 3.2 or 4.2 +* Python 3.8, 3.9, 3.10, 3.11 +* django-appconf (included in ``install_requires``) +* pytz (included in ``install_requires``) + + +## Documentation + +See http://django-user-accounts.readthedocs.org/ for the `django-user-accounts` documentation. +On September 17th, 2015, we did a Pinax Hangout on `django-user-accounts`. You can read the recap blog post and find the video here http://blog.pinaxproject.com/2015/10/12/recap-september-pinax-hangout/. + +The Pinax documentation is available at http://pinaxproject.com/pinax/. If you would like to help us improve our documentation or write more documentation, please join our Slack team and let us know! + + +### Templates + +Default templates are provided by the `pinax-templates` app in the +[account](https://github.com/pinax/pinax-templates/tree/master/pinax/templates/templates/account) section of that project. + +Reference pinax-templates +[installation instructions](https://github.com/pinax/pinax-templates/blob/master/README.md#installation) to include these templates in your project. + +View live `pinax-templates` examples and source at [Pinax Templates](https://templates.pinaxproject.com/)! + +See the `django-user-accounts` [templates](https://django-user-accounts.readthedocs.io/en/latest/templates.html) documentation for more information. + + +## Contribute + +For an overview on how contributing to Pinax works read this [blog post](http://blog.pinaxproject.com/2016/02/26/recap-february-pinax-hangout/) +and watch the included video, or read our [How to Contribute](http://pinaxproject.com/pinax/how_to_contribute/) section. For concrete contribution ideas, please see our +[Ways to Contribute/What We Need Help With](http://pinaxproject.com/pinax/ways_to_contribute/) section. + +In case of any questions we recommend you join our [Pinax Slack team](http://slack.pinaxproject.com) and ping us there instead of creating an issue on GitHub. Creating issues on GitHub is of course also valid but we are usually able to help you faster if you ping us in Slack. + +We also highly recommend reading our blog post on [Open Source and Self-Care](http://blog.pinaxproject.com/2016/01/19/open-source-and-self-care/). + + +## Code of Conduct + +In order to foster a kind, inclusive, and harassment-free community, the Pinax Project +has a [code of conduct](http://pinaxproject.com/pinax/code_of_conduct/). +We ask you to treat everyone as a smart human programmer that shares an interest in Python, Django, and Pinax with you. + + +## Connect with Pinax + +For updates and news regarding the Pinax Project, please follow us on Twitter [@pinaxproject](https://twitter.com/pinaxproject) and check out our [Pinax Project blog](http://blog.pinaxproject.com). + + +## License + +Copyright (c) 2012-present James Tauber and contributors under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/README.rst b/README.rst deleted file mode 100644 index 2adab254..00000000 --- a/README.rst +++ /dev/null @@ -1,96 +0,0 @@ -==================== -Django User Accounts -==================== - -.. image:: http://slack.pinaxproject.com/badge.svg - :target: http://slack.pinaxproject.com/ - -.. image:: https://img.shields.io/travis/pinax/django-user-accounts.svg - :target: https://travis-ci.org/pinax/django-user-accounts - -.. image:: https://img.shields.io/coveralls/pinax/django-user-accounts.svg - :target: https://coveralls.io/r/pinax/django-user-accounts - -.. image:: https://img.shields.io/pypi/dm/django-user-accounts.svg - :target: https://pypi.python.org/pypi/django-user-accounts/ - -.. image:: https://img.shields.io/pypi/v/django-user-accounts.svg - :target: https://pypi.python.org/pypi/django-user-accounts/ - -.. image:: https://img.shields.io/badge/license-MIT-blue.svg - :target: https://pypi.python.org/pypi/django-user-accounts/ - - -Pinax -------- - -Pinax is an open-source platform built on the Django Web Framework. It is an ecosystem of reusable Django apps, themes, and starter project templates. -This collection can be found at http://pinaxproject.com. - -This app was developed as part of the Pinax ecosystem but is just a Django app and can be used independently of other Pinax apps. - - -django-user-accounts -------------------------- - -``django-user-accounts`` provides a Django project with a very extensible infrastructure for dealing -with user accounts. - - -Features ----------- - -* Functionality for: - - - Log in (email or username authentication) - - Sign up - - Email confirmation - - Signup tokens for private betas - - Password reset - - Account management (update account settings and change password) - - Account deletion - -* Extensible class-based views and hooksets -* Custom ``User`` model support - - -Requirements --------------- - -* Django 1.8 or 1.9 -* Python 2.7, 3.3, 3.4 or 3.5 -* django-appconf (included in ``install_requires``) -* pytz (included in ``install_requires``) - - -Documentation ----------------- - -See http://django-user-accounts.readthedocs.org/ for the ``django-user-accounts`` documentation. -We recently did a Pinax Hangout on ``django-user-accounts``, you can read the recap blog post and find the video here http://blog.pinaxproject.com/2015/10/12/recap-september-pinax-hangout/. - -The Pinax documentation is available at http://pinaxproject.com/pinax/. If you would like to help us improve our documentation or write more documentation, please join our Slack team and let us know! - - -Contribute ----------------- - -See this blog post http://blog.pinaxproject.com/2016/02/26/recap-february-pinax-hangout/ including a video, or our How to Contribute (http://pinaxproject.com/pinax/how_to_contribute/) section for an overview on how contributing to Pinax works. For concrete contribution ideas, please see our Ways to Contribute/What We Need Help With (http://pinaxproject.com/pinax/ways_to_contribute/) section. - -In case of any questions, we would recommend for you to join our Pinax Slack team (http://slack.pinaxproject.com) and ping us there instead of creating an issue on GitHub. Creating issues on GitHub is of course also valid but we are usually able to help you faster if you ping us in Slack. - -We would also highly recommend for your to read our Open Source and Self-Care blog post (http://blog.pinaxproject.com/2016/01/19/open-source-and-self-care/). - - -Code of Conduct ------------------ - -In order to foster a kind, inclusive, and harassment-free community, the Pinax Project has a code of conduct, which can be found here http://pinaxproject.com/pinax/code_of_conduct/. -We'd like to ask you to treat everyone as a smart human programmer that shares an interest in Python, Django, and Pinax with you. - - - -Pinax Project Blog and Twitter --------------------------------- - -For updates and news regarding the Pinax Project, please follow us on Twitter at @pinaxproject and check out our blog http://blog.pinaxproject.com. diff --git a/account/__init__.py b/account/__init__.py index 94c34e63..3e2d550b 100644 --- a/account/__init__.py +++ b/account/__init__.py @@ -1 +1 @@ -__version__ = "1.4.0.dev1" +__version__ = "3.3.2" diff --git a/account/admin.py b/account/admin.py index 7e0e3a28..c2f60b5f 100644 --- a/account/admin.py +++ b/account/admin.py @@ -1,8 +1,13 @@ -from __future__ import unicode_literals - from django.contrib import admin -from account.models import Account, SignupCode, AccountDeletion, EmailAddress +from account.models import ( + Account, + AccountDeletion, + EmailAddress, + PasswordExpiry, + PasswordHistory, + SignupCode, +) class SignupCodeAdmin(admin.ModelAdmin): @@ -17,6 +22,9 @@ class AccountAdmin(admin.ModelAdmin): raw_id_fields = ["user"] + def get_queryset(self, request): + return super().get_queryset(request).select_related('user') + class AccountDeletionAdmin(AccountAdmin): @@ -28,8 +36,29 @@ class EmailAddressAdmin(AccountAdmin): list_display = ["user", "email", "verified", "primary"] search_fields = ["email", "user__username"] + def get_queryset(self, request): + return super().get_queryset(request).select_related('user') + + +class PasswordExpiryAdmin(admin.ModelAdmin): + + raw_id_fields = ["user"] + + +class PasswordHistoryAdmin(admin.ModelAdmin): + + raw_id_fields = ["user"] + list_display = ["user", "timestamp"] + list_filter = ["user"] + ordering = ["user__username", "-timestamp"] + + def get_queryset(self, request): + return super().get_queryset(request).select_related('user') + admin.site.register(Account, AccountAdmin) admin.site.register(SignupCode, SignupCodeAdmin) admin.site.register(AccountDeletion, AccountDeletionAdmin) admin.site.register(EmailAddress, EmailAddressAdmin) +admin.site.register(PasswordExpiry, PasswordExpiryAdmin) +admin.site.register(PasswordHistory, PasswordHistoryAdmin) diff --git a/account/auth_backends.py b/account/auth_backends.py index c8c4320f..a93f15a0 100644 --- a/account/auth_backends.py +++ b/account/auth_backends.py @@ -1,45 +1,62 @@ -from __future__ import unicode_literals - -from django.db.models import Q - from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend +from django.db.models import Q from account.models import EmailAddress from account.utils import get_user_lookup_kwargs +User = get_user_model() + + +class AccountModelBackend(ModelBackend): + """ + This authentication backend ensures that the account is always selected + on any query with the user, so we don't issue extra unnecessary queries + """ + + def get_user(self, user_id): + """Get the user and select account at the same time""" + user = User._default_manager.filter(pk=user_id).select_related("account").first() + if not user: + return None + return user if self.user_can_authenticate(user) else None + + +class UsernameAuthenticationBackend(AccountModelBackend): + """Username authentication""" -class UsernameAuthenticationBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + """Authenticate the user based on user""" + if username is None or password is None: + return None - def authenticate(self, **credentials): - User = get_user_model() try: lookup_kwargs = get_user_lookup_kwargs({ - "{username}__iexact": credentials["username"] + "{username}__iexact": username }) user = User.objects.get(**lookup_kwargs) - except (User.DoesNotExist, KeyError): + except User.DoesNotExist: return None - else: - try: - if user.check_password(credentials["password"]): - return user - except KeyError: - return None + + if user.check_password(password): + return user -class EmailAuthenticationBackend(ModelBackend): +class EmailAuthenticationBackend(AccountModelBackend): + """Email authentication""" - def authenticate(self, **credentials): + def authenticate(self, request, username=None, password=None, **kwargs): + """Authenticate the user based email""" qs = EmailAddress.objects.filter(Q(primary=True) | Q(verified=True)) + + if username is None or password is None: + return None + try: - email_address = qs.get(email__iexact=credentials["username"]) - except (EmailAddress.DoesNotExist, KeyError): + email_address = qs.get(email__iexact=username) + except EmailAddress.DoesNotExist: return None - else: - user = email_address.user - try: - if user.check_password(credentials["password"]): - return user - except KeyError: - return None + + user = email_address.user + if user.check_password(password): + return user diff --git a/account/callbacks.py b/account/callbacks.py deleted file mode 100644 index d54213fc..00000000 --- a/account/callbacks.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import unicode_literals - - -def account_delete_mark(deletion): - deletion.user.is_active = False - deletion.user.save() - - -def account_delete_expunge(deletion): - deletion.user.delete() diff --git a/account/conf.py b/account/conf.py index 66cd7b33..dfb1bf87 100644 --- a/account/conf.py +++ b/account/conf.py @@ -1,17 +1,11 @@ -from __future__ import unicode_literals - import importlib -from django.conf import settings +from django.conf import settings # noqa from django.core.exceptions import ImproperlyConfigured -from django.utils.translation import get_language_info - -import pytz -from appconf import AppConf - -from account.timezones import TIMEZONES from account.languages import LANGUAGES +from account.timezones import TIMEZONES +from appconf import AppConf def load_path_attr(path): @@ -32,36 +26,36 @@ class AccountAppConf(AppConf): OPEN_SIGNUP = True LOGIN_URL = "account_login" + LOGOUT_URL = "account_logout" SIGNUP_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/" PASSWORD_CHANGE_REDIRECT_URL = "account_password" PASSWORD_RESET_REDIRECT_URL = "account_login" + PASSWORD_RESET_TOKEN_URL = "account_password_reset_token" + PASSWORD_EXPIRY = 0 + PASSWORD_USE_HISTORY = False + ACCOUNT_APPROVAL_REQUIRED = False + PASSWORD_STRIP = True REMEMBER_ME_EXPIRY = 60 * 60 * 24 * 365 * 10 - USER_DISPLAY = lambda user: user.username # flake8: noqa + USER_DISPLAY = lambda user: user.username # noqa CREATE_ON_SAVE = True EMAIL_UNIQUE = True EMAIL_CONFIRMATION_REQUIRED = False EMAIL_CONFIRMATION_EMAIL = True EMAIL_CONFIRMATION_EXPIRE_DAYS = 3 + EMAIL_CONFIRMATION_AUTO_LOGIN = False EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = "account_login" EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL = None EMAIL_CONFIRMATION_URL = "account_confirm_email" SETTINGS_REDIRECT_URL = "account_settings" NOTIFY_ON_PASSWORD_CHANGE = True - DELETION_MARK_CALLBACK = "account.callbacks.account_delete_mark" - DELETION_EXPUNGE_CALLBACK = "account.callbacks.account_delete_expunge" DELETION_EXPUNGE_HOURS = 48 + DEFAULT_HTTP_PROTOCOL = "https" HOOKSET = "account.hooks.AccountDefaultHookSet" TIMEZONES = TIMEZONES LANGUAGES = LANGUAGES - USE_AUTH_AUTHENTICATE = False - - def configure_deletion_mark_callback(self, value): - return load_path_attr(value) - - def configure_deletion_expunge_callback(self, value): - return load_path_attr(value) - def configure_hookset(self, value): + @staticmethod + def configure_hookset(value): return load_path_attr(value)() diff --git a/account/context_processors.py b/account/context_processors.py index f10a9d8c..73dd6525 100644 --- a/account/context_processors.py +++ b/account/context_processors.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from account.conf import settings from account.models import Account diff --git a/account/decorators.py b/account/decorators.py index fd349a8f..82a8658b 100644 --- a/account/decorators.py +++ b/account/decorators.py @@ -1,21 +1,19 @@ -from __future__ import unicode_literals - import functools -from django.utils.decorators import available_attrs +from django.contrib.auth import REDIRECT_FIELD_NAME from account.utils import handle_redirect_to_login -def login_required(func=None, redirect_field_name="next", login_url=None): +def login_required(func=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url=None): """ Decorator for views that checks that the user is logged in, redirecting to the log in page if necessary. """ def decorator(view_func): - @functools.wraps(view_func, assigned=available_attrs(view_func)) + @functools.wraps(view_func) def _wrapped_view(request, *args, **kwargs): - if request.user.is_authenticated(): + if request.user.is_authenticated: return view_func(request, *args, **kwargs) return handle_redirect_to_login( request, diff --git a/account/fields.py b/account/fields.py index 3049f928..fe80486d 100644 --- a/account/fields.py +++ b/account/fields.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db import models from account.conf import settings @@ -15,4 +13,4 @@ def __init__(self, *args, **kwargs): "blank": True, } defaults.update(kwargs) - return super(TimeZoneField, self).__init__(*args, **defaults) + super(TimeZoneField, self).__init__(*args, **defaults) diff --git a/account/forms.py b/account/forms.py index d807e247..14f2f2a1 100644 --- a/account/forms.py +++ b/account/forms.py @@ -1,47 +1,59 @@ -from __future__ import unicode_literals - import re - -try: - from collections import OrderedDict -except ImportError: - OrderedDict = None +from collections import OrderedDict from django import forms -from django.utils.translation import ugettext_lazy as _ - from django.contrib import auth from django.contrib.auth import get_user_model +from django.utils.encoding import force_str +from django.utils.translation import gettext_lazy as _ from account.conf import settings from account.hooks import hookset from account.models import EmailAddress from account.utils import get_user_lookup_kwargs +alnum_re = re.compile(r"^[\w\-\.\+]+$") -alnum_re = re.compile(r"^\w+$") +User = get_user_model() +USER_FIELD_MAX_LENGTH = getattr(User, User.USERNAME_FIELD).field.max_length + + +class PasswordField(forms.CharField): + + def __init__(self, *args, **kwargs): + kwargs.setdefault("widget", forms.PasswordInput(render_value=False)) + self.strip = kwargs.pop("strip", True) + super(PasswordField, self).__init__(*args, **kwargs) + + def to_python(self, value): + if value in self.empty_values: + return "" + value = force_str(value) + if self.strip: + value = value.strip() + return value class SignupForm(forms.Form): username = forms.CharField( label=_("Username"), - max_length=30, + max_length=USER_FIELD_MAX_LENGTH, widget=forms.TextInput(), required=True ) - password = forms.CharField( + email = forms.EmailField( + label=_("Email"), + widget=forms.TextInput(), required=True + ) + password = PasswordField( label=_("Password"), - widget=forms.PasswordInput(render_value=False) + strip=settings.ACCOUNT_PASSWORD_STRIP, ) - password_confirm = forms.CharField( + password_confirm = PasswordField( label=_("Password (again)"), - widget=forms.PasswordInput(render_value=False) + strip=settings.ACCOUNT_PASSWORD_STRIP, ) - email = forms.EmailField( - label=_("Email"), - widget=forms.TextInput(), required=True) - code = forms.CharField( max_length=64, required=False, @@ -50,8 +62,9 @@ class SignupForm(forms.Form): def clean_username(self): if not alnum_re.search(self.cleaned_data["username"]): - raise forms.ValidationError(_("Usernames can only contain letters, numbers and underscores.")) - User = get_user_model() + raise forms.ValidationError( + _("Usernames can only contain letters, numbers and the following special characters ./+/-/_") + ) lookup_kwargs = get_user_lookup_kwargs({ "{username}__iexact": self.cleaned_data["username"] }) @@ -68,17 +81,20 @@ def clean_email(self): raise forms.ValidationError(_("A user is registered with this email address.")) def clean(self): - if "password" in self.cleaned_data and "password_confirm" in self.cleaned_data: - if self.cleaned_data["password"] != self.cleaned_data["password_confirm"]: - raise forms.ValidationError(_("You must type the same password each time.")) + if ( + "password" in self.cleaned_data and + "password_confirm" in self.cleaned_data and + self.cleaned_data["password"] != self.cleaned_data["password_confirm"] + ): + raise forms.ValidationError(_("You must type the same password each time.")) return self.cleaned_data class LoginForm(forms.Form): - password = forms.CharField( + password = PasswordField( label=_("Password"), - widget=forms.PasswordInput(render_value=False) + strip=settings.ACCOUNT_PASSWORD_STRIP, ) remember = forms.BooleanField( label=_("Remember Me"), @@ -105,14 +121,14 @@ def user_credentials(self): class LoginUsernameForm(LoginForm): - username = forms.CharField(label=_("Username"), max_length=30) + username = forms.CharField(label=_("Username"), max_length=USER_FIELD_MAX_LENGTH) authentication_fail_message = _("The username and/or password you specified are not correct.") identifier_field = "username" def __init__(self, *args, **kwargs): super(LoginUsernameForm, self).__init__(*args, **kwargs) field_order = ["username", "password", "remember"] - if not OrderedDict or hasattr(self.fields, "keyOrder"): + if hasattr(self.fields, "keyOrder"): self.fields.keyOrder = field_order else: self.fields = OrderedDict((k, self.fields[k]) for k in field_order) @@ -127,7 +143,7 @@ class LoginEmailForm(LoginForm): def __init__(self, *args, **kwargs): super(LoginEmailForm, self).__init__(*args, **kwargs) field_order = ["email", "password", "remember"] - if not OrderedDict or hasattr(self.fields, "keyOrder"): + if hasattr(self.fields, "keyOrder"): self.fields.keyOrder = field_order else: self.fields = OrderedDict((k, self.fields[k]) for k in field_order) @@ -159,8 +175,9 @@ def clean_password_current(self): def clean_password_new_confirm(self): if "password_new" in self.cleaned_data and "password_new_confirm" in self.cleaned_data: - if self.cleaned_data["password_new"] != self.cleaned_data["password_new_confirm"]: - raise forms.ValidationError(_("You must type the same password each time.")) + password_new = self.cleaned_data["password_new"] + password_new_confirm = self.cleaned_data["password_new_confirm"] + return hookset.clean_password(password_new, password_new_confirm) return self.cleaned_data["password_new_confirm"] @@ -188,8 +205,9 @@ class PasswordResetTokenForm(forms.Form): def clean_password_confirm(self): if "password" in self.cleaned_data and "password_confirm" in self.cleaned_data: - if self.cleaned_data["password"] != self.cleaned_data["password_confirm"]: - raise forms.ValidationError(_("You must type the same password each time.")) + password = self.cleaned_data["password"] + password_confirm = self.cleaned_data["password_confirm"] + return hookset.clean_password(password, password_confirm) return self.cleaned_data["password_confirm"] diff --git a/account/hooks.py b/account/hooks.py index 4128b4cb..170942aa 100644 --- a/account/hooks.py +++ b/account/hooks.py @@ -1,38 +1,45 @@ import hashlib import random +from django import forms from django.core.mail import send_mail from django.template.loader import render_to_string +from django.utils.translation import gettext_lazy as _ from account.conf import settings -class AccountDefaultHookSet(object): +class AccountDefaultHookSet: - def send_invitation_email(self, to, ctx): + @staticmethod + def send_invitation_email(to, ctx): subject = render_to_string("account/email/invite_user_subject.txt", ctx) message = render_to_string("account/email/invite_user.txt", ctx) send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, to) - def send_confirmation_email(self, to, ctx): + @staticmethod + def send_confirmation_email(to, ctx): subject = render_to_string("account/email/email_confirmation_subject.txt", ctx) subject = "".join(subject.splitlines()) # remove superfluous line breaks message = render_to_string("account/email/email_confirmation_message.txt", ctx) send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, to) - def send_password_change_email(self, to, ctx): + @staticmethod + def send_password_change_email(to, ctx): subject = render_to_string("account/email/password_change_subject.txt", ctx) subject = "".join(subject.splitlines()) message = render_to_string("account/email/password_change.txt", ctx) send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, to) - def send_password_reset_email(self, to, ctx): + @staticmethod + def send_password_reset_email(to, ctx): subject = render_to_string("account/email/password_reset_subject.txt", ctx) subject = "".join(subject.splitlines()) message = render_to_string("account/email/password_reset.txt", ctx) send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, to) - def generate_random_token(self, extra=None, hash_func=hashlib.sha256): + @staticmethod + def generate_random_token(extra=None, hash_func=hashlib.sha256): if extra is None: extra = [] bits = extra + [str(random.SystemRandom().getrandbits(512))] @@ -47,14 +54,30 @@ def generate_signup_code_token(self, email=None): def generate_email_confirmation_token(self, email): return self.generate_random_token([email]) - def get_user_credentials(self, form, identifier_field): + @staticmethod + def get_user_credentials(form, identifier_field): return { "username": form.cleaned_data[identifier_field], "password": form.cleaned_data["password"], } + @staticmethod + def clean_password(password_new, password_new_confirm): + if password_new != password_new_confirm: + raise forms.ValidationError(_("You must type the same password each time.")) + return password_new -class HookProxy(object): + @staticmethod + def account_delete_mark(deletion): + deletion.user.is_active = False + deletion.user.save() + + @staticmethod + def account_delete_expunge(deletion): + deletion.user.delete() + + +class HookProxy: def __getattr__(self, attr): return getattr(settings.ACCOUNT_HOOKSET, attr) diff --git a/account/languages.py b/account/languages.py index 1a280aa6..6bb83ba9 100644 --- a/account/languages.py +++ b/account/languages.py @@ -1,14 +1,16 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals +from django.conf import settings +from django.utils.translation import get_language_info +""" # List of language code and languages local names. # # This list is output of code: -# [ -# (code, get_language_info(code).get("name_local")) -# for code, lang in settings.LANGUAGES -# ] -# + +[ + (code, get_language_info(code).get("name_local")) + for code, lang in settings.LANGUAGES +] +""" LANGUAGES = [ ("af", "Afrikaans"), @@ -27,7 +29,7 @@ ("de", "Deutsch"), ("el", "Ελληνικά"), ("en", "English"), - ("en-a", "Australian English"), + ("en-au", "Australian English"), ("en-gb", "British English"), ("eo", "Esperanto"), ("es", "español"), @@ -36,7 +38,7 @@ ("es-ni", "español de Nicaragua"), ("es-ve", "español de Venezuela"), ("et", "eesti"), - ("e", "Basque"), + ("eu", "Basque"), ("fa", "فارسی"), ("fi", "suomi"), ("fr", "français"), @@ -46,7 +48,7 @@ ("he", "עברית"), ("hi", "Hindi"), ("hr", "Hrvatski"), - ("h", "Magyar"), + ("hu", "Magyar"), ("ia", "Interlingua"), ("id", "Bahasa Indonesia"), ("io", "ido"), @@ -76,7 +78,7 @@ ("pt", "Português"), ("pt-br", "Português Brasileiro"), ("ro", "Română"), - ("r", "Русский"), + ("ru", "Русский"), ("sk", "slovenský"), ("sl", "Slovenščina"), ("sq", "shqip"), @@ -98,3 +100,5 @@ ("zh-hant", "繁體中文"), ("zh-tw", "繁體中文") ] + +DEFAULT_LANGUAGE = get_language_info(settings.LANGUAGE_CODE)["code"] diff --git a/account/locale/de/LC_MESSAGES/django.mo b/account/locale/de/LC_MESSAGES/django.mo index 64860b30..4a033999 100644 Binary files a/account/locale/de/LC_MESSAGES/django.mo and b/account/locale/de/LC_MESSAGES/django.mo differ diff --git a/account/locale/de/LC_MESSAGES/django.po b/account/locale/de/LC_MESSAGES/django.po index 28c39646..42faae5c 100644 --- a/account/locale/de/LC_MESSAGES/django.po +++ b/account/locale/de/LC_MESSAGES/django.po @@ -1,153 +1,232 @@ # SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. -# -# Translators: -# Frank , 2014 +# msgid "" msgstr "" "Project-Id-Version: django-user-accounts\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2014-07-30 15:12-0600\n" -"PO-Revision-Date: 2014-12-05 11:26+0000\n" -"Last-Translator: Frank \n" -"Language-Team: German (http://www.transifex.com/projects/p/django-user-accounts/language/de/)\n" +"POT-Creation-Date: 2017-10-17 18:31+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"accounts/language/de/)\n" +"Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Language: de\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: forms.py:27 forms.py:107 +#: account/forms.py:45 account/forms.py:125 msgid "Username" msgstr "Benutzername" -#: forms.py:33 forms.py:79 +#: account/forms.py:51 account/forms.py:140 account/forms.py:186 +#: account/forms.py:215 +msgid "Email" +msgstr "Email" + +#: account/forms.py:55 account/forms.py:97 msgid "Password" msgstr "Passwort" -#: forms.py:37 +#: account/forms.py:59 msgid "Password (again)" msgstr "Passwort (wiederholen)" -#: forms.py:41 forms.py:122 forms.py:168 forms.py:197 -msgid "Email" -msgstr "Email" - -#: forms.py:52 +#: account/forms.py:70 msgid "Usernames can only contain letters, numbers and underscores." msgstr "Der Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten." -#: forms.py:60 +#: account/forms.py:78 msgid "This username is already taken. Please choose another." msgstr "Dieser Benutzername ist bereits vergeben. Bitte wählen sie einen Anderen." -#: forms.py:67 forms.py:217 +#: account/forms.py:85 account/forms.py:235 msgid "A user is registered with this email address." msgstr "Es ist bereits ein Benutzer registriert mit dieser Email Adresse." -#: forms.py:72 forms.py:162 forms.py:191 +#: account/forms.py:90 account/forms.py:180 account/forms.py:209 msgid "You must type the same password each time." msgstr "Die eingegebnen Passwörter stimmen nicht überein." -#: forms.py:83 +#: account/forms.py:101 msgid "Remember Me" -msgstr "Auf desem Computer merken" +msgstr "Auf diesem Computer merken" -#: forms.py:96 +#: account/forms.py:114 msgid "This account is inactive." msgstr "Dieses Konto ist nicht aktiv." -#: forms.py:108 +#: account/forms.py:126 msgid "The username and/or password you specified are not correct." msgstr "Die Email Adresse und/oder das angegebene Passwort sind nicht korrekt." -#: forms.py:123 +#: account/forms.py:141 msgid "The email address and/or password you specified are not correct." msgstr "Die Email Adresse und/oder das eingegebene Passwort sind nicht korrekt." -#: forms.py:138 +#: account/forms.py:156 msgid "Current Password" msgstr "Derzeitiges Passwort" -#: forms.py:142 forms.py:180 +#: account/forms.py:160 account/forms.py:198 msgid "New Password" msgstr "Neues Passwort" -#: forms.py:146 forms.py:184 +#: account/forms.py:164 account/forms.py:202 msgid "New Password (again)" msgstr "Neues Passwort (wiederholen)" -#: forms.py:156 +#: account/forms.py:174 msgid "Please type your current password." msgstr "Bitte derzeitiges Passwort eingeben." -#: forms.py:173 +#: account/forms.py:191 msgid "Email address can not be found." msgstr "Email Adresse wurde nicht gefunden." -#: forms.py:199 +#: account/forms.py:217 msgid "Timezone" msgstr "Zeitzone" -#: forms.py:205 +#: account/forms.py:223 msgid "Language" msgstr "Sprache" -#: models.py:34 +#: account/middleware.py:92 +msgid "Your password has expired. Please save a new password." +msgstr "Ihr Passwort ist abgelaufen. Bitte wählen Sie ein neues Passwort." + +#: account/models.py:36 account/models.py:412 msgid "user" -msgstr "benutzer" +msgstr "user" -#: models.py:35 +#: account/models.py:37 msgid "timezone" -msgstr "zeitzone" +msgstr "timezone" -#: models.py:37 +#: account/models.py:39 msgid "language" -msgstr "sprache" +msgstr "language" + +#: account/models.py:140 +msgid "code" +msgstr "code" + +#: account/models.py:141 +msgid "max uses" +msgstr "max uses" + +#: account/models.py:142 +msgid "expiry" +msgstr "expiry" + +#: account/models.py:145 +msgid "notes" +msgstr "notes" + +#: account/models.py:146 +msgid "sent" +msgstr "sent" + +#: account/models.py:147 +msgid "created" +msgstr "created" + +#: account/models.py:148 +msgid "use count" +msgstr "use count" + +#: account/models.py:151 +msgid "signup code" +msgstr "signup code" -#: models.py:250 +#: account/models.py:152 +msgid "signup codes" +msgstr "signup codes" + +#: account/models.py:259 +msgid "verified" +msgstr "verified" + +#: account/models.py:260 +msgid "primary" +msgstr "primary" + +#: account/models.py:265 msgid "email address" -msgstr "email adresse" +msgstr "email address" -#: models.py:251 +#: account/models.py:266 msgid "email addresses" -msgstr "email adressen" +msgstr "email addresses" -#: models.py:300 +#: account/models.py:316 msgid "email confirmation" -msgstr "email bestätigung" +msgstr "email confirmation" -#: models.py:301 +#: account/models.py:317 msgid "email confirmations" -msgstr "email bestätigungen" +msgstr "email confirmations" + +#: account/models.py:366 +msgid "date requested" +msgstr "date requested" + +#: account/models.py:367 +msgid "date expunged" +msgstr "date expunged" + +#: account/models.py:370 +msgid "account deletion" +msgstr "account deletion" + +#: account/models.py:371 +msgid "account deletions" +msgstr "account deletions" + +#: account/models.py:400 +msgid "password history" +msgstr "password history" + +#: account/models.py:401 +msgid "password histories" +msgstr "password histories" -#: views.py:42 +#: account/views.py:54 account/views.py:554 +msgid "Password successfully changed." +msgstr "Passwort wurde gändert." + +#: account/views.py:129 #, python-brace-format msgid "Confirmation email sent to {email}." msgstr "Eine Bestätigungsemail wurde an {email} gesendet." -#: views.py:46 +#: account/views.py:133 #, python-brace-format msgid "The code {code} is invalid." msgstr "Der code {code} ist ungültig." -#: views.py:379 +#: account/views.py:459 #, python-brace-format msgid "You have confirmed {email}." msgstr "Sie haben {email} bestätigt." -#: views.py:452 views.py:585 -msgid "Password successfully changed." -msgstr "Passwort wurde gändert." +#: account/views.py:463 +#, python-brace-format +msgid "Email confirmation for {email} has expired." +msgstr "" -#: views.py:664 +#: account/views.py:729 msgid "Account settings updated." msgstr "Kontoeinstellungen aktualisiert." -#: views.py:748 +#: account/views.py:813 #, python-brace-format msgid "" "Your account is now inactive and your data will be expunged in the next " "{expunge_hours} hours." -msgstr "Ihr Account ist jetzt deaktiviert und Ihre Daten werden in den nächsten {expunge_hours} Stunden endgültig gelöscht." +msgstr "" +"Ihr Account ist jetzt deaktiviert und Ihre Daten werden in den nächsten " +"{expunge_hours} Stunden endgültig gelöscht." diff --git a/account/locale/es/LC_MESSAGES/django.mo b/account/locale/es/LC_MESSAGES/django.mo index 85cc36e6..668253d8 100644 Binary files a/account/locale/es/LC_MESSAGES/django.mo and b/account/locale/es/LC_MESSAGES/django.mo differ diff --git a/account/locale/es/LC_MESSAGES/django.po b/account/locale/es/LC_MESSAGES/django.po index dea3eaa2..38744a9d 100644 --- a/account/locale/es/LC_MESSAGES/django.po +++ b/account/locale/es/LC_MESSAGES/django.po @@ -1,7 +1,7 @@ # SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. -# +# # Translators: # Erik Rivera , 2012 # Martin Gaitan , 2014 @@ -10,146 +10,227 @@ msgid "" msgstr "" "Project-Id-Version: django-user-accounts\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2014-07-30 15:12-0600\n" -"PO-Revision-Date: 2014-07-31 20:44+0000\n" -"Last-Translator: Brian Rosner \n" -"Language-Team: Spanish (http://www.transifex.com/projects/p/django-user-accounts/language/es/)\n" +"POT-Creation-Date: 2017-03-30 18:22-0600\n" +"PO-Revision-Date: 2017-03-30 18:22-0600\n" +"Last-Translator: Pelana \n" +"Language-Team: Spanish (http://www.transifex.com/projects/p/django-user-" +"accounts/language/es/)\n" +"Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Language: es\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 1.8.9\n" -#: forms.py:27 forms.py:107 +#: account/forms.py:45 account/forms.py:125 msgid "Username" -msgstr "Nombre de usuario" +msgstr "Usuario" -#: forms.py:33 forms.py:79 +#: account/forms.py:51 account/forms.py:97 msgid "Password" msgstr "Contraseña" -#: forms.py:37 +#: account/forms.py:55 msgid "Password (again)" msgstr "Contraseña (repetir)" -#: forms.py:41 forms.py:122 forms.py:168 forms.py:197 +#: account/forms.py:59 account/forms.py:140 account/forms.py:186 +#: account/forms.py:215 msgid "Email" msgstr "Correo electrónico" -#: forms.py:52 +#: account/forms.py:70 msgid "Usernames can only contain letters, numbers and underscores." -msgstr "Los nombres de usuario solo pueden contener letras, números y subguiones" +msgstr "" +"Los nombres de usuario solo pueden contener letras, números y subguiones" -#: forms.py:60 +#: account/forms.py:78 msgid "This username is already taken. Please choose another." msgstr "Este nombre de usuario ya está en uso. Por favor elija otro." -#: forms.py:67 forms.py:217 +#: account/forms.py:85 account/forms.py:235 msgid "A user is registered with this email address." msgstr "Un usuario se ha registrado con esta dirección de correo electrónico." -#: forms.py:72 forms.py:162 forms.py:191 +#: account/forms.py:90 account/forms.py:180 account/forms.py:209 msgid "You must type the same password each time." msgstr "Debe escribir la misma contraseña cada vez." -#: forms.py:83 +#: account/forms.py:101 msgid "Remember Me" msgstr "Recordarme" -#: forms.py:96 +#: account/forms.py:114 msgid "This account is inactive." msgstr "Esta cuenta está inactiva." -#: forms.py:108 +#: account/forms.py:126 msgid "The username and/or password you specified are not correct." -msgstr "El nombre de usuario y/o la contraseña que ha especificado no son correctas." +msgstr "" +"El nombre de usuario y/o la contraseña que ha especificado no son correctas." -#: forms.py:123 +#: account/forms.py:141 msgid "The email address and/or password you specified are not correct." -msgstr "La dirección de correo electrónico y/o la contraseña que ha especificado no son correctas." +msgstr "" +"La dirección de correo electrónico y/o la contraseña que ha especificado no " +"son correctas." -#: forms.py:138 +#: account/forms.py:156 msgid "Current Password" msgstr "Contraseña actual" -#: forms.py:142 forms.py:180 +#: account/forms.py:160 account/forms.py:198 msgid "New Password" msgstr "Contraseña nueva" -#: forms.py:146 forms.py:184 +#: account/forms.py:164 account/forms.py:202 msgid "New Password (again)" msgstr "Contraseña nueva (repetir)" -#: forms.py:156 +#: account/forms.py:174 msgid "Please type your current password." msgstr "Por favor escriba su contraseña actual." -#: forms.py:173 +#: account/forms.py:191 msgid "Email address can not be found." msgstr "El email no pudo ser encontrado." -#: forms.py:199 +#: account/forms.py:217 msgid "Timezone" msgstr "Zona horaria" -#: forms.py:205 +#: account/forms.py:223 msgid "Language" msgstr "Idioma" -#: models.py:34 +#: account/middleware.py:92 +msgid "Your password has expired. Please save a new password." +msgstr "Tu contraseña expiró. Guarda tu Contraseña." + +#: account/models.py:36 account/models.py:412 msgid "user" msgstr "usuario" -#: models.py:35 +#: account/models.py:37 msgid "timezone" msgstr "zona horaria" -#: models.py:37 +#: account/models.py:39 msgid "language" msgstr "idioma" -#: models.py:250 +#: account/models.py:140 +msgid "code" +msgstr "código" + +#: account/models.py:141 +msgid "max uses" +msgstr "usos maximos" + +#: account/models.py:142 +msgid "expiry" +msgstr "expiró" + +#: account/models.py:145 +msgid "notes" +msgstr "notas" + +#: account/models.py:146 +msgid "sent" +msgstr "enviado" + +#: account/models.py:147 +msgid "created" +msgstr "creado" + +#: account/models.py:148 +msgid "use count" +msgstr "usos" + +#: account/models.py:151 +msgid "signup code" +msgstr "código " + +#: account/models.py:152 +msgid "signup codes" +msgstr "códigos de registro" + +#: account/models.py:259 +msgid "verified" +msgstr "verificado" + +#: account/models.py:260 +msgid "primary" +msgstr "primario" + +#: account/models.py:265 msgid "email address" msgstr "correo electrónico" -#: models.py:251 +#: account/models.py:266 msgid "email addresses" msgstr "correos electrónicos" -#: models.py:300 +#: account/models.py:316 msgid "email confirmation" msgstr "confirmación de correo electrónico" -#: models.py:301 +#: account/models.py:317 msgid "email confirmations" msgstr "confirmaciones de correos electrónicos" -#: views.py:42 +#: account/models.py:366 +msgid "date requested" +msgstr "fecha solicitada" + +#: account/models.py:367 +msgid "date expunged" +msgstr "fecha de expiración" + +#: account/models.py:370 +msgid "account deletion" +msgstr "cuenta borrada" + +#: account/models.py:371 +msgid "account deletions" +msgstr "cuentas borradas" + +#: account/models.py:400 +msgid "password history" +msgstr "historial de contraseña" + +#: account/models.py:401 +msgid "password histories" +msgstr "historico de contraseñas" + +#: account/views.py:50 account/views.py:524 +msgid "Password successfully changed." +msgstr "La contraseña se ha cambiado con éxito." + +#: account/views.py:125 #, python-brace-format msgid "Confirmation email sent to {email}." msgstr "Email de confirmación enviado a {email}." -#: views.py:46 +#: account/views.py:129 #, python-brace-format msgid "The code {code} is invalid." msgstr "El código {code} es inválido." -#: views.py:379 +#: account/views.py:442 #, python-brace-format msgid "You have confirmed {email}." msgstr "Has confirmado {email}." -#: views.py:452 views.py:585 -msgid "Password successfully changed." -msgstr "La contraseña se ha cambiado con éxito." - -#: views.py:664 +#: account/views.py:672 msgid "Account settings updated." msgstr "Los ajustes de la cuenta actualizados." -#: views.py:748 +#: account/views.py:756 #, python-brace-format msgid "" "Your account is now inactive and your data will be expunged in the next " "{expunge_hours} hours." -msgstr "Tu cuenta está ahora inactiva y tus datos serán eliminados en las próximas {expunge_hours} horas." +msgstr "" +"Tu cuenta está ahora inactiva y tus datos serán eliminados en las próximas " +"{expunge_hours} horas." diff --git a/account/locale/ru/LC_MESSAGES/django.mo b/account/locale/ru/LC_MESSAGES/django.mo index ad65e19a..6e7eb76c 100644 Binary files a/account/locale/ru/LC_MESSAGES/django.mo and b/account/locale/ru/LC_MESSAGES/django.mo differ diff --git a/account/locale/ru/LC_MESSAGES/django.po b/account/locale/ru/LC_MESSAGES/django.po index afee979e..21695ada 100644 --- a/account/locale/ru/LC_MESSAGES/django.po +++ b/account/locale/ru/LC_MESSAGES/django.po @@ -1,23 +1,25 @@ # SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. -# +# # Translators: -# Brian Rosner , 2015 # Eugene MechanisM , 2012 msgid "" msgstr "" "Project-Id-Version: django-user-accounts\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2014-07-30 15:12-0600\n" -"PO-Revision-Date: 2015-04-23 15:27+0000\n" -"Last-Translator: Brian Rosner \n" -"Language-Team: Russian (http://www.transifex.com/projects/p/django-user-accounts/language/ru/)\n" +"PO-Revision-Date: 2014-08-12 01:34+0800\n" +"Last-Translator: Vladislav 'SnoUweR' Kovalev \n" +"Language-Team: Russian (http://www.transifex.com/projects/p/django-user-" +"accounts/language/ru/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language: ru\n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"X-Generator: Poedit 1.6.7\n" #: forms.py:27 forms.py:107 msgid "Username" @@ -29,7 +31,7 @@ msgstr "Пароль" #: forms.py:37 msgid "Password (again)" -msgstr "Пароль(еще раз)" +msgstr "Пароль (еще раз)" #: forms.py:41 forms.py:122 forms.py:168 forms.py:197 msgid "Email" @@ -37,7 +39,8 @@ msgstr "Email" #: forms.py:52 msgid "Usernames can only contain letters, numbers and underscores." -msgstr "Имена пользователей могут состоять только из букв, цифер и подчеркиваний." +msgstr "" +"Имена пользователей могут состоять только из букв, цифер и подчеркиваний." #: forms.py:60 msgid "This username is already taken. Please choose another." @@ -45,15 +48,15 @@ msgstr "Это имя пользователя уже занято. Пожалу #: forms.py:67 forms.py:217 msgid "A user is registered with this email address." -msgstr "Пользователь зарегистрирован с этим email адресом." +msgstr "Данный электронный адрес уже используется в системе." #: forms.py:72 forms.py:162 forms.py:191 msgid "You must type the same password each time." -msgstr "Вы должны писать одинаковый пароль каждый раз." +msgstr "Пароли не совпадают." #: forms.py:83 msgid "Remember Me" -msgstr "Запомнить Меня" +msgstr "Запомнить меня" #: forms.py:96 msgid "This account is inactive." @@ -65,27 +68,27 @@ msgstr "Имя пользователя и/или пароль введено н #: forms.py:123 msgid "The email address and/or password you specified are not correct." -msgstr "Email-адрес и/или пароль введен некорректно." +msgstr "Email-адрес и/или пароль введено некорректно." #: forms.py:138 msgid "Current Password" -msgstr "Действующий пароль" +msgstr "Текущий пароль" #: forms.py:142 forms.py:180 msgid "New Password" -msgstr "Новый Пароль" +msgstr "Новый пароль" #: forms.py:146 forms.py:184 msgid "New Password (again)" -msgstr "Новый Пароль(еще раз)" +msgstr "Новый пароль (еще раз)" #: forms.py:156 msgid "Please type your current password." -msgstr "Пожалуйста, введите ваш действующий пароль." +msgstr "Пожалуйста, введите ваш текущий пароль." #: forms.py:173 msgid "Email address can not be found." -msgstr "" +msgstr "Указанный адрес электронной почты не найден." #: forms.py:199 msgid "Timezone" @@ -126,17 +129,17 @@ msgstr "подтверждения email" #: views.py:42 #, python-brace-format msgid "Confirmation email sent to {email}." -msgstr "" +msgstr "Письмо с подтверждением было отправлено на {email}." #: views.py:46 #, python-brace-format msgid "The code {code} is invalid." -msgstr "" +msgstr "Код {code} - неверный." #: views.py:379 #, python-brace-format msgid "You have confirmed {email}." -msgstr "" +msgstr "Вы успешно подтвердили {email}." #: views.py:452 views.py:585 msgid "Password successfully changed." @@ -152,3 +155,5 @@ msgid "" "Your account is now inactive and your data will be expunged in the next " "{expunge_hours} hours." msgstr "" +"Ваш аккаунт сейчас неактивен. Вся информация о нём будет удалена в течение " +"{expunge_hours} часов." diff --git a/account/locale/tr/LC_MESSAGES/django.mo b/account/locale/tr/LC_MESSAGES/django.mo new file mode 100644 index 00000000..84e46953 Binary files /dev/null and b/account/locale/tr/LC_MESSAGES/django.mo differ diff --git a/account/locale/tr/LC_MESSAGES/django.po b/account/locale/tr/LC_MESSAGES/django.po new file mode 100644 index 00000000..cfdba40b --- /dev/null +++ b/account/locale/tr/LC_MESSAGES/django.po @@ -0,0 +1,216 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-04-11 21:57+0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: account/forms.py:28 account/forms.py:108 +msgid "Username" +msgstr "Kullanıcı adı" + +#: account/forms.py:34 account/forms.py:80 +msgid "Password" +msgstr "Şifre" + +#: account/forms.py:38 +msgid "Password (again)" +msgstr "Şifre (tekrar)" + +#: account/forms.py:42 account/forms.py:123 account/forms.py:169 +#: account/forms.py:198 +msgid "Email" +msgstr "Eposta" + +#: account/forms.py:53 +msgid "Usernames can only contain letters, numbers and underscores." +msgstr "Kullanıcı adı sadece harfler, sayılar ve alt çizgiler içerebilir." + +#: account/forms.py:61 +msgid "This username is already taken. Please choose another." +msgstr "Bu kullanıcı adı daha önce alınmış. Lütfen başka bir tane seçiniz." + +#: account/forms.py:68 account/forms.py:218 +msgid "A user is registered with this email address." +msgstr "Bir kullanıcı bu eposta adresi ile kaydedildi." + +#: account/forms.py:73 account/forms.py:163 account/forms.py:192 +msgid "You must type the same password each time." +msgstr "Şifrenizi her seferinde aynı girmelisiniz." + +#: account/forms.py:84 +msgid "Remember Me" +msgstr "Beni Hatırla" + +#: account/forms.py:97 +msgid "This account is inactive." +msgstr "Bu hesap inaktif." + +#: account/forms.py:109 +msgid "The username and/or password you specified are not correct." +msgstr "Belirttiğiniz kullanıcı adı ve/veya şifre doğru değil." + +#: account/forms.py:124 +msgid "The email address and/or password you specified are not correct." +msgstr "Belirttiğiniz eposta adresi ve/veya şifre doğru değil." + +#: account/forms.py:139 +msgid "Current Password" +msgstr "Şuanki Şifre" + +#: account/forms.py:143 account/forms.py:181 +msgid "New Password" +msgstr "Yeni Şifre" + +#: account/forms.py:147 account/forms.py:185 +msgid "New Password (again)" +msgstr "Yeni Şifre (tekrar)" + +#: account/forms.py:157 +msgid "Please type your current password." +msgstr "Lütfen şimdiki şifrenizi giriniz." + +#: account/forms.py:174 +msgid "Email address can not be found." +msgstr "Eposta adresiniz bulunamıyor." + +#: account/forms.py:200 +msgid "Timezone" +msgstr "Zaman Dilimi" + +#: account/forms.py:206 +msgid "Language" +msgstr "Dil" + +#: account/models.py:36 +msgid "user" +msgstr "kullanıcı" + +#: account/models.py:37 +msgid "timezone" +msgstr "zaman dilimi" + +#: account/models.py:39 +msgid "language" +msgstr "dil" + +#: account/models.py:135 +msgid "code" +msgstr "kod" + +#: account/models.py:136 +msgid "max uses" +msgstr "maximum kullanım" + +#: account/models.py:137 +msgid "expiry" +msgstr "zaman aşımı" + +#: account/models.py:140 +msgid "notes" +msgstr "notlar" + +#: account/models.py:141 +msgid "sent" +msgstr "gönderildi" + +#: account/models.py:142 +msgid "created" +msgstr "oluşturuldu" + +#: account/models.py:143 +msgid "use count" +msgstr "kullanım sayısı" + +#: account/models.py:146 +msgid "signup code" +msgstr "kayıt kodu" + +#: account/models.py:147 +msgid "signup codes" +msgstr "kayıt kodları" + +#: account/models.py:254 +msgid "verified" +msgstr "onaylandı" + +#: account/models.py:255 +msgid "primary" +msgstr "birincil" + +#: account/models.py:260 +msgid "email address" +msgstr "eposta adresi" + +#: account/models.py:261 +msgid "email addresses" +msgstr "eposta adresleri" + +#: account/models.py:311 +msgid "email confirmation" +msgstr "eposta onayı" + +#: account/models.py:312 +msgid "email confirmations" +msgstr "eposta onayları" + +#: account/models.py:361 +msgid "date requested" +msgstr "istenen tarih" + +#: account/models.py:362 +msgid "date expunged" +msgstr "silinen tarih" + +#: account/models.py:365 +msgid "account deletion" +msgstr "hesap silinmesi" + +#: account/models.py:366 +msgid "account deletions" +msgstr "hespa silinmeleri" + +#: account/views.py:42 +#, python-brace-format +msgid "Confirmation email sent to {email}." +msgstr "Onay epostası {email} adresine yollandı." + +#: account/views.py:46 +#, python-brace-format +msgid "The code {code} is invalid." +msgstr "{code} kodu geçersiz." + +#: account/views.py:384 +#, python-brace-format +msgid "You have confirmed {email}." +msgstr "{email} adresini onayladınız." + +#: account/views.py:457 account/views.py:593 +msgid "Password successfully changed." +msgstr "Şifre başarıyla değiştirildi." + +#: account/views.py:684 +msgid "Account settings updated." +msgstr "Hesap ayarları güncelendi." + +#: account/views.py:768 +#, python-brace-format +msgid "" +"Your account is now inactive and your data will be expunged in the next " +"{expunge_hours} hours." +msgstr "Hesabınız inaktiftir ve verileriniz" +"{expunge_hours} saat sonra silinecektir." + diff --git a/account/management/commands/expunge_deleted.py b/account/management/commands/expunge_deleted.py index d1746492..7313d55b 100644 --- a/account/management/commands/expunge_deleted.py +++ b/account/management/commands/expunge_deleted.py @@ -1,6 +1,3 @@ -from __future__ import print_function -from __future__ import unicode_literals - from django.core.management.base import BaseCommand from account.models import AccountDeletion diff --git a/account/management/commands/user_password_expiry.py b/account/management/commands/user_password_expiry.py new file mode 100644 index 00000000..6733a598 --- /dev/null +++ b/account/management/commands/user_password_expiry.py @@ -0,0 +1,39 @@ +from django.contrib.auth import get_user_model +from django.core.management.base import LabelCommand + +from account.conf import settings +from account.models import PasswordExpiry + + +class Command(LabelCommand): + + help = "Create user-specific password expiration period." + label = "username" + + def add_arguments(self, parser): + super(Command, self).add_arguments(parser) + parser.add_argument( + "-e", "--expire", + type=int, + nargs="?", + default=settings.ACCOUNT_PASSWORD_EXPIRY, + help="number of seconds until password expires" + ) + + def handle_label(self, username, **options): + User = get_user_model() + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + return 'User "{}" not found'.format(username) + + expire = options["expire"] + + # Modify existing PasswordExpiry or create new if needed. + if not hasattr(user, "password_expiry"): + PasswordExpiry.objects.create(user=user, expiry=expire) + else: + user.password_expiry.expiry = expire + user.password_expiry.save() + + return 'User "{}" password expiration set to {} seconds'.format(username, expire) diff --git a/account/management/commands/user_password_history.py b/account/management/commands/user_password_history.py new file mode 100644 index 00000000..bffc452b --- /dev/null +++ b/account/management/commands/user_password_history.py @@ -0,0 +1,45 @@ +import datetime + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +import pytz +from account.models import PasswordHistory + + +class Command(BaseCommand): + + help = "Create password history for all users without existing history." + + def add_arguments(self, parser): + parser.add_argument( + "-d", "--days", + type=int, + nargs="?", + default=10, + help="age of current password (in days)" + ) + parser.add_argument( + "-f", "--force", + action="store_true", + help="create new password history for all users, regardless of existing history" + ) + + def handle(self, *args, **options): + User = get_user_model() + users = User.objects.all() + if not options["force"]: + users = users.filter(password_history=None) + + if not users: + return "No users found without password history" + + days = options["days"] + timestamp = datetime.datetime.now(tz=pytz.UTC) - datetime.timedelta(days=days) + + # Create new PasswordHistory on `timestamp` + PasswordHistory.objects.bulk_create( + [PasswordHistory(user=user, timestamp=timestamp) for user in users] + ) + + return "Password history set to {} for {} users".format(timestamp, len(users)) diff --git a/account/managers.py b/account/managers.py index 3c7776b0..ec30d4d4 100644 --- a/account/managers.py +++ b/account/managers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db import models diff --git a/account/middleware.py b/account/middleware.py index 916eaf19..6d998586 100644 --- a/account/middleware.py +++ b/account/middleware.py @@ -1,13 +1,21 @@ -from __future__ import unicode_literals +from urllib.parse import urlparse, urlunparse -from django.utils import translation, timezone +from django.contrib import messages +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.http import HttpResponseRedirect, QueryDict +from django.urls import resolve, reverse +from django.utils import timezone, translation from django.utils.cache import patch_vary_headers +from django.utils.deprecation import MiddlewareMixin as BaseMiddleware +from django.utils.translation import gettext_lazy as _ +from account import signals from account.conf import settings from account.models import Account +from account.utils import check_password_expired -class LocaleMiddleware(object): +class LocaleMiddleware(BaseMiddleware): """ This is a very simple middleware that parses a request and decides what translation object to install in the current @@ -16,8 +24,9 @@ class LocaleMiddleware(object): (if the language is available, of course). """ - def get_language_for_user(self, request): - if request.user.is_authenticated(): + @staticmethod + def get_language_for_user(request): + if request.user.is_authenticated: try: account = Account.objects.get(user=request.user) return account.language @@ -29,20 +38,22 @@ def process_request(self, request): translation.activate(self.get_language_for_user(request)) request.LANGUAGE_CODE = translation.get_language() - def process_response(self, request, response): + @staticmethod + def process_response(request, response): patch_vary_headers(response, ("Accept-Language",)) response["Content-Language"] = translation.get_language() translation.deactivate() return response -class TimezoneMiddleware(object): +class TimezoneMiddleware(BaseMiddleware): """ This middleware sets the timezone used to display dates in templates to the user's timezone. """ - def process_request(self, request): + @staticmethod + def process_request(request): try: account = getattr(request.user, "account", None) except Account.DoesNotExist: @@ -51,3 +62,32 @@ def process_request(self, request): if account: tz = settings.TIME_ZONE if not account.timezone else account.timezone timezone.activate(tz) + + +class ExpiredPasswordMiddleware(BaseMiddleware): + + def process_request(self, request): + if request.user.is_authenticated and not request.user.is_staff: + next_url = resolve(request.path).url_name + # Authenticated users must be allowed to access + # "change password" page and "log out" page. + # even if password is expired. + if next_url not in [ + settings.ACCOUNT_PASSWORD_CHANGE_REDIRECT_URL, + settings.ACCOUNT_LOGOUT_URL, + ] and check_password_expired(request.user): + signals.password_expired.send(sender=self, user=request.user) + messages.add_message( + request, + messages.WARNING, + _("Your password has expired. Please save a new password.") + ) + redirect_field_name = REDIRECT_FIELD_NAME + + change_password_url = reverse(settings.ACCOUNT_PASSWORD_CHANGE_REDIRECT_URL) + url_bits = list(urlparse(change_password_url)) + querystring = QueryDict(url_bits[4], mutable=True) + querystring[redirect_field_name] = next_url + url_bits[4] = querystring.urlencode(safe="/") + + return HttpResponseRedirect(urlunparse(url_bits)) diff --git a/account/migrations/0001_initial.py b/account/migrations/0001_initial.py index 7205e1c9..90682dcb 100644 --- a/account/migrations/0001_initial.py +++ b/account/migrations/0001_initial.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, serialize=False, verbose_name='ID', primary_key=True)), ('timezone', account.fields.TimeZoneField(default='', verbose_name='timezone', max_length=100, choices=[(b'Africa/Abidjan', b'Africa/Abidjan'), (b'Africa/Accra', b'Africa/Accra'), (b'Africa/Addis_Ababa', b'Africa/Addis_Ababa'), (b'Africa/Algiers', b'Africa/Algiers'), (b'Africa/Asmara', b'Africa/Asmara'), (b'Africa/Asmera', b'Africa/Asmera'), (b'Africa/Bamako', b'Africa/Bamako'), (b'Africa/Bangui', b'Africa/Bangui'), (b'Africa/Banjul', b'Africa/Banjul'), (b'Africa/Bissau', b'Africa/Bissau'), (b'Africa/Blantyre', b'Africa/Blantyre'), (b'Africa/Brazzaville', b'Africa/Brazzaville'), (b'Africa/Bujumbura', b'Africa/Bujumbura'), (b'Africa/Cairo', b'Africa/Cairo'), (b'Africa/Casablanca', b'Africa/Casablanca'), (b'Africa/Ceuta', b'Africa/Ceuta'), (b'Africa/Conakry', b'Africa/Conakry'), (b'Africa/Dakar', b'Africa/Dakar'), (b'Africa/Dar_es_Salaam', b'Africa/Dar_es_Salaam'), (b'Africa/Djibouti', b'Africa/Djibouti'), (b'Africa/Douala', b'Africa/Douala'), (b'Africa/El_Aaiun', b'Africa/El_Aaiun'), (b'Africa/Freetown', b'Africa/Freetown'), (b'Africa/Gaborone', b'Africa/Gaborone'), (b'Africa/Harare', b'Africa/Harare'), (b'Africa/Johannesburg', b'Africa/Johannesburg'), (b'Africa/Juba', b'Africa/Juba'), (b'Africa/Kampala', b'Africa/Kampala'), (b'Africa/Khartoum', b'Africa/Khartoum'), (b'Africa/Kigali', b'Africa/Kigali'), (b'Africa/Kinshasa', b'Africa/Kinshasa'), (b'Africa/Lagos', b'Africa/Lagos'), (b'Africa/Libreville', b'Africa/Libreville'), (b'Africa/Lome', b'Africa/Lome'), (b'Africa/Luanda', b'Africa/Luanda'), (b'Africa/Lubumbashi', b'Africa/Lubumbashi'), (b'Africa/Lusaka', b'Africa/Lusaka'), (b'Africa/Malabo', b'Africa/Malabo'), (b'Africa/Maputo', b'Africa/Maputo'), (b'Africa/Maseru', b'Africa/Maseru'), (b'Africa/Mbabane', b'Africa/Mbabane'), (b'Africa/Mogadishu', b'Africa/Mogadishu'), (b'Africa/Monrovia', b'Africa/Monrovia'), (b'Africa/Nairobi', b'Africa/Nairobi'), (b'Africa/Ndjamena', b'Africa/Ndjamena'), (b'Africa/Niamey', b'Africa/Niamey'), (b'Africa/Nouakchott', b'Africa/Nouakchott'), (b'Africa/Ouagadougou', b'Africa/Ouagadougou'), (b'Africa/Porto-Novo', b'Africa/Porto-Novo'), (b'Africa/Sao_Tome', b'Africa/Sao_Tome'), (b'Africa/Timbuktu', b'Africa/Timbuktu'), (b'Africa/Tripoli', b'Africa/Tripoli'), (b'Africa/Tunis', b'Africa/Tunis'), (b'Africa/Windhoek', b'Africa/Windhoek'), (b'America/Adak', b'America/Adak'), (b'America/Anchorage', b'America/Anchorage'), (b'America/Anguilla', b'America/Anguilla'), (b'America/Antigua', b'America/Antigua'), (b'America/Araguaina', b'America/Araguaina'), (b'America/Argentina/Buenos_Aires', b'America/Argentina/Buenos_Aires'), (b'America/Argentina/Catamarca', b'America/Argentina/Catamarca'), (b'America/Argentina/ComodRivadavia', b'America/Argentina/ComodRivadavia'), (b'America/Argentina/Cordoba', b'America/Argentina/Cordoba'), (b'America/Argentina/Jujuy', b'America/Argentina/Jujuy'), (b'America/Argentina/La_Rioja', b'America/Argentina/La_Rioja'), (b'America/Argentina/Mendoza', b'America/Argentina/Mendoza'), (b'America/Argentina/Rio_Gallegos', b'America/Argentina/Rio_Gallegos'), (b'America/Argentina/Salta', b'America/Argentina/Salta'), (b'America/Argentina/San_Juan', b'America/Argentina/San_Juan'), (b'America/Argentina/San_Luis', b'America/Argentina/San_Luis'), (b'America/Argentina/Tucuman', b'America/Argentina/Tucuman'), (b'America/Argentina/Ushuaia', b'America/Argentina/Ushuaia'), (b'America/Aruba', b'America/Aruba'), (b'America/Asuncion', b'America/Asuncion'), (b'America/Atikokan', b'America/Atikokan'), (b'America/Atka', b'America/Atka'), (b'America/Bahia', b'America/Bahia'), (b'America/Bahia_Banderas', b'America/Bahia_Banderas'), (b'America/Barbados', b'America/Barbados'), (b'America/Belem', b'America/Belem'), (b'America/Belize', b'America/Belize'), (b'America/Blanc-Sablon', b'America/Blanc-Sablon'), (b'America/Boa_Vista', b'America/Boa_Vista'), (b'America/Bogota', b'America/Bogota'), (b'America/Boise', b'America/Boise'), (b'America/Buenos_Aires', b'America/Buenos_Aires'), (b'America/Cambridge_Bay', b'America/Cambridge_Bay'), (b'America/Campo_Grande', b'America/Campo_Grande'), (b'America/Cancun', b'America/Cancun'), (b'America/Caracas', b'America/Caracas'), (b'America/Catamarca', b'America/Catamarca'), (b'America/Cayenne', b'America/Cayenne'), (b'America/Cayman', b'America/Cayman'), (b'America/Chicago', b'America/Chicago'), (b'America/Chihuahua', b'America/Chihuahua'), (b'America/Coral_Harbour', b'America/Coral_Harbour'), (b'America/Cordoba', b'America/Cordoba'), (b'America/Costa_Rica', b'America/Costa_Rica'), (b'America/Creston', b'America/Creston'), (b'America/Cuiaba', b'America/Cuiaba'), (b'America/Curacao', b'America/Curacao'), (b'America/Danmarkshavn', b'America/Danmarkshavn'), (b'America/Dawson', b'America/Dawson'), (b'America/Dawson_Creek', b'America/Dawson_Creek'), (b'America/Denver', b'America/Denver'), (b'America/Detroit', b'America/Detroit'), (b'America/Dominica', b'America/Dominica'), (b'America/Edmonton', b'America/Edmonton'), (b'America/Eirunepe', b'America/Eirunepe'), (b'America/El_Salvador', b'America/El_Salvador'), (b'America/Ensenada', b'America/Ensenada'), (b'America/Fort_Wayne', b'America/Fort_Wayne'), (b'America/Fortaleza', b'America/Fortaleza'), (b'America/Glace_Bay', b'America/Glace_Bay'), (b'America/Godthab', b'America/Godthab'), (b'America/Goose_Bay', b'America/Goose_Bay'), (b'America/Grand_Turk', b'America/Grand_Turk'), (b'America/Grenada', b'America/Grenada'), (b'America/Guadeloupe', b'America/Guadeloupe'), (b'America/Guatemala', b'America/Guatemala'), (b'America/Guayaquil', b'America/Guayaquil'), (b'America/Guyana', b'America/Guyana'), (b'America/Halifax', b'America/Halifax'), (b'America/Havana', b'America/Havana'), (b'America/Hermosillo', b'America/Hermosillo'), (b'America/Indiana/Indianapolis', b'America/Indiana/Indianapolis'), (b'America/Indiana/Knox', b'America/Indiana/Knox'), (b'America/Indiana/Marengo', b'America/Indiana/Marengo'), (b'America/Indiana/Petersburg', b'America/Indiana/Petersburg'), (b'America/Indiana/Tell_City', b'America/Indiana/Tell_City'), (b'America/Indiana/Vevay', b'America/Indiana/Vevay'), (b'America/Indiana/Vincennes', b'America/Indiana/Vincennes'), (b'America/Indiana/Winamac', b'America/Indiana/Winamac'), (b'America/Indianapolis', b'America/Indianapolis'), (b'America/Inuvik', b'America/Inuvik'), (b'America/Iqaluit', b'America/Iqaluit'), (b'America/Jamaica', b'America/Jamaica'), (b'America/Jujuy', b'America/Jujuy'), (b'America/Juneau', b'America/Juneau'), (b'America/Kentucky/Louisville', b'America/Kentucky/Louisville'), (b'America/Kentucky/Monticello', b'America/Kentucky/Monticello'), (b'America/Knox_IN', b'America/Knox_IN'), (b'America/Kralendijk', b'America/Kralendijk'), (b'America/La_Paz', b'America/La_Paz'), (b'America/Lima', b'America/Lima'), (b'America/Los_Angeles', b'America/Los_Angeles'), (b'America/Louisville', b'America/Louisville'), (b'America/Lower_Princes', b'America/Lower_Princes'), (b'America/Maceio', b'America/Maceio'), (b'America/Managua', b'America/Managua'), (b'America/Manaus', b'America/Manaus'), (b'America/Marigot', b'America/Marigot'), (b'America/Martinique', b'America/Martinique'), (b'America/Matamoros', b'America/Matamoros'), (b'America/Mazatlan', b'America/Mazatlan'), (b'America/Mendoza', b'America/Mendoza'), (b'America/Menominee', b'America/Menominee'), (b'America/Merida', b'America/Merida'), (b'America/Metlakatla', b'America/Metlakatla'), (b'America/Mexico_City', b'America/Mexico_City'), (b'America/Miquelon', b'America/Miquelon'), (b'America/Moncton', b'America/Moncton'), (b'America/Monterrey', b'America/Monterrey'), (b'America/Montevideo', b'America/Montevideo'), (b'America/Montreal', b'America/Montreal'), (b'America/Montserrat', b'America/Montserrat'), (b'America/Nassau', b'America/Nassau'), (b'America/New_York', b'America/New_York'), (b'America/Nipigon', b'America/Nipigon'), (b'America/Nome', b'America/Nome'), (b'America/Noronha', b'America/Noronha'), (b'America/North_Dakota/Beulah', b'America/North_Dakota/Beulah'), (b'America/North_Dakota/Center', b'America/North_Dakota/Center'), (b'America/North_Dakota/New_Salem', b'America/North_Dakota/New_Salem'), (b'America/Ojinaga', b'America/Ojinaga'), (b'America/Panama', b'America/Panama'), (b'America/Pangnirtung', b'America/Pangnirtung'), (b'America/Paramaribo', b'America/Paramaribo'), (b'America/Phoenix', b'America/Phoenix'), (b'America/Port-au-Prince', b'America/Port-au-Prince'), (b'America/Port_of_Spain', b'America/Port_of_Spain'), (b'America/Porto_Acre', b'America/Porto_Acre'), (b'America/Porto_Velho', b'America/Porto_Velho'), (b'America/Puerto_Rico', b'America/Puerto_Rico'), (b'America/Rainy_River', b'America/Rainy_River'), (b'America/Rankin_Inlet', b'America/Rankin_Inlet'), (b'America/Recife', b'America/Recife'), (b'America/Regina', b'America/Regina'), (b'America/Resolute', b'America/Resolute'), (b'America/Rio_Branco', b'America/Rio_Branco'), (b'America/Rosario', b'America/Rosario'), (b'America/Santa_Isabel', b'America/Santa_Isabel'), (b'America/Santarem', b'America/Santarem'), (b'America/Santiago', b'America/Santiago'), (b'America/Santo_Domingo', b'America/Santo_Domingo'), (b'America/Sao_Paulo', b'America/Sao_Paulo'), (b'America/Scoresbysund', b'America/Scoresbysund'), (b'America/Shiprock', b'America/Shiprock'), (b'America/Sitka', b'America/Sitka'), (b'America/St_Barthelemy', b'America/St_Barthelemy'), (b'America/St_Johns', b'America/St_Johns'), (b'America/St_Kitts', b'America/St_Kitts'), (b'America/St_Lucia', b'America/St_Lucia'), (b'America/St_Thomas', b'America/St_Thomas'), (b'America/St_Vincent', b'America/St_Vincent'), (b'America/Swift_Current', b'America/Swift_Current'), (b'America/Tegucigalpa', b'America/Tegucigalpa'), (b'America/Thule', b'America/Thule'), (b'America/Thunder_Bay', b'America/Thunder_Bay'), (b'America/Tijuana', b'America/Tijuana'), (b'America/Toronto', b'America/Toronto'), (b'America/Tortola', b'America/Tortola'), (b'America/Vancouver', b'America/Vancouver'), (b'America/Virgin', b'America/Virgin'), (b'America/Whitehorse', b'America/Whitehorse'), (b'America/Winnipeg', b'America/Winnipeg'), (b'America/Yakutat', b'America/Yakutat'), (b'America/Yellowknife', b'America/Yellowknife'), (b'Antarctica/Casey', b'Antarctica/Casey'), (b'Antarctica/Davis', b'Antarctica/Davis'), (b'Antarctica/DumontDUrville', b'Antarctica/DumontDUrville'), (b'Antarctica/Macquarie', b'Antarctica/Macquarie'), (b'Antarctica/Mawson', b'Antarctica/Mawson'), (b'Antarctica/McMurdo', b'Antarctica/McMurdo'), (b'Antarctica/Palmer', b'Antarctica/Palmer'), (b'Antarctica/Rothera', b'Antarctica/Rothera'), (b'Antarctica/South_Pole', b'Antarctica/South_Pole'), (b'Antarctica/Syowa', b'Antarctica/Syowa'), (b'Antarctica/Troll', b'Antarctica/Troll'), (b'Antarctica/Vostok', b'Antarctica/Vostok'), (b'Arctic/Longyearbyen', b'Arctic/Longyearbyen'), (b'Asia/Aden', b'Asia/Aden'), (b'Asia/Almaty', b'Asia/Almaty'), (b'Asia/Amman', b'Asia/Amman'), (b'Asia/Anadyr', b'Asia/Anadyr'), (b'Asia/Aqtau', b'Asia/Aqtau'), (b'Asia/Aqtobe', b'Asia/Aqtobe'), (b'Asia/Ashgabat', b'Asia/Ashgabat'), (b'Asia/Ashkhabad', b'Asia/Ashkhabad'), (b'Asia/Baghdad', b'Asia/Baghdad'), (b'Asia/Bahrain', b'Asia/Bahrain'), (b'Asia/Baku', b'Asia/Baku'), (b'Asia/Bangkok', b'Asia/Bangkok'), (b'Asia/Beirut', b'Asia/Beirut'), (b'Asia/Bishkek', b'Asia/Bishkek'), (b'Asia/Brunei', b'Asia/Brunei'), (b'Asia/Calcutta', b'Asia/Calcutta'), (b'Asia/Chita', b'Asia/Chita'), (b'Asia/Choibalsan', b'Asia/Choibalsan'), (b'Asia/Chongqing', b'Asia/Chongqing'), (b'Asia/Chungking', b'Asia/Chungking'), (b'Asia/Colombo', b'Asia/Colombo'), (b'Asia/Dacca', b'Asia/Dacca'), (b'Asia/Damascus', b'Asia/Damascus'), (b'Asia/Dhaka', b'Asia/Dhaka'), (b'Asia/Dili', b'Asia/Dili'), (b'Asia/Dubai', b'Asia/Dubai'), (b'Asia/Dushanbe', b'Asia/Dushanbe'), (b'Asia/Gaza', b'Asia/Gaza'), (b'Asia/Harbin', b'Asia/Harbin'), (b'Asia/Hebron', b'Asia/Hebron'), (b'Asia/Ho_Chi_Minh', b'Asia/Ho_Chi_Minh'), (b'Asia/Hong_Kong', b'Asia/Hong_Kong'), (b'Asia/Hovd', b'Asia/Hovd'), (b'Asia/Irkutsk', b'Asia/Irkutsk'), (b'Asia/Istanbul', b'Asia/Istanbul'), (b'Asia/Jakarta', b'Asia/Jakarta'), (b'Asia/Jayapura', b'Asia/Jayapura'), (b'Asia/Jerusalem', b'Asia/Jerusalem'), (b'Asia/Kabul', b'Asia/Kabul'), (b'Asia/Kamchatka', b'Asia/Kamchatka'), (b'Asia/Karachi', b'Asia/Karachi'), (b'Asia/Kashgar', b'Asia/Kashgar'), (b'Asia/Kathmandu', b'Asia/Kathmandu'), (b'Asia/Katmandu', b'Asia/Katmandu'), (b'Asia/Khandyga', b'Asia/Khandyga'), (b'Asia/Kolkata', b'Asia/Kolkata'), (b'Asia/Krasnoyarsk', b'Asia/Krasnoyarsk'), (b'Asia/Kuala_Lumpur', b'Asia/Kuala_Lumpur'), (b'Asia/Kuching', b'Asia/Kuching'), (b'Asia/Kuwait', b'Asia/Kuwait'), (b'Asia/Macao', b'Asia/Macao'), (b'Asia/Macau', b'Asia/Macau'), (b'Asia/Magadan', b'Asia/Magadan'), (b'Asia/Makassar', b'Asia/Makassar'), (b'Asia/Manila', b'Asia/Manila'), (b'Asia/Muscat', b'Asia/Muscat'), (b'Asia/Nicosia', b'Asia/Nicosia'), (b'Asia/Novokuznetsk', b'Asia/Novokuznetsk'), (b'Asia/Novosibirsk', b'Asia/Novosibirsk'), (b'Asia/Omsk', b'Asia/Omsk'), (b'Asia/Oral', b'Asia/Oral'), (b'Asia/Phnom_Penh', b'Asia/Phnom_Penh'), (b'Asia/Pontianak', b'Asia/Pontianak'), (b'Asia/Pyongyang', b'Asia/Pyongyang'), (b'Asia/Qatar', b'Asia/Qatar'), (b'Asia/Qyzylorda', b'Asia/Qyzylorda'), (b'Asia/Rangoon', b'Asia/Rangoon'), (b'Asia/Riyadh', b'Asia/Riyadh'), (b'Asia/Saigon', b'Asia/Saigon'), (b'Asia/Sakhalin', b'Asia/Sakhalin'), (b'Asia/Samarkand', b'Asia/Samarkand'), (b'Asia/Seoul', b'Asia/Seoul'), (b'Asia/Shanghai', b'Asia/Shanghai'), (b'Asia/Singapore', b'Asia/Singapore'), (b'Asia/Srednekolymsk', b'Asia/Srednekolymsk'), (b'Asia/Taipei', b'Asia/Taipei'), (b'Asia/Tashkent', b'Asia/Tashkent'), (b'Asia/Tbilisi', b'Asia/Tbilisi'), (b'Asia/Tehran', b'Asia/Tehran'), (b'Asia/Tel_Aviv', b'Asia/Tel_Aviv'), (b'Asia/Thimbu', b'Asia/Thimbu'), (b'Asia/Thimphu', b'Asia/Thimphu'), (b'Asia/Tokyo', b'Asia/Tokyo'), (b'Asia/Ujung_Pandang', b'Asia/Ujung_Pandang'), (b'Asia/Ulaanbaatar', b'Asia/Ulaanbaatar'), (b'Asia/Ulan_Bator', b'Asia/Ulan_Bator'), (b'Asia/Urumqi', b'Asia/Urumqi'), (b'Asia/Ust-Nera', b'Asia/Ust-Nera'), (b'Asia/Vientiane', b'Asia/Vientiane'), (b'Asia/Vladivostok', b'Asia/Vladivostok'), (b'Asia/Yakutsk', b'Asia/Yakutsk'), (b'Asia/Yekaterinburg', b'Asia/Yekaterinburg'), (b'Asia/Yerevan', b'Asia/Yerevan'), (b'Atlantic/Azores', b'Atlantic/Azores'), (b'Atlantic/Bermuda', b'Atlantic/Bermuda'), (b'Atlantic/Canary', b'Atlantic/Canary'), (b'Atlantic/Cape_Verde', b'Atlantic/Cape_Verde'), (b'Atlantic/Faeroe', b'Atlantic/Faeroe'), (b'Atlantic/Faroe', b'Atlantic/Faroe'), (b'Atlantic/Jan_Mayen', b'Atlantic/Jan_Mayen'), (b'Atlantic/Madeira', b'Atlantic/Madeira'), (b'Atlantic/Reykjavik', b'Atlantic/Reykjavik'), (b'Atlantic/South_Georgia', b'Atlantic/South_Georgia'), (b'Atlantic/St_Helena', b'Atlantic/St_Helena'), (b'Atlantic/Stanley', b'Atlantic/Stanley'), (b'Australia/ACT', b'Australia/ACT'), (b'Australia/Adelaide', b'Australia/Adelaide'), (b'Australia/Brisbane', b'Australia/Brisbane'), (b'Australia/Broken_Hill', b'Australia/Broken_Hill'), (b'Australia/Canberra', b'Australia/Canberra'), (b'Australia/Currie', b'Australia/Currie'), (b'Australia/Darwin', b'Australia/Darwin'), (b'Australia/Eucla', b'Australia/Eucla'), (b'Australia/Hobart', b'Australia/Hobart'), (b'Australia/LHI', b'Australia/LHI'), (b'Australia/Lindeman', b'Australia/Lindeman'), (b'Australia/Lord_Howe', b'Australia/Lord_Howe'), (b'Australia/Melbourne', b'Australia/Melbourne'), (b'Australia/NSW', b'Australia/NSW'), (b'Australia/North', b'Australia/North'), (b'Australia/Perth', b'Australia/Perth'), (b'Australia/Queensland', b'Australia/Queensland'), (b'Australia/South', b'Australia/South'), (b'Australia/Sydney', b'Australia/Sydney'), (b'Australia/Tasmania', b'Australia/Tasmania'), (b'Australia/Victoria', b'Australia/Victoria'), (b'Australia/West', b'Australia/West'), (b'Australia/Yancowinna', b'Australia/Yancowinna'), (b'Brazil/Acre', b'Brazil/Acre'), (b'Brazil/DeNoronha', b'Brazil/DeNoronha'), (b'Brazil/East', b'Brazil/East'), (b'Brazil/West', b'Brazil/West'), (b'CET', b'CET'), (b'CST6CDT', b'CST6CDT'), (b'Canada/Atlantic', b'Canada/Atlantic'), (b'Canada/Central', b'Canada/Central'), (b'Canada/East-Saskatchewan', b'Canada/East-Saskatchewan'), (b'Canada/Eastern', b'Canada/Eastern'), (b'Canada/Mountain', b'Canada/Mountain'), (b'Canada/Newfoundland', b'Canada/Newfoundland'), (b'Canada/Pacific', b'Canada/Pacific'), (b'Canada/Saskatchewan', b'Canada/Saskatchewan'), (b'Canada/Yukon', b'Canada/Yukon'), (b'Chile/Continental', b'Chile/Continental'), (b'Chile/EasterIsland', b'Chile/EasterIsland'), (b'Cuba', b'Cuba'), (b'EET', b'EET'), (b'EST', b'EST'), (b'EST5EDT', b'EST5EDT'), (b'Egypt', b'Egypt'), (b'Eire', b'Eire'), (b'Etc/GMT', b'Etc/GMT'), (b'Etc/GMT+0', b'Etc/GMT+0'), (b'Etc/GMT+1', b'Etc/GMT+1'), (b'Etc/GMT+10', b'Etc/GMT+10'), (b'Etc/GMT+11', b'Etc/GMT+11'), (b'Etc/GMT+12', b'Etc/GMT+12'), (b'Etc/GMT+2', b'Etc/GMT+2'), (b'Etc/GMT+3', b'Etc/GMT+3'), (b'Etc/GMT+4', b'Etc/GMT+4'), (b'Etc/GMT+5', b'Etc/GMT+5'), (b'Etc/GMT+6', b'Etc/GMT+6'), (b'Etc/GMT+7', b'Etc/GMT+7'), (b'Etc/GMT+8', b'Etc/GMT+8'), (b'Etc/GMT+9', b'Etc/GMT+9'), (b'Etc/GMT-0', b'Etc/GMT-0'), (b'Etc/GMT-1', b'Etc/GMT-1'), (b'Etc/GMT-10', b'Etc/GMT-10'), (b'Etc/GMT-11', b'Etc/GMT-11'), (b'Etc/GMT-12', b'Etc/GMT-12'), (b'Etc/GMT-13', b'Etc/GMT-13'), (b'Etc/GMT-14', b'Etc/GMT-14'), (b'Etc/GMT-2', b'Etc/GMT-2'), (b'Etc/GMT-3', b'Etc/GMT-3'), (b'Etc/GMT-4', b'Etc/GMT-4'), (b'Etc/GMT-5', b'Etc/GMT-5'), (b'Etc/GMT-6', b'Etc/GMT-6'), (b'Etc/GMT-7', b'Etc/GMT-7'), (b'Etc/GMT-8', b'Etc/GMT-8'), (b'Etc/GMT-9', b'Etc/GMT-9'), (b'Etc/GMT0', b'Etc/GMT0'), (b'Etc/Greenwich', b'Etc/Greenwich'), (b'Etc/UCT', b'Etc/UCT'), (b'Etc/UTC', b'Etc/UTC'), (b'Etc/Universal', b'Etc/Universal'), (b'Etc/Zulu', b'Etc/Zulu'), (b'Europe/Amsterdam', b'Europe/Amsterdam'), (b'Europe/Andorra', b'Europe/Andorra'), (b'Europe/Athens', b'Europe/Athens'), (b'Europe/Belfast', b'Europe/Belfast'), (b'Europe/Belgrade', b'Europe/Belgrade'), (b'Europe/Berlin', b'Europe/Berlin'), (b'Europe/Bratislava', b'Europe/Bratislava'), (b'Europe/Brussels', b'Europe/Brussels'), (b'Europe/Bucharest', b'Europe/Bucharest'), (b'Europe/Budapest', b'Europe/Budapest'), (b'Europe/Busingen', b'Europe/Busingen'), (b'Europe/Chisinau', b'Europe/Chisinau'), (b'Europe/Copenhagen', b'Europe/Copenhagen'), (b'Europe/Dublin', b'Europe/Dublin'), (b'Europe/Gibraltar', b'Europe/Gibraltar'), (b'Europe/Guernsey', b'Europe/Guernsey'), (b'Europe/Helsinki', b'Europe/Helsinki'), (b'Europe/Isle_of_Man', b'Europe/Isle_of_Man'), (b'Europe/Istanbul', b'Europe/Istanbul'), (b'Europe/Jersey', b'Europe/Jersey'), (b'Europe/Kaliningrad', b'Europe/Kaliningrad'), (b'Europe/Kiev', b'Europe/Kiev'), (b'Europe/Lisbon', b'Europe/Lisbon'), (b'Europe/Ljubljana', b'Europe/Ljubljana'), (b'Europe/London', b'Europe/London'), (b'Europe/Luxembourg', b'Europe/Luxembourg'), (b'Europe/Madrid', b'Europe/Madrid'), (b'Europe/Malta', b'Europe/Malta'), (b'Europe/Mariehamn', b'Europe/Mariehamn'), (b'Europe/Minsk', b'Europe/Minsk'), (b'Europe/Monaco', b'Europe/Monaco'), (b'Europe/Moscow', b'Europe/Moscow'), (b'Europe/Nicosia', b'Europe/Nicosia'), (b'Europe/Oslo', b'Europe/Oslo'), (b'Europe/Paris', b'Europe/Paris'), (b'Europe/Podgorica', b'Europe/Podgorica'), (b'Europe/Prague', b'Europe/Prague'), (b'Europe/Riga', b'Europe/Riga'), (b'Europe/Rome', b'Europe/Rome'), (b'Europe/Samara', b'Europe/Samara'), (b'Europe/San_Marino', b'Europe/San_Marino'), (b'Europe/Sarajevo', b'Europe/Sarajevo'), (b'Europe/Simferopol', b'Europe/Simferopol'), (b'Europe/Skopje', b'Europe/Skopje'), (b'Europe/Sofia', b'Europe/Sofia'), (b'Europe/Stockholm', b'Europe/Stockholm'), (b'Europe/Tallinn', b'Europe/Tallinn'), (b'Europe/Tirane', b'Europe/Tirane'), (b'Europe/Tiraspol', b'Europe/Tiraspol'), (b'Europe/Uzhgorod', b'Europe/Uzhgorod'), (b'Europe/Vaduz', b'Europe/Vaduz'), (b'Europe/Vatican', b'Europe/Vatican'), (b'Europe/Vienna', b'Europe/Vienna'), (b'Europe/Vilnius', b'Europe/Vilnius'), (b'Europe/Volgograd', b'Europe/Volgograd'), (b'Europe/Warsaw', b'Europe/Warsaw'), (b'Europe/Zagreb', b'Europe/Zagreb'), (b'Europe/Zaporozhye', b'Europe/Zaporozhye'), (b'Europe/Zurich', b'Europe/Zurich'), (b'GB', b'GB'), (b'GB-Eire', b'GB-Eire'), (b'GMT', b'GMT'), (b'GMT+0', b'GMT+0'), (b'GMT-0', b'GMT-0'), (b'GMT0', b'GMT0'), (b'Greenwich', b'Greenwich'), (b'HST', b'HST'), (b'Hongkong', b'Hongkong'), (b'Iceland', b'Iceland'), (b'Indian/Antananarivo', b'Indian/Antananarivo'), (b'Indian/Chagos', b'Indian/Chagos'), (b'Indian/Christmas', b'Indian/Christmas'), (b'Indian/Cocos', b'Indian/Cocos'), (b'Indian/Comoro', b'Indian/Comoro'), (b'Indian/Kerguelen', b'Indian/Kerguelen'), (b'Indian/Mahe', b'Indian/Mahe'), (b'Indian/Maldives', b'Indian/Maldives'), (b'Indian/Mauritius', b'Indian/Mauritius'), (b'Indian/Mayotte', b'Indian/Mayotte'), (b'Indian/Reunion', b'Indian/Reunion'), (b'Iran', b'Iran'), (b'Israel', b'Israel'), (b'Jamaica', b'Jamaica'), (b'Japan', b'Japan'), (b'Kwajalein', b'Kwajalein'), (b'Libya', b'Libya'), (b'MET', b'MET'), (b'MST', b'MST'), (b'MST7MDT', b'MST7MDT'), (b'Mexico/BajaNorte', b'Mexico/BajaNorte'), (b'Mexico/BajaSur', b'Mexico/BajaSur'), (b'Mexico/General', b'Mexico/General'), (b'NZ', b'NZ'), (b'NZ-CHAT', b'NZ-CHAT'), (b'Navajo', b'Navajo'), (b'PRC', b'PRC'), (b'PST8PDT', b'PST8PDT'), (b'Pacific/Apia', b'Pacific/Apia'), (b'Pacific/Auckland', b'Pacific/Auckland'), (b'Pacific/Bougainville', b'Pacific/Bougainville'), (b'Pacific/Chatham', b'Pacific/Chatham'), (b'Pacific/Chuuk', b'Pacific/Chuuk'), (b'Pacific/Easter', b'Pacific/Easter'), (b'Pacific/Efate', b'Pacific/Efate'), (b'Pacific/Enderbury', b'Pacific/Enderbury'), (b'Pacific/Fakaofo', b'Pacific/Fakaofo'), (b'Pacific/Fiji', b'Pacific/Fiji'), (b'Pacific/Funafuti', b'Pacific/Funafuti'), (b'Pacific/Galapagos', b'Pacific/Galapagos'), (b'Pacific/Gambier', b'Pacific/Gambier'), (b'Pacific/Guadalcanal', b'Pacific/Guadalcanal'), (b'Pacific/Guam', b'Pacific/Guam'), (b'Pacific/Honolulu', b'Pacific/Honolulu'), (b'Pacific/Johnston', b'Pacific/Johnston'), (b'Pacific/Kiritimati', b'Pacific/Kiritimati'), (b'Pacific/Kosrae', b'Pacific/Kosrae'), (b'Pacific/Kwajalein', b'Pacific/Kwajalein'), (b'Pacific/Majuro', b'Pacific/Majuro'), (b'Pacific/Marquesas', b'Pacific/Marquesas'), (b'Pacific/Midway', b'Pacific/Midway'), (b'Pacific/Nauru', b'Pacific/Nauru'), (b'Pacific/Niue', b'Pacific/Niue'), (b'Pacific/Norfolk', b'Pacific/Norfolk'), (b'Pacific/Noumea', b'Pacific/Noumea'), (b'Pacific/Pago_Pago', b'Pacific/Pago_Pago'), (b'Pacific/Palau', b'Pacific/Palau'), (b'Pacific/Pitcairn', b'Pacific/Pitcairn'), (b'Pacific/Pohnpei', b'Pacific/Pohnpei'), (b'Pacific/Ponape', b'Pacific/Ponape'), (b'Pacific/Port_Moresby', b'Pacific/Port_Moresby'), (b'Pacific/Rarotonga', b'Pacific/Rarotonga'), (b'Pacific/Saipan', b'Pacific/Saipan'), (b'Pacific/Samoa', b'Pacific/Samoa'), (b'Pacific/Tahiti', b'Pacific/Tahiti'), (b'Pacific/Tarawa', b'Pacific/Tarawa'), (b'Pacific/Tongatapu', b'Pacific/Tongatapu'), (b'Pacific/Truk', b'Pacific/Truk'), (b'Pacific/Wake', b'Pacific/Wake'), (b'Pacific/Wallis', b'Pacific/Wallis'), (b'Pacific/Yap', b'Pacific/Yap'), (b'Poland', b'Poland'), (b'Portugal', b'Portugal'), (b'ROC', b'ROC'), (b'ROK', b'ROK'), (b'Singapore', b'Singapore'), (b'Turkey', b'Turkey'), (b'UCT', b'UCT'), (b'US/Alaska', b'US/Alaska'), (b'US/Aleutian', b'US/Aleutian'), (b'US/Arizona', b'US/Arizona'), (b'US/Central', b'US/Central'), (b'US/East-Indiana', b'US/East-Indiana'), (b'US/Eastern', b'US/Eastern'), (b'US/Hawaii', b'US/Hawaii'), (b'US/Indiana-Starke', b'US/Indiana-Starke'), (b'US/Michigan', b'US/Michigan'), (b'US/Mountain', b'US/Mountain'), (b'US/Pacific', b'US/Pacific'), (b'US/Pacific-New', b'US/Pacific-New'), (b'US/Samoa', b'US/Samoa'), (b'UTC', b'UTC'), (b'Universal', b'Universal'), (b'W-SU', b'W-SU'), (b'WET', b'WET'), (b'Zulu', b'Zulu')], blank=True)), ('language', models.CharField(default=b'en-us', max_length=10, verbose_name='language', choices=[(b'af', 'Afrikaans'), (b'ar', '\u0627\u0644\u0639\u0631\u0628\u064a\u0651\u0629'), (b'ast', 'asturianu'), (b'az', 'Az\u0259rbaycanca'), (b'bg', '\u0431\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438'), (b'be', '\u0431\u0435\u043b\u0430\u0440\u0443\u0441\u043a\u0430\u044f'), (b'bn', '\u09ac\u09be\u0982\u09b2\u09be'), (b'br', 'brezhoneg'), (b'bs', 'bosanski'), (b'ca', 'catal\xe0'), (b'cs', '\u010desky'), (b'cy', 'Cymraeg'), (b'da', 'dansk'), (b'de', 'Deutsch'), (b'el', '\u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac'), (b'en', 'English'), (b'en-au', 'Australian English'), (b'en-gb', 'British English'), (b'eo', 'Esperanto'), (b'es', 'espa\xf1ol'), (b'es-ar', 'espa\xf1ol de Argentina'), (b'es-mx', 'espa\xf1ol de Mexico'), (b'es-ni', 'espa\xf1ol de Nicaragua'), (b'es-ve', 'espa\xf1ol de Venezuela'), (b'et', 'eesti'), (b'eu', 'Basque'), (b'fa', '\u0641\u0627\u0631\u0633\u06cc'), (b'fi', 'suomi'), (b'fr', 'fran\xe7ais'), (b'fy', 'frysk'), (b'ga', 'Gaeilge'), (b'gl', 'galego'), (b'he', '\u05e2\u05d1\u05e8\u05d9\u05ea'), (b'hi', 'Hindi'), (b'hr', 'Hrvatski'), (b'hu', 'Magyar'), (b'ia', 'Interlingua'), (b'id', 'Bahasa Indonesia'), (b'io', 'ido'), (b'is', '\xcdslenska'), (b'it', 'italiano'), (b'ja', '\u65e5\u672c\u8a9e'), (b'ka', '\u10e5\u10d0\u10e0\u10d7\u10e3\u10da\u10d8'), (b'kk', '\u049a\u0430\u0437\u0430\u049b'), (b'km', 'Khmer'), (b'kn', 'Kannada'), (b'ko', '\ud55c\uad6d\uc5b4'), (b'lb', 'L\xebtzebuergesch'), (b'lt', 'Lietuvi\u0161kai'), (b'lv', 'latvie\u0161u'), (b'mk', '\u041c\u0430\u043a\u0435\u0434\u043e\u043d\u0441\u043a\u0438'), (b'ml', 'Malayalam'), (b'mn', 'Mongolian'), (b'mr', '\u092e\u0930\u093e\u0920\u0940'), (b'my', '\u1019\u103c\u1014\u103a\u1019\u102c\u1018\u102c\u101e\u102c'), (b'nb', 'norsk (bokm\xe5l)'), (b'ne', '\u0928\u0947\u092a\u093e\u0932\u0940'), (b'nl', 'Nederlands'), (b'nn', 'norsk (nynorsk)'), (b'os', '\u0418\u0440\u043e\u043d'), (b'pa', 'Punjabi'), (b'pl', 'polski'), (b'pt', 'Portugu\xeas'), (b'pt-br', 'Portugu\xeas Brasileiro'), (b'ro', 'Rom\xe2n\u0103'), (b'ru', '\u0420\u0443\u0441\u0441\u043a\u0438\u0439'), (b'sk', 'slovensk\xfd'), (b'sl', 'Sloven\u0161\u010dina'), (b'sq', 'shqip'), (b'sr', '\u0441\u0440\u043f\u0441\u043a\u0438'), (b'sr-latn', 'srpski (latinica)'), (b'sv', 'svenska'), (b'sw', 'Kiswahili'), (b'ta', '\u0ba4\u0bae\u0bbf\u0bb4\u0bcd'), (b'te', '\u0c24\u0c46\u0c32\u0c41\u0c17\u0c41'), (b'th', '\u0e20\u0e32\u0e29\u0e32\u0e44\u0e17\u0e22'), (b'tr', 'T\xfcrk\xe7e'), (b'tt', '\u0422\u0430\u0442\u0430\u0440\u0447\u0430'), (b'udm', '\u0423\u0434\u043c\u0443\u0440\u0442'), (b'uk', '\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430'), (b'ur', '\u0627\u0631\u062f\u0648'), (b'vi', 'Ti\xea\u0301ng Vi\xea\u0323t'), (b'zh-cn', '\u7b80\u4f53\u4e2d\u6587'), (b'zh-hans', '\u7b80\u4f53\u4e2d\u6587'), (b'zh-hant', '\u7e41\u9ad4\u4e2d\u6587'), (b'zh-tw', '\u7e41\u9ad4\u4e2d\u6587')])), - ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL, verbose_name='user', related_name='account')), + ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL, verbose_name='user', related_name='account', on_delete=models.CASCADE)), ], ), migrations.CreateModel( @@ -45,7 +45,7 @@ class Migration(migrations.Migration): ('email', models.EmailField(unique=True, max_length=254)), ('verified', models.BooleanField(default=False, verbose_name='verified')), ('primary', models.BooleanField(default=False, verbose_name='primary')), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], options={ 'verbose_name': 'email address', @@ -59,7 +59,7 @@ class Migration(migrations.Migration): ('created', models.DateTimeField(default=django.utils.timezone.now)), ('sent', models.DateTimeField(null=True)), ('key', models.CharField(unique=True, max_length=64)), - ('email_address', models.ForeignKey(to='account.EmailAddress')), + ('email_address', models.ForeignKey(to='account.EmailAddress', on_delete=models.CASCADE)), ], options={ 'verbose_name': 'email confirmation', @@ -78,7 +78,7 @@ class Migration(migrations.Migration): ('sent', models.DateTimeField(null=True, verbose_name='sent', blank=True)), ('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='created')), ('use_count', models.PositiveIntegerField(default=0, editable=False, verbose_name='use count')), - ('inviter', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ('inviter', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), ], options={ 'verbose_name': 'signup code', @@ -90,8 +90,8 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, serialize=False, verbose_name='ID', primary_key=True)), ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), - ('signup_code', models.ForeignKey(to='account.SignupCode')), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('signup_code', models.ForeignKey(to='account.SignupCode', on_delete=models.CASCADE)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], ), ] diff --git a/account/migrations/0002_fix_str.py b/account/migrations/0002_fix_str.py index f28cb525..864ed004 100644 --- a/account/migrations/0002_fix_str.py +++ b/account/migrations/0002_fix_str.py @@ -3,6 +3,7 @@ from django.db import migrations, models +from account.conf import settings import account.fields @@ -17,96 +18,9 @@ class Migration(migrations.Migration): model_name="account", name="language", field=models.CharField( - default="en-us", + default=settings.LANGUAGE_CODE, verbose_name="language", - choices=[ - ("af", "Afrikaans"), - ("ar", "العربيّة"), - ("ast", "asturian"), - ("az", "Azərbaycanca"), - ("bg", "български"), - ("be", "беларуская"), - ("bn", "বাংলা"), - ("br", "brezhoneg"), - ("bs", "bosanski"), - ("ca", "català"), - ("cs", "česky"), - ("cy", "Cymraeg"), - ("da", "dansk"), - ("de", "Deutsch"), - ("el", "Ελληνικά"), - ("en", "English"), - ("en-a", "Australian English"), - ("en-gb", "British English"), - ("eo", "Esperanto"), - ("es", "español"), - ("es-ar", "español de Argentina"), - ("es-mx", "español de Mexico"), - ("es-ni", "español de Nicaragua"), - ("es-ve", "español de Venezuela"), - ("et", "eesti"), - ("e", "Basque"), - ("fa", "فارسی"), - ("fi", "suomi"), - ("fr", "français"), - ("fy", "frysk"), - ("ga", "Gaeilge"), - ("gl", "galego"), - ("he", "עברית"), - ("hi", "Hindi"), - ("hr", "Hrvatski"), - ("h", "Magyar"), - ("ia", "Interlingua"), - ("id", "Bahasa Indonesia"), - ("io", "ido"), - ("is", "Íslenska"), - ("it", "italiano"), - ("ja", "日本語"), - ("ka", "ქართული"), - ("kk", "Қазақ"), - ("km", "Khmer"), - ("kn", "Kannada"), - ("ko", "한국어"), - ("lb", "Lëtzebuergesch"), - ("lt", "Lietuviškai"), - ("lv", "latvieš"), - ("mk", "Македонски"), - ("ml", "Malayalam"), - ("mn", "Mongolian"), - ("mr", "मराठी"), - ("my", "မြန်မာဘာသာ"), - ("nb", "norsk (bokmål)"), - ("ne", "नेपाली"), - ("nl", "Nederlands"), - ("nn", "norsk (nynorsk)"), - ("os", "Ирон"), - ("pa", "Punjabi"), - ("pl", "polski"), - ("pt", "Português"), - ("pt-br", "Português Brasileiro"), - ("ro", "Română"), - ("r", "Русский"), - ("sk", "slovenský"), - ("sl", "Slovenščina"), - ("sq", "shqip"), - ("sr", "српски"), - ("sr-latn", "srpski (latinica)"), - ("sv", "svenska"), - ("sw", "Kiswahili"), - ("ta", "தமிழ்"), - ("te", "తెలుగు"), - ("th", "ภาษาไทย"), - ("tr", "Türkçe"), - ("tt", "Татарча"), - ("udm", "Удмурт"), - ("uk", "Українська"), - ("ur", "اردو"), - ("vi", "Tiếng Việt"), - ("zh-cn", "简体中文"), - ("zh-hans", "简体中文"), - ("zh-hant", "繁體中文"), - ("zh-tw", "繁體中文") - ], + choices=settings.ACCOUNT_LANGUAGES, max_length=10 ), ), diff --git a/account/migrations/0003_passwordexpiry_passwordhistory.py b/account/migrations/0003_passwordexpiry_passwordhistory.py new file mode 100644 index 00000000..2c71d0c3 --- /dev/null +++ b/account/migrations/0003_passwordexpiry_passwordhistory.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.1 on 2016-09-13 08:55 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('account', '0002_fix_str'), + ] + + operations = [ + migrations.CreateModel( + name='PasswordExpiry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('expiry', models.PositiveIntegerField(default=0)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='password_expiry', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + ), + migrations.CreateModel( + name='PasswordHistory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=255)), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_history', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/account/migrations/0004_auto_20170416_1821.py b/account/migrations/0004_auto_20170416_1821.py new file mode 100644 index 00000000..6eb55b47 --- /dev/null +++ b/account/migrations/0004_auto_20170416_1821.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-04-16 18:21 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0003_passwordexpiry_passwordhistory'), + ] + + operations = [ + migrations.AlterModelOptions( + name='passwordhistory', + options={'verbose_name': 'password history', 'verbose_name_plural': 'password histories'}, + ), + ] diff --git a/account/migrations/0005_update_default_language.py b/account/migrations/0005_update_default_language.py new file mode 100644 index 00000000..8e86cc3f --- /dev/null +++ b/account/migrations/0005_update_default_language.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2017-10-03 18:10 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0004_auto_20170416_1821'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='language', + field=models.CharField(choices=[('af', 'Afrikaans'), ('ar', '\u0627\u0644\u0639\u0631\u0628\u064a\u0651\u0629'), ('ast', 'asturian'), ('az', 'Az\u0259rbaycanca'), ('bg', '\u0431\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438'), ('be', '\u0431\u0435\u043b\u0430\u0440\u0443\u0441\u043a\u0430\u044f'), ('bn', '\u09ac\u09be\u0982\u09b2\u09be'), ('br', 'brezhoneg'), ('bs', 'bosanski'), ('ca', 'catal\xe0'), ('cs', '\u010desky'), ('cy', 'Cymraeg'), ('da', 'dansk'), ('de', 'Deutsch'), ('el', '\u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac'), ('en', 'English'), ('en-au', 'Australian English'), ('en-gb', 'British English'), ('eo', 'Esperanto'), ('es', 'espa\xf1ol'), ('es-ar', 'espa\xf1ol de Argentina'), ('es-mx', 'espa\xf1ol de Mexico'), ('es-ni', 'espa\xf1ol de Nicaragua'), ('es-ve', 'espa\xf1ol de Venezuela'), ('et', 'eesti'), ('eu', 'Basque'), ('fa', '\u0641\u0627\u0631\u0633\u06cc'), ('fi', 'suomi'), ('fr', 'fran\xe7ais'), ('fy', 'frysk'), ('ga', 'Gaeilge'), ('gl', 'galego'), ('he', '\u05e2\u05d1\u05e8\u05d9\u05ea'), ('hi', 'Hindi'), ('hr', 'Hrvatski'), ('hu', 'Magyar'), ('ia', 'Interlingua'), ('id', 'Bahasa Indonesia'), ('io', 'ido'), ('is', '\xcdslenska'), ('it', 'italiano'), ('ja', '\u65e5\u672c\u8a9e'), ('ka', '\u10e5\u10d0\u10e0\u10d7\u10e3\u10da\u10d8'), ('kk', '\u049a\u0430\u0437\u0430\u049b'), ('km', 'Khmer'), ('kn', 'Kannada'), ('ko', '\ud55c\uad6d\uc5b4'), ('lb', 'L\xebtzebuergesch'), ('lt', 'Lietuvi\u0161kai'), ('lv', 'latvie\u0161'), ('mk', '\u041c\u0430\u043a\u0435\u0434\u043e\u043d\u0441\u043a\u0438'), ('ml', 'Malayalam'), ('mn', 'Mongolian'), ('mr', '\u092e\u0930\u093e\u0920\u0940'), ('my', '\u1019\u103c\u1014\u103a\u1019\u102c\u1018\u102c\u101e\u102c'), ('nb', 'norsk (bokm\xe5l)'), ('ne', '\u0928\u0947\u092a\u093e\u0932\u0940'), ('nl', 'Nederlands'), ('nn', 'norsk (nynorsk)'), ('os', '\u0418\u0440\u043e\u043d'), ('pa', 'Punjabi'), ('pl', 'polski'), ('pt', 'Portugu\xeas'), ('pt-br', 'Portugu\xeas Brasileiro'), ('ro', 'Rom\xe2n\u0103'), ('ru', '\u0420\u0443\u0441\u0441\u043a\u0438\u0439'), ('sk', 'slovensk\xfd'), ('sl', 'Sloven\u0161\u010dina'), ('sq', 'shqip'), ('sr', '\u0441\u0440\u043f\u0441\u043a\u0438'), ('sr-latn', 'srpski (latinica)'), ('sv', 'svenska'), ('sw', 'Kiswahili'), ('ta', '\u0ba4\u0bae\u0bbf\u0bb4\u0bcd'), ('te', '\u0c24\u0c46\u0c32\u0c41\u0c17\u0c41'), ('th', '\u0e20\u0e32\u0e29\u0e32\u0e44\u0e17\u0e22'), ('tr', 'T\xfcrk\xe7e'), ('tt', '\u0422\u0430\u0442\u0430\u0440\u0447\u0430'), ('udm', '\u0423\u0434\u043c\u0443\u0440\u0442'), ('uk', '\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430'), ('ur', '\u0627\u0631\u062f\u0648'), ('vi', 'Ti\xea\u0301ng Vi\xea\u0323t'), ('zh-cn', '\u7b80\u4f53\u4e2d\u6587'), ('zh-hans', '\u7b80\u4f53\u4e2d\u6587'), ('zh-hant', '\u7e41\u9ad4\u4e2d\u6587'), ('zh-tw', '\u7e41\u9ad4\u4e2d\u6587')], default='en', max_length=10, verbose_name='language'), + ), + ] diff --git a/account/migrations/0006_alter_signupcode_max_uses.py b/account/migrations/0006_alter_signupcode_max_uses.py new file mode 100644 index 00000000..67d84636 --- /dev/null +++ b/account/migrations/0006_alter_signupcode_max_uses.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2023-09-12 20:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0005_update_default_language'), + ] + + operations = [ + migrations.AlterField( + model_name='signupcode', + name='max_uses', + field=models.PositiveIntegerField(default=1, verbose_name='max uses'), + ), + ] diff --git a/account/migrations/0007_alter_emailconfirmation_sent.py b/account/migrations/0007_alter_emailconfirmation_sent.py new file mode 100644 index 00000000..b2e0d627 --- /dev/null +++ b/account/migrations/0007_alter_emailconfirmation_sent.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2023-09-12 21:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0006_alter_signupcode_max_uses'), + ] + + operations = [ + migrations.AlterField( + model_name='emailconfirmation', + name='sent', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/account/mixins.py b/account/mixins.py index 6444e185..3250880e 100644 --- a/account/mixins.py +++ b/account/mixins.py @@ -1,19 +1,19 @@ -from __future__ import unicode_literals +from django.contrib.auth import REDIRECT_FIELD_NAME from account.conf import settings from account.utils import handle_redirect_to_login -class LoginRequiredMixin(object): +class LoginRequiredMixin: - redirect_field_name = "next" + redirect_field_name = REDIRECT_FIELD_NAME login_url = None def dispatch(self, request, *args, **kwargs): self.request = request self.args = args self.kwargs = kwargs - if request.user.is_authenticated(): + if request.user.is_authenticated: return super(LoginRequiredMixin, self).dispatch(request, *args, **kwargs) return self.redirect_to_login() diff --git a/account/models.py b/account/models.py index 12408fa6..fea2e976 100644 --- a/account/models.py +++ b/account/models.py @@ -1,55 +1,52 @@ -from __future__ import unicode_literals - import datetime +import functools import operator +from urllib.parse import urlencode -try: - from urllib.parse import urlencode -except ImportError: # python 2 - from urllib import urlencode - -from django.core.urlresolvers import reverse +from django import forms +from django.contrib.auth.models import AnonymousUser +from django.contrib.sites.models import Site from django.db import models, transaction from django.db.models import Q from django.db.models.signals import post_save from django.dispatch import receiver -from django.utils import timezone, translation, six -from django.utils.encoding import python_2_unicode_compatible -from django.utils.translation import ugettext_lazy as _ - -from django.contrib.auth.models import AnonymousUser -from django.contrib.sites.models import Site +from django.urls import reverse +from django.utils import timezone, translation +from django.utils.translation import gettext_lazy as _ import pytz - from account import signals from account.conf import settings from account.fields import TimeZoneField from account.hooks import hookset +from account.languages import DEFAULT_LANGUAGE from account.managers import EmailAddressManager, EmailConfirmationManager from account.signals import signup_code_sent, signup_code_used -@python_2_unicode_compatible class Account(models.Model): - user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name="account", verbose_name=_("user")) + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + related_name="account", + verbose_name=_("user"), + on_delete=models.CASCADE, + ) timezone = TimeZoneField(_("timezone")) language = models.CharField( _("language"), max_length=10, choices=settings.ACCOUNT_LANGUAGES, - default=settings.LANGUAGE_CODE + default=DEFAULT_LANGUAGE, ) @classmethod def for_request(cls, request): user = getattr(request, "user", None) - if user and user.is_authenticated(): - try: - return Account._default_manager.get(user=user) - except Account.DoesNotExist: - pass + if user and user.is_authenticated: + account = user.account + if account: + return account return AnonymousAccount(request) @classmethod @@ -59,7 +56,7 @@ def create(cls, request=None, **kwargs): account = cls(**kwargs) if "language" not in kwargs: if request is None: - account.language = settings.LANGUAGE_CODE + account.language = DEFAULT_LANGUAGE else: account.language = translation.get_language_from_request(request, check_path=True) account.save() @@ -78,22 +75,22 @@ def now(self): Returns a timezone aware datetime localized to the account's timezone. """ now = datetime.datetime.utcnow().replace(tzinfo=pytz.timezone("UTC")) - timezone = settings.TIME_ZONE if not self.timezone else self.timezone - return now.astimezone(pytz.timezone(timezone)) + tz = settings.TIME_ZONE if not self.timezone else self.timezone + return now.astimezone(pytz.timezone(tz)) def localtime(self, value): """ Given a datetime object as value convert it to the timezone of the account. """ - timezone = settings.TIME_ZONE if not self.timezone else self.timezone + tz = settings.TIME_ZONE if not self.timezone else self.timezone if value.tzinfo is None: value = pytz.timezone(settings.TIME_ZONE).localize(value) - return value.astimezone(pytz.timezone(timezone)) + return value.astimezone(pytz.timezone(tz)) @receiver(post_save, sender=settings.AUTH_USER_MODEL) -def user_post_save(sender, **kwargs): +def user_post_save(*args, **kwargs): """ After User.save is called we check to see if it was a created user. If so, we check if the User object wants account creation. If all passes we @@ -102,20 +99,24 @@ def user_post_save(sender, **kwargs): We only run on user creation to avoid having to check for existence on each call to User.save. """ + + # Disable post_save during manage.py loaddata + if kwargs.get("raw", False): + return False + user, created = kwargs["instance"], kwargs["created"] disabled = getattr(user, "_disable_account_creation", not settings.ACCOUNT_CREATE_ON_SAVE) if created and not disabled: Account.create(user=user) -@python_2_unicode_compatible -class AnonymousAccount(object): +class AnonymousAccount: def __init__(self, request=None): self.user = AnonymousUser() self.timezone = settings.TIME_ZONE if request is None: - self.language = settings.LANGUAGE_CODE + self.language = DEFAULT_LANGUAGE else: self.language = translation.get_language_from_request(request, check_path=True) @@ -123,7 +124,6 @@ def __str__(self): return "AnonymousAccount" -@python_2_unicode_compatible class SignupCode(models.Model): class AlreadyExists(Exception): @@ -133,9 +133,9 @@ class InvalidCode(Exception): pass code = models.CharField(_("code"), max_length=64, unique=True) - max_uses = models.PositiveIntegerField(_("max uses"), default=0) + max_uses = models.PositiveIntegerField(_("max uses"), default=1) expiry = models.DateTimeField(_("expiry"), null=True, blank=True) - inviter = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True) + inviter = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE) email = models.EmailField(max_length=254, blank=True) notes = models.TextField(_("notes"), blank=True) sent = models.DateTimeField(_("sent"), null=True, blank=True) @@ -149,8 +149,7 @@ class Meta: def __str__(self): if self.email: return "{0} [{1}]".format(self.email, self.code) - else: - return self.code + return self.code @classmethod def exists(cls, code=None, email=None): @@ -158,10 +157,10 @@ def exists(cls, code=None, email=None): if code: checks.append(Q(code=code)) if email: - checks.append(Q(email=code)) + checks.append(Q(email=email)) if not checks: return False - return cls._default_manager.filter(six.moves.reduce(operator.or_, checks)).exists() + return cls._default_manager.filter(functools.reduce(operator.or_, checks)).exists() @classmethod def create(cls, **kwargs): @@ -212,7 +211,7 @@ def use(self, user): signup_code_used.send(sender=result.__class__, signup_code_result=result) def send(self, **kwargs): - protocol = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http") + protocol = settings.ACCOUNT_DEFAULT_HTTP_PROTOCOL current_site = kwargs["site"] if "site" in kwargs else Site.objects.get_current() if "signup_url" not in kwargs: signup_url = "{0}://{1}{2}?{3}".format( @@ -237,8 +236,8 @@ def send(self, **kwargs): class SignupCodeResult(models.Model): - signup_code = models.ForeignKey(SignupCode) - user = models.ForeignKey(settings.AUTH_USER_MODEL) + signup_code = models.ForeignKey(SignupCode, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) timestamp = models.DateTimeField(default=timezone.now) def save(self, **kwargs): @@ -246,10 +245,9 @@ def save(self, **kwargs): self.signup_code.calculate_use_count() -@python_2_unicode_compatible class EmailAddress(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) email = models.EmailField(max_length=254, unique=settings.ACCOUNT_EMAIL_UNIQUE) verified = models.BooleanField(_("verified"), default=False) primary = models.BooleanField(_("primary"), default=False) @@ -296,13 +294,22 @@ def change(self, new_email, confirm=True): if confirm: self.send_confirmation() + def validate_unique(self, exclude=None): + super(EmailAddress, self).validate_unique(exclude=exclude) + + qs = EmailAddress.objects.filter(email__iexact=self.email) + + if qs.exists() and settings.ACCOUNT_EMAIL_UNIQUE: + raise forms.ValidationError({ + "email": _("A user is registered with this email address."), + }) + -@python_2_unicode_compatible class EmailConfirmation(models.Model): - email_address = models.ForeignKey(EmailAddress) + email_address = models.ForeignKey(EmailAddress, on_delete=models.CASCADE) created = models.DateTimeField(default=timezone.now) - sent = models.DateTimeField(null=True) + sent = models.DateTimeField(blank=True, null=True) key = models.CharField(max_length=64, unique=True) objects = EmailConfirmationManager() @@ -335,7 +342,7 @@ def confirm(self): def send(self, **kwargs): current_site = kwargs["site"] if "site" in kwargs else Site.objects.get_current() - protocol = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http") + protocol = settings.ACCOUNT_DEFAULT_HTTP_PROTOCOL activate_url = "{0}://{1}{2}".format( protocol, current_site.domain, @@ -372,7 +379,7 @@ def expunge(cls, hours_ago=None): before = timezone.now() - datetime.timedelta(hours=hours_ago) count = 0 for account_deletion in cls.objects.filter(date_requested__lt=before, user__isnull=False): - settings.ACCOUNT_DELETION_EXPUNGE_CALLBACK(account_deletion) + hookset.account_delete_expunge(account_deletion) account_deletion.date_expunged = timezone.now() account_deletion.save() count += 1 @@ -380,8 +387,34 @@ def expunge(cls, hours_ago=None): @classmethod def mark(cls, user): - account_deletion, created = cls.objects.get_or_create(user=user) + account_deletion, created = cls.objects.get_or_create(user=user) # skipcq: PYL-W0612 account_deletion.email = user.email account_deletion.save() - settings.ACCOUNT_DELETION_MARK_CALLBACK(account_deletion) + hookset.account_delete_mark(account_deletion) return account_deletion + + +class PasswordHistory(models.Model): + """ + Contains single password history for user. + """ + class Meta: + verbose_name = _("password history") + verbose_name_plural = _("password histories") + + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="password_history", on_delete=models.CASCADE) + password = models.CharField(max_length=255) # encrypted password + timestamp = models.DateTimeField(default=timezone.now) # password creation time + + +class PasswordExpiry(models.Model): + """ + Holds the password expiration period for a single user. + """ + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + related_name="password_expiry", + verbose_name=_("user"), + on_delete=models.CASCADE, + ) + expiry = models.PositiveIntegerField(default=0) diff --git a/account/signals.py b/account/signals.py index d0e49387..47e5ce56 100644 --- a/account/signals.py +++ b/account/signals.py @@ -1,15 +1,14 @@ -from __future__ import unicode_literals - import django.dispatch -user_signed_up = django.dispatch.Signal(providing_args=["user", "form", "request"]) -user_sign_up_attempt = django.dispatch.Signal(providing_args=["username", "email", "result"]) -user_logged_in = django.dispatch.Signal(providing_args=["user", "form"]) -user_login_attempt = django.dispatch.Signal(providing_args=["username", "result"]) -signup_code_sent = django.dispatch.Signal(providing_args=["signup_code"]) -signup_code_used = django.dispatch.Signal(providing_args=["signup_code_result"]) -email_confirmed = django.dispatch.Signal(providing_args=["email_address"]) -email_confirmation_sent = django.dispatch.Signal(providing_args=["confirmation"]) -password_changed = django.dispatch.Signal(providing_args=["user"]) -account_updated = django.dispatch.Signal(providing_args=["user", "request"]) +user_signed_up = django.dispatch.Signal() +user_sign_up_attempt = django.dispatch.Signal() +user_logged_in = django.dispatch.Signal() +user_login_attempt = django.dispatch.Signal() +signup_code_sent = django.dispatch.Signal() +signup_code_used = django.dispatch.Signal() +email_confirmed = django.dispatch.Signal() +email_confirmation_sent = django.dispatch.Signal() +password_changed = django.dispatch.Signal() +password_expired = django.dispatch.Signal() +account_updated = django.dispatch.Signal() diff --git a/account/templatetags/account_tags.py b/account/templatetags/account_tags.py index 9370a663..99d307dc 100644 --- a/account/templatetags/account_tags.py +++ b/account/templatetags/account_tags.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import template from django.template.base import kwarg_re from django.template.defaulttags import URLNode @@ -8,7 +6,6 @@ from account.utils import user_display - register = template.Library() @@ -28,7 +25,7 @@ def render(self, context): @register.tag(name="user_display") -def do_user_display(parser, token): +def do_user_display(parser, token): # skipcq: PYL-W0613 """ Example usage:: @@ -54,16 +51,18 @@ def do_user_display(parser, token): class URLNextNode(URLNode): - def add_next(self, url, context): + @staticmethod + def add_next(url, context): """ With both `redirect_field_name` and `redirect_field_value` available in the context, add on a querystring to handle "next" redirecting. """ - if all([key in context for key in ["redirect_field_name", "redirect_field_value"]]): - if context["redirect_field_value"]: - url += "?" + urlencode({ - context["redirect_field_name"]: context["redirect_field_value"], - }) + if all( + key in context for key in ["redirect_field_name", "redirect_field_value"] + ) and context["redirect_field_value"]: + url += "?" + urlencode({ + context["redirect_field_name"]: context["redirect_field_value"], + }) return url def render(self, context): @@ -75,8 +74,7 @@ def render(self, context): if self.asvar: context[self.asvar] = url return "" - else: - return url + return url @register.tag @@ -95,11 +93,11 @@ def urlnext(parser, token): kwargs = {} asvar = None bits = bits[2:] - if len(bits) >= 2 and bits[-2] == 'as': + if len(bits) >= 2 and bits[-2] == "as": asvar = bits[-1] bits = bits[:-2] - if len(bits): + if len(bits) > 0: for bit in bits: match = kwarg_re.match(bit) if not match: diff --git a/account/tests/settings.py b/account/tests/settings.py new file mode 100644 index 00000000..6ec3bbf8 --- /dev/null +++ b/account/tests/settings.py @@ -0,0 +1,48 @@ +DEBUG = True +USE_TZ = True +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "account", + "account.tests", +] +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} +SITE_ID = 1 +ROOT_URLCONF = "account.tests.urls" +SECRET_KEY = "notasecret" +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + # insert your TEMPLATE_DIRS here + ], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this + # list if you haven"t customized them: + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] +MIDDLEWARE = [ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware" +] +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/account/tests/templates/account/admin_approval_sent.html b/account/tests/templates/account/admin_approval_sent.html new file mode 100644 index 00000000..e69de29b diff --git a/account/tests/templates/account/email/password_reset.txt b/account/tests/templates/account/email/password_reset.txt new file mode 100644 index 00000000..ad84fec3 --- /dev/null +++ b/account/tests/templates/account/email/password_reset.txt @@ -0,0 +1 @@ +{{ password_reset_url }} diff --git a/account/tests/templates/account/email/password_reset_subject.txt b/account/tests/templates/account/email/password_reset_subject.txt new file mode 100644 index 00000000..e965047a --- /dev/null +++ b/account/tests/templates/account/email/password_reset_subject.txt @@ -0,0 +1 @@ +Hello diff --git a/account/tests/templates/account/password_reset_sent.html b/account/tests/templates/account/password_reset_sent.html new file mode 100644 index 00000000..9ed72bb4 --- /dev/null +++ b/account/tests/templates/account/password_reset_sent.html @@ -0,0 +1 @@ +# empty for now diff --git a/account/tests/templates/account/password_reset_token.html b/account/tests/templates/account/password_reset_token.html new file mode 100644 index 00000000..9ed72bb4 --- /dev/null +++ b/account/tests/templates/account/password_reset_token.html @@ -0,0 +1 @@ +# empty for now diff --git a/account/tests/templates/account/password_reset_token_fail.html b/account/tests/templates/account/password_reset_token_fail.html new file mode 100644 index 00000000..9ed72bb4 --- /dev/null +++ b/account/tests/templates/account/password_reset_token_fail.html @@ -0,0 +1 @@ +# empty for now diff --git a/account/tests/templates/account/settings.html b/account/tests/templates/account/settings.html new file mode 100644 index 00000000..9ed72bb4 --- /dev/null +++ b/account/tests/templates/account/settings.html @@ -0,0 +1 @@ +# empty for now diff --git a/account/tests/test_auth.py b/account/tests/test_auth.py index 8f852a48..e639849b 100644 --- a/account/tests/test_auth.py +++ b/account/tests/test_auth.py @@ -1,7 +1,6 @@ -from django.test import TestCase, override_settings - from django.contrib.auth import authenticate from django.contrib.auth.models import User +from django.test import TestCase, override_settings @override_settings( @@ -30,6 +29,18 @@ def test_missing_credentials(self): self.assertTrue(authenticate() is None) self.assertTrue(authenticate(username="user1") is None) + def test_successful_auth_django_2_1(self): + created_user = self.create_user("user1", "user1@example.com", "password") + request = None + authed_user = authenticate(request, username="user1", password="password") + self.assertTrue(authed_user is not None) + self.assertEqual(created_user.pk, authed_user.pk) + + def test_unsuccessful_auth_django_2_1(self): + request = None + authed_user = authenticate(request, username="user-does-not-exist", password="password") + self.assertTrue(authed_user is None) + @override_settings( AUTHENTICATION_BACKENDS=[ @@ -56,3 +67,15 @@ def test_missing_credentials(self): self.create_user("user1", "user1@example.com", "password") self.assertTrue(authenticate() is None) self.assertTrue(authenticate(username="user1@example.com") is None) + + def test_successful_auth_django_2_1(self): + created_user = self.create_user("user1", "user1@example.com", "password") + request = None + authed_user = authenticate(request, username="user1@example.com", password="password") + self.assertTrue(authed_user is not None) + self.assertEqual(created_user.pk, authed_user.pk) + + def test_unsuccessful_auth_django_2_1(self): + request = None + authed_user = authenticate(request, username="user-does-not-exist", password="password") + self.assertTrue(authed_user is None) diff --git a/account/tests/test_commands.py b/account/tests/test_commands.py new file mode 100644 index 00000000..4747a61b --- /dev/null +++ b/account/tests/test_commands.py @@ -0,0 +1,226 @@ +from io import StringIO + +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.test import TestCase, override_settings + +from account.conf import settings +from account.models import PasswordExpiry, PasswordHistory + + +@override_settings( + ACCOUNT_PASSWORD_EXPIRY=500 +) +class UserPasswordExpiryTests(TestCase): + + def setUp(self): + self.UserModel = get_user_model() + self.user = self.UserModel.objects.create_user(username="patrick") + + def test_set_explicit_password_expiry(self): + """ + Ensure specific password expiry is set. + """ + self.assertFalse(hasattr(self.user, "password_expiry")) + expiration_period = 60 + out = StringIO() + call_command( + "user_password_expiry", + "patrick", + "--expire={}".format(expiration_period), + stdout=out + ) + + user = self.UserModel.objects.get(username="patrick") + user_expiry = user.password_expiry + self.assertEqual(user_expiry.expiry, expiration_period) + self.assertIn('User "{}" password expiration set to {} seconds'.format( + self.user.username, expiration_period), out.getvalue(), + ) + + def test_set_default_password_expiry(self): + """ + Ensure default password expiry (from settings) is set. + """ + self.assertFalse(hasattr(self.user, "password_expiry")) + out = StringIO() + call_command( + "user_password_expiry", + "patrick", + stdout=out + ) + + user = self.UserModel.objects.get(username="patrick") + user_expiry = user.password_expiry + default_expiration = settings.ACCOUNT_PASSWORD_EXPIRY + self.assertEqual(user_expiry.expiry, default_expiration) + self.assertIn('User "{}" password expiration set to {} seconds'.format( + self.user.username, default_expiration), out.getvalue(), + ) + + def test_reset_existing_password_expiry(self): + """ + Ensure existing password expiry is reset. + """ + previous_expiry = 123 + existing_expiry = PasswordExpiry.objects.create(user=self.user, expiry=previous_expiry) + out = StringIO() + call_command( + "user_password_expiry", + "patrick", + stdout=out + ) + + user = self.UserModel.objects.get(username="patrick") + user_expiry = user.password_expiry + self.assertEqual(user_expiry, existing_expiry) + default_expiration = settings.ACCOUNT_PASSWORD_EXPIRY + self.assertEqual(user_expiry.expiry, default_expiration) + self.assertNotEqual(user_expiry.expiry, previous_expiry) + + def test_bad_username(self): + """ + Ensure proper operation when username is not found. + """ + bad_username = "asldkfj" + out = StringIO() + call_command( + "user_password_expiry", + bad_username, + stdout=out + ) + self.assertIn('User "{}" not found'.format(bad_username), out.getvalue()) + + +class UserPasswordHistoryTests(TestCase): + + def setUp(self): + self.UserModel = get_user_model() + self.user = self.UserModel.objects.create_user(username="patrick") + + def test_set_history(self): + """ + Ensure password history is created. + """ + self.assertFalse(self.user.password_history.all()) + password_age = 5 # days + out = StringIO() + call_command( + "user_password_history", + "--days={}".format(password_age), + stdout=out + ) + + user = self.UserModel.objects.get(username="patrick") + password_history = user.password_history.all() + self.assertEqual(password_history.count(), 1) + self.assertIn("Password history set to ", out.getvalue()) + self.assertIn("for {} users".format(1), out.getvalue()) + + def test_set_history_exists(self): + """ + Ensure password history is NOT created. + """ + PasswordHistory.objects.create(user=self.user) + password_age = 5 # days + out = StringIO() + call_command( + "user_password_history", + "--days={}".format(password_age), + stdout=out + ) + + user = self.UserModel.objects.get(username="patrick") + password_history = user.password_history.all() + self.assertEqual(password_history.count(), 1) + self.assertIn("No users found without password history", out.getvalue()) + + def test_set_history_one_exists(self): + """ + Ensure password history is created for users without existing history. + """ + another_user = self.UserModel.objects.create_user(username="james") + PasswordHistory.objects.create(user=another_user) + + password_age = 5 # days + out = StringIO() + call_command( + "user_password_history", + "--days={}".format(password_age), + stdout=out + ) + + user = self.UserModel.objects.get(username="patrick") + password_history = user.password_history.all() + self.assertEqual(password_history.count(), 1) + + # verify user with existing history did not get another entry + user = self.UserModel.objects.get(username="james") + password_history = user.password_history.all() + self.assertEqual(password_history.count(), 1) + + self.assertIn("Password history set to ", out.getvalue()) + self.assertIn("for {} users".format(1), out.getvalue()) + + def test_set_history_force(self): + """ + Ensure specific password history is created for all users. + """ + another_user = self.UserModel.objects.create_user(username="james") + PasswordHistory.objects.create(user=another_user) + + password_age = 5 # days + out = StringIO() + call_command( + "user_password_history", + "--days={}".format(password_age), + "--force", + stdout=out + ) + + user = self.UserModel.objects.get(username="patrick") + password_history = user.password_history.all() + self.assertEqual(password_history.count(), 1) + + # verify user with existing history DID get another entry + user = self.UserModel.objects.get(username="james") + password_history = user.password_history.all() + self.assertEqual(password_history.count(), 2) + + self.assertIn("Password history set to ", out.getvalue()) + self.assertIn("for {} users".format(2), out.getvalue()) + + def test_set_history_multiple(self): + """ + Ensure password history is created for all users without existing history. + """ + self.UserModel.objects.create_user(username="second") + self.UserModel.objects.create_user(username="third") + + password_age = 5 # days + out = StringIO() + call_command( + "user_password_history", + "--days={}".format(password_age), + stdout=out + ) + + user = self.UserModel.objects.get(username="patrick") + password_history = user.password_history.all() + self.assertEqual(password_history.count(), 1) + first_timestamp = password_history[0].timestamp + + user = self.UserModel.objects.get(username="second") + password_history = user.password_history.all() + self.assertEqual(password_history.count(), 1) + second_timestamp = password_history[0].timestamp + self.assertEqual(first_timestamp, second_timestamp) + + user = self.UserModel.objects.get(username="third") + password_history = user.password_history.all() + self.assertEqual(password_history.count(), 1) + third_timestamp = password_history[0].timestamp + self.assertEqual(first_timestamp, third_timestamp) + + self.assertIn("Password history set to ", out.getvalue()) + self.assertIn("for {} users".format(3), out.getvalue()) diff --git a/account/tests/test_decorators.py b/account/tests/test_decorators.py new file mode 100644 index 00000000..06c62240 --- /dev/null +++ b/account/tests/test_decorators.py @@ -0,0 +1,26 @@ +from unittest import mock + +from django.http import HttpResponse +from django.test import TestCase + +from account.decorators import login_required + + +@login_required +def mock_view(request, *args, **kwargs): + return HttpResponse("OK", status=200) + + +class LoginRequiredDecoratorTestCase(TestCase): + + def test_authenticated_user_is_allowed(self): + request = mock.MagicMock() + request.user.is_authenticated = True + response = mock_view(request) + self.assertEqual(response.status_code, 200) + + def test_unauthenticated_user_gets_redirected(self): + request = mock.MagicMock() + request.user.is_authenticated = False + response = mock_view(request) + self.assertEqual(response.status_code, 302) diff --git a/account/tests/test_email_address.py b/account/tests/test_email_address.py new file mode 100644 index 00000000..4f47977e --- /dev/null +++ b/account/tests/test_email_address.py @@ -0,0 +1,25 @@ +from django.contrib.auth.models import User +from django.forms import ValidationError +from django.test import TestCase, override_settings + +from account.models import EmailAddress + + +@override_settings(ACCOUNT_EMAIL_UNIQUE=True) +class UniqueEmailAddressTestCase(TestCase): + def test_unique_email(self): + user = User.objects.create_user("user1", email="user1@example.com", password="password") + + email_1 = EmailAddress(user=user, email="user2@example.com") + email_1.full_clean() + email_1.save() + + validation_error = False + try: + email_2 = EmailAddress(user=user, email="USER2@example.com") + email_2.full_clean() + email_2.save() + except ValidationError: + validation_error = True + + self.assertTrue(validation_error) diff --git a/account/tests/test_models.py b/account/tests/test_models.py new file mode 100644 index 00000000..0f9cf67a --- /dev/null +++ b/account/tests/test_models.py @@ -0,0 +1,45 @@ +from django.test import TestCase + +from account.models import SignupCode + + +class SignupCodeModelTestCase(TestCase): + def test_exists_no_match(self): + code = SignupCode(email="foobar@example.com", code="FOOFOO") + code.save() + + self.assertFalse(SignupCode.exists(code="BARBAR")) + self.assertFalse(SignupCode.exists(email="bar@example.com")) + self.assertFalse(SignupCode.exists(email="bar@example.com", code="BARBAR")) + self.assertFalse(SignupCode.exists()) + + def test_exists_email_only_match(self): + code = SignupCode(email="foobar@example.com", code="FOOFOO") + code.save() + + self.assertTrue(SignupCode.exists(email="foobar@example.com")) + + def test_exists_code_only_match(self): + code = SignupCode(email="foobar@example.com", code="FOOFOO") + code.save() + + self.assertTrue(SignupCode.exists(code="FOOFOO")) + self.assertTrue(SignupCode.exists(email="bar@example.com", code="FOOFOO")) + + def test_exists_email_match_code_mismatch(self): + code = SignupCode(email="foobar@example.com", code="FOOFOO") + code.save() + + self.assertTrue(SignupCode.exists(email="foobar@example.com", code="BARBAR")) + + def test_exists_code_match_email_mismatch(self): + code = SignupCode(email="foobar@example.com", code="FOOFOO") + code.save() + + self.assertTrue(SignupCode.exists(email="bar@example.com", code="FOOFOO")) + + def test_exists_both_match(self): + code = SignupCode(email="foobar@example.com", code="FOOFOO") + code.save() + + self.assertTrue(SignupCode.exists(email="foobar@example.com", code="FOOFOO")) diff --git a/account/tests/test_password.py b/account/tests/test_password.py new file mode 100644 index 00000000..88f5f1ca --- /dev/null +++ b/account/tests/test_password.py @@ -0,0 +1,221 @@ +import datetime + +import django +from django.contrib.auth.hashers import check_password, make_password +from django.contrib.auth.models import User +from django.test import TestCase, modify_settings, override_settings +from django.urls import reverse + +import pytz + +from account.models import PasswordExpiry, PasswordHistory +from account.utils import check_password_expired + + +def middleware_kwarg(value): + if django.VERSION >= (1, 10): + kwarg = "MIDDLEWARE" + else: + kwarg = "MIDDLEWARE_CLASSES" + return {kwarg: value} + + +@override_settings( + ACCOUNT_PASSWORD_USE_HISTORY=True +) +@modify_settings( + **middleware_kwarg({ + "append": "account.middleware.ExpiredPasswordMiddleware" + }) +) +class PasswordExpirationTestCase(TestCase): + + def setUp(self): + self.username = "user1" + self.email = "user1@example.com" + self.password = "changeme" + self.user = User.objects.create_user( + self.username, + email=self.email, + password=self.password, + ) + # create PasswordExpiry for user + self.expiry = PasswordExpiry.objects.create( + user=self.user, + expiry=60, # password expires after sixty seconds + ) + # create PasswordHistory for user + self.history = PasswordHistory.objects.create( + user=self.user, + password=make_password(self.password) + ) + + def test_signup(self): + """ + Ensure new user has one PasswordHistory and no PasswordExpiry. + """ + email = "foobar@example.com" + password = "bar" + post_data = { + "username": "foo", + "password": password, + "password_confirm": password, + "email": email, + } + response = self.client.post(reverse("account_signup"), post_data) + self.assertEqual(response.status_code, 302) + user = User.objects.get(email=email) + self.assertFalse(hasattr(user, "password_expiry")) + latest_history = user.password_history.latest("timestamp") + self.assertTrue(latest_history) + + # verify password is not expired + self.assertFalse(check_password_expired(user)) + # verify raw password matches encrypted password in history + self.assertTrue(check_password(password, latest_history.password)) + + def test_get_not_expired(self): + """ + Ensure authenticated user can retrieve account settings page + without "password change" redirect. + """ + self.client.login(username=self.username, password=self.password) + + # get account settings page (could be any application page) + response = self.client.get(reverse("account_settings")) + self.assertEqual(response.status_code, 200) + + def test_get_expired(self): + """ + Ensure authenticated user is redirected to change password + when retrieving account settings page if password is expired. + """ + # set PasswordHistory timestamp in past so password is expired. + self.history.timestamp = ( + datetime.datetime.now(tz=pytz.UTC) - datetime.timedelta(days=1, seconds=self.expiry.expiry) + ) + self.history.save() + + self.client.login(username=self.username, password=self.password) + + # get account settings page (could be any application page) + url_name = "account_settings" + response = self.client.get(reverse(url_name)) + + # verify desired page is set as "?next=" in redirect URL + redirect_url = "{}?next={}".format(reverse("account_password"), url_name) + self.assertRedirects(response, redirect_url) + + def test_password_expiration_reset(self): + """ + Ensure changing password results in new PasswordHistory. + """ + history_count = self.user.password_history.count() + + # get login + self.client.login(username=self.username, password=self.password) + + # post new password to reset PasswordHistory + new_password = "lynyrdskynyrd" + post_data = { + "password_current": self.password, + "password_new": new_password, + "password_new_confirm": new_password, + } + self.client.post( + reverse("account_password"), + post_data + ) + # Should see one more history entry for this user + self.assertEqual(self.user.password_history.count(), history_count + 1) + + latest = PasswordHistory.objects.latest("timestamp") + self.assertTrue(latest != self.history) + self.assertTrue(latest.timestamp > self.history.timestamp) + + +@modify_settings( + **middleware_kwarg({ + "append": "account.middleware.ExpiredPasswordMiddleware" + }) +) +class ExistingUserNoHistoryTestCase(TestCase): + """ + Tests where user has no PasswordHistory. + """ + + def setUp(self): + self.username = "user1" + self.email = "user1@example.com" + self.password = "changeme" + self.user = User.objects.create_user( + self.username, + email=self.email, + password=self.password, + ) + + def test_get_no_history(self): + """ + Ensure authenticated user without password history can retrieve + account settings page without "password change" redirect. + """ + self.client.login(username=self.username, password=self.password) + + with override_settings( + ACCOUNT_PASSWORD_USE_HISTORY=True + ): + # get account settings page (could be any application page) + response = self.client.get(reverse("account_settings")) + self.assertEqual(response.status_code, 200) + + def test_password_expiration_reset(self): + """ + Ensure changing password results in new PasswordHistory, + even when no PasswordHistory exists. + """ + history_count = self.user.password_history.count() + + # get login + self.client.login(username=self.username, password=self.password) + + # post new password to reset PasswordHistory + new_password = "lynyrdskynyrd" + post_data = { + "password_current": self.password, + "password_new": new_password, + "password_new_confirm": new_password, + } + with override_settings( + ACCOUNT_PASSWORD_USE_HISTORY=True + ): + self.client.post( + reverse("account_password"), + post_data + ) + # Should see one more history entry for this user + self.assertEqual(self.user.password_history.count(), history_count + 1) + + def test_password_reset(self): + """ + Ensure changing password results in NO new PasswordHistory + when ACCOUNT_PASSWORD_USE_HISTORY == False. + """ + # get login + self.client.login(username=self.username, password=self.password) + + # post new password to reset PasswordHistory + new_password = "lynyrdskynyrd" + post_data = { + "password_current": self.password, + "password_new": new_password, + "password_new_confirm": new_password, + } + with override_settings( + ACCOUNT_PASSWORD_USE_HISTORY=False + ): + self.client.post( + reverse("account_password"), + post_data + ) + # history count should be zero + self.assertEqual(self.user.password_history.count(), 0) diff --git a/account/tests/test_views.py b/account/tests/test_views.py index cddab22f..717eabce 100644 --- a/account/tests/test_views.py +++ b/account/tests/test_views.py @@ -1,11 +1,14 @@ +from urllib.parse import urlparse + from django.conf import settings +from django.contrib.auth.models import User from django.core import mail -from django.core.urlresolvers import reverse from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils.http import int_to_base36 -from django.contrib.auth.models import User - -from account.models import SignupCode, EmailConfirmation +from account.models import EmailConfirmation, SignupCode +from account.views import INTERNAL_RESET_URL_TOKEN, PasswordResetTokenView class SignupViewTestCase(TestCase): @@ -49,6 +52,8 @@ def test_valid_code(self): } response = self.client.post(reverse("account_signup"), data) self.assertEqual(response.status_code, 302) + u = User.objects.get(username=data['username']) + self.assertTrue(u.is_active) def test_invalid_code(self): with self.settings(ACCOUNT_OPEN_SIGNUP=False): @@ -123,6 +128,23 @@ def test_session_next_url(self): response = self.client.post(reverse("account_signup"), data) self.assertRedirects(response, next_url, fetch_redirect_response=False) + def test_register_with_moderation(self): + signup_code = SignupCode.create() + signup_code.save() + with self.settings(ACCOUNT_OPEN_SIGNUP=True, ACCOUNT_APPROVAL_REQUIRED=True): + data = { + "username": "foo", + "password": "bar", + "password_confirm": "bar", + "email": "foobar@example.com", + "code": signup_code.code, + } + response = self.client.post(reverse("account_signup"), data) + self.assertEqual(response.status_code, 200) + self.assertFalse(self.client.session.get('_auth_user_id')) + u = User.objects.get(username=data['username']) + self.assertFalse(u.is_active) + class LoginViewTestCase(TestCase): @@ -254,7 +276,9 @@ def test_post_not_required(self): fetch_redirect_response=False ) - @override_settings(ACCOUNT_EMAIL_CONFIRMATION_REQUIRED=False, ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL="/somewhere/") + @override_settings( + ACCOUNT_EMAIL_CONFIRMATION_REQUIRED=False, ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL="/somewhere/" + ) def test_post_not_required_redirect_override(self): email_confirmation = self.signup() response = self.client.post(reverse("account_confirm_email", kwargs={"key": email_confirmation.key}), {}) @@ -264,6 +288,20 @@ def test_post_not_required_redirect_override(self): fetch_redirect_response=False ) + @override_settings( + ACCOUNT_EMAIL_CONFIRMATION_REQUIRED=True, + ACCOUNT_EMAIL_CONFIRMATION_AUTO_LOGIN=True, + ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL="/somewhere/", + ) + def test_post_auto_login(self): + email_confirmation = self.signup() + response = self.client.post(reverse("account_confirm_email", kwargs={"key": email_confirmation.key}), {}) + self.assertRedirects( + response, + settings.ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL, + fetch_redirect_response=False + ) + class ChangePasswordViewTestCase(TestCase): @@ -334,3 +372,82 @@ def test_post_authenticated_success_no_mail(self): fetch_redirect_response=False ) self.assertEqual(len(mail.outbox), 0) + + +class PasswordResetTokenViewTestCase(TestCase): + + def signup(self): + data = { + "username": "foo", + "password": "bar", + "password_confirm": "bar", + "email": "foobar@example.com", + "code": "abc123", + } + self.client.post(reverse("account_signup"), data) + mail.outbox = [] + return User.objects.get(username="foo") + + def request_password_reset(self): + user = self.signup() + data = { + "email": user.email, + } + self.client.post(reverse("account_password_reset"), data) + parsed = urlparse(mail.outbox[0].body.strip()) + return user, parsed.path + + def test_get_bad_user(self): + url = reverse( + "account_password_reset_token", + kwargs={ + "uidb36": int_to_base36(100), + "token": "notoken", + } + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_get_abuse_reset_token(self): + user = self.signup() + url = reverse( + "account_password_reset_token", + kwargs={ + "uidb36": int_to_base36(user.id), + "token": INTERNAL_RESET_URL_TOKEN, + } + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, + PasswordResetTokenView.template_name_fail) + + def test_get_reset(self): + user, url = self.request_password_reset() + response = self.client.get(url) + self.assertRedirects( + response, + reverse( + "account_password_reset_token", + kwargs={ + "uidb36": int_to_base36(user.id), + "token": INTERNAL_RESET_URL_TOKEN, + } + ), + fetch_redirect_response=False + ) + + def test_post_reset(self): + user, url = self.request_password_reset() + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + data = { + "password": "new-password", + "password_confirm": "new-password", + } + response = self.client.post(response["Location"], data) + self.assertRedirects( + response, + reverse(settings.ACCOUNT_PASSWORD_RESET_REDIRECT_URL), + fetch_redirect_response=False + ) diff --git a/account/tests/urls.py b/account/tests/urls.py index 679e33aa..4651b183 100644 --- a/account/tests/urls.py +++ b/account/tests/urls.py @@ -1,6 +1,5 @@ -from django.conf.urls import include, url - +from django.urls import include, re_path urlpatterns = [ - url(r"^", include("account.urls")), + re_path(r"^", include("account.urls")), ] diff --git a/account/timezones.py b/account/timezones.py index 20946852..265f5528 100644 --- a/account/timezones.py +++ b/account/timezones.py @@ -1,6 +1,3 @@ -from __future__ import unicode_literals - - TIMEZONES = [ ("Africa/Abidjan", "Africa/Abidjan"), ("Africa/Accra", "Africa/Accra"), diff --git a/account/urls.py b/account/urls.py index c5c1de3e..5fa6c46c 100644 --- a/account/urls.py +++ b/account/urls.py @@ -1,21 +1,29 @@ -from __future__ import unicode_literals - -from django.conf.urls import url - -from account.views import SignupView, LoginView, LogoutView, DeleteView -from account.views import ConfirmEmailView -from account.views import ChangePasswordView, PasswordResetView, PasswordResetTokenView -from account.views import SettingsView +from django.urls import path +from account.views import ( + ChangePasswordView, + ConfirmEmailView, + DeleteView, + LoginView, + LogoutView, + PasswordResetTokenView, + PasswordResetView, + SettingsView, + SignupView, +) urlpatterns = [ - url(r"^signup/$", SignupView.as_view(), name="account_signup"), - url(r"^login/$", LoginView.as_view(), name="account_login"), - url(r"^logout/$", LogoutView.as_view(), name="account_logout"), - url(r"^confirm_email/(?P\w+)/$", ConfirmEmailView.as_view(), name="account_confirm_email"), - url(r"^password/$", ChangePasswordView.as_view(), name="account_password"), - url(r"^password/reset/$", PasswordResetView.as_view(), name="account_password_reset"), - url(r"^password/reset/(?P[0-9A-Za-z]+)-(?P.+)/$", PasswordResetTokenView.as_view(), name="account_password_reset_token"), - url(r"^settings/$", SettingsView.as_view(), name="account_settings"), - url(r"^delete/$", DeleteView.as_view(), name="account_delete"), + path("signup/", SignupView.as_view(), name="account_signup"), + path("login/", LoginView.as_view(), name="account_login"), + path("logout/", LogoutView.as_view(), name="account_logout"), + path("confirm_email//", ConfirmEmailView.as_view(), name="account_confirm_email"), + path("password/", ChangePasswordView.as_view(), name="account_password"), + path("password/reset/", PasswordResetView.as_view(), name="account_password_reset"), + path( + "password/reset///", + PasswordResetTokenView.as_view(), + name="account_password_reset_token", + ), + path("settings/", SettingsView.as_view(), name="account_settings"), + path("delete/", DeleteView.as_view(), name="account_delete"), ] diff --git a/account/utils.py b/account/utils.py index c493320d..91ae8094 100644 --- a/account/utils.py +++ b/account/utils.py @@ -1,19 +1,18 @@ -from __future__ import unicode_literals - +import datetime import functools -try: - from urllib.parse import urlparse, urlunparse -except ImportError: # python 2 - from urlparse import urlparse, urlunparse +from urllib.parse import urlparse, urlunparse -from django.core import urlresolvers +from django.contrib.auth import get_user_model from django.core.exceptions import SuspiciousOperation from django.http import HttpResponseRedirect, QueryDict - -from django.contrib.auth import get_user_model +from django.urls import NoReverseMatch, reverse +from django.utils import timezone +from django.utils.encoding import force_str from account.conf import settings +from .models import PasswordHistory + def get_user_lookup_kwargs(kwargs): result = {} @@ -40,19 +39,18 @@ def default_redirect(request, fallback_url, **kwargs): ) if next_url and is_safe(next_url): return next_url - else: - try: - fallback_url = urlresolvers.reverse(fallback_url) - except urlresolvers.NoReverseMatch: - if callable(fallback_url): - raise - if "/" not in fallback_url and "." not in fallback_url: - raise - # assert the fallback URL is safe to return to caller. if it is - # determined unsafe then raise an exception as the fallback value comes - # from the a source the developer choose. - is_safe(fallback_url, raise_on_fail=True) - return fallback_url + try: + fallback_url = reverse(fallback_url) + except NoReverseMatch: + if callable(fallback_url): + raise + if "/" not in fallback_url and "." not in fallback_url: + raise + # assert the fallback URL is safe to return to caller. if it is + # determined unsafe then raise an exception as the fallback value comes + # from the a source the developer choose. + is_safe(fallback_url, raise_on_fail=True) + return fallback_url def user_display(user): @@ -86,13 +84,13 @@ def handle_redirect_to_login(request, **kwargs): if next_url is None: next_url = request.get_full_path() try: - login_url = urlresolvers.reverse(login_url) - except urlresolvers.NoReverseMatch: + login_url = reverse(login_url) + except NoReverseMatch: if callable(login_url): raise if "/" not in login_url and "." not in login_url: raise - url_bits = list(urlparse(login_url)) + url_bits = list(urlparse(force_str(login_url))) if redirect_field_name: querystring = QueryDict(url_bits[4], mutable=True) querystring[redirect_field_name] = next_url @@ -106,3 +104,41 @@ def get_form_data(form, field_name, default=None): else: key = field_name return form.data.get(key, default) + + +# https://stackoverflow.com/a/70419609/6461688 +def is_ajax(request): + """ + Return True if the request was sent with XMLHttpRequest, False otherwise. + """ + return request.META.get('HTTP_X_REQUESTED_WITH') == 'XMLHttpRequest' + + +def check_password_expired(user): + """ + Return True if password is expired and system is using + password expiration, False otherwise. + """ + if not settings.ACCOUNT_PASSWORD_USE_HISTORY: + return False + + if hasattr(user, "password_expiry"): + # user-specific value + expiry = user.password_expiry.expiry + else: + # use global value + expiry = settings.ACCOUNT_PASSWORD_EXPIRY + + if expiry == 0: # zero indicates no expiration + return False + + try: + # get latest password info + latest = user.password_history.latest("timestamp") + except PasswordHistory.DoesNotExist: + return False + + now = timezone.now() + expiration = latest.timestamp + datetime.timedelta(seconds=expiry) + + return bool(expiration < now) diff --git a/account/views.py b/account/views.py index ed70d22e..fcaf8678 100644 --- a/account/views.py +++ b/account/views.py @@ -1,30 +1,129 @@ -from __future__ import unicode_literals - +from django.contrib import auth, messages +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password +from django.contrib.auth.tokens import default_token_generator +from django.contrib.sites.shortcuts import get_current_site +from django.core.exceptions import ImproperlyConfigured from django.http import Http404, HttpResponseForbidden -from django.shortcuts import redirect, get_object_or_404 +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils.decorators import method_decorator from django.utils.http import base36_to_int, int_to_base36 -from django.core.urlresolvers import reverse -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_protect +from django.views.decorators.debug import sensitive_post_parameters from django.views.generic.base import TemplateResponseMixin, View from django.views.generic.edit import FormView -from django.contrib import auth, messages -from django.contrib.auth import get_user_model -from django.contrib.sites.shortcuts import get_current_site -from django.contrib.auth.tokens import default_token_generator - from account import signals from account.conf import settings -from account.forms import SignupForm, LoginUsernameForm -from account.forms import ChangePasswordForm, PasswordResetForm, PasswordResetTokenForm -from account.forms import SettingsForm +from account.forms import ( + ChangePasswordForm, + LoginUsernameForm, + PasswordResetForm, + PasswordResetTokenForm, + SettingsForm, + SignupForm, +) from account.hooks import hookset from account.mixins import LoginRequiredMixin -from account.models import SignupCode, EmailAddress, EmailConfirmation, Account, AccountDeletion -from account.utils import default_redirect, get_form_data +from account.models import ( + Account, + AccountDeletion, + EmailAddress, + EmailConfirmation, + PasswordHistory, + SignupCode, +) +from account.utils import default_redirect, get_form_data, is_ajax + + +class PasswordMixin: + """ + Mixin handling common elements of password change. + + Required attributes in inheriting class: + + form_password_field - example: "password" + fallback_url_setting - example: "ACCOUNT_PASSWORD_RESET_REDIRECT_URL" + + Required methods in inheriting class: + + get_user() + change_password() + after_change_password() + get_redirect_field_name() + + """ + + redirect_field_name = "next" + messages = { + "password_changed": { + "level": messages.SUCCESS, + "text": _("Password successfully changed.") + } + } + + def get_context_data(self, **kwargs): + ctx = super(PasswordMixin, self).get_context_data(**kwargs) + redirect_field_name = self.get_redirect_field_name() + ctx.update({ + "redirect_field_name": redirect_field_name, + "redirect_field_value": self.request.POST.get( + redirect_field_name, + self.request.GET.get(redirect_field_name, ""), + ), + }) + return ctx + + def change_password(self, form): + user = self.get_user() + user.set_password(form.cleaned_data[self.form_password_field]) + user.save() + return user + + def after_change_password(self): + user = self.get_user() + signals.password_changed.send(sender=self, user=user) + if settings.ACCOUNT_NOTIFY_ON_PASSWORD_CHANGE: + self.send_password_email(user) + if self.messages.get("password_changed"): + messages.add_message( + self.request, + self.messages["password_changed"]["level"], + self.messages["password_changed"]["text"] + ) + def get_redirect_field_name(self): + return self.redirect_field_name -class SignupView(FormView): + def get_success_url(self, fallback_url=None, **kwargs): + if fallback_url is None: + fallback_url = getattr(settings, self.fallback_url_setting, None) + kwargs.setdefault("redirect_field_name", self.get_redirect_field_name()) + return default_redirect(self.request, fallback_url, **kwargs) + + def send_password_email(self, user): + protocol = settings.ACCOUNT_DEFAULT_HTTP_PROTOCOL + current_site = get_current_site(self.request) + ctx = { + "user": user, + "protocol": protocol, + "current_site": current_site, + } + hookset.send_password_change_email([user.email], ctx) + + def create_password_history(self, form, user): + if settings.ACCOUNT_PASSWORD_USE_HISTORY: + password = form.cleaned_data[self.form_password_field] + PasswordHistory.objects.create( + user=user, + password=make_password(password) + ) + + +class SignupView(PasswordMixin, FormView): template_name = "account/signup.html" template_name_ajax = "account/ajax/signup.html" @@ -32,8 +131,11 @@ class SignupView(FormView): template_name_email_confirmation_sent_ajax = "account/ajax/email_confirmation_sent.html" template_name_signup_closed = "account/signup_closed.html" template_name_signup_closed_ajax = "account/ajax/signup_closed.html" + template_name_admin_approval_sent = "account/admin_approval_sent.html" + template_name_admin_approval_sent_ajax = "account/ajax/admin_approval_sent.html" form_class = SignupForm form_kwargs = {} + form_password_field = "password" redirect_field_name = "next" identifier_field = "username" messages = { @@ -46,12 +148,16 @@ class SignupView(FormView): "text": _("The code {code} is invalid.") } } + fallback_url_setting = "ACCOUNT_SIGNUP_REDIRECT_URL" def __init__(self, *args, **kwargs): self.created_user = None kwargs["signup_code"] = None super(SignupView, self).__init__(*args, **kwargs) + @method_decorator(sensitive_post_parameters()) + @method_decorator(csrf_protect) + @method_decorator(never_cache) def dispatch(self, request, *args, **kwargs): self.request = request self.args = args @@ -72,14 +178,14 @@ def setup_signup_code(self): self.signup_code_present = False def get(self, *args, **kwargs): - if self.request.user.is_authenticated(): + if self.request.user.is_authenticated: return redirect(default_redirect(self.request, settings.ACCOUNT_LOGIN_REDIRECT_URL)) if not self.is_open(): return self.closed() return super(SignupView, self).get(*args, **kwargs) def post(self, *args, **kwargs): - if self.request.user.is_authenticated(): + if self.request.user.is_authenticated: raise Http404() if not self.is_open(): return self.closed() @@ -94,19 +200,9 @@ def get_initial(self): return initial def get_template_names(self): - if self.request.is_ajax(): + if is_ajax(self.request): return [self.template_name_ajax] - else: - return [self.template_name] - - def get_context_data(self, **kwargs): - ctx = super(SignupView, self).get_context_data(**kwargs) - redirect_field_name = self.get_redirect_field_name() - ctx.update({ - "redirect_field_name": redirect_field_name, - "redirect_field_value": self.request.POST.get(redirect_field_name, self.request.GET.get(redirect_field_name, "")), - }) - return ctx + return [self.template_name] def get_form_kwargs(self): kwargs = super(SignupView, self).get_form_kwargs() @@ -134,41 +230,37 @@ def form_valid(self, form): self.created_user.is_active = False self.created_user.save() self.create_account(form) + self.create_password_history(form, self.created_user) self.after_signup(form) + if settings.ACCOUNT_APPROVAL_REQUIRED: + # Notify site admins about the user wanting activation + self.created_user.is_active = False + self.created_user.save() + return self.account_approval_required_response() if settings.ACCOUNT_EMAIL_CONFIRMATION_EMAIL and not email_address.verified: self.send_email_confirmation(email_address) if settings.ACCOUNT_EMAIL_CONFIRMATION_REQUIRED and not email_address.verified: return self.email_confirmation_required_response() - else: - show_message = [ - settings.ACCOUNT_EMAIL_CONFIRMATION_EMAIL, - self.messages.get("email_confirmation_sent"), - not email_address.verified - ] - if all(show_message): - messages.add_message( - self.request, - self.messages["email_confirmation_sent"]["level"], - self.messages["email_confirmation_sent"]["text"].format(**{ - "email": form.cleaned_data["email"] - }) - ) - # attach form to self to maintain compatibility with login_user - # API. this should only be relied on by d-u-a and it is not a stable - # API for site developers. - self.form = form - self.login_user() + show_message = [ + settings.ACCOUNT_EMAIL_CONFIRMATION_EMAIL, + self.messages.get("email_confirmation_sent"), + not email_address.verified + ] + if all(show_message): + messages.add_message( + self.request, + self.messages["email_confirmation_sent"]["level"], + self.messages["email_confirmation_sent"]["text"].format(**{ + "email": form.cleaned_data["email"] + }) + ) + # attach form to self to maintain compatibility with login_user + # API. this should only be relied on by d-u-a and it is not a stable + # API for site developers. + self.form = form # skipcq: PYL-W0201 + self.login_user() return redirect(self.get_success_url()) - def get_success_url(self, fallback_url=None, **kwargs): - if fallback_url is None: - fallback_url = settings.ACCOUNT_SIGNUP_REDIRECT_URL - kwargs.setdefault("redirect_field_name", self.get_redirect_field_name()) - return default_redirect(self.request, fallback_url, **kwargs) - - def get_redirect_field_name(self): - return self.redirect_field_name - def create_user(self, form, commit=True, model=None, **kwargs): User = model if User is None: @@ -188,7 +280,7 @@ def create_user(self, form, commit=True, model=None, **kwargs): user.save() return user - def create_account(self, form): + def create_account(self, form): # skipcq: PYL-W0613 return Account.create(request=self.request, user=self.created_user, create_email=False) def generate_username(self, form): @@ -197,11 +289,11 @@ def generate_username(self, form): "Override SignupView.generate_username in a subclass." ) - def create_email_address(self, form, **kwargs): + def create_email_address(self, form, **kwargs): # skipcq: PYL-W0613 kwargs.setdefault("primary", True) kwargs.setdefault("verified", False) if self.signup_code: - kwargs["verified"] = self.signup_code.email and self.created_user.email == self.signup_code.email + kwargs["verified"] = self.created_user.email == self.signup_code.email if self.signup_code.email else False return EmailAddress.objects.add_email(self.created_user, self.created_user.email, **kwargs) def use_signup_code(self, user): @@ -215,16 +307,9 @@ def after_signup(self, form): signals.user_signed_up.send(sender=SignupForm, user=self.created_user, form=form, request=self.request) def login_user(self): - user = self.created_user - if settings.ACCOUNT_USE_AUTH_AUTHENTICATE: - # call auth.authenticate to ensure we set the correct backend for - # future look ups using auth.get_user(). - user = auth.authenticate(**self.user_credentials()) - else: - # set auth backend to ModelBackend, but this may not be used by - # everyone. this code path is deprecated and will be removed in - # favor of using auth.authenticate above. - user.backend = "django.contrib.auth.backends.ModelBackend" + user = auth.authenticate(**self.user_credentials()) + if not user: + raise ImproperlyConfigured("Configured auth backends failed to authenticate on signup") auth.login(self.request, user) self.request.session.set_expiry(0) @@ -237,20 +322,20 @@ def get_code(self): def is_open(self): if self.signup_code: return True - else: - if self.signup_code_present: - if self.messages.get("invalid_signup_code"): - messages.add_message( - self.request, - self.messages["invalid_signup_code"]["level"], - self.messages["invalid_signup_code"]["text"].format(**{ - "code": self.get_code(), - }) - ) + + if self.signup_code_present and self.messages.get("invalid_signup_code"): + messages.add_message( + self.request, + self.messages["invalid_signup_code"]["level"], + self.messages["invalid_signup_code"]["text"].format(**{ + "code": self.get_code(), + }) + ) + return settings.ACCOUNT_OPEN_SIGNUP def email_confirmation_required_response(self): - if self.request.is_ajax(): + if is_ajax(self.request): template_name = self.template_name_email_confirmation_sent_ajax else: template_name = self.template_name_email_confirmation_sent @@ -265,7 +350,7 @@ def email_confirmation_required_response(self): return self.response_class(**response_kwargs) def closed(self): - if self.request.is_ajax(): + if is_ajax(self.request): template_name = self.template_name_signup_closed_ajax else: template_name = self.template_name_signup_closed @@ -275,6 +360,22 @@ def closed(self): } return self.response_class(**response_kwargs) + def account_approval_required_response(self): + if is_ajax(self.request): + template_name = self.template_name_admin_approval_sent_ajax + else: + template_name = self.template_name_admin_approval_sent + + response_kwargs = { + "request": self.request, + "template": template_name, + "context": { + "email": self.created_user.email, + "success_url": self.get_success_url(), + } + } + return self.response_class(**response_kwargs) + class LoginView(FormView): @@ -284,23 +385,31 @@ class LoginView(FormView): form_kwargs = {} redirect_field_name = "next" + @method_decorator(sensitive_post_parameters()) + @method_decorator(csrf_protect) + @method_decorator(never_cache) + def dispatch(self, *args, **kwargs): + return super(LoginView, self).dispatch(*args, **kwargs) + def get(self, *args, **kwargs): - if self.request.user.is_authenticated(): + if self.request.user.is_authenticated: return redirect(self.get_success_url()) return super(LoginView, self).get(*args, **kwargs) def get_template_names(self): - if self.request.is_ajax(): + if is_ajax(self.request): return [self.template_name_ajax] - else: - return [self.template_name] + return [self.template_name] def get_context_data(self, **kwargs): ctx = super(LoginView, self).get_context_data(**kwargs) redirect_field_name = self.get_redirect_field_name() ctx.update({ "redirect_field_name": redirect_field_name, - "redirect_field_value": self.request.POST.get(redirect_field_name, self.request.GET.get(redirect_field_name, "")), + "redirect_field_value": self.request.POST.get( + redirect_field_name, + self.request.GET.get(redirect_field_name, ""), + ), }) return ctx @@ -322,7 +431,8 @@ def form_valid(self, form): self.after_login(form) return redirect(self.get_success_url()) - def after_login(self, form): + @staticmethod + def after_login(form): signals.user_logged_in.send(sender=LoginView, user=form.user, form=form) def get_success_url(self, fallback_url=None, **kwargs): @@ -345,14 +455,18 @@ class LogoutView(TemplateResponseMixin, View): template_name = "account/logout.html" redirect_field_name = "next" + @method_decorator(never_cache) + def dispatch(self, *args, **kwargs): + return super(LogoutView, self).dispatch(*args, **kwargs) + def get(self, *args, **kwargs): - if not self.request.user.is_authenticated(): + if not self.request.user.is_authenticated: return redirect(self.get_redirect_url()) ctx = self.get_context_data() return self.render_to_response(ctx) def post(self, *args, **kwargs): - if self.request.user.is_authenticated(): + if self.request.user.is_authenticated: auth.logout(self.request) return redirect(self.get_redirect_url()) @@ -361,7 +475,10 @@ def get_context_data(self, **kwargs): redirect_field_name = self.get_redirect_field_name() ctx.update({ "redirect_field_name": redirect_field_name, - "redirect_field_value": self.request.POST.get(redirect_field_name, self.request.GET.get(redirect_field_name, "")), + "redirect_field_value": self.request.POST.get( + redirect_field_name, + self.request.GET.get(redirect_field_name, ""), + ), }) return ctx @@ -382,6 +499,10 @@ class ConfirmEmailView(TemplateResponseMixin, View): "email_confirmed": { "level": messages.SUCCESS, "text": _("You have confirmed {email}.") + }, + "email_confirmation_expired": { + "level": messages.ERROR, + "text": _("Email confirmation for {email} has expired.") } } @@ -398,17 +519,30 @@ def get(self, *args, **kwargs): def post(self, *args, **kwargs): self.object = confirmation = self.get_object() - confirmation.confirm() - self.after_confirmation(confirmation) - redirect_url = self.get_redirect_url() - if not redirect_url: - ctx = self.get_context_data() - return self.render_to_response(ctx) - if self.messages.get("email_confirmed"): + self.user = self.request.user + confirmed = confirmation.confirm() is not None + if confirmed: + self.after_confirmation(confirmation) + if settings.ACCOUNT_EMAIL_CONFIRMATION_AUTO_LOGIN: + self.user = self.login_user(confirmation.email_address.user) + redirect_url = self.get_redirect_url() + if not redirect_url: + ctx = self.get_context_data() + return self.render_to_response(ctx) + if self.messages.get("email_confirmed"): + messages.add_message( + self.request, + self.messages["email_confirmed"]["level"], + self.messages["email_confirmed"]["text"].format(**{ + "email": confirmation.email_address.email + }) + ) + else: + redirect_url = self.get_redirect_url() messages.add_message( self.request, - self.messages["email_confirmed"]["level"], - self.messages["email_confirmed"]["text"].format(**{ + self.messages["email_confirmation_expired"]["level"], + self.messages["email_confirmation_expired"]["text"].format(**{ "email": confirmation.email_address.email }) ) @@ -422,7 +556,8 @@ def get_object(self, queryset=None): except EmailConfirmation.DoesNotExist: raise Http404() - def get_queryset(self): + @staticmethod + def get_queryset(): qs = EmailConfirmation.objects.all() qs = qs.select_related("email_address__user") return qs @@ -433,20 +568,25 @@ def get_context_data(self, **kwargs): return ctx def get_redirect_url(self): - if self.request.user.is_authenticated(): + if self.user.is_authenticated: if not settings.ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL: return settings.ACCOUNT_LOGIN_REDIRECT_URL return settings.ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL - else: - return settings.ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL + return settings.ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL - def after_confirmation(self, confirmation): + @staticmethod + def after_confirmation(confirmation): user = confirmation.email_address.user user.is_active = True user.save() + def login_user(self, user): + user.backend = "django.contrib.auth.backends.ModelBackend" + auth.login(self.request, user) + return user + -class ChangePasswordView(FormView): +class ChangePasswordView(PasswordMixin, FormView): template_name = "account/password_change.html" form_class = ChangePasswordForm @@ -457,41 +597,30 @@ class ChangePasswordView(FormView): "text": _("Password successfully changed.") } } + form_password_field = "password_new" + fallback_url_setting = "ACCOUNT_PASSWORD_CHANGE_REDIRECT_URL" def get(self, *args, **kwargs): - if not self.request.user.is_authenticated(): + if not self.request.user.is_authenticated: return redirect("account_password_reset") return super(ChangePasswordView, self).get(*args, **kwargs) def post(self, *args, **kwargs): - if not self.request.user.is_authenticated(): + if not self.request.user.is_authenticated: return HttpResponseForbidden() return super(ChangePasswordView, self).post(*args, **kwargs) - def change_password(self, form): - user = self.request.user - user.set_password(form.cleaned_data["password_new"]) - user.save() - # required on Django >= 1.7 to keep the user authenticated - if hasattr(auth, "update_session_auth_hash"): - auth.update_session_auth_hash(self.request, user) + def form_valid(self, form): + self.change_password(form) + self.create_password_history(form, self.request.user) + self.after_change_password() + return redirect(self.get_success_url()) - def after_change_password(self): - user = self.request.user - signals.password_changed.send(sender=ChangePasswordView, user=user) - if settings.ACCOUNT_NOTIFY_ON_PASSWORD_CHANGE: - self.send_email(user) - if self.messages.get("password_changed"): - messages.add_message( - self.request, - self.messages["password_changed"]["level"], - self.messages["password_changed"]["text"] - ) + def get_user(self): + return self.request.user def get_form_kwargs(self): - """ - Returns the keyword arguments for instantiating the form. - """ + """Returns the keyword arguments for instantiating the form.""" kwargs = {"user": self.request.user, "initial": self.get_initial()} if self.request.method in ["POST", "PUT"]: kwargs.update({ @@ -500,38 +629,11 @@ def get_form_kwargs(self): }) return kwargs - def form_valid(self, form): - self.change_password(form) - self.after_change_password() - return redirect(self.get_success_url()) - - def get_context_data(self, **kwargs): - ctx = super(ChangePasswordView, self).get_context_data(**kwargs) - redirect_field_name = self.get_redirect_field_name() - ctx.update({ - "redirect_field_name": redirect_field_name, - "redirect_field_value": self.request.POST.get(redirect_field_name, self.request.GET.get(redirect_field_name, "")), - }) - return ctx - - def get_redirect_field_name(self): - return self.redirect_field_name - - def get_success_url(self, fallback_url=None, **kwargs): - if fallback_url is None: - fallback_url = settings.ACCOUNT_PASSWORD_CHANGE_REDIRECT_URL - kwargs.setdefault("redirect_field_name", self.get_redirect_field_name()) - return default_redirect(self.request, fallback_url, **kwargs) - - def send_email(self, user): - protocol = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http") - current_site = get_current_site(self.request) - ctx = { - "user": user, - "protocol": protocol, - "current_site": current_site, - } - hookset.send_password_change_email([user.email], ctx) + def change_password(self, form): + user = super(ChangePasswordView, self).change_password(form) + # required on Django >= 1.7 to keep the user authenticated + if hasattr(auth, "update_session_auth_hash"): + auth.update_session_auth_hash(self.request, user) class PasswordResetView(FormView): @@ -541,6 +643,10 @@ class PasswordResetView(FormView): form_class = PasswordResetForm token_generator = default_token_generator + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + return super(PasswordResetView, self).dispatch(*args, **kwargs) + def get_context_data(self, **kwargs): context = super(PasswordResetView, self).get_context_data(**kwargs) if self.request.method == "POST" and "resend" in self.request.POST: @@ -558,92 +664,79 @@ def form_valid(self, form): def send_email(self, email): User = get_user_model() - protocol = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http") + protocol = settings.ACCOUNT_DEFAULT_HTTP_PROTOCOL current_site = get_current_site(self.request) email_qs = EmailAddress.objects.filter(email__iexact=email) for user in User.objects.filter(pk__in=email_qs.values("user")): uid = int_to_base36(user.id) token = self.make_token(user) - password_reset_url = "{0}://{1}{2}".format( - protocol, - current_site.domain, - reverse("account_password_reset_token", kwargs=dict(uidb36=uid, token=token)) - ) + path = reverse(settings.ACCOUNT_PASSWORD_RESET_TOKEN_URL, kwargs=dict(uidb36=uid, token=token)) + password_reset_url = f"{protocol}://{current_site.domain}{path}" ctx = { "user": user, "current_site": current_site, "password_reset_url": password_reset_url, } - hookset.send_password_reset_email([user.email], ctx) + hookset.send_password_reset_email([email], ctx) def make_token(self, user): return self.token_generator.make_token(user) -class PasswordResetTokenView(FormView): +INTERNAL_RESET_URL_TOKEN = "set-password" +INTERNAL_RESET_SESSION_TOKEN = "_password_reset_token" + + +class PasswordResetTokenView(PasswordMixin, FormView): template_name = "account/password_reset_token.html" template_name_fail = "account/password_reset_token_fail.html" form_class = PasswordResetTokenForm token_generator = default_token_generator - redirect_field_name = "next" - messages = { - "password_changed": { - "level": messages.SUCCESS, - "text": _("Password successfully changed.") - }, - } + form_password_field = "password" + fallback_url_setting = "ACCOUNT_PASSWORD_RESET_REDIRECT_URL" + + @method_decorator(sensitive_post_parameters()) + @method_decorator(never_cache) + def dispatch(self, *args, **kwargs): + user = self.get_user() + if user is not None: + token = kwargs["token"] + if token == INTERNAL_RESET_URL_TOKEN: + session_token = self.request.session.get(INTERNAL_RESET_SESSION_TOKEN, "") + if self.check_token(user, session_token): + return super(PasswordResetTokenView, self).dispatch(*args, **kwargs) + else: + if self.check_token(user, token): + # Store the token in the session and redirect to the + # password reset form at a URL without the token. That + # avoids the possibility of leaking the token in the + # HTTP Referer header. + self.request.session[INTERNAL_RESET_SESSION_TOKEN] = token + redirect_url = self.request.path.replace(token, INTERNAL_RESET_URL_TOKEN) + return redirect(redirect_url) + return self.token_fail() def get(self, request, **kwargs): form_class = self.get_form_class() form = self.get_form(form_class) ctx = self.get_context_data(form=form) - if not self.check_token(self.get_user(), self.kwargs["token"]): - return self.token_fail() return self.render_to_response(ctx) def get_context_data(self, **kwargs): ctx = super(PasswordResetTokenView, self).get_context_data(**kwargs) - redirect_field_name = self.get_redirect_field_name() ctx.update({ "uidb36": self.kwargs["uidb36"], "token": self.kwargs["token"], - "redirect_field_name": redirect_field_name, - "redirect_field_value": self.request.POST.get(redirect_field_name, self.request.GET.get(redirect_field_name, "")), }) return ctx - def change_password(self, form): - user = self.get_user() - user.set_password(form.cleaned_data["password"]) - user.save() - - def after_change_password(self): - user = self.get_user() - signals.password_changed.send(sender=PasswordResetTokenView, user=user) - if settings.ACCOUNT_NOTIFY_ON_PASSWORD_CHANGE: - self.send_email(user) - if self.messages.get("password_changed"): - messages.add_message( - self.request, - self.messages["password_changed"]["level"], - self.messages["password_changed"]["text"] - ) - def form_valid(self, form): self.change_password(form) + self.create_password_history(form, self.get_user()) self.after_change_password() return redirect(self.get_success_url()) - def get_redirect_field_name(self): - return self.redirect_field_name - - def get_success_url(self, fallback_url=None, **kwargs): - if fallback_url is None: - fallback_url = settings.ACCOUNT_PASSWORD_RESET_REDIRECT_URL - kwargs.setdefault("redirect_field_name", self.get_redirect_field_name()) - return default_redirect(self.request, fallback_url, **kwargs) - def get_user(self): try: uid_int = base36_to_int(self.kwargs["uidb36"]) @@ -662,16 +755,6 @@ def token_fail(self): } return self.response_class(**response_kwargs) - def send_email(self, user): - protocol = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http") - current_site = get_current_site(self.request) - ctx = { - "user": user, - "protocol": protocol, - "current_site": current_site, - } - hookset.send_password_change_email([user.email], ctx) - class SettingsView(LoginRequiredMixin, FormView): @@ -737,7 +820,10 @@ def get_context_data(self, **kwargs): redirect_field_name = self.get_redirect_field_name() ctx.update({ "redirect_field_name": redirect_field_name, - "redirect_field_value": self.request.POST.get(redirect_field_name, self.request.GET.get(redirect_field_name, "")), + "redirect_field_value": self.request.POST.get( + redirect_field_name, + self.request.GET.get(redirect_field_name, ""), + ), }) return ctx @@ -763,7 +849,7 @@ def get_success_url(self, fallback_url=None, **kwargs): return default_redirect(self.request, fallback_url, **kwargs) -class DeleteView(LogoutView): +class DeleteView(LoginRequiredMixin, LogoutView): template_name = "account/delete.html" messages = { diff --git a/docs/changelog.rst b/docs/changelog.rst deleted file mode 100644 index ea0cad9a..00000000 --- a/docs/changelog.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. _changelog: - -CHANGELOG -========= - -1.0 ---- - -* initial release -* if migrating from Pinax; see :ref:`migration` diff --git a/docs/commands.rst b/docs/commands.rst new file mode 100644 index 00000000..1e4288c4 --- /dev/null +++ b/docs/commands.rst @@ -0,0 +1,37 @@ +.. _commands: + +=================== +Management Commands +=================== + +user_password_history +--------------------- + +Creates an initial password history for all users who don't already +have a password history. + +Accepts two optional arguments:: + + -d --days - Sets the age of the current password. Default is 10 days. + -f --force - Sets a new password history for ALL users, regardless of prior history. + +user_password_expiry +-------------------- + +Creates a password expiry specific to one user. + +Password expiration checks use a global value (``ACCOUNT_PASSWORD_EXPIRY``) +for the expiration time period. This value can be superseded on a per-user basis +by creating a user password expiry. + +Requires one argument:: + + [] - username(s) of the user(s) who needs specific password expiry. + +Accepts one optional argument:: + + -e --expire - Sets the number of seconds for password expiration. + Default is the current global ACCOUNT_PASSWORD_EXPIRY value. + +After creation, you can modify user password expiration from the Django +admin. Find the desired user at ``/admin/account/passwordexpiry/`` and change the ``expiry`` value. diff --git a/docs/conf.py b/docs/conf.py index 82cea0c9..431a23c8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,4 @@ -from __future__ import unicode_literals - +import datetime import os import sys @@ -10,17 +9,17 @@ master_doc = "index" project = "django-user-accounts" copyright_holder = "James Tauber and contributors" -copyright = "2014, {0}".format(copyright_holder) +copyright = f"{datetime.datetime.now().year}, {copyright_holder}" exclude_patterns = ["_build"] pygments_style = "sphinx" html_theme = "default" -htmlhelp_basename = "{0}doc".format(project) +htmlhelp_basename = f"{project}doc" latex_documents = [ - ("index", "{0}.tex".format(project), "{0} Documentation".format(project), + ("index", f"{project}.tex", f"{project} Documentation", "Pinax", "manual"), ] man_pages = [ - ("index", project, "{0} Documentation".format(project), + ("index", project, f"{project} Documentation", ["Pinax"], 1) ] diff --git a/docs/faq.rst b/docs/faq.rst index 004dca2b..8e9a659d 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -37,31 +37,3 @@ because you can choose not to do any email address storage. If you don't use a custom user model then make sure you take extra precaution. When editing email addresses either in the shell or admin make sure you update in both places. Only the primary email address is stored on the ``User`` model. - -Why does auto-login after sign up not log my user in? -===================================================== - -If you are using Django 1.6+ and ``django.contrib.auth.backends.ModelBackend`` -does not exist in your ``AUTHENTICATION_BACKENDS`` then you will experience an -issue where users are not logged in after sign up (when -``ACCOUNT_EMAIL_CONFIRMATION_REQUIRED`` is ``False``.) - -This has been fixed, but the default behavior is buggy (for this use case) to -maintain backward compatibility. In a future version of django-user-accounts -the default behavior will not be buggy. - -To fix, simply set:: - - ACCOUNT_USE_AUTH_AUTHENTICATE = True - -in your Django settings. This will cause the ``login_user`` method of -``SignupView`` to use proper backend authentication to determine the correct -authentication backend for the user. You will need to make sure that -``SignupView.identifier_field`` is set to represent the correct field on the -sign up form to use as the username for credentials. By default the ``username`` -field is used (to be consistent with the default username authentication used -for log in.) - -If you have a custom need for user credentials passed to the authentication -backends, you may override the behavior using the hookset -``get_user_credentials``. diff --git a/docs/index.rst b/docs/index.rst index 1cf5cf2f..27219b30 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,6 +21,6 @@ Contents settings templates signals - changelog + commands migration faq diff --git a/docs/installation.rst b/docs/installation.rst index a34c9206..28388934 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -8,9 +8,11 @@ Install the development version:: pip install django-user-accounts -Add ``account`` to your ``INSTALLED_APPS`` setting:: +Make sure that ``django.contrib.sites`` is in ``INSTALLED_APPS`` and add + ``account`` to this setting:::: INSTALLED_APPS = ( + "django.contrib.sites", # ... "account", # ... @@ -27,12 +29,25 @@ Add ``account.urls`` to your URLs definition:: ... ) -Add ``account.context_processors.account`` to ``TEMPLATE_CONTEXT_PROCESSORS``:: - - TEMPLATE_CONTEXT_PROCESSORS = [ - ... - "account.context_processors.account", - ... +Add ``account.context_processors.account`` to ``context_processors``:: + + TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + + # add django-user-accounts context processor + 'account.context_processors.account', + ], + }, + }, ] Add ``account.middleware.LocaleMiddleware`` and @@ -45,6 +60,22 @@ Add ``account.middleware.LocaleMiddleware`` and ... ] +Optionally include ``account.middleware.ExpiredPasswordMiddleware`` in +``MIDDLEWARE_CLASSES`` if you need password expiration support:: + + MIDDLEWARE_CLASSES = [ + ... + "account.middleware.ExpiredPasswordMiddleware", + ... + ] + +Set the authentication backends to the following:: + + AUTHENTICATION_BACKENDS = [ + 'account.auth_backends.AccountModelBackend', + 'django.contrib.auth.backends.ModelBackend' + ] + Once everything is in place make sure you run ``migrate`` to modify the database with the ``account`` app models. diff --git a/docs/settings.rst b/docs/settings.rst index c530c14b..411dac83 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -9,112 +9,170 @@ Settings Default: ``True`` +If ``True``, creation of new accounts is allowed. When the signup view is +called, the template ``account/signup.html`` will be displayed, usually +showing a form to collect the new user data. + +If ``False``, creation of new accounts is disabled. When the signup view is +called, the template ``account/signup_closed.html`` will be displayed. + ``ACCOUNT_LOGIN_URL`` ===================== Default: ``"account_login"`` +The name of the urlconf that calls the login view. + ``ACCOUNT_SIGNUP_REDIRECT_URL`` =============================== Default: ``"/"`` +The url where the user will be redirected after a successful signup. + ``ACCOUNT_LOGIN_REDIRECT_URL`` ============================== Default: ``"/"`` +The url where the user will be redirected after a successful authentication, +unless the ``next`` parameter is defined in the request. + ``ACCOUNT_LOGOUT_REDIRECT_URL`` =============================== Default: ``"/"`` +The url where the user will be redirected after logging out. ``ACCOUNT_PASSWORD_CHANGE_REDIRECT_URL`` ======================================== Default: ``"account_password"`` +The url where the user will be redirected after changing his password. + ``ACCOUNT_PASSWORD_RESET_REDIRECT_URL`` ======================================= Default: ``"account_login"`` +The url where the user will be redirected after resetting his password. + ``ACCOUNT_REMEMBER_ME_EXPIRY`` ============================== Default: ``60 * 60 * 24 * 365 * 10`` +The number of seconds that the user will remain authenticated after he logs in +the site. + ``ACCOUNT_USER_DISPLAY`` ======================== Default: ``lambda user: user.username`` +The function that will be called by the template tag user_display. + ``ACCOUNT_CREATE_ON_SAVE`` ========================== Default: ``True`` +If ``True``, an account instance will be created when a new user is created. + ``ACCOUNT_EMAIL_UNIQUE`` ======================== Default: ``True`` +If ``False``, more than one user can have the same email address. + ``ACCOUNT_EMAIL_CONFIRMATION_REQUIRED`` ======================================= Default: ``False`` +If ``True``, new user accounts will be created as inactive. The user must use +the activation link to activate his account. + ``ACCOUNT_EMAIL_CONFIRMATION_EMAIL`` ==================================== Default: ``True`` +If ``True``, an email confirmation message will be sent to the user when they +make a new account. + ``ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS`` ========================================== Default: ``3`` +After this time, the email confirmation link will not be longer valid. + ``ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL`` ===================================================== Default: ``"account_login"`` +A urlconf name where the user will be redirected after confirming an email +address, if he is not authenticated. + ``ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL`` ========================================================= Default: ``None`` +A urlconf name where the user will be redirected after confirming an email +address, if he is authenticated. If not set, this url will be the one defined +in ``ACCOUNT_LOGIN_REDIRECT_URL``. + ``ACCOUNT_EMAIL_CONFIRMATION_URL`` ================================== Default: ``"account_confirm_email"`` +A urlconf name that will be used to confirm the user email (usually from the +email message they received). + ``ACCOUNT_SETTINGS_REDIRECT_URL`` ================================= Default: ``"account_settings"`` +The url where the user will be redirected after updating their account settings. + ``ACCOUNT_NOTIFY_ON_PASSWORD_CHANGE`` ===================================== Default: ``True`` +If ``True``, an notification email will be sent whenever a user changes their +password. + ``ACCOUNT_DELETION_MARK_CALLBACK`` ================================== Default: ``"account.callbacks.account_delete_mark"`` +This function will be called just after a user asks for account deletion. + ``ACCOUNT_DELETION_EXPUNGE_CALLBACK`` ===================================== Default: ``"account.callbacks.account_delete_expunge"`` +The function that will be called to expunge accounts. + ``ACCOUNT_DELETION_EXPUNGE_HOURS`` ================================== Default: ``48`` +The minimum time in hours since a user asks for account deletion until their +account is deleted. + ``ACCOUNT_HOOKSET`` =================== @@ -134,12 +192,33 @@ override the following methods: Default: ``list(zip(pytz.all_timezones, pytz.all_timezones))`` +A list of time zones available for the user to set as their current time zone. + ``ACCOUNT_LANGUAGES`` ===================== -See full list in: https://github.com/pinax/django-user-accounts/blob/master/account/language_list.py +A tuple of languages available for the user to set as their preferred language. + +See full list in: https://github.com/pinax/django-user-accounts/blob/master/account/languages.py ``ACCOUNT_USE_AUTH_AUTHENTICATE`` ================================= Default: ``False`` + +If ``True``, ``django.contrib.auth.authenticate`` will be used to authenticate +the user. + +.. note:: + According to the comments in the code, this setting is deprecated and, + in the future, ``django.contrib.auth.authenticate`` will be the preferred + method. + +``ACCOUNT_APPROVAL_REQUIRED`` +================================== + +Default: ``False`` + +This setting will make new registrations inactive, until staff will set ``is_active`` +flag in admin panel. Additional integration (like sending notifications to staff) +is possible with ``account.signals.user_signed_up`` signal. diff --git a/docs/signals.rst b/docs/signals.rst index 8b5ea2c0..64cdbbde 100644 --- a/docs/signals.rst +++ b/docs/signals.rst @@ -52,7 +52,7 @@ email_confirmed --------------- Triggered when a user confirmed an email. Providing argument -``email_address``(EmailAddress instance). +``email_address`` (EmailAddress instance). email_confirmation_sent @@ -69,6 +69,13 @@ Triggered when a user changes his password. Providing argument ``user`` (User instance). +password_expired +---------------- + +Triggered when a user password is expired. Providing argument ``user`` +(User instance). + + account_updated --------------- diff --git a/docs/templates.rst b/docs/templates.rst index 61c14994..aa8cc2db 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -11,6 +11,7 @@ to build from. Note, this document assumes you have read the installation docs. .. _pinax-theme-bootstrap: https://github.com/pinax/pinax-theme-bootstrap .. _starting point: https://github.com/pinax/pinax-theme-bootstrap/tree/master/pinax_theme_bootstrap/templates/account + Template Files =============== @@ -19,41 +20,279 @@ don't use ``pinax-theme-bootstrap``, then you will have to create these templates yourself. -Login/Registration/Signup Templates:: +Login/Registration/Signup Templates +----------------------------------- + +``account/login.html`` +~~~~~~~~~~~~~~~~~~~~~~ + +The template with the form to authenticate the user. The template has the +following context: + +``form`` + The login form. + +``redirect_field_name`` + The name of the hidden field that will hold the url where to redirect the + user after login. + +``redirect_field_value`` + The actual url where the user will be redirected after login. + +``account/logout.html`` +~~~~~~~~~~~~~~~~~~~~~~~ + +The default template shown after the user has been logged out. + +``account/signup.html`` +~~~~~~~~~~~~~~~~~~~~~~~ + +The template with the form to registrate a new user. The template has the +following context: + +``form`` + The form used to create the new user. + +``redirect_field_name`` + The name of the hidden field that will hold the url where to redirect the + user after signing up. + +``redirect_field_value`` + The actual url where the user will be redirected after signing up. + +``account/signup_closed.html`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A template to inform the user that creating new users is not allowed (mainly +because ``settings.ACCOUNT_OPEN_SIGNUP`` is ``False``). + + +Registration Approval Templates +------------------------------- + +These templates are only used when ``settings.ACCOUNT_APPROVAL_REQUIRED`` is +``True``. + +``account/admin_approval_sent.html`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The template shown after a new user has been created (with ``is_active`` set to +``False``). It should explain that an administrator will need to approve their +registration before they can use it. The template has the following context: + +``email`` + The email address for the newly created user. + +``success_url`` + The URL where the user will be directed to. + +``account/ajax/admin_approval_sent.html`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The same template as ``account/admin_approval_sent.html`` but for AJAX +responses; is rendered with the same context. + + +Email Confirmation Templates +---------------------------- + +``account/email_confirm.html`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A template to confirm an email address. The template has the following context: + +``email`` + The email address where the activation link has been sent. + +``confirmation`` + The EmailConfirmation instance to be confirmed. + +``account/email_confirmation_sent.html`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The template shown after a new user has been created. It should tell the user +that an activation link has been sent to his email address. The template has +the following context: + +``email`` + The email address where the activation link has been sent. + +``success_url`` + A url where the user can be redirected from this page. For example to + show a link to go back. + +``account/email_confirmed.html`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A template shown after an email address has been confirmed. The template +context is the same as in email_confirm.html. + +``email`` + The email address that has been confirmed. + +Password Management Templates +----------------------------- + +``account/password_change.html`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The template that shows the form to change the user's password, when the user +is authenticated. The template has the following context: + +``form`` + The form to change the password. + +``account/password_reset.html`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A template with a form to type an email address to reset a user's password. +The template has the following context: + +``form`` + The form to reset the password. + +``account/password_reset_sent.html`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A template to inform the user that his password has been reset and that he +should receive an email with a link to create a new password. The template has +the following context: + +``form`` + An instance of ``PasswordResetForm``. Usually the fields of this form + must be hidden. + +``resend`` + If ``True`` it means that the reset link has been resent to the user. + +``account/password_reset_token.html`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The template that shows the form to change the user's password. The user should +have come here following the link received to reset his password. The template +has the following context: + +``form`` + The form to set the new password. + +``account/password_reset_token_fail.html`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A template to inform the user that he is not allowed to change the password, +because the authentication token is wrong. The template has the following +context: + +``url`` + The url to request a new reset token. + + +Account Settings +---------------- + +``account/settings.html`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +A template with a form where the user may change his email address, time zone +and preferred language. The template has the following context: + +``form`` + The form to change the settings. + + +Emails (actual emails themselves) +--------------------------------- + +``account/email/email_confirmation_subject.txt`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The subject line of the email that will be sent to the new user to validate the +email address. It will be rendered as a single line. The template has the +following context: + +``email_address`` + The actual email address where the activation message will be sent. + +``user`` + The new user object. + +``activate_url`` + The complete url for account activation, including protocol and domain. + +``current_site`` + The domain name of the site. + +``key`` + The confirmation key. + +``account/email/email_confirmation_message.txt`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The body of the activation email. It has the same context as the subject +template (see above). + +``account/email/invite_user.txt`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The body of the invitation sent to somebody to join the site. The template has +the following context: + +``signup_code`` + An instance of account.models.SignupCode. + +``current_site`` + The instance of django.contrib.sites.models.Site that identifies the site. + +``signup_url`` + The link used to use the invitation and create a new account. + +``account/email/invite_user_subject.txt`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The subject line of the invitation sent to somebody to join the site. The +template has the same context as in invite_user.txt. + +``account/email/password_change.txt`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The body of the email used to inform the user that his password has been +changed. The template has the following context: + +``user`` + The user whom the password belongs to. + +``protocol`` + The application protocol (usually http or https) being used in the site. + +``current_site`` + The instance of django.contrib.sites.models.Site that identifies the site. + +``account/email/password_change_subject.txt`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - account/login.html - account/logout.html - account/signup.html - account/signup_closed.html +The subject line of the email used to inform the user that his password has +been changed. The context is the same as in password_change.txt. -Email Confirmation Templates:: +``account/email/password_reset.txt`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - account/email_confirm.html - account/email_confirmation_sent.html - account/email_confirmed.html +The body of the email with a link to reset a user's password. The template has +the following context: -Password Management Templates:: +``user`` + The user whom the password belongs to. - account/password_change.html - account/password_reset.html - account/password_reset_sent.html - account/password_reset_token.html - account/password_reset_token_fail.html +``current_site`` + The instance of django.contrib.sites.models.Site that identifies the site. -Account Settings:: +``password_reset_url`` + The link that the user needs to follow to set a new password. - account/settings.html +``account/email/password_reset_subject.txt`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Emails (actual emails themselves):: +The subject line of the email with a link to reset a user's password. The +context is the same as in password_reset.txt. - account/email/email_confirmation_message.txt - account/email/email_confirmation_subject.txt - account/email/invite_user.txt - account/email/invite_user_subject.txt - account/email/password_change.txt - account/email/password_change_subject.txt - account/email/password_reset.txt - account/email/password_reset_subject.txt Template Tags ============= diff --git a/docs/usage.rst b/docs/usage.rst index e1b19a1a..d22faa61 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -41,7 +41,7 @@ defined in your project:: super(SignupView, self).after_signup(form) def update_profile(self, form): - profile = self.created_user.profile # replace with your reverse one-to-one profile attribute + profile = self.created_user.profile # replace with your reverse one-to-one profile attribute only if you've defined a `related_name`. profile.some_attr = "some value" profile.save() @@ -174,6 +174,7 @@ If you want to get rid of username you'll need to do some extra work: class SignupView(account.views.SignupView): form_class = myproject.forms.SignupForm + identifier_field = 'email' def generate_username(self, form): # do something to generate a unique username (required by the @@ -254,3 +255,69 @@ file called lib/tests.py:: And in your settings:: TEST_RUNNER = "lib.tests.MyTestDiscoverRunner" + +Restricting views to authenticated users +======================================== + +``django.contrib.auth`` includes a convenient decorator and a mixin to restrict +views to authenticated users. ``django-user-accounts`` includes a modified +version of these decorator and mixin that should be used instead of the +usual ones. + +If you want to restrict a function based view, use the decorator:: + + from account.decorators import login_required + + @login_required + def restricted_view(request): + pass + +To do the same with class based views, use the mixin:: + + from account.mixins import LoginRequiredMixin + + class RestrictedView(LoginRequiredMixin, View): + pass + + +Defining a custom password checker +================================== + +First add the path to the module which contains the +`AccountDefaultHookSet` subclass to your settings:: + + ACCOUNT_HOOKSET = "scenemachine.hooks.AccountHookSet" + +Then define a custom `clean_password` method on the `AccountHookSet` +class. + +Here is an example that harnesses the `VeryFacistCheck` dictionary +checker from `cracklib`_.:: + + import cracklib + + from django import forms from django.conf import settings from + django.template.defaultfilters import mark_safe from + django.utils.translation import gettext_lazy as _ + + from account.hooks import AccountDefaultHookSet + + + class AccountHookSet(AccountDefaultHookSet): + + def clean_password(self, password_new, password_new_confirm): + password_new = super(AccountHookSet, self).clean_password(password_new, password_new_confirm) + try: + dictpath = "/usr/share/cracklib/pw_dict" + if dictpath: + cracklib.VeryFascistCheck(password_new, dictpath=dictpath) + else: + cracklib.VeryFascistCheck(password_new) + return password_new + except ValueError as e: + message = _(unicode(e)) + raise forms.ValidationError, mark_safe(message) + return password_new + + +.. _cracklib: https://pypi.python.org/pypi/cracklib/2.8.19 diff --git a/makemigrations.py b/makemigrations.py new file mode 100644 index 00000000..eb2aa60d --- /dev/null +++ b/makemigrations.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +import os +import sys + +import django +from django.conf import settings + +DEFAULT_SETTINGS = dict( + INSTALLED_APPS=[ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sites", + "account", + "account.tests" + ], + MIDDLEWARE_CLASSES=[], + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } + }, + SITE_ID=1, + ROOT_URLCONF="account.tests.urls", + SECRET_KEY="notasecret", +) + + +def run(*args): + if not settings.configured: + settings.configure(**DEFAULT_SETTINGS) + + django.setup() + + parent = os.path.dirname(os.path.abspath(__file__)) + sys.path.insert(0, parent) + + django.core.management.call_command( + "makemigrations", + "account", + *args + ) + + +if __name__ == "__main__": + run(*sys.argv[1:]) diff --git a/manage.py b/manage.py new file mode 100644 index 00000000..646bf7ab --- /dev/null +++ b/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "account.tests.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..ef886100 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,68 @@ +[build-system] +requires = ["setuptools>=61.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "django-user-accounts" +authors = [{name = "Pinax Team", email = "team@pinaxproject.com"}] +description = "a Django user account app" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.2", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] +dependencies = [ + "Django>=3.2", + "django-appconf>=1.0.4", + "pytz>=2020.4", +] +dynamic = ["version"] + +[project.readme] +file = "README.md" +content-type = "text/markdown" + +[project.urls] +Homepage = "http://github.com/pinax/django-user-accounts" + +[tool.isort] +profile = "hug" +src_paths = ["account"] +multi_line_output = 3 +known_django = "django" +known_third_party = "account,six,mock,appconf,jsonfield,pytz" +sections = "FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" +skip_glob = "account/migrations/*,docs" +include_trailing_comma = "True" + +[tool.pytest.ini_options] +testpaths = ["account/tests"] +DJANGO_SETTINGS_MODULE = "account.tests.settings" + +[tool.ruff] +line-length = 120 + +[tool.ruff.per-file-ignores] +"account/migrations/**.py" = ["E501"] + +[tool.setuptools] +package-dir = {"" = "."} +include-package-data = true +zip-safe = false + +[tool.setuptools.dynamic] +version = {attr = "account.__version__"} + +[tool.setuptools.package-data] +account = ["locale/*/LC_MESSAGES/*"] diff --git a/runtests.py b/runtests.py index 103f3e24..5a60937d 100644 --- a/runtests.py +++ b/runtests.py @@ -4,78 +4,18 @@ import django -from django.conf import settings - - -DEFAULT_SETTINGS = dict( - DEBUG=True, - USE_TZ=True, - INSTALLED_APPS=[ - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.sites", - "django.contrib.messages", - "account", - "account.tests", - ], - MIDDLEWARE_CLASSES=[ - "django.contrib.sessions.middleware.SessionMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.auth.middleware.SessionAuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - ], - DATABASES={ - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": ":memory:", - } - }, - SITE_ID=1, - ROOT_URLCONF="account.tests.urls", - SECRET_KEY="notasecret", - TEMPLATES=[ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - # insert your TEMPLATE_DIRS here - ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this - # list if you haven't customized them: - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.debug', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, - ] -) - def runtests(*test_args): - if not settings.configured: - settings.configure(**DEFAULT_SETTINGS) - + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "account.tests.settings") django.setup() parent = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, parent) - try: - from django.test.runner import DiscoverRunner - runner_class = DiscoverRunner - test_args = ["account.tests"] - except ImportError: - from django.test.simple import DjangoTestSuiteRunner - runner_class = DjangoTestSuiteRunner - test_args = ["tests"] + from django.test.runner import DiscoverRunner + runner_class = DiscoverRunner + if not test_args: + test_args = ["account/tests"] failures = runner_class(verbosity=1, interactive=True, failfast=False).run_tests(test_args) sys.exit(failures) diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2a9acf13..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal = 1 diff --git a/setup.py b/setup.py deleted file mode 100644 index 1fcebec4..00000000 --- a/setup.py +++ /dev/null @@ -1,38 +0,0 @@ -from setuptools import setup, find_packages - -import account - - -setup( - name="django-user-accounts", - version=account.__version__, - author="Brian Rosner", - author_email="brosner@gmail.com", - description="a Django user account app", - long_description=open("README.rst").read(), - license="MIT", - url="http://github.com/pinax/django-user-accounts", - packages=find_packages(), - install_requires=[ - "django-appconf>=1.0.1", - "pytz>=2015.6" - ], - zip_safe=False, - package_data={ - "account": [ - "locale/*/LC_MESSAGES/*", - ], - }, - test_suite="runtests.runtests", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Web Environment", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 3", - "Framework :: Django", - ] -) diff --git a/tox.ini b/tox.ini index 56b86cb6..65db9c11 100644 --- a/tox.ini +++ b/tox.ini @@ -2,29 +2,17 @@ ignore = E265,E501 max-line-length = 100 max-complexity = 10 -exclude = account/migrations/*,docs/* +exclude = **/migrations,docs +inline-quotes = double -[tox] -envlist = - py27-{1.8,1.9,master}, - py32-{1.8}, - py33-{1.8}, - py34-{1.8,1.9,master}, - py35-{1.8,1.9,master} +[coverage:run] +source = account +omit = account/conf.py,tests/*,account/migrations/* +branch = true +data_file = .coverage -[testenv] -deps = - py{27,33,34,35}: coverage==4.0.2 - py32: coverage==3.7.1 - flake8==2.5.0 - 1.8: Django>=1.8,<1.9 - 1.9: Django>=1.9,<1.10 - master: https://github.com/django/django/tarball/master -usedevelop = True -setenv = - LANG=en_US.UTF-8 - LANGUAGE=en_US:en - LC_ALL=en_US.UTF-8 -commands = - flake8 account - coverage run setup.py test +[coverage:report] +omit = account/conf.py,tests/*,account/migrations/* +exclude_lines = + coverage: omit +show_missing = True