[RFC PATCH 11/19] templates: Enhance profile view further

Stephen Finucane stephen at that.guru
Thu Aug 12 07:36:57 AEST 2021


Fill in the gaps intentionally missed previously by amalgamating most
user-specific views into the user profile view.

Signed-off-by: Stephen Finucane <stephen at that.guru>
---
 patchwork/forms.py                            | 252 ++++++++++++++---
 patchwork/templates/patchwork/profile.html    |  86 +++++-
 .../patchwork/user-link-confirm.html          |  17 --
 patchwork/templates/patchwork/user-link.html  |  32 ---
 patchwork/tests/views/test_user.py            |  52 ++--
 patchwork/urls.py                             |  14 +-
 patchwork/views/mail.py                       |   6 +-
 patchwork/views/user.py                       | 263 +++++++++++++-----
 8 files changed, 513 insertions(+), 209 deletions(-)
 delete mode 100644 patchwork/templates/patchwork/user-link-confirm.html
 delete mode 100644 patchwork/templates/patchwork/user-link.html

diff --git patchwork/forms.py patchwork/forms.py
index 24322c78..5f8dff96 100644
--- patchwork/forms.py
+++ patchwork/forms.py
@@ -4,10 +4,12 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 
 from django.contrib.auth.models import User
+from django.core import exceptions
 from django import forms
 from django.db.models import Q
 from django.db.utils import ProgrammingError
 
+from patchwork import models
 from patchwork.models import Bundle
 from patchwork.models import Patch
 from patchwork.models import State
@@ -15,13 +17,14 @@ from patchwork.models import UserProfile
 
 
 class RegistrationForm(forms.Form):
+
     first_name = forms.CharField(max_length=30, required=False)
     last_name = forms.CharField(max_length=30, required=False)
-    username = forms.RegexField(regex=r'^\w+$', max_length=30,
-                                label=u'Username')
-    email = forms.EmailField(max_length=100, label=u'Email address')
-    password = forms.CharField(widget=forms.PasswordInput(),
-                               label='Password')
+    username = forms.RegexField(
+        regex=r'^\w+$', max_length=30, label='Username'
+    )
+    email = forms.EmailField(max_length=100, label='Email address')
+    password = forms.CharField(widget=forms.PasswordInput(), label='Password')
 
     def clean_username(self):
         value = self.cleaned_data['username']
@@ -29,8 +32,9 @@ class RegistrationForm(forms.Form):
             User.objects.get(username__iexact=value)
         except User.DoesNotExist:
             return self.cleaned_data['username']
-        raise forms.ValidationError('This username is already taken. '
-                                    'Please choose another.')
+        raise forms.ValidationError(
+            'This username is already taken. Please choose another.'
+        )
 
     def clean_email(self):
         value = self.cleaned_data['email']
@@ -38,21 +42,24 @@ class RegistrationForm(forms.Form):
             user = User.objects.get(email__iexact=value)
         except User.DoesNotExist:
             return self.cleaned_data['email']
-        raise forms.ValidationError('This email address is already in use '
-                                    'for the account "%s".\n' % user.username)
+        raise forms.ValidationError(
+            'This email address is already in use '
+            'for the account "%s".\n' % user.username
+        )
 
     def clean(self):
         return self.cleaned_data
 
 
-class EmailForm(forms.Form):
-    email = forms.EmailField(max_length=200)
-
-
 class BundleForm(forms.ModelForm):
+
     name = forms.RegexField(
-        regex=r'^[^/]+$', min_length=1, max_length=50, label=u'Name',
-        error_messages={'invalid': 'Bundle names can\'t contain slashes'})
+        regex=r'^[^/]+$',
+        min_length=1,
+        max_length=50,
+        label='Name',
+        error_messages={'invalid': 'Bundle names can\'t contain slashes'},
+    )
 
     class Meta:
         model = Bundle
@@ -61,37 +68,180 @@ class BundleForm(forms.ModelForm):
 
 class CreateBundleForm(BundleForm):
 
-    def __init__(self, *args, **kwargs):
-        super(CreateBundleForm, self).__init__(*args, **kwargs)
-
-    class Meta:
-        model = Bundle
-        fields = ['name']
-
     def clean_name(self):
         name = self.cleaned_data['name']
-        count = Bundle.objects.filter(owner=self.instance.owner,
-                                      name=name).count()
+        count = Bundle.objects.filter(
+            owner=self.instance.owner, name=name
+        ).count()
         if count > 0:
-            raise forms.ValidationError('A bundle called %s already exists'
-                                        % name)
+            raise forms.ValidationError(
+                'A bundle called %s already exists' % name
+            )
         return name
 
+    class Meta:
+        model = Bundle
+        fields = ['name']
+
 
 class DeleteBundleForm(forms.Form):
