Repository: cdriehuys/django-simple-email-auth

Issue:
Allow for password resets
As a user of the app, I would like an easy way to generate password reset tokens and email them to a user.

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 976104a..2434b01 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -7,6 +7,8 @@ In Development
 **************
 
 Features
+* #6: Add `PasswordReset` model to facilitate password resets using verified
+  email addresses.
 * #20: Add `EmailAddress.send_already_verified` method to send a notification to
   the user that their email address has already been verified.
 
diff --git a/email_auth/admin.py b/email_auth/admin.py
index 52b1a87..5061900 100644
--- a/email_auth/admin.py
+++ b/email_auth/admin.py
@@ -63,3 +63,17 @@ class EmailVerificationAdmin(admin.ModelAdmin):
     list_display = ("email", "time_sent", "time_created", "time_updated")
     readonly_fields = ("time_created", "time_sent", "time_updated", "token")
     search_fields = ("email__address", "token")
+
+
+@admin.register(models.PasswordReset)
+class PasswordResetAdmin(admin.ModelAdmin):
+    """
+    Admin for the ``PasswordReset`` model.
+    """
+
+    autocomplete_fields = ("email",)
+    date_hierarchy = "time_created"
+    fields = ("email", "token", "time_sent", "time_created", "time_updated")
+    list_display = ("email", "time_sent", "time_created", "time_updated")
+    readonly_fields = ("time_created", "time_sent", "time_updated", "token")
+    search_fields = ("email__address", "token")
diff --git a/email_auth/migrations/0004_passwordreset.py b/email_auth/migrations/0004_passwordreset.py
new file mode 100644
index 0000000..79fff65
--- /dev/null
+++ b/email_auth/migrations/0004_passwordreset.py
@@ -0,0 +1,70 @@
+# Generated by Django 2.2.2 on 2019-09-18 18:42
+
+from django.db import migrations, models
+import django.db.models.deletion
+import email_auth.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("email_auth", "0003_rm_emailaddress_normalizedaddress")]
+
+    operations = [
+        migrations.CreateModel(
+            name="PasswordReset",
+            fields=[
+                (
+                    "time_created",
+                    models.DateTimeField(
+                        auto_now_add=True,
+                        help_text="The time that the instance was created.",
+                        verbose_name="creation time",
+                    ),
+                ),
+                (
+                    "time_sent",
+                    models.DateTimeField(
+                        blank=True,
+                        help_text="The time that the token was emailed out.",
+                        null=True,
+                        verbose_name="sent time",
+                    ),
+                ),
+                (
+                    "time_updated",
+                    models.DateTimeField(
+                        auto_now=True,
+                        help_text="The time of the last update to the instance.",
+                        verbose_name="last update time",
+                    ),
+                ),
+                (
+                    "token",
+                    models.CharField(
+                        default=email_auth.models.generate_token,
+                        help_text="The random token identifying the password reset.",
+                        max_length=64,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="token",
+                    ),
+                ),
+                (
+                    "email",
+                    models.ForeignKey(
+                        help_text="The email address that the reset token is sent to.",
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="password_resets",
+                        related_query_name="password_reset",
+                        to="email_auth.EmailAddress",
+                        verbose_name="email",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "password reset",
+                "verbose_name_plural": "password resets",
+                "ordering": ("time_created",),
+            },
+        )
+    ]
diff --git a/email_auth/models.py b/email_auth/models.py
index 5d9108c..8735d34 100644
--- a/email_auth/models.py
+++ b/email_auth/models.py
@@ -278,3 +278,84 @@ class EmailVerification(models.Model):
         """
         self.email.verify()
         self.delete()
+
+
+class PasswordReset(models.Model):
+    """
+    A model containing a token that can be used to reset a user's
+    password.
+    """
+
+    email = models.ForeignKey(
+        EmailAddress,
+        help_text=_("The email address that the reset token is sent to."),
+        on_delete=models.CASCADE,
+        related_name="password_resets",
+        related_query_name="password_reset",
+        verbose_name=_("email"),
+    )
+    time_created = models.DateTimeField(
+        auto_now_add=True,
+        help_text=_("The time that the instance was created."),
+        verbose_name=_("creation time"),
+    )
+    time_sent = models.DateTimeField(
+        blank=True,
+        help_text=_("The time that the token was emailed out."),
+        null=True,
+        verbose_name=_("sent time"),
+    )
+    time_updated = models.DateTimeField(
+        auto_now=True,
+        help_text=_("The time of the last update to the instance."),
+        verbose_name=_("last update time"),
+    )
+    token = models.CharField(
+        default=generate_token,
+        help_text=_("The random token identifying the password reset."),
+        max_length=TOKEN_LENGTH,
+        primary_key=True,
+        verbose_name=_("token"),
+    )
+
+    class Meta:
+        ordering = ("time_created",)
+        verbose_name = _("password reset")
+        verbose_name_plural = _("password resets")
+
+    def __repr__(self):
+        """
+        Returns:
+            A complete string representation of the instance, suitable
+            for debugging purposes.
+        """
+        return build_repr(
+            self,
+            ["email", "time_created", "time_sent", "time_updated", "token"],
+        )
+
+    def __str__(self):
+        """
+        Returns:
+            A string describing the email address the password reset is
+            associated with.
+        """
+        return f"Password reset for '{self.email}'"
+
+    def send_email(self):
+        """
+        Send the token authorizing the password reset to the email
+        address associated with the instance.
+        """
+        context = {"password_reset": self}
+
+        email_utils.send_email(
+            context=context,
+            from_email=settings.DEFAULT_FROM_EMAIL,
+            recipient_list=[self.email.address],
+            subject=_("Reset Your Password"),
+            template_name="email_auth/emails/reset-password",
+        )
+
+        self.time_sent = timezone.now()
+        self.save()


Test patch:
diff --git a/email_auth/test/models/test_password_reset_model.py b/email_auth/test/models/test_password_reset_model.py
new file mode 100644
index 0000000..2450f5a
--- /dev/null
+++ b/email_auth/test/models/test_password_reset_model.py
@@ -0,0 +1,77 @@
+from unittest import mock
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.utils import timezone
+
+from email_auth import models
+from email_auth.test import test_utils
+
+
+def test_init_with_all_fields():
+    """
+    Test creating a new password reset with all fields that aren't
+    automatically populated.
+    """
+    password_reset = models.PasswordReset(email=models.EmailAddress())
+
+    assert password_reset.pk
+    assert password_reset.token
+
+
+def test_repr():
+    """
+    The string representation of a token should contain all the fields
+    required to reconstruct it.
+    """
+    user = get_user_model()()
+    email = models.EmailAddress(user=user)
+    password_reset = models.PasswordReset(
+        email=email,
+        time_created=timezone.now(),
+        time_sent=timezone.now(),
+        time_updated=timezone.now(),
+    )
+    expected = test_utils.create_expected_repr(
+        password_reset,
+        ["email", "time_created", "time_sent", "time_updated", "token"],
+    )
+
+    assert repr(password_reset) == expected
+
+
+@mock.patch("email_auth.models.PasswordReset.save", autospec=True)
+@mock.patch("email_auth.models.email_utils.send_email", autospec=True)
+@mock.patch("email_auth.models.timezone.now", autospec=True)
+def test_send_duplicate_notification(mock_now, mock_send_email, mock_save):
+    """
+    This method should send the password reset token to the associated
+    email address and record the send time of the email.
+    """
+    email = models.EmailAddress(address="test@example.com")
+    password_reset = models.PasswordReset(email=email)
+
+    password_reset.send_email()
+
+    assert mock_send_email.call_args[1] == {
+        "context": {"password_reset": password_reset},
+        "from_email": settings.DEFAULT_FROM_EMAIL,
+        "recipient_list": [email.address],
+        "subject": "Reset Your Password",
+        "template_name": "email_auth/emails/reset-password",
+    }
+
+    assert password_reset.time_sent == mock_now.return_value
+    assert password_reset.save.call_count == 1
+
+
+def test_str():
+    """
+    Converting a password reset to a string should return a message
+    including the email address the token is associated with.
+    """
+    email = models.EmailAddress(address="test@example.com")
+    password_reset = models.PasswordReset(email=email)
+    expected = f"Password reset for '{email}'"
+
+    assert str(password_reset) == expected