From patchwork Wed Sep 1 16:57:48 2021 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Stephen Finucane X-Patchwork-Id: 1523348 Return-Path: X-Original-To: incoming@patchwork.ozlabs.org Delivered-To: patchwork-incoming@bilbo.ozlabs.org Authentication-Results: ozlabs.org; dkim=fail reason="key not found in DNS" header.d=that.guru header.i=@that.guru header.a=rsa-sha256 header.s=x header.b=Gi7GSejh; dkim-atps=neutral Authentication-Results: ozlabs.org; spf=pass (sender SPF authorized) smtp.mailfrom=lists.ozlabs.org (client-ip=2404:9400:2:0:216:3eff:fee1:b9f1; helo=lists.ozlabs.org; envelope-from=patchwork-bounces+incoming=patchwork.ozlabs.org@lists.ozlabs.org; receiver=) Received: from lists.ozlabs.org (lists.ozlabs.org [IPv6:2404:9400:2:0:216:3eff:fee1:b9f1]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (4096 bits)) (No client certificate requested) by ozlabs.org (Postfix) with ESMTPS id 4H09jH3TVvz9sXN for ; Thu, 2 Sep 2021 03:17:23 +1000 (AEST) Received: from boromir.ozlabs.org (localhost [IPv6:::1]) by lists.ozlabs.org (Postfix) with ESMTP id 4H09jH2JPMz30Hr for ; Thu, 2 Sep 2021 03:17:23 +1000 (AEST) Authentication-Results: lists.ozlabs.org; dkim=fail reason="key not found in DNS" header.d=that.guru header.i=@that.guru header.a=rsa-sha256 header.s=x header.b=Gi7GSejh; dkim-atps=neutral X-Original-To: patchwork@lists.ozlabs.org Delivered-To: patchwork@lists.ozlabs.org Authentication-Results: lists.ozlabs.org; spf=none (no SPF record) smtp.mailfrom=that.guru (client-ip=136.175.108.57; helo=mail-108-mta57.mxroute.com; envelope-from=stephen@that.guru; receiver=) Authentication-Results: lists.ozlabs.org; dkim=fail reason="key not found in DNS" header.d=that.guru header.i=@that.guru header.a=rsa-sha256 header.s=x header.b=Gi7GSejh; dkim-atps=neutral Received: from mail-108-mta57.mxroute.com (mail-108-mta57.mxroute.com [136.175.108.57]) (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) (No client certificate requested) by lists.ozlabs.org (Postfix) with ESMTPS id 4H09hm4G4bz2yKT for ; Thu, 2 Sep 2021 03:16:55 +1000 (AEST) Received: from filter004.mxroute.com ([149.28.56.236] filter004.mxroute.com) (Authenticated sender: mN4UYu2MZsgR) by mail-108-mta57.mxroute.com (ZoneMTA) with ESMTPSA id 17ba25d091c00074ba.001 for (version=TLSv1/SSLv3 cipher=ECDHE-RSA-AES128-GCM-SHA256); Wed, 01 Sep 2021 17:16:51 +0000 X-Zone-Loop: 96e29c6fdc920aeeeeb4a796ee9f1455d1f059a6bcf3 X-Originating-IP: [149.28.56.236] DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=that.guru; s=x; h=Content-Transfer-Encoding:MIME-Version:References:In-Reply-To: Message-Id:Date:Subject:Cc:To:From:Sender:Reply-To:Content-Type:Content-ID: Content-Description:Resent-Date:Resent-From:Resent-Sender:Resent-To:Resent-Cc :Resent-Message-ID:List-Id:List-Help:List-Unsubscribe:List-Subscribe: List-Post:List-Owner:List-Archive; bh=7jZcUEBWnp0gOugu1Rhdm8wRNlmYBhoLtJ/mGrY2Ais=; b=Gi7GSejhdSmdLB1YxJbu6xSana 7XmbALILNe4tLIZL7z5EysZf/rpRmLXyPmu/A2GIpQL8QpGk0HhMisSfWR3GWAndHr3FsCelEHypI vsrxetBOgOcxO8QE+t3aLINOs32VsxCwii6+66w1QjMJkuButZVxOLymlfEIS9cW+HMknCX6waVzV 2gDKF2aQrMJXY8PBLek2IKCWlqVuXq3hwhKAQqXYz8FHPGVnsXWec5o32Ud9gJsiy1LjZ5f5iocTP t1ZECxYegzDMnboFKGtLo7WFNZ1/5/xUSEETAGgSf3sl+dlKqFokkcDNiD+JThWHPSlm8OFSpsJ67 MUqRQz5Q==; From: Stephen Finucane To: patchwork@lists.ozlabs.org Subject: [RFC PATCH v2 11/19] templates: Enhance profile view further Date: Wed, 1 Sep 2021 17:57:48 +0100 Message-Id: <20210901165756.181192-12-stephen@that.guru> X-Mailer: git-send-email 2.31.1 In-Reply-To: <20210901165756.181192-1-stephen@that.guru> References: <20210901165756.181192-1-stephen@that.guru> MIME-Version: 1.0 X-AuthUser: stephen@that.guru X-Spam: Yes X-BeenThere: patchwork@lists.ozlabs.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: Patchwork development List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: patchwork-bounces+incoming=patchwork.ozlabs.org@lists.ozlabs.org Sender: "Patchwork" Fill in the gaps intentionally missed previously by amalgamating most user-specific views into the user profile view. Signed-off-by: Stephen Finucane --- patchwork/forms.py | 252 ++++++++++++++--- patchwork/templates/patchwork/profile.html | 86 +++++- .../patchwork/user-link-confirm.html | 15 - patchwork/templates/patchwork/user-link.html | 28 -- 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(+), 203 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' %} +
+{% elif message.tags == 'warning' %} +
+{% elif message.tags == 'error' %} +
+{% else %} +
+{% endif %} + {{ message }} + +
+{% endfor %}
@@ -100,7 +114,6 @@ Settings -{# TODO: Add view to enable this #}

# @@ -108,6 +121,7 @@

{% csrf_token %} +