+
     name = 'deletebundleform'
     form_name = forms.CharField(initial=name, widget=forms.HiddenInput)
     bundle_id = forms.IntegerField(widget=forms.HiddenInput)
 
 
+class UserForm(forms.ModelForm):
+
+    name = 'user-form'
+
+    class Meta:
+        model = User
+        fields = ['first_name', 'last_name']
+
+
+class EmailForm(forms.Form):
+
+    email = forms.EmailField(max_length=200)
+
+
+class UserLinkEmailForm(forms.Form):
+
+    name = 'user-link-email-form'
+
+    email = forms.EmailField(max_length=200)
+
+    def __init__(self, user, *args, **kwargs):
+        self.user = user
+        super().__init__(*args, **kwargs)
+
+    def clean_email(self):
+        email = self.cleaned_data['email']
+
+        # ensure this email is not already linked to our account
+        try:
+            models.Person.objects.get(email=email, user=self.user)
+        except models.Person.DoesNotExist:
+            pass
+        else:
+            raise exceptions.ValidationError(
+                "That email is already linked to your account."
+            )
+
+        return email
+
+
+class UserUnlinkEmailForm(forms.Form):
+
+    name = 'user-unlink-email-form'
+
+    email = forms.EmailField(max_length=200)
+
+    def __init__(self, user, *args, **kwargs):
+        self.user = user
+        super().__init__(*args, **kwargs)
+
+    def clean_email(self):
+        email = self.cleaned_data['email']
+
+        # ensure we're not unlinking the final email
+        if email == self.user.email:
+            raise exceptions.ValidationError(
+                "You can't unlink your primary email."
+            )
+
+        # and that this email is in fact our email to unlink
+        try:
+            models.Person.objects.get(email=email, user=self.user)
+        except models.Person.DoesNotExist:
+            raise exceptions.ValidationError(
+                "That email is not linked to your account."
+            )
+
+        return email
+
+
+class UserPrimaryEmailForm(forms.ModelForm):
+
+    name = 'user-primary-email-form'
+
+    class Meta:
+        model = User
+        fields = ['email']
+
+
+class UserEmailOptinForm(forms.Form):
+
+    name = 'user-email-optin-form'
+
+    email = forms.EmailField(max_length=200)
+
+    def __init__(self, user, *args, **kwargs):
+        self.user = user
+        super().__init__(*args, **kwargs)
+
+    def clean_email(self):
+        email = self.cleaned_data['email']
+
+        # ensure this email is linked to our account
+        try:
+            models.Person.objects.get(email=email, user=self.user)
+        except models.Person.DoesNotExist:
+            raise exceptions.ValidationError(
+                "You can't configure mail preferences for an email that is "
+                "not associated with your account."
+            )
+
+        return email
+
+
+class UserEmailOptoutForm(forms.Form):
+
+    name = 'user-email-optout-form'
+
+    email = forms.EmailField(max_length=200)
+
+    def __init__(self, user, *args, **kwargs):
+        self.user = user
+        super().__init__(*args, **kwargs)
+
+    def clean_email(self):
+        email = self.cleaned_data['email']
+
+        # ensure this email is linked to our account
+        try:
+            models.Person.objects.get(email=email, user=self.user)
+        except models.Person.DoesNotExist:
+            raise exceptions.ValidationError(
+                "You can't configure mail preferences for an email that is "
+                "not associated with your account"
+            )
+
+        try:
+            models.EmailOptout.objects.get(email=email)
+        except models.EmailOptout.DoesNotExist:
+            pass
+        else:
+            raise exceptions.ValidationError(
+                "You have already opted out of emails to this address."
+            )
+
+        return email
+
+
 class UserProfileForm(forms.ModelForm):
 
+    name = 'user-profile-form'
+    show_ids = forms.TypedChoiceField(
+        coerce=lambda x: x == 'yes',
+        choices=(('yes', 'Yes'), ('no', 'No')),
+        widget=forms.RadioSelect,
+    )
+
     class Meta:
         model = UserProfile
         fields = ['items_per_page', 'show_ids']
-        labels = {
-            'show_ids': 'Show Patch IDs:'
-        }
+        labels = {'show_ids': 'Show Patch IDs:'}
 
 
 def _get_delegate_qs(project, instance=None):
@@ -101,20 +251,23 @@ def _get_delegate_qs(project, instance=None):
     if not project:
         raise ValueError('Expected a project')
 
-    q = Q(profile__in=UserProfile.objects
-          .filter(maintainer_projects=project)
-          .values('pk').query)
+    q = Q(
+        profile__in=UserProfile.objects.filter(maintainer_projects=project)
+        .values('pk')
+        .query
+    )
     if instance and instance.delegate:
         q = q | Q(username=instance.delegate)
