├── .editorconfig ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── email_registration ├── __init__.py ├── locale │ └── de │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── models.py ├── signals.py ├── templates │ └── registration │ │ ├── email_registration_email.txt │ │ ├── email_registration_form.html │ │ ├── email_registration_include.html │ │ ├── email_registration_sent.html │ │ └── password_set_form.html ├── urls.py ├── utils.py └── views.py ├── setup.cfg ├── setup.py └── tests ├── .gitignore ├── cov.sh ├── manage.py ├── requirements.txt ├── testapp ├── __init__.py ├── models.py ├── settings.py ├── templates │ ├── 404.html │ ├── base.html │ ├── plain.html │ └── registration │ │ └── login.html ├── test_registration.py └── urls.py ├── tox-update-venvs.sh └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.py] 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | .*.swp 4 | \#*# 5 | /secrets.py 6 | .DS_Store 7 | ._* 8 | /MANIFEST 9 | /_build 10 | /build 11 | /dist 12 | /django_email_registration.egg-info 13 | /tests/venv 14 | .tox 15 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ".yarn/|yarn.lock" 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.1.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-merge-conflict 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | - repo: https://github.com/asottile/pyupgrade 11 | rev: v2.31.0 12 | hooks: 13 | - id: pyupgrade 14 | args: [--py38-plus] 15 | - repo: https://github.com/adamchainz/django-upgrade 16 | rev: 1.4.0 17 | hooks: 18 | - id: django-upgrade 19 | args: [--target-version, "3.2"] 20 | - repo: https://github.com/pycqa/isort 21 | rev: 5.10.1 22 | hooks: 23 | - id: isort 24 | args: [--profile=black, --lines-after-imports=2, --combine-as] 25 | - repo: https://github.com/psf/black 26 | rev: 22.1.0 27 | hooks: 28 | - id: black 29 | - repo: https://github.com/pycqa/flake8 30 | rev: 4.0.1 31 | hooks: 32 | - id: flake8 33 | args: ["--ignore=E203,E501,W503"] 34 | - repo: https://github.com/pre-commit/mirrors-prettier 35 | rev: v2.5.1 36 | hooks: 37 | - id: prettier 38 | args: [--list-different, --no-semi] 39 | exclude: "^conf/|.*\\.html$" 40 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | sudo: false 4 | python: 5 | - "3.5" 6 | - "2.7" 7 | env: 8 | - REQ="Django>=1.11,<2.0" 9 | - REQ="Django>=2.0,<2.1" 10 | matrix: 11 | exclude: 12 | - python: "2.7" 13 | env: REQ="Django>=2.0,<2.1" 14 | install: 15 | - pip install -U pip wheel 16 | - pip install $REQ flake8 towel 17 | - python setup.py install 18 | # command to run tests, e.g. python setup.py test 19 | script: "cd tests && ./manage.py test testapp && cd .. && flake8 ." 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Matthias Kestenholz and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of django-email-registration nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include MANIFEST.in 3 | include README.rst 4 | recursive-include email_registration/static * 5 | recursive-include email_registration/locale * 6 | recursive-include email_registration/templates * 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | django-email-registration 3 | ========================= 4 | 5 | The eleventy-eleventh email registration app for Django. 6 | 7 | But this one does not feed your cat. 8 | 9 | .. image:: https://travis-ci.org/matthiask/django-email-registration.png?branch=master 10 | :target: https://travis-ci.org/matthiask/django-email-registration 11 | 12 | 13 | Usage 14 | ===== 15 | 16 | This example assumes you are using a recent version of Django, jQuery and 17 | Twitter Bootstrap. 18 | 19 | 1. Install ``django-email-registration`` using pip. 20 | 21 | 2. Copy this code somewhere on your login or registration page:: 22 | 23 |

{% trans "Send an activation link" %}

