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 6b4a8e2b..00000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -sudo: false -language: python -python: - - "2.7" - - "3.3" - - "3.4" - - "3.5" -env: - - DJANGO=1.8 - - DJANGO=1.9 - - DJANGO=1.10 - - DJANGO=master -matrix: - exclude: - - python: "3.3" - env: DJANGO=1.9 - - python: "3.3" - env: DJANGO=1.10 - - 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 9a82c1c5..b68b889d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,79 @@ -# 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 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 96465f3a..79e1f602 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,11 +1,13 @@ -include .coveragerc +global-exclude *.py[cod] include CHANGELOG.md -include CONTRIBUTING.md include LICENSE -include tox.ini -include README.rst -include runtests.py +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 ad2f4333..00000000 --- a/README.rst +++ /dev/null @@ -1,97 +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 - - Password expiration - - Account management (update account settings and change password) - - Account deletion - -* Extensible class-based views and hooksets -* Custom ``User`` model support - - -Requirements --------------- - -* Django 1.8, 1.9, or 1.10 -* 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 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 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 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 8c0d5d5b..3e2d550b 100644 --- a/account/__init__.py +++ b/account/__init__.py @@ -1 +1 @@ -__version__ = "2.0.0" +__version__ = "3.3.2" diff --git a/account/admin.py b/account/admin.py index 48a0ce6a..c2f60b5f 100644 --- a/account/admin.py +++ b/account/admin.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import admin from account.models import ( @@ -24,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): @@ -35,6 +36,9 @@ 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): @@ -48,6 +52,9 @@ class PasswordHistoryAdmin(admin.ModelAdmin): 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) 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/conf.py b/account/conf.py index 4e620af2..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): @@ -38,11 +32,13 @@ class AccountAppConf(AppConf): 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 @@ -55,9 +51,11 @@ class AccountAppConf(AppConf): SETTINGS_REDIRECT_URL = "account_settings" NOTIFY_ON_PASSWORD_CHANGE = True DELETION_EXPUNGE_HOURS = 48 + DEFAULT_HTTP_PROTOCOL = "https" HOOKSET = "account.hooks.AccountDefaultHookSet" TIMEZONES = TIMEZONES LANGUAGES = LANGUAGES - 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 46ddcdd7..82a8658b 100644 --- a/account/decorators.py +++ b/account/decorators.py @@ -1,9 +1,6 @@ -from __future__ import unicode_literals - import functools from django.contrib.auth import REDIRECT_FIELD_NAME -from django.utils.decorators import available_attrs from account.utils import handle_redirect_to_login @@ -14,9 +11,9 @@ def login_required(func=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url 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 4aa0cb92..14f2f2a1 100644 --- a/account/forms.py +++ b/account/forms.py @@ -1,26 +1,21 @@ -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.encoding import force_text -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): @@ -33,7 +28,7 @@ def __init__(self, *args, **kwargs): def to_python(self, value): if value in self.empty_values: return "" - value = force_text(value) + value = force_str(value) if self.strip: value = value.strip() return value @@ -43,10 +38,14 @@ class SignupForm(forms.Form): username = forms.CharField( label=_("Username"), - max_length=30, + max_length=USER_FIELD_MAX_LENGTH, widget=forms.TextInput(), required=True ) + email = forms.EmailField( + label=_("Email"), + widget=forms.TextInput(), required=True + ) password = PasswordField( label=_("Password"), strip=settings.ACCOUNT_PASSWORD_STRIP, @@ -55,10 +54,6 @@ class SignupForm(forms.Form): label=_("Password (again)"), strip=settings.ACCOUNT_PASSWORD_STRIP, ) - email = forms.EmailField( - label=_("Email"), - widget=forms.TextInput(), required=True) - code = forms.CharField( max_length=64, required=False, @@ -67,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"] }) @@ -85,9 +81,12 @@ 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 @@ -122,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) @@ -144,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) @@ -176,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"] @@ -205,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 606eee73..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,21 +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"], } - def account_delete_mark(self, deletion): + @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 + + @staticmethod + def account_delete_mark(deletion): deletion.user.is_active = False deletion.user.save() - def account_delete_expunge(self, deletion): + @staticmethod + def account_delete_expunge(deletion): deletion.user.delete() -class HookProxy(object): +class HookProxy: def __getattr__(self, attr): return getattr(settings.ACCOUNT_HOOKSET, attr) diff --git a/account/languages.py b/account/languages.py index cc9a5cce..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"), @@ -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..12f30a27 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..8b88c758 100644 --- a/account/locale/de/LC_MESSAGES/django.po +++ b/account/locale/de/LC_MESSAGES/django.po @@ -1,153 +1,233 @@ # 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: 2023-11-03 11:29+0100\n" +"Last-Translator: Guenther Meyer \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 "E-Mail" + +#: 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." +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." +msgstr "Es ist bereits ein Benutzer mit dieser E-Mail-Adresse registriert." -#: 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." +msgstr "Die eingegebenen 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." +msgstr "Der Benutzername 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." +msgstr "Die E-Mail-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." +msgstr "E-Mail-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" + +#: account/models.py:152 +msgid "signup codes" +msgstr "signup codes" -#: models.py:250 +#: 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." +msgstr "Eine Bestätigungs-E-Mail 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." +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 "Die E-Mail-Bestätigung für {email} ist abgelaufen." -#: 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/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 index b13424c8..6733a598 100644 --- a/account/management/commands/user_password_expiry.py +++ b/account/management/commands/user_password_expiry.py @@ -25,7 +25,7 @@ def handle_label(self, username, **options): try: user = User.objects.get(username=username) except User.DoesNotExist: - return "User \"{}\" not found".format(username) + return 'User "{}" not found'.format(username) expire = options["expire"] @@ -36,4 +36,4 @@ def handle_label(self, username, **options): user.password_expiry.expiry = expire user.password_expiry.save() - return "User \"{}\" password expiration set to {} seconds".format(username, expire) + 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 index 4349416f..bffc452b 100644 --- a/account/management/commands/user_password_history.py +++ b/account/management/commands/user_password_history.py @@ -1,9 +1,9 @@ import datetime -import pytz from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand +import pytz from account.models import PasswordHistory 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 e54decd8..6d998586 100644 --- a/account/middleware.py +++ b/account/middleware.py @@ -1,19 +1,13 @@ -from __future__ import unicode_literals - -try: - from urllib.parse import urlparse, urlunparse -except ImportError: # python 2 - from urlparse import urlparse, urlunparse - -import django +from urllib.parse import urlparse, urlunparse from django.contrib import messages from django.contrib.auth import REDIRECT_FIELD_NAME -from django.core.urlresolvers import resolve, reverse from django.http import HttpResponseRedirect, QueryDict -from django.utils import translation, timezone +from django.urls import resolve, reverse +from django.utils import timezone, translation from django.utils.cache import patch_vary_headers -from django.utils.translation import ugettext_lazy as _ +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 @@ -21,12 +15,6 @@ from account.utils import check_password_expired -if django.VERSION >= (1, 10): - from django.utils.deprecation import MiddlewareMixin as BaseMiddleware -else: - BaseMiddleware = object - - class LocaleMiddleware(BaseMiddleware): """ This is a very simple middleware that parses a request @@ -36,8 +24,9 @@ class LocaleMiddleware(BaseMiddleware): (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 @@ -49,7 +38,8 @@ 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() @@ -62,7 +52,8 @@ class TimezoneMiddleware(BaseMiddleware): 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: @@ -76,15 +67,15 @@ def process_request(self, request): class ExpiredPasswordMiddleware(BaseMiddleware): def process_request(self, request): - if request.user.is_authenticated() and not request.user.is_staff: + 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, - ]: - if check_password_expired(request.user): + 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, 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/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 240926dc..3250880e 100644 --- a/account/mixins.py +++ b/account/mixins.py @@ -1,12 +1,10 @@ -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 = REDIRECT_FIELD_NAME login_url = None @@ -15,7 +13,7 @@ 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 9b18c92c..2ccaa229 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 = getattr(user, "account", None) + 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 @@ -113,14 +110,13 @@ def user_post_save(sender, **kwargs): 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) @@ -128,7 +124,6 @@ def __str__(self): return "AnonymousAccount" -@python_2_unicode_compatible class SignupCode(models.Model): class AlreadyExists(Exception): @@ -138,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) @@ -154,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): @@ -163,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): @@ -217,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( @@ -242,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): @@ -251,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) @@ -301,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() @@ -340,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, @@ -377,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 @@ -385,10 +387,10 @@ 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 @@ -400,7 +402,7 @@ class Meta: verbose_name = _("password history") verbose_name_plural = _("password histories") - user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="password_history") + 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 @@ -409,5 +411,10 @@ 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")) + 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 8bc14a96..88df377c 100644 --- a/account/signals.py +++ b/account/signals.py @@ -1,15 +1,12 @@ -from __future__ import unicode_literals - import django.dispatch - -user_signed_up = django.dispatch.Signal(providing_args=["user", "form"]) -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"]) -password_expired = django.dispatch.Signal(providing_args=["user"]) +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() 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/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 index 594c9e71..4747a61b 100644 --- a/account/tests/test_commands.py +++ b/account/tests/test_commands.py @@ -1,16 +1,11 @@ +from io import StringIO + from django.contrib.auth import get_user_model from django.core.management import call_command -from django.test import ( - override_settings, - TestCase, -) -from django.utils.six import StringIO +from django.test import TestCase, override_settings -from ..conf import settings -from ..models import ( - PasswordExpiry, - PasswordHistory, -) +from account.conf import settings +from account.models import PasswordExpiry, PasswordHistory @override_settings( @@ -39,7 +34,9 @@ def test_set_explicit_password_expiry(self): 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()) + self.assertIn('User "{}" password expiration set to {} seconds'.format( + self.user.username, expiration_period), out.getvalue(), + ) def test_set_default_password_expiry(self): """ @@ -57,7 +54,9 @@ def test_set_default_password_expiry(self): 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()) + self.assertIn('User "{}" password expiration set to {} seconds'.format( + self.user.username, default_expiration), out.getvalue(), + ) def test_reset_existing_password_expiry(self): """ @@ -90,7 +89,7 @@ def test_bad_username(self): bad_username, stdout=out ) - self.assertIn("User \"{}\" not found".format(bad_username), out.getvalue()) + self.assertIn('User "{}" not found'.format(bad_username), out.getvalue()) class UserPasswordHistoryTests(TestCase): 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 index e05cce3b..88f5f1ca 100644 --- a/account/tests/test_password.py +++ b/account/tests/test_password.py @@ -1,25 +1,15 @@ import datetime -import pytz import django - -from django.contrib.auth.hashers import ( - check_password, - make_password, -) +from django.contrib.auth.hashers import check_password, make_password from django.contrib.auth.models import User -from django.core.urlresolvers import reverse -from django.test import ( - TestCase, - modify_settings, - override_settings, -) +from django.test import TestCase, modify_settings, override_settings +from django.urls import reverse -from ..models import ( - PasswordExpiry, - PasswordHistory, -) -from ..utils import check_password_expired +import pytz + +from account.models import PasswordExpiry, PasswordHistory +from account.utils import check_password_expired def middleware_kwarg(value): @@ -93,7 +83,7 @@ def test_get_not_expired(self): # get account settings page (could be any application page) response = self.client.get(reverse("account_settings")) - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) def test_get_expired(self): """ @@ -101,7 +91,9 @@ def test_get_expired(self): 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.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) @@ -135,7 +127,7 @@ def test_password_expiration_reset(self): post_data ) # Should see one more history entry for this user - self.assertEquals(self.user.password_history.count(), history_count + 1) + self.assertEqual(self.user.password_history.count(), history_count + 1) latest = PasswordHistory.objects.latest("timestamp") self.assertTrue(latest != self.history) @@ -174,7 +166,7 @@ def test_get_no_history(self): ): # get account settings page (could be any application page) response = self.client.get(reverse("account_settings")) - self.assertEquals(response.status_code, 200) + self.assertEqual(response.status_code, 200) def test_password_expiration_reset(self): """ @@ -201,7 +193,7 @@ def test_password_expiration_reset(self): post_data ) # Should see one more history entry for this user - self.assertEquals(self.user.password_history.count(), history_count + 1) + self.assertEqual(self.user.password_history.count(), history_count + 1) def test_password_reset(self): """ @@ -226,4 +218,4 @@ def test_password_reset(self): post_data ) # history count should be zero - self.assertEquals(self.user.password_history.count(), 0) + self.assertEqual(self.user.password_history.count(), 0) diff --git a/account/tests/test_views.py b/account/tests/test_views.py index 9444ea38..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}), {}) @@ -348,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 cb084d5a..91ae8094 100644 --- a/account/utils.py +++ b/account/utils.py @@ -1,20 +1,16 @@ -from __future__ import unicode_literals - import datetime import functools -import pytz -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 @@ -43,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): @@ -89,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 @@ -111,6 +106,14 @@ def get_form_data(form, field_name, default=None): 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 @@ -135,10 +138,7 @@ def check_password_expired(user): except PasswordHistory.DoesNotExist: return False - now = datetime.datetime.now(tz=pytz.UTC) + now = timezone.now() expiration = latest.timestamp + datetime.timedelta(seconds=expiry) - if expiration < now: - return True - else: - return False + return bool(expiration < now) diff --git a/account/views.py b/account/views.py index e1513599..5fbf6473 100644 --- a/account/views.py +++ b/account/views.py @@ -1,31 +1,45 @@ -from __future__ import unicode_literals - -from django.core.urlresolvers import reverse -from django.http import Http404, HttpResponseForbidden -from django.shortcuts import redirect, get_object_or_404 -from django.utils.http import base36_to_int, int_to_base36 -from django.utils.translation import ugettext_lazy as _ -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.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 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.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 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, PasswordHistory -from account.utils import default_redirect, get_form_data - - -class PasswordMixin(object): +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. @@ -56,7 +70,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 @@ -88,7 +105,7 @@ def get_success_url(self, fallback_url=None, **kwargs): return default_redirect(self.request, fallback_url, **kwargs) def send_password_email(self, user): - protocol = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http") + protocol = settings.ACCOUNT_DEFAULT_HTTP_PROTOCOL current_site = get_current_site(self.request) ctx = { "user": user, @@ -114,6 +131,8 @@ class SignupView(PasswordMixin, 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" @@ -136,6 +155,9 @@ def __init__(self, *args, **kwargs): 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 @@ -156,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() @@ -178,10 +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] + return [self.template_name] def get_form_kwargs(self): kwargs = super(SignupView, self).get_form_kwargs() @@ -211,29 +232,33 @@ def form_valid(self, form): 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 create_user(self, form, commit=True, model=None, **kwargs): @@ -255,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): @@ -264,7 +289,7 @@ 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: @@ -283,6 +308,8 @@ def after_signup(self, form): def login_user(self): 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) @@ -295,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 @@ -323,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 @@ -333,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): @@ -342,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 @@ -380,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): @@ -403,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()) @@ -419,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 @@ -440,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.") } } @@ -456,21 +519,30 @@ def get(self, *args, **kwargs): def post(self, *args, **kwargs): self.object = confirmation = self.get_object() - confirmation.confirm() - self.after_confirmation(confirmation) - if settings.ACCOUNT_EMAIL_CONFIRMATION_AUTO_LOGIN: - self.user = self.login_user(confirmation.email_address.user) + 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: - self.user = self.request.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"): + 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 }) ) @@ -484,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 @@ -495,14 +568,14 @@ def get_context_data(self, **kwargs): return ctx def get_redirect_url(self): - if self.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() @@ -528,12 +601,12 @@ class ChangePasswordView(PasswordMixin, FormView): 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) @@ -547,9 +620,7 @@ 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({ @@ -572,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: @@ -589,28 +664,29 @@ 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) +INTERNAL_RESET_URL_TOKEN = "set-password" +INTERNAL_RESET_SESSION_TOKEN = "_password_reset_token" + + class PasswordResetTokenView(PasswordMixin, FormView): template_name = "account/password_reset_token.html" @@ -620,12 +696,31 @@ class PasswordResetTokenView(PasswordMixin, FormView): 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): @@ -638,7 +733,7 @@ def get_context_data(self, **kwargs): def form_valid(self, form): self.change_password(form) - self.create_password_history(form, self.request.user) + self.create_password_history(form, self.get_user()) self.after_change_password() return redirect(self.get_success_url()) @@ -721,7 +816,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 @@ -747,7 +845,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/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/installation.rst b/docs/installation.rst index 44d09223..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 @@ -54,6 +69,13 @@ Optionally include ``account.middleware.ExpiredPasswordMiddleware`` in ... ] +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 6cb76db3..411dac83 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -9,122 +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"`` -``ACCOUNT_PASSWORD_EXPIRY`` -======================================= - -Default: ``0`` - -``ACCOUNT_PASSWORD_USE_HISTORY`` -======================================= - -Default: ``False`` +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`` =================== @@ -144,7 +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 9b7b8f09..356d6b7b 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 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 7ccd109c..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 @@ -255,35 +256,68 @@ And in your settings:: TEST_RUNNER = "lib.tests.MyTestDiscoverRunner" +Restricting views to authenticated users +======================================== -Enabling password expiration -============================ +``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. -Password expiration is disabled by default. In order to enable password expiration -you must add entries to your settings file:: +If you want to restrict a function based view, use the decorator:: - ACCOUNT_PASSWORD_EXPIRY = 60*60*24*5 # seconds until pw expires, this example shows five days - ACCOUNT_PASSWORD_USE_HISTORY = True + from account.decorators import login_required -and include `ExpiredPasswordMiddleware` with your middleware settings:: + @login_required + def restricted_view(request): + pass - MIDDLEWARE_CLASSES = { - ... - "account.middleware.ExpiredPasswordMiddleware", - } +To do the same with class based views, use the mixin:: -``ACCOUNT_PASSWORD_EXPIRY`` indicates the duration a password will stay valid. After that period -the password must be reset in order to view any page. If ``ACCOUNT_PASSWORD_EXPIRY`` is zero (0) -then passwords never expire. + from account.mixins import LoginRequiredMixin -If ``ACCOUNT_PASSWORD_USE_HISTORY`` is False, no history will be generated and password -expiration WILL NOT be checked. + class RestrictedView(LoginRequiredMixin, View): + pass -If ``ACCOUNT_PASSWORD_USE_HISTORY`` is True, a password history entry is created each time -the user changes their password. This entry links the user with their most recent -(encrypted) password and a timestamp. Unless deleted manually, PasswordHistory items -are saved forever, allowing password history checking for new passwords. -For an authenticated user, ``ExpiredPasswordMiddleware`` prevents retrieving or posting -to any page except the password change page and log out page when the user password is expired. -However, if the user is "staff" (can access the Django admin site), the password check is skipped. +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 index e6881231..eb2aa60d 100644 --- a/makemigrations.py +++ b/makemigrations.py @@ -3,10 +3,8 @@ import sys import django - from django.conf import settings - DEFAULT_SETTINGS = dict( INSTALLED_APPS=[ "django.contrib.auth", 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 471171eb..5a60937d 100644 --- a/runtests.py +++ b/runtests.py @@ -4,79 +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", - ], - 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', - ], - }, - }, - ] -) - -DEFAULT_SETTINGS["MIDDLEWARE" if django.VERSION >= (1, 10) else "MIDDLEWARE_CLASSES"] = [ - "django.contrib.sessions.middleware.SessionMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.auth.middleware.SessionAuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", -] - 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 e21bcd7f..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,1.10,master}, - py33-{1.8}, - py34-{1.8,1.9,1.10,master}, - py35-{1.8,1.9,1.10,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 - 1.10: Django>=1.10,<1.11 - 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