+
     return User.objects.complex_filter(q)
 
 
 class PatchForm(forms.ModelForm):
-
     def __init__(self, instance=None, project=None, *args, **kwargs):
         super(PatchForm, self).__init__(instance=instance, *args, **kwargs)
         self.fields['delegate'] = forms.ModelChoiceField(
-            queryset=_get_delegate_qs(project, instance), required=False)
+            queryset=_get_delegate_qs(project, instance), required=False
+        )
 
     class Meta:
         model = Patch
@@ -122,12 +275,14 @@ class PatchForm(forms.ModelForm):
 
 
 class OptionalModelChoiceField(forms.ModelChoiceField):
+
     no_change_choice = ('*', 'no change')
     to_field_name = None
 
     def __init__(self, *args, **kwargs):
         super(OptionalModelChoiceField, self).__init__(
-            initial=self.no_change_choice[0], *args, **kwargs)
+            initial=self.no_change_choice[0], *args, **kwargs
+        )
 
     def _get_choices(self):
         # _get_choices queries the database, which can fail if the db
@@ -135,7 +290,8 @@ class OptionalModelChoiceField(forms.ModelChoiceField):
         # set of choices for now.
         try:
             choices = list(
-                super(OptionalModelChoiceField, self)._get_choices())
+                super(OptionalModelChoiceField, self)._get_choices()
+            )
         except ProgrammingError:
             choices = []
         choices.append(self.no_change_choice)
@@ -153,31 +309,39 @@ class OptionalModelChoiceField(forms.ModelChoiceField):
 
 
 class OptionalBooleanField(forms.TypedChoiceField):
-
     def is_no_change(self, value):
         return value == self.empty_value
 
 
 class MultiplePatchForm(forms.Form):
+
     action = 'update'
     archived = OptionalBooleanField(
-        choices=[('*', 'no change'), ('True', 'Archived'),
-                 ('False', 'Unarchived')],
+        choices=[
+            ('*', 'no change'),
+            ('True', 'Archived'),
+            ('False', 'Unarchived'),
+        ],
         coerce=lambda x: x == 'True',
-        empty_value='*')
+        empty_value='*',
+    )
 
     def __init__(self, project, *args, **kwargs):
         super(MultiplePatchForm, self).__init__(*args, **kwargs)
         self.fields['delegate'] = OptionalModelChoiceField(
-            queryset=_get_delegate_qs(project=project), required=False)
+            queryset=_get_delegate_qs(project=project), required=False
+        )
         self.fields['state'] = OptionalModelChoiceField(
-            queryset=State.objects.all())
+            queryset=State.objects.all()
+        )
 
     def save(self, instance, commit=True):
         opts = instance.__class__._meta
         if self.errors:
-            raise ValueError("The %s could not be changed because the data "
-                             "didn't validate." % opts.object_name)
+            raise ValueError(
+                "The %s could not be changed because the data "
+                "didn't validate." % opts.object_name
+            )
         data = self.cleaned_data
         # Update the instance
         for f in opts.fields:
diff --git patchwork/templates/patchwork/profile.html patchwork/templates/patchwork/profile.html
index 7a0b54fe..a5a57150 100644
--- patchwork/templates/patchwork/profile.html
+++ patchwork/templates/patchwork/profile.html
@@ -3,6 +3,20 @@
 {% block title %}{{ user.username }}{% endblock %}
 
 {% block body %}
+{% for message in messages %}
+{% if message.tags == 'success' %}
+<div class="notification is-success">
+{% elif message.tags == 'warning' %}
+<div class="notification is-warning">
+{% elif message.tags == 'error' %}
+<div class="notification is-danger">
+{% else %}
+<div class="notification">
+{% endif %}
+  {{ message }}
+  <button class="delete" onclick="dismiss(this);"></button>
+</div>
+{% endfor %}
 <div class="container" style="margin-top: 1rem;">
   <div class="columns">
     <div class="column is-3">
@@ -100,7 +114,6 @@
         Settings
       </h1>
 