24 |
26 | {% csrf_token %} 27 |
28 | 30 |
31 | 33 |
34 | 35 | 48 | 49 | (Alternatively, include the template snippet 50 | ``registration/email_registration_include.html`` somewhere.) 51 | 52 | 3. Add ``email_registration`` to ``INSTALLED_APPS`` and include 53 | ``email_registration.urls`` somewhere in your URLconf. 54 | 55 | 4. Make sure that Django is able to 56 | `send emails `_. 57 | 58 | 5. Presto. 59 | -------------------------------------------------------------------------------- /email_registration/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 5, 0) 2 | __version__ = ".".join(map(str, VERSION)) 3 | -------------------------------------------------------------------------------- /email_registration/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/django-email-registration/a014c5af6a470f02693a7beb38559887e1fa8eb8/email_registration/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /email_registration/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2013-06-10 12:25+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 20 | 21 | #: utils.py:79 22 | msgid "The link is expired. Please request another registration link." 23 | msgstr "Der Link ist verfallen. Bitte beantragen Sie einen neuen Link." 24 | 25 | #: utils.py:84 26 | msgid "Unable to verify the signature. Please request a new registration link." 27 | msgstr "" 28 | "Konnte die Signatur nicht verifizieren. Bitte beantragen Sie einen neuen " 29 | "Link." 30 | 31 | #: utils.py:90 utils.py:99 32 | msgid "" 33 | "Something went wrong while decoding the registration request. Please try " 34 | "again." 35 | msgstr "Etwas ist schiefgelaufen. Bitte versuchen Sie es nochmals." 36 | 37 | #: utils.py:103 38 | msgid "The link has already been used." 39 | msgstr "Der Link wurde schon verwendet." 40 | 41 | #: views.py:19 views.py:21 42 | msgid "email address" 43 | msgstr "Emailadresse" 44 | 45 | #: views.py:28 views.py:61 46 | msgid "" 47 | "This email address already exists as an account. Did you want to reset your " 48 | "password?" 49 | msgstr "" 50 | "Die Emailadresse existiert schon im System. Wollten Sie Ihr Passwort " 51 | "zurücksetzen?" 52 | 53 | #: views.py:82 54 | msgid "Successfully set the new password. Please login now." 55 | msgstr "" 56 | "Das neue Passwort wurde erfolgreich gesetzt. Sie können sich jetzt anmelden." 57 | 58 | #: views.py:87 59 | msgid "Please set a password." 60 | msgstr "Bitte setzen Sie ein neues Passwort." 61 | 62 | #: templates/registration/email_registration_email.txt:2 63 | msgid "Registration link" 64 | msgstr "Registrierungslink" 65 | 66 | #: templates/registration/email_registration_email.txt:4 67 | msgid "Click the following link to activate your account:" 68 | msgstr "Besuchen Sie den folgenden Link um Ihr Konto zu aktivieren:" 69 | 70 | #: templates/registration/email_registration_email.txt:8 71 | msgid "" 72 | "Please note that this link expires after 3 days. If you wait\n" 73 | "longer, you'll have to request another registration link." 74 | msgstr "" 75 | "Dieser Link ist nur 3 Tage gültig. Wenn Sie länger warten,\n" 76 | "müssen Sie einen neuen Link anfordern." 77 | 78 | #: templates/registration/email_registration_email.txt:11 79 | msgid "Best regards" 80 | msgstr "Freundliche Grüsse" 81 | 82 | #: templates/registration/email_registration_form.html:11 83 | #: templates/registration/email_registration_include.html:11 84 | msgid "Register" 85 | msgstr "Registrieren" 86 | 87 | #: templates/registration/email_registration_include.html:7 88 | msgid "Email address" 89 | msgstr "Emailadresse" 90 | 91 | #: templates/registration/email_registration_sent.html:3 92 | #, python-format 93 | msgid "" 94 | "We sent you an email to %(email)s.\n" 95 | " Please click the contained link to finish your registration." 96 | msgstr "" 97 | "Wir haben ein Email an %(email)s verschickt. Bitte besuchen Sie den darin " 98 | "enthaltenen Link um die Registrierung abzuschliessen." 99 | 100 | #: templates/registration/password_set_form.html:4 101 | #: templates/registration/password_set_form.html:8 102 | #: templates/registration/password_set_form.html:21 103 | msgid "Set password" 104 | msgstr "Passwort setzen" 105 | 106 | #~ msgid "Successfully created a new user. Please set a password." 107 | #~ msgstr "" 108 | #~ "Ein Nutzer wurde erfolgreich erstellt. Bitte setzen Sie ein Passwort." 109 | -------------------------------------------------------------------------------- /email_registration/models.py: -------------------------------------------------------------------------------- 1 | # Intentionally left empty. We don't need no models! 2 | -------------------------------------------------------------------------------- /email_registration/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | password_set = Signal() 5 | -------------------------------------------------------------------------------- /email_registration/templates/registration/email_registration_email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% trans "Registration link" %} 3 | 4 | {% trans "Click the following link to activate your account:" %} 5 | 6 | {{ url }} 7 | 8 | {% blocktrans %}Please note that this link expires after 3 days. If you wait 9 | longer, you'll have to request another registration link.{% endblocktrans %} 10 | 11 | {% trans "Best regards" %} 12 | -------------------------------------------------------------------------------- /email_registration/templates/registration/email_registration_form.html: -------------------------------------------------------------------------------- 1 | {% load i18n towel_form_tags %} 2 |
3 | {% csrf_token %} 4 | {% form_errors form %} 5 |
6 | {% form_item_plain form.email %} 7 |
8 |
9 | 11 | 15 |
16 | -------------------------------------------------------------------------------- /email_registration/templates/registration/email_registration_include.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
4 | {% csrf_token %} 5 |
6 | 8 |
9 |
10 | 12 |
13 | 14 | 33 | -------------------------------------------------------------------------------- /email_registration/templates/registration/email_registration_sent.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
3 |