-{# TODO: Add view to enable this #}
       <section class="block">
         <h2 id="profile" class="title is-4">
           <a href="#profile" title="Permalink to this section">#</a>
@@ -108,6 +121,7 @@
         </h2>
         <form method="post">
           {% csrf_token %}
+          <input type="hidden" name="form_name" value="user-form">
           <div class="field">
             <label for="id_username" class="label">
               Username
@@ -143,6 +157,12 @@
           <a href="#linked-emails" title="Permalink to this section">#</a>
           Linked emails
         </h2>
+{% if user_link_email_form.non_field_errors %}
+        <div class="notification is-danger is-light">
+          <button class="delete" onclick="dismiss(this);"></button>
+          {{ user_link_email_form.non_field_errors }}
+        </div>
+{% endif %}
 {% for email in linked_emails %}
         <div class="card">
           <div class="card-content">
@@ -155,14 +175,17 @@
               </div>
 {% if user.email != email.email %}
               <div class="column is-narrow">
-                <form method="post" action="{% url 'user-unlink' person_id=email.id %}">
+                <form method="post">
                   {% csrf_token %}
+                  <input type="hidden" name="form_name" value="user-unlink-email-form">
+                  <input type="hidden" name="email" value="{{ email.email }}">
                   <button class="button is-danger">Unlink</button>
                 </form>
               </div>
-{# TODO: Add view to enable this #}
               <div class="column is-narrow">
                 <form method="post">
+                  <input type="hidden" name="form_name" value="user-primary-email-form">
+                  <input type="hidden" name="email" value="{{ email.email }}">
                   {% csrf_token %}
                   <button class="button is-info">Make primary</button>
                 </form>
@@ -170,14 +193,16 @@
 {% endif %}
               <div class="column is-narrow">
 {% if email.is_optout %}
-                <form method="post" action="{% url 'mail-optin' %}">
+                <form method="post">
                   {% csrf_token %}
+                  <input type="hidden" name="form_name" value="user-email-optin-form">
                   <input type="hidden" name="email" value="{{ email.email }}"/>
                   <button class="button is-info is-right">Opt-in</button>
                 </form>
 {% else %}
-                <form method="post" action="{% url 'mail-optout' %}">
+                <form method="post">
                   {% csrf_token %}
+                  <input type="hidden" name="form_name" value="user-email-optout-form">
                   <input type="hidden" name="email" value="{{ email.email }}"/>
                   <button class="button is-info">Opt-out</button>
                 </form>
@@ -189,14 +214,18 @@
 {% endfor %}
         <div class="block"></div>
         <div class="block">
-          <form class="block" method="post" action="{% url 'user-link' %}">
+          <form class="block" method="post">
             {% csrf_token %}
+            <input type="hidden" name="form_name" value="user-link-email-form">
             <label for="id_email" class="label">
               Add email address
             </label>
             <div class="field is-grouped">
               <div class="control">
-                <input id="id_email" type="email" name="email" placeholder="e.g. bobsmith at example.com" class="input" required>
+                <input id="id_email" type="email" name="email" placeholder="e.g. bobsmith at example.com" class="input" value="{{ user_link_email_form.email.value|default:'' }}" required>
+{% for error in user_link_email_form.email.errors %}
+                <p class="help is-danger">{{ error }}</p>
+{% endfor %}
               </div>
               <div class="control">
                 <button class="button is-info">
@@ -213,8 +242,15 @@
           <a href="#profile-settings" title="Permalink to this section">#</a>
           Profile settings
         </h2>
+{% if user_profile_form.non_field_errors %}
+        <div class="notification is-danger is-light">
+          <button class="delete" onclick="dismiss(this);"></button>
+          {{ user_profile_form.non_field_errors }}
+        </div>
+{% endif %}
         <form class="block" method="post">
           {% csrf_token %}
+          <input type="hidden" name="form_name" value="user-profile-form">
           <div class="field">
             <label for="id_items_per_page" class="label">
               Items per page
@@ -222,6 +258,9 @@
             <div class="control">
               <input id="id_items_per_page" type="number" name="items_per_page" class="input" value="{{ user.profile.items_per_page }}" required>
               <p class="help">Number of items to display per page</p>
+{% for error in user_profile_form.items_per_page.errors %}
+              <p class="help is-danger">{{ error }}</p>
+{% endfor %}
             </div>
           </div>
           <div class="field">
@@ -230,14 +269,17 @@
             </p>
             <div class="control">
               <label class="radio">
-                <input type="radio" name="show_ids">
+                <input type="radio" name="show_ids" value="yes" {% if user.profile.show_ids %}checked{% endif %}>
                 Yes
               </label>
               <label class="radio">
-                <input type="radio" name="show_ids">
+                <input type="radio" name="show_ids" value="no" {% if not user.profile.show_ids %}checked{% endif %}>
                 No
               </label>
               <p class="help">Show click-to-copy patch IDs in the list view</p>
+{% for error in user_profile_form.show_ids.errors %}
+              <p class="help is-danger">{{ error }}</p>
+{% endfor %}
             </div>
           </div>
           <div class="control">
@@ -251,8 +293,15 @@
           <a href="#security" title="Permalink to this section">#</a>
           Security
         </h2>
-        <form class="block" method="post" action="{% url 'password_change' %}">
+{% if user_password_form.non_field_errors %}
+        <div class="notification is-danger is-light">
+          <button class="delete" onclick="dismiss(this);"></button>
+          {{ user_password_form.non_field_errors }}
+        </div>
+{% endif %}
+        <form class="block" method="post">
           {% csrf_token %}
+          <input type="hidden" name="form_name" value="user-password-form">
           <div class="field">
             <label for="id_old_password" class="label">
               Current password
@@ -260,22 +309,31 @@
             <div class="control">
               <input id="id_old_password" type="password" name="old_password" class="input" required>
             </div>
+{% for error in user_password_form.old_password.errors %}
+            <p class="help is-danger">{{ error }}</p>
+{% endfor %}
           </div>
           <div class="field">
             <label for="id_new_password1" class="label">
               New password
             </label>
             <div class="control">
-              <input id="id_new_password1" type="password" name="new_password1" class="input" required>
+              <input id="id_new_password1" type="password" name="new_password1" class="input" autocomplete="new-password" required>
             </div>
+{% for error in user_password_form.new_password1.errors %}
+            <p class="help is-danger">{{ error }}</p>
+{% endfor %}
           </div>
           <div class="field">
             <label for="id_new_password2" class="label">
               Confirm password
             </label>
             <div class="control">
-              <input id="id_new_password2" type="password" name="new_password2" class="input" required>
+              <input id="id_new_password2" type="password" name="new_password2" class="input" autocomplete="new-password" required>
             </div>
+{% for error in user_password_form.new_password2.errors %}
+            <p class="help is-danger">{{ error }}</p>
+{% endfor %}
           </div>
           <div class="control">
             <button class="button is-primary is-disabled">Update password</button>
@@ -333,5 +391,9 @@ document.addEventListener('DOMContentLoaded', () => {
     });
   }
 });
+
+function dismiss(el){
+  el.parentNode.style.display = 'none';
+};
 </script>
 {% endblock %}
diff --git patchwork/templates/patchwork/user-link-confirm.html patchwork/templates/patchwork/user-link-confirm.html
deleted file mode 100644
index aa91fcbd..00000000
--- patchwork/templates/patchwork/user-link-confirm.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}Link accounts{% endblock %}
-{% block heading %}Link accounts for {{ user.username }}{% endblock %}
-
-{% block body %}
-
-{% if errors %}
-<p>{{ errors }}</p>
-{% else %}
-<p>
-  You have successfully linked the email address {{ person.email }} to your
-  Patchwork account
-</p>
-{% endif %}
-<p>Back to <a href="{% url 'user-profile' %}">your profile</a>.</p>
-{% endblock %}
diff --git patchwork/templates/patchwork/user-link.html patchwork/templates/patchwork/user-link.html
deleted file mode 100644
index 8b3fe8f6..00000000
--- patchwork/templates/patchwork/user-link.html
+++ /dev/null
@@ -1,32 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}Link accounts{% endblock %}
-{% block heading %}Link accounts for {{ user.username }}{% endblock %}
-
-{% block body %}
-{% if confirmation and not error %}
-<p>
-  A confirmation email has been sent to {{ confirmation.email }}.
-  Click on the link provided in the email to confirm that this address
-  belongs to you.
-</p>
-{% else %}
-{% if form.errors %}
-<p>
-  There was an error submitting your link request.
-</p>
-{{ form.non_field_errors }}
-{% endif %}
-{% if error %}
-<ul class="errorlist">
-  <li>{{ error }}</li>
-</ul>
-{% endif %}
-
-<form action="{% url 'user-link' %}" method="post">
-  {% csrf_token %}
-  {{ linkform.email.errors }}
-  Link an email address: {{ linkform.email }}
-</form>
-{% endif %}
-{% endblock %}
diff --git patchwork/tests/views/test_user.py patchwork/tests/views/test_user.py
index 22bb9839..abd9e583 100644
--- patchwork/tests/views/test_user.py
+++ patchwork/tests/views/test_user.py
@@ -243,29 +243,40 @@ class UserLinkTest(_UserTestCase):
         self.secondary_email = _generate_secondary_email(self.user)
 
     def test_user_person_request_form(self):
-        response = self.client.get(reverse('user-link'))
-        self.assertEqual(response.status_code, 200)
-        self.assertTrue(response.context['linkform'])
-
-    def test_user_person_request_empty(self):
-        response = self.client.post(reverse('user-link'), {'email': ''})
+        response = self.client.get(reverse('user-profile'))
         self.assertEqual(response.status_code, 200)
-        self.assertTrue(response.context['linkform'])
-        self.assertFormError(response, 'linkform', 'email',
-                             'This field is required.')
+        self.assertTrue(response.context['user_link_email_form'])
 