{% blocktrans %}We sent you an email to {{ email }}. 4 | Please click the contained link to finish your registration.{% endblocktrans %}

5 |
6 | -------------------------------------------------------------------------------- /email_registration/templates/registration/password_set_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n towel_form_tags %} 3 | 4 | {% block title %}{% trans "Set password" %} - {{ block.super }}{% endblock %} 5 | 6 | {% block page-header %} 7 | 10 | {% endblock %} 11 | 12 | {% block content %} 13 |
14 | {% csrf_token %} 15 | {% form_errors form %} 16 | {% form_item form.new_password1 %} 17 | {% form_item form.new_password2 %} 18 | 19 |
20 | 22 |
23 | 24 | 25 |
26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /email_registration/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from email_registration.views import email_registration_confirm, email_registration_form 4 | 5 | 6 | urlpatterns = [ 7 | path( 8 | "", 9 | email_registration_form, 10 | name="email_registration_form", 11 | ), 12 | path( 13 | "/", 14 | email_registration_confirm, 15 | name="email_registration_confirm", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /email_registration/utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.core import signing 3 | from django.core.mail import EmailMultiAlternatives 4 | from django.template.loader import TemplateDoesNotExist, render_to_string 5 | from django.utils.http import int_to_base36 6 | from django.utils.translation import gettext as _ 7 | 8 | 9 | try: 10 | from django.urls import reverse 11 | except ImportError: # pragma: no cover 12 | from django.core.urlresolvers import reverse 13 | 14 | 15 | def get_signer(salt="email_registration"): 16 | """ 17 | Returns the signer instance used to sign and unsign the registration 18 | link tokens 19 | """ 20 | return signing.TimestampSigner(salt=salt) 21 | 22 | 23 | def get_last_login_timestamp(user): 24 | """ 25 | Django 1.7 allows the `last_login` timestamp to be `None` for new users. 26 | """ 27 | return int(user.last_login.strftime("%s")) if user.last_login else 0 28 | 29 | 30 | def get_confirmation_url(email, request, user=None): 31 | """ 32 | Returns the confirmation URL 33 | """ 34 | code = [email, "", ""] 35 | if user: 36 | code[1] = str(user.id) 37 | code[2] = int_to_base36(get_last_login_timestamp(user)) 38 | 39 | return request.build_absolute_uri( 40 | reverse( 41 | "email_registration_confirm", 42 | kwargs={ 43 | "code": get_signer().sign(":".join(code)), 44 | }, 45 | ) 46 | ) 47 | 48 | 49 | def send_registration_mail(email, request, user=None): 50 | """ 51 | Sends the registration mail 52 | 53 | * ``email``: The email address where the registration link should be 54 | sent to. 55 | * ``request``: A HTTP request instance, used to construct the complete 56 | URL (including protocol and domain) for the registration link. 57 | * ``user``: Optional user instance. If the user exists already and you 58 | just want to send a link where the user can choose his/her password. 59 | 60 | The mail is rendered using the following two templates: 61 | 62 | * ``registration/email_registration_email.txt``: The first line of this 63 | template will be the subject, the third to the last line the body of the 64 | email. 65 | * ``registration/email_registration_email.html``: The body of the HTML 66 | version of the mail. This template is **NOT** available by default and 67 | is not required either. 68 | """ 69 | 70 | render_to_mail( 71 | "registration/email_registration_email", 72 | { 73 | "url": get_confirmation_url(email, request, user=user), 74 | }, 75 | to=[email], 76 | ).send() 77 | 78 | 79 | class InvalidCode(Exception): 80 | """Problems occurred during decoding the registration link""" 81 | 82 | pass 83 | 84 | 85 | def decode(code, max_age=3 * 86400): 86 | """ 87 | Decodes the code from the registration link and returns a tuple consisting 88 | of the verified email address and the associated user instance or ``None`` 89 | if no user was passed to ``send_registration_mail`` 90 | 91 | This method raises ``InvalidCode`` exceptions containing an translated 92 | message what went wrong suitable for presenting directly to the user. 93 | """ 94 | try: 95 | data = get_signer().unsign(code, max_age=max_age) 96 | except signing.SignatureExpired: 97 | raise InvalidCode( 98 | _("The link is expired. Please request another registration link.") 99 | ) 100 | 101 | except signing.BadSignature: 102 | raise InvalidCode( 103 | _( 104 | "Unable to verify the signature. Please request a new" 105 | " registration link." 106 | ) 107 | ) 108 | 109 | parts = data.rsplit(":", 2) 110 | if len(parts) != 3: 111 | raise InvalidCode( 112 | _( 113 | "Something went wrong while decoding the" 114 | " registration request. Please try again." 115 | ) 116 | ) 117 | 118 | email, uid, timestamp = parts 119 | if uid and timestamp: 120 | try: 121 | user = User.objects.get(pk=uid) 122 | except (User.DoesNotExist, TypeError, ValueError): 123 | raise InvalidCode( 124 | _( 125 | "Something went wrong while decoding the" 126 | " registration request. Please try again." 127 | ) 128 | ) 129 | 130 | if timestamp != int_to_base36(get_last_login_timestamp(user)): 131 | raise InvalidCode(_("The link has already been used.")) 132 | 133 | else: 134 | user = None 135 | 136 | return email, user 137 | 138 | 139 | def render_to_mail(template, context, **kwargs): 140 | """ 141 | Renders a mail and returns the resulting ``EmailMultiAlternatives`` 142 | instance 143 | 144 | * ``template``: The base name of the text and HTML (optional) version of 145 | the mail. 146 | * ``context``: The context used to render the mail. This context instance 147 | should contain everything required. 148 | * Additional keyword arguments are passed to the ``EmailMultiAlternatives`` 149 | instantiation. Use those to specify the ``to``, ``headers`` etc. 150 | arguments. 151 | 152 | Usage example:: 153 | 154 | # Render the template myproject/hello_mail.txt (first line contains 155 | # the subject, third to last the body) and optionally the template 156 | # myproject/hello_mail.html containing the alternative HTML 157 | # representation. 158 | message = render_to_mail('myproject/hello_mail', {}, to=[email]) 159 | message.send() 160 | """ 161 | lines = iter(render_to_string("%s.txt" % template, context).splitlines()) 162 | 163 | subject = "" 164 | while True: 165 | line = next(lines) 166 | if line: 167 | subject = line 168 | break 169 | 170 | body = "\n".join(lines).strip("\n") 171 | message = EmailMultiAlternatives(subject=subject, body=body, **kwargs) 172 | 173 | try: 174 | message.attach_alternative( 175 | render_to_string("%s.html" % template, context), "text/html" 176 | ) 177 | except TemplateDoesNotExist: 178 | pass 179 | 180 | return message 181 | -------------------------------------------------------------------------------- /email_registration/views.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import messages 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.forms import SetPasswordForm 5 | from django.core.exceptions import FieldDoesNotExist 6 | from django.shortcuts import redirect, render 7 | from django.utils.crypto import get_random_string 8 | from django.utils.translation import gettext as _, gettext_lazy 9 | from django.views.decorators.http import require_POST 10 | 11 | from email_registration.signals import password_set 12 | from email_registration.utils import InvalidCode, decode, send_registration_mail 13 | 14 | 15 | User = get_user_model() 16 | USERNAME_FIELD = User.USERNAME_FIELD 17 | 18 | 19 | class RegistrationForm(forms.Form): 20 | email = forms.EmailField( 21 | label=gettext_lazy("email address"), 22 | max_length=75, 23 | widget=forms.TextInput( 24 | attrs={ 25 | "placeholder": gettext_lazy("email address"), 26 | } 27 | ), 28 | ) 29 | 30 | def clean_email(self): 31 | email = self.cleaned_data.get("email") 32 | if email and User.objects.filter(email=email).exists(): 33 | raise forms.ValidationError( 34 | _( 35 | "This email address already exists as an account." 36 | " Did you want to reset your password?" 37 | ) 38 | ) 39 | return email 40 | 41 | 42 | @require_POST 43 | def email_registration_form(request, form_class=RegistrationForm): 44 | # TODO unajaxify this view for the release? 45 | form = form_class(request.POST) 46 | 47 | if form.is_valid(): 48 | email = form.cleaned_data["email"] 49 | send_registration_mail(email, request) 50 | 51 | return render( 52 | request, 53 | "registration/email_registration_sent.html", 54 | { 55 | "email": email, 56 | }, 57 | ) 58 | 59 | return render( 60 | request, 61 | "registration/email_registration_form.html", 62 | { 63 | "form": form, 64 | }, 65 | ) 66 | 67 | 68 | def email_registration_confirm( 69 | request, code, max_age=3 * 86400, form_class=SetPasswordForm 70 | ): 71 | try: 72 | email, user = decode(code, max_age=max_age) 73 | except InvalidCode as exc: 74 | messages.error(request, "%s" % exc) 75 | return redirect("/") 76 | 77 | if not user: 78 | if User.objects.filter(email=email).exists(): 79 | messages.error( 80 | request, 81 | _( 82 | "This email address already exists as an account." 83 | " Did you want to reset your password?" 84 | ), 85 | ) 86 | return redirect("/") 87 | 88 | username_field = User._meta.get_field(USERNAME_FIELD) 89 | 90 | kwargs = {} 91 | if username_field.name == "email": 92 | kwargs["email"] = email 93 | else: 94 | username = email 95 | 96 | # If email exceeds max length of field set username to random 97 | # string 98 | max_length = username_field.max_length 99 | if len(username) > max_length: 100 | username = get_random_string(25 if max_length >= 25 else max_length) 101 | kwargs[username_field.name] = username 102 | 103 | # Set value for 'email' field in case the user model has one 104 | try: 105 | User._meta.get_field("email") 106 | kwargs["email"] = email 107 | except FieldDoesNotExist: 108 | pass 109 | 110 | user = User(**kwargs) 111 | 112 | if request.method == "POST": 113 | form = form_class(user, request.POST) 114 | if form.is_valid(): 115 | user = form.save() 116 | 117 | password_set.send( 118 | sender=user.__class__, 119 | request=request, 120 | user=user, 121 | password=form.cleaned_data.get("new_password1"), 122 | ) 123 | 124 | messages.success( 125 | request, _("Successfully set the new password. Please login now.") 126 | ) 127 | 128 | return redirect("login") 129 | 130 | else: 131 | messages.success(request, _("Please set a password.")) 132 | form = form_class(user) 133 | 134 | return render( 135 | request, 136 | "registration/password_set_form.html", 137 | { 138 | "form": form, 139 | }, 140 | ) 141 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude=venv,.tox,docs 3 | 4 | [bdist_wheel] 5 | universal = 1 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | 5 | from setuptools import find_packages, setup 6 | 7 | 8 | def read(filename): 9 | return open(os.path.join(os.path.dirname(__file__), filename)).read() 10 | 11 | 12 | setup( 13 | name="django-email-registration", 14 | version=__import__("email_registration").__version__, 15 | description="So simple you'll burst into tears right away.", 16 | long_description=read("README.rst"), 17 | author="Matthias Kestenholz", 18 | author_email="mk@406.ch", 19 | url="http://github.com/matthiask/django-email-registration/", 20 | license="BSD License", 21 | platforms=["OS Independent"], 22 | packages=find_packages(), 23 | include_package_data=True, 24 | classifiers=[ 25 | "Development Status :: 5 - Production/Stable", 26 | "Environment :: Web Environment", 27 | "Framework :: Django", 28 | "Intended Audience :: Developers", 29 | "License :: OSI Approved :: BSD License", 30 | "Operating System :: OS Independent", 31 | "Programming Language :: Python", 32 | "Programming Language :: Python :: 2", 33 | "Programming Language :: Python :: 2.7", 34 | "Programming Language :: Python :: 3", 35 | "Programming Language :: Python :: 3.4", 36 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 37 | "Topic :: Software Development", 38 | "Topic :: Software Development :: Libraries :: Application Frameworks", 39 | ], 40 | zip_safe=False, 41 | ) 42 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | /.coverage 2 | /htmlcov 3 | -------------------------------------------------------------------------------- /tests/cov.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | venv/bin/coverage run --branch --include="*email_registration/*" --omit="*tests*" ./manage.py test testapp 3 | venv/bin/coverage html 4 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | from os.path import abspath, dirname 5 | 6 | 7 | if __name__ == "__main__": 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 9 | 10 | sys.path.insert(0, dirname(dirname(abspath(__file__)))) 11 | 12 | from django.core.management import execute_from_command_line 13 | 14 | execute_from_command_line(sys.argv) 15 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | Django 2 | coverage 3 | towel 4 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/django-email-registration/a014c5af6a470f02693a7beb38559887e1fa8eb8/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiask/django-email-registration/a014c5af6a470f02693a7beb38559887e1fa8eb8/tests/testapp/models.py -------------------------------------------------------------------------------- /tests/testapp/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | SITE_ID = 1 5 | 6 | DATABASES = { 7 | "default": { 8 | "ENGINE": "django.db.backends.sqlite3", 9 | "NAME": ":memory:", 10 | } 11 | } 12 | 13 | INSTALLED_APPS = [ 14 | "django.contrib.auth", 15 | "django.contrib.admin", 16 | "django.contrib.contenttypes", 17 | "django.contrib.sessions", 18 | "django.contrib.staticfiles", 19 | "django.contrib.messages", 20 | "testapp", 21 | "towel", 22 | "email_registration", 23 | ] 24 | 25 | MEDIA_ROOT = "/media/" 26 | STATIC_URL = "/static/" 27 | BASEDIR = os.path.dirname(__file__) 28 | MEDIA_ROOT = os.path.join(BASEDIR, "media/") 29 | STATIC_ROOT = os.path.join(BASEDIR, "static/") 30 | SECRET_KEY = "supersikret" 31 | LOGIN_REDIRECT_URL = "/?login=1" 32 | 33 | ROOT_URLCONF = "testapp.urls" 34 | LANGUAGES = (("en", "English"), ("de", "German")) 35 | MIDDLEWARE = ( 36 | "django.middleware.common.CommonMiddleware", 37 | "django.contrib.sessions.middleware.SessionMiddleware", 38 | "django.middleware.csrf.CsrfViewMiddleware", 39 | "django.contrib.auth.middleware.AuthenticationMiddleware", 40 | "django.contrib.messages.middleware.MessageMiddleware", 41 | ) 42 | TEMPLATES = [ 43 | { 44 | "BACKEND": "django.template.backends.django.DjangoTemplates", 45 | "DIRS": [ 46 | os.path.join(BASEDIR, "templates"), 47 | ], 48 | "OPTIONS": { 49 | "context_processors": [ 50 | "django.template.context_processors.debug", 51 | "django.template.context_processors.request", 52 | "django.contrib.auth.context_processors.auth", 53 | "django.contrib.messages.context_processors.messages", 54 | ], 55 | "loaders": [ 56 | "django.template.loaders.filesystem.Loader", 57 | "django.template.loaders.app_directories.Loader", 58 | ], 59 | }, 60 | }, 61 | ] 62 | -------------------------------------------------------------------------------- /tests/testapp/templates/404.html: -------------------------------------------------------------------------------- 1 |

Page not found

2 | -------------------------------------------------------------------------------- /tests/testapp/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} 5 | 6 | 7 |

8 | {% if messages %} 9 |
    10 | {% for message in messages %} 11 | {{ message }} 12 | {% endfor %} 13 |
14 | {% endif %} 15 | {% block content %}{% endblock %} 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/testapp/templates/plain.html: -------------------------------------------------------------------------------- 1 | {% block title %}{% endblock %} 2 | {% block content %}{% endblock %} 3 | -------------------------------------------------------------------------------- /tests/testapp/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |
4 | {% csrf_token %} 5 | 6 | {{ form }} 7 |
8 | 9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /tests/testapp/test_registration.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | from urllib.parse import unquote 4 | 5 | from django.contrib.auth.models import User 6 | from django.core import mail 7 | from django.test import TestCase 8 | from django.test.client import RequestFactory 9 | from django.utils import timezone 10 | 11 | 12 | try: 13 | from django.urls import reverse 14 | except ImportError: # pragma: no cover 15 | from django.core.urlresolvers import reverse 16 | 17 | from email_registration.utils import get_signer, send_registration_mail 18 | 19 | 20 | def _messages(response): 21 | return [m.message for m in response.context["messages"]] 22 | 23 | 24 | class RegistrationTest(TestCase): 25 | def test_registration(self): 26 | response = self.client.get("/er/") 27 | 28 | self.assertEqual(response.status_code, 405) 29 | self.assertEqual(response["Allow"], "POST") 30 | 31 | response = self.client.post( 32 | "/er/", 33 | { 34 | "email": "test@example.com", 35 | }, 36 | ) 37 | self.assertContains(response, "We sent you an email to test@example.com.") 38 | 39 | self.assertEqual(len(mail.outbox), 1) 40 | body = mail.outbox[0].body 41 | url = unquote([line for line in body.splitlines() if "testserver" in line][0]) 42 | 43 | self.assertTrue("http://testserver/er/test@example.com:::" in url) 44 | 45 | response = self.client.get(url) 46 | self.assertContains(response, 'id="id_new_password2"') 47 | 48 | response = self.client.post( 49 | url, 50 | { 51 | "new_password1": "pass", 52 | "new_password2": "passss", 53 | }, 54 | ) 55 | self.assertEqual(response.status_code, 200) 56 | 57 | response = self.client.post( 58 | url, 59 | { 60 | "new_password1": "pass", 61 | "new_password2": "pass", 62 | }, 63 | ) 64 | self.assertRedirects(response, "/ac/login/") 65 | 66 | user = User.objects.get() 67 | self.assertEqual(user.username, "test@example.com") 68 | self.assertEqual(user.email, "test@example.com") 69 | self.assertEqual(user.is_active, True) 70 | 71 | response = self.client.post( 72 | "/ac/login/", 73 | { 74 | "username": "test@example.com", 75 | "password": "pass", 76 | }, 77 | ) 78 | self.assertRedirects(response, "/?login=1") 79 | 80 | def test_existing_user(self): 81 | user = User.objects.create( 82 | username="test", 83 | ) 84 | 85 | request = RequestFactory().get("/") 86 | send_registration_mail("test@example.com", request, user=user) 87 | 88 | self.assertEqual(len(mail.outbox), 1) 89 | body = mail.outbox[0].body 90 | url = unquote([line for line in body.splitlines() if "testserver" in line][0]) 91 | 92 | self.assertTrue( 93 | re.match(r"http://testserver/er/test@example.com:\d+:\w+:", url) 94 | ) 95 | 96 | response = self.client.get(url) 97 | 98 | self.assertContains(response, 'id="id_new_password2"') 99 | 100 | response = self.client.post( 101 | url, 102 | { 103 | "new_password1": "pass", 104 | "new_password2": "pass", 105 | }, 106 | ) 107 | self.assertRedirects(response, "/ac/login/") 108 | 109 | user = User.objects.get() 110 | self.assertEqual(user.username, "test") 111 | self.assertEqual(user.email, "") 112 | self.assertTrue(user.check_password("pass")) 113 | 114 | time.sleep(1.1) 115 | user.last_login = timezone.now() 116 | user.save() 117 | 118 | response = self.client.get(url, follow=True) 119 | self.assertEqual(_messages(response), ["The link has already been used."]) 120 | self.assertRedirects(response, "/") 121 | 122 | response = self.client.get( 123 | url.replace("/er/", "/er-quick/", 1), 124 | follow=True, 125 | ) 126 | self.assertEqual( 127 | _messages(response), 128 | ["The link is expired. Please request another registration link."], 129 | ) 130 | 131 | response = self.client.get( 132 | url.replace("com:", "ch:", 1), 133 | follow=True, 134 | ) 135 | self.assertEqual( 136 | _messages(response), 137 | [ 138 | "Unable to verify the signature." 139 | " Please request a new registration link." 140 | ], 141 | ) 142 | 143 | user.delete() 144 | response = self.client.get(url, follow=True) 145 | self.assertEqual( 146 | _messages(response), 147 | [ 148 | "Something went wrong while decoding the" 149 | " registration request. Please try again." 150 | ], 151 | ) 152 | 153 | def test_already_existing_email(self): 154 | user = User.objects.create( 155 | username="test@example.com", 156 | email="test@example.com", 157 | ) 158 | response = self.client.post( 159 | "/er/", 160 | { 161 | "email": user.email, 162 | }, 163 | ) 164 | self.assertContains( 165 | response, "Did you want to reset your password?", status_code=200 166 | ) 167 | 168 | def test_user_created_in_the_meantime(self): 169 | response = self.client.post( 170 | "/er/", 171 | { 172 | "email": "test@example.com", 173 | }, 174 | ) 175 | self.assertContains(response, "We sent you an email to test@example.com.") 176 | 177 | self.assertEqual(len(mail.outbox), 1) 178 | body = mail.outbox[0].body 179 | url = unquote([line for line in body.splitlines() if "testserver" in line][0]) 180 | 181 | self.assertTrue("http://testserver/er/test@example.com:::" in url) 182 | 183 | User.objects.create_user("test", "test@example.com", "blaa") 184 | 185 | response = self.client.get(url, follow=True) 186 | self.assertEqual( 187 | _messages(response), 188 | [ 189 | "This email address already exists as an account." 190 | " Did you want to reset your password?" 191 | ], 192 | ) 193 | 194 | def test_crap(self): 195 | user = User.objects.create_user("test", "test@example.com", "pass") 196 | 197 | code = [ 198 | user.email, 199 | str(user.id), 200 | # Intentionally forget the timestamp. 201 | ] 202 | 203 | url = reverse( 204 | "email_registration_confirm", 205 | kwargs={ 206 | "code": get_signer().sign(":".join(code)), 207 | }, 208 | ) 209 | 210 | response = self.client.get(url, follow=True) 211 | self.assertEqual( 212 | _messages(response), 213 | [ 214 | "Something went wrong while decoding the" 215 | " registration request. Please try again." 216 | ], 217 | ) 218 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 3 | from django.urls import include, path 4 | from django.views import generic 5 | 6 | from email_registration.views import email_registration_confirm 7 | 8 | 9 | urlpatterns = [ 10 | path("admin/", admin.site.urls), 11 | path("", generic.TemplateView.as_view(template_name="base.html")), 12 | path("ac/", include("django.contrib.auth.urls")), 13 | path("er/", include("email_registration.urls")), 14 | path("er-quick//", email_registration_confirm, {"max_age": 1}), 15 | ] + staticfiles_urlpatterns() 16 | -------------------------------------------------------------------------------- /tests/tox-update-venvs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ( 4 | cd .tox/py27-1.7.X 5 | bin/pip install -U --editable=git+git://github.com/django/django.git@master#egg=django-dev 6 | ) 7 | ( 8 | cd .tox/py33-1.7.X 9 | bin/pip install -U --editable=git+git://github.com/django/django.git@master#egg=django-dev 10 | ) 11 | -------------------------------------------------------------------------------- /tests/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | setupdir = .. 3 | distribute = False 4 | envlist = 5 | py26-1.4.x, 6 | py26-1.5.x, 7 | py26-1.6.x, 8 | py27-1.4.x, 9 | py27-1.5.x, 10 | py27-1.6.x, 11 | py27-1.7.x, 12 | py33-1.5.x, 13 | py33-1.6.x, 14 | py33-1.7.x, 15 | 16 | [testenv] 17 | downloadcache = {toxworkdir}/_download/ 18 | commands = 19 | {envpython} manage.py test {posargs:testapp} --settings=testapp.settings 20 | setenv = 21 | PYTHONPATH = .:{toxworkdir}/../.. 22 | FEINCMS_RUN_TESTS = 1 23 | 24 | [testenv:py26-1.4.x] 25 | basepython = python2.6 26 | deps = 27 | django==1.4.7 28 | towel==0.2.0 29 | 30 | [testenv:py27-1.4.x] 31 | basepython = python2.7 32 | deps = 33 | django==1.4.7 34 | towel==0.2.0 35 | 36 | [testenv:py26-1.5.x] 37 | basepython = python2.6 38 | deps = 39 | django==1.5.3 40 | towel==0.2.0 41 | 42 | [testenv:py27-1.5.x] 43 | basepython = python2.7 44 | deps = 45 | django==1.5.3 46 | towel==0.2.0 47 | 48 | [testenv:py26-1.6.x] 49 | basepython = python2.6 50 | deps = 51 | Django==1.6.0 52 | towel==0.2.0 53 | 54 | [testenv:py27-1.6.x] 55 | basepython = python2.7 56 | deps = 57 | Django==1.6.0 58 | towel==0.2.0 59 | 60 | [testenv:py27-1.7.x] 61 | basepython = python2.7 62 | deps = 63 | --editable=git+git://github.com/django/django.git@master#egg=django-dev 64 | towel==0.2.0 65 | 66 | [testenv:py33-1.5.x] 67 | basepython = python3.3 68 | deps = 69 | Django==1.5.4 70 | towel==0.2.0 71 | 72 | [testenv:py33-1.6.x] 73 | basepython = python3.3 74 | deps = 75 | Django==1.6.0 76 | towel==0.2.0 77 | 78 | [testenv:py33-1.7.x] 79 | basepython = python3.3 80 | deps = 81 | --editable=git+git://github.com/django/django.git@master#egg=django-dev 82 | towel==0.2.0 83 | --------------------------------------------------------------------------------