-    def test_user_person_request_invalid(self):
-        response = self.client.post(reverse('user-link'), {'email': 'foo'})
+    def _test_user_link_error(self, email, error):
+        response = self.client.post(
+            reverse('user-profile'),
+            {'form_name': 'user-link-email-form', 'email': email},
+        )
         self.assertEqual(response.status_code, 200)
-        self.assertTrue(response.context['linkform'])
-        self.assertFormError(response, 'linkform', 'email',
-                             error_strings['email'])
-
-    def test_user_person_request_valid(self):
-        response = self.client.post(reverse('user-link'),
-                                    {'email': self.secondary_email})
+        self.assertTrue(response.context['user_link_email_form'])
+        self.assertFormError(
+            response, 'user_link_email_form', 'email', error)
+
+    def test_user_link_empty_request(self):
+        self._test_user_link_error('', 'This field is required.')
+
+    def test_user_link_invalid_email(self):
+        self._test_user_link_error('foo', error_strings['email'])
+
+    def test_user_link_email_already_linked(self):
+        self._test_user_link_error(
+            self.user.email, 'That email is already linked to your account.')
+
+    def test_user_link_success(self):
+        response = self.client.post(
+            reverse('user-profile'),
+            {
+                'form_name': 'user-link-email-form',
+                'email': self.secondary_email,
+            },
+        )
         self.assertEqual(response.status_code, 200)
-        self.assertTrue(response.context['confirmation'])
+        self.assertTrue(response.context['user_link_email_form'])
 
         # check that we have a confirmation saved
         self.assertEqual(EmailConfirmation.objects.count(), 1)
@@ -283,8 +294,7 @@ class UserLinkTest(_UserTestCase):
 
         # ...and that the URL is valid
         response = self.client.get(_confirmation_url(conf))
-        self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, 'patchwork/user-link-confirm.html')
+        self.assertRedirects(response, reverse('user-profile'))
 
 
 class ConfirmationTest(TestCase):
diff --git patchwork/urls.py patchwork/urls.py
index 0c557f78..fa14f40c 100644
--- patchwork/urls.py
+++ patchwork/urls.py
@@ -128,19 +128,7 @@ urlpatterns = [
     path('user/todo/', user_views.todo_lists, name='user-todos'),
     path('user/todo/<project_id>/', user_views.todo_list, name='user-todo'),
     path('user/bundles/', bundle_views.bundle_list, name='user-bundles'),
-    path('user/link/', user_views.link, name='user-link'),
-    path('user/unlink/<person_id>/', user_views.unlink, name='user-unlink'),
-    # password change
-    path(
-        'user/password-change/',
-        auth_views.PasswordChangeView.as_view(),
-        name='password_change',
-    ),
-    path(
-        'user/password-change/done/',
-        auth_views.PasswordChangeDoneView.as_view(),
-        name='password_change_done',
-    ),
+    # password reset
     path(
         'user/password-reset/',
         auth_views.PasswordResetView.as_view(),
diff --git patchwork/views/mail.py patchwork/views/mail.py
index 8b31fc9e..1a2019eb 100644
--- patchwork/views/mail.py
+++ patchwork/views/mail.py
@@ -86,8 +86,8 @@ def _optinout(request, action):
 
     email = form.cleaned_data['email']
     if (
-        action == 'optin'
-        and EmailOptout.objects.filter(email=email).count() == 0
+        action == 'optin' and
+        EmailOptout.objects.filter(email=email).count() == 0
     ):
         context['error'] = (
             "The email address %s is not on the patchwork "
@@ -109,7 +109,7 @@ def _optinout(request, action):
     except smtplib.SMTPException:
         context['confirmation'] = None
         context['error'] = (
-            'An error occurred during confirmation . '
+            'An error occurred during confirmation. '
             'Please try again later.'
         )
         context['admins'] = conf_settings.ADMINS
diff --git patchwork/views/user.py patchwork/views/user.py
index 7bf6377e..d1a1180e 100644
--- patchwork/views/user.py
+++ patchwork/views/user.py
@@ -6,7 +6,10 @@
 import smtplib
 
 from django.contrib import auth
+from django.contrib.auth import forms as auth_forms
 from django.contrib.auth.decorators import login_required
+from django.contrib.auth import update_session_auth_hash
+from django.contrib import messages
 from django.contrib.sites.models import Site
 from django.conf import settings
 from django.core.mail import send_mail
@@ -17,8 +20,12 @@ from django.template.loader import render_to_string
 from django.urls import reverse
 
 from patchwork.filters import DelegateFilter
-from patchwork.forms import EmailForm
+from patchwork import forms
 from patchwork.forms import RegistrationForm
+from patchwork.forms import UserLinkEmailForm
+from patchwork.forms import UserUnlinkEmailForm
+from patchwork.forms import UserPrimaryEmailForm
+from patchwork.forms import UserForm
 from patchwork.forms import UserProfileForm
 from patchwork.models import EmailConfirmation
 from patchwork.models import EmailOptout
@@ -96,20 +103,199 @@ def register_confirm(request, conf):
     return render(request, 'patchwork/registration-confirm.html')
 
 
+def _opt_in(request, email):
+    conf = EmailConfirmation(type='optin', email=email)
+    conf.save()
+
+    context = {'confirmation': conf}
+    subject = render_to_string('patchwork/mails/optin-request-subject.txt')
+    message = render_to_string(
+        'patchwork/mails/optin-request.txt', context, request=request)
+
+    try:
+        send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [email])
+    except smtplib.SMTPException:
+        messages.error(
+            request,
+            'An error occurred while submitting this request. '
+            'Please contact an administrator.'
+        )
+        return False
+
+    return True
+
+
+def _opt_out(request, email):
+    conf = EmailConfirmation(type='optout', email=email)
+    conf.save()
+
+    context = {'confirmation': conf}
+    subject = render_to_string('patchwork/mails/optout-request-subject.txt')
+    message = render_to_string(
+        'patchwork/mails/optout-request.txt', context, request=request)
+
+    try:
+        send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [email])
+    except smtplib.SMTPException:
+        messages.error(
+            request,
+            'An error occurred while submitting this request. '
+            'Please contact an administrator.'
+        )
+        return False
+
+    return True
+
+
+def _send_confirmation_email(request, email):
+    conf = EmailConfirmation(type='userperson', user=request.user, email=email)
+    conf.save()
+
+    context = {'confirmation': conf}
+    subject = render_to_string('patchwork/mails/user-link-subject.txt')
+    message = render_to_string(
+        'patchwork/mails/user-link.txt',
+        context,
+        request=request,
+    )
+
+    try:
+        send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [email])
+    except smtplib.SMTPException:
+        messages.error(
+            request,
+            'An error occurred while submitting this request. '
+            'Please contact an administrator.'
+        )
+        return False
+
+    return True
+
+
 @login_required
 def profile(request):
+    user_link_email_form = UserLinkEmailForm(user=request.user)
+    user_unlink_email_form = UserUnlinkEmailForm(user=request.user)
+    user_primary_email_form = UserPrimaryEmailForm(instance=request.user)
+    user_email_optin_form = forms.UserEmailOptinForm(user=request.user)
+    user_email_optout_form = forms.UserEmailOptoutForm(user=request.user)
+    user_form = UserForm(instance=request.user)
+    user_password_form = auth_forms.PasswordChangeForm(user=request.user)
+    user_profile_form = UserProfileForm(instance=request.user.profile)
+
     if request.method == 'POST':
-        form = UserProfileForm(
-            instance=request.user.profile, data=request.POST
-        )
-        if form.is_valid():
-            form.save()
-    else:
-        form = UserProfileForm(instance=request.user.profile)
+        form_name = request.POST.get('form_name', '')
+        if form_name == UserLinkEmailForm.name:
+            user_link_email_form = UserLinkEmailForm(
+                user=request.user, data=request.POST
+            )
+            if user_link_email_form.is_valid():
+                if _send_confirmation_email(
+                    request, user_link_email_form.cleaned_data['email'],
+                ):
+                    messages.success(
+                        request,
+                        'Added new email. Check your email for confirmation.',
+                    )
+                    return HttpResponseRedirect(reverse('user-profile'))
+
+            messages.error(request, 'Error linking new email.')
+        elif form_name == UserUnlinkEmailForm.name:
+            user_unlink_email_form = UserUnlinkEmailForm(
+                user=request.user, data=request.POST
+            )
+            if user_unlink_email_form.is_valid():
+                person = get_object_or_404(
+                    Person, email=user_unlink_email_form.cleaned_data['email']
+                )
+                person.user = None
+                person.save()
+                messages.success(request, 'Unlinked email.')
+                return HttpResponseRedirect(reverse('user-profile'))
+
+            messages.error(request, 'Error unlinking email.')
+        elif form_name == UserPrimaryEmailForm.name:
+            user_primary_email_form = UserPrimaryEmailForm(
+                instance=request.user, data=request.POST
+            )
+            if user_primary_email_form.is_valid():
+                user_primary_email_form.save()
+                messages.success(request, 'Primary email updated.')
+                return HttpResponseRedirect(reverse('user-profile'))
+
+            messages.error(request, 'Error updating primary email.')
+        elif form_name == forms.UserEmailOptinForm.name:
+            user_email_optin_form = forms.UserEmailOptinForm(
+                user=request.user, data=request.POST)
+            if user_email_optin_form.is_valid():
+                if _opt_in(
+                    request, user_email_optin_form.cleaned_data['email'],
+                ):
+                    messages.success(
+                        request,
+                        'Requested opt-in to email from Patchwork. '
+                        'Check your email for confirmation.',
+                    )
+                    return HttpResponseRedirect(reverse('user-profile'))
+
+            messages.error(request, 'Error opting into email.')
+        elif form_name == forms.UserEmailOptoutForm.name:
+            user_email_optout_form = forms.UserEmailOptoutForm(
+                user=request.user, data=request.POST)
+            if user_email_optout_form.is_valid():
+                if _opt_out(
+                    request, user_email_optout_form.cleaned_data['email'],
+                ):
+                    messages.success(
+                        request,
+                        'Requested opt-out from email from Patchwork. '
+                        'Check your email for confirmation.',
+                    )
+                    return HttpResponseRedirect(reverse('user-profile'))
+
+            messages.error(request, 'Error opting out of email.')
+        elif form_name == UserForm.name:
+            user_form = UserForm(instance=request.user, data=request.POST)
+            if user_form.is_valid():
+                user_form.save()
+                messages.success(request, 'Name updated.')
+                return HttpResponseRedirect(reverse('user-profile'))
+
+            messages.error(request, 'Error updating name.')
+        elif form_name == 'user-password-form':
+            user_password_form = auth_forms.PasswordChangeForm(
+                user=request.user, data=request.POST
+            )
+            if user_password_form.is_valid():
+                user_password_form.save()
+                update_session_auth_hash(request, request.user)
+                messages.success(request, 'Password updated.')
+                return HttpResponseRedirect(reverse('user-profile'))
+
+            messages.error(request, 'Error updating password.')
+        elif form_name == UserProfileForm.name:
+            user_profile_form = UserProfileForm(
+                instance=request.user.profile, data=request.POST
+            )
+            if user_profile_form.is_valid():
+                user_profile_form.save()
+                messages.success(request, 'Preferences updated.')
+                return HttpResponseRedirect(reverse('user-profile'))
+
+            messages.error(request, 'Error updating preferences.')
+        else:
+            messages.error(request, 'Unrecognized request')
 
     context = {
         'bundles': request.user.bundles.all(),
-        'profileform': form,
+        'user_link_email_form': user_link_email_form,
+        'user_unlink_email_form': user_unlink_email_form,
+        'user_primary_email_form': user_primary_email_form,
+        'user_email_optin_form': user_email_optin_form,
+        'user_email_optout_form': user_email_optout_form,
+        'user_form': user_form,
+        'user_password_form': user_password_form,
+        'user_profile_form': user_profile_form,
     }
 
     # This looks unsafe but is actually fine: it just gets the names
@@ -127,55 +313,11 @@ def profile(request):
         select={'is_optout': optout_query}
     )
     context['linked_emails'] = people
-    context['linkform'] = EmailForm()
     context['api_token'] = request.user.profile.token
-    if settings.ENABLE_REST_API:
-        context['rest_api_enabled'] = True
 
     return render(request, 'patchwork/profile.html', context)
 
 
- at login_required
-def link(request):
-    context = {}
-
-    if request.method == 'POST':
-        form = EmailForm(request.POST)
-        if form.is_valid():
-            conf = EmailConfirmation(
-                type='userperson',
-                user=request.user,
-                email=form.cleaned_data['email'],
-            )
-            conf.save()
-
-            context['confirmation'] = conf
-
-            subject = render_to_string('patchwork/mails/user-link-subject.txt')
-            message = render_to_string(
-                'patchwork/mails/user-link.txt', context, request=request
-            )
-            try:
-                send_mail(
-                    subject,
-                    message,
-                    settings.DEFAULT_FROM_EMAIL,
-                    [form.cleaned_data['email']],
-                )
-            except smtplib.SMTPException:
-                context['confirmation'] = None
-                context['error'] = (
-                    'An error occurred during confirmation. '
-                    'Please try again later'
-                )
-    else:
-        form = EmailForm()
-
-    context['linkform'] = form
-
-    return render(request, 'patchwork/user-link.html', context)
-
-
 @login_required
 def link_confirm(request, conf):
     try:
@@ -187,20 +329,7 @@ def link_confirm(request, conf):
     person.save()
     conf.deactivate()
 
-    context = {
-        'person': person,
-    }
-
-    return render(request, 'patchwork/user-link-confirm.html', context)
-
-
- at login_required
-def unlink(request, person_id):
-    person = get_object_or_404(Person, id=person_id)
-
-    if request.method == 'POST' and person.email != request.user.email:
-        person.user = None
-        person.save()
+    messages.success(request, 'Successfully linked email to account.')
 
     return HttpResponseRedirect(reverse('user-profile'))
 
-- 
2.31.1



More information about the Patchwork mailing list