implement Account Settings page for Marketplace (bug 735767)

This commit is contained in:
Chris Van 2012-03-28 11:00:30 -07:00
Родитель 63859c8b12
Коммит 3ce76cd13f
33 изменённых файлов: 1080 добавлений и 126 удалений

Просмотреть файл

@ -172,9 +172,12 @@ class UserRegisterForm(happyforms.ModelForm, UsernameMixin, PasswordMixin):
details here, so we'd have to rewrite most of it anyway.
"""
username = forms.CharField(max_length=50)
display_name = forms.CharField(max_length=50, required=False)
location = forms.CharField(max_length=100, required=False)
occupation = forms.CharField(max_length=100, required=False)
display_name = forms.CharField(label=_lazy('Display Name'), max_length=50,
required=False)
location = forms.CharField(label=_lazy('Location'), max_length=100,
required=False)
occupation = forms.CharField(label=_lazy('Occupation'), max_length=100,
required=False)
password = forms.CharField(max_length=255,
min_length=PasswordMixin.min_length,
error_messages=PasswordMixin.error_msg,
@ -362,13 +365,7 @@ class UserEditForm(UserRegisterForm, PasswordMixin):
return u
class AdminUserEditForm(UserEditForm):
admin_log = forms.CharField(required=True, label=_('Reason for change'),
widget=forms.Textarea())
confirmationcode = forms.CharField(required=False, max_length=255,
label=_('Confirmation code'))
notes = forms.CharField(required=False, widget=forms.Textarea())
anonymize = forms.BooleanField(required=False)
class BaseAdminUserEditForm(object):
def changed_fields(self):
"""Returns changed_data ignoring these fields."""
@ -392,6 +389,17 @@ class AdminUserEditForm(UserEditForm):
' other field.'))
return self.cleaned_data['anonymize']
class AdminUserEditForm(BaseAdminUserEditForm, UserEditForm):
"""This is the form used by admins to edit users' info."""
admin_log = forms.CharField(required=True, label='Reason for change',
widget=forms.Textarea(attrs={'rows': 4}))
confirmationcode = forms.CharField(required=False, max_length=255,
label='Confirmation code')
notes = forms.CharField(required=False, label='Notes',
widget=forms.Textarea(attrs={'rows': 4}))
anonymize = forms.BooleanField(required=False)
def save(self, *args, **kw):
profile = super(AdminUserEditForm, self).save(log_for_developer=False)
if self.cleaned_data['anonymize']:

Просмотреть файл

@ -120,15 +120,15 @@ class individual_contact(_NOTIFICATION):
class app_individual_contact(individual_contact):
app = True
label = loc('Mozilla needs to contact me about my individual app')
label = _('Mozilla needs to contact me about my individual app')
class app_surveys(_NOTIFICATION):
id = 13
group = 'user'
group = 'dev'
short = 'surveys'
label = loc('Mozilla may email me with relevant App Developer news and '
'surveys')
label = _('Mozilla wants to contact me about relevant App Developer news '
'and surveys')
mandatory = False
default_checked = False
app = True
@ -137,10 +137,22 @@ class app_surveys(_NOTIFICATION):
NOTIFICATION_GROUPS = {'dev': _('Developer'),
'user': _('User Notifications')}
APP_NOTIFICATIONS = [app_reply, app_new_review, app_individual_contact,
app_surveys]
APP_NOTIFICATIONS_BY_ID = dict((l.id, l) for l in APP_NOTIFICATIONS)
APP_NOTIFICATIONS_DEFAULT = [l.id for l in APP_NOTIFICATIONS]
APP_NOTIFICATIONS_CHOICES = [(l.id, l.label) for l in APP_NOTIFICATIONS]
APP_NOTIFICATIONS_CHOICES_NOT_DEV = [(l.id, l.label) for l in APP_NOTIFICATIONS
if l.group != 'dev']
NOTIFICATIONS = [x for x in vars().values()
if isclass(x) and issubclass(x, _NOTIFICATION)
and x != _NOTIFICATION and not getattr(x, 'app', False)]
NOTIFICATIONS_BY_ID = dict((l.id, l) for l in NOTIFICATIONS)
ALL_NOTIFICATIONS_BY_ID = dict((l.id, l) for l in
NOTIFICATIONS + APP_NOTIFICATIONS)
NOTIFICATIONS_BY_SHORT = dict((l.short, l) for l in NOTIFICATIONS)
NOTIFICATION = AttributeDict((l.__name__, l) for l in NOTIFICATIONS)
@ -149,8 +161,3 @@ NOTIFICATIONS_CHOICES = [(l.id, l.label) for l in NOTIFICATIONS]
NOTIFICATIONS_CHOICES_NOT_DEV = [(l.id, l.label) for l in NOTIFICATIONS
if l.group != 'dev']
APP_NOTIFICATIONS = [app_reply, app_new_review, app_individual_contact]
APP_NOTIFICATIONS_DEFAULT = [l.id for l in APP_NOTIFICATIONS]
APP_NOTIFICATIONS_CHOICES = [(l.id, l.label) for l in APP_NOTIFICATIONS]
APP_NOTIFICATIONS_CHOICES_NOT_DEV = [(l.id, l.label) for l in APP_NOTIFICATIONS
if l.group != 'dev']

Просмотреть файл

@ -229,7 +229,7 @@ class TestEdit(UserViewBase):
r = self.client.post(self.url, self.data)
self.assertRedirects(r, self.url, 302)
eq_(UserNotification.objects.count(), len(email.NOTIFICATION))
eq_(UserNotification.objects.count(), len(email.NOTIFICATIONS))
eq_(UserNotification.objects.filter(enabled=True).count(),
len(filter(lambda x: x.mandatory, email.NOTIFICATIONS)))
self.check_default_choices(choices, checked=False)
@ -248,7 +248,7 @@ class TestEdit(UserViewBase):
mandatory = [n.id for n in email.NOTIFICATIONS if n.mandatory]
total = len(self.data['notifications'] + mandatory)
eq_(UserNotification.objects.count(), len(email.NOTIFICATION))
eq_(UserNotification.objects.count(), len(email.NOTIFICATIONS))
eq_(UserNotification.objects.filter(enabled=True).count(), total)
doc = pq(self.client.get(self.url, self.data).content)
@ -257,31 +257,14 @@ class TestEdit(UserViewBase):
eq_(doc('.more-none').length, len(email.NOTIFICATION_GROUPS))
eq_(doc('.more-all').length, len(email.NOTIFICATION_GROUPS))
@patch.object(settings, 'APP_PREVIEW', True)
def test_edit_app_notifications(self):
AddonUser.objects.create(user=self.user,
addon=Addon.objects.create(type=amo.ADDON_EXTENSION))
self.post_notifications(email.APP_NOTIFICATIONS_CHOICES)
def test_edit_notifications_non_dev(self):
self.post_notifications(email.NOTIFICATIONS_CHOICES_NOT_DEV)
@patch.object(settings, 'APP_PREVIEW', True)
def test_edit_app_notifications_non_dev(self):
self.post_notifications(email.APP_NOTIFICATIONS_CHOICES_NOT_DEV)
def _test_edit_notifications_non_dev_error(self):
def test_edit_notifications_non_dev_error(self):
self.data['notifications'] = [2, 4, 6]
r = self.client.post(self.url, self.data)
assert r.context['form'].errors['notifications']
def test_edit_notifications_non_dev_error(self):
self._test_edit_notifications_non_dev_error()
@patch.object(settings, 'APP_PREVIEW', True)
def test_edit_app_notifications_non_dev_error(self):
self._test_edit_notifications_non_dev_error()
def test_collections_toggles(self):
r = self.client.get(self.url)
eq_(r.status_code, 200)
@ -289,14 +272,6 @@ class TestEdit(UserViewBase):
eq_(doc('#profile-misc').length, 1,
'Collections options should be visible.')
@patch.object(settings, 'APP_PREVIEW', True)
def test_apps_collections_toggles(self):
r = self.client.get(self.url)
eq_(r.status_code, 200)
doc = pq(r.content)
eq_(doc('#profile-misc').length, 0,
'Collections options should not be visible.')
class TestEditAdmin(UserViewBase):
fixtures = ['base/users']

Просмотреть файл

@ -28,7 +28,7 @@ users_patterns = patterns('',
url('^delete_photo$', views.delete_photo, name='users.delete_photo'),
url('^edit$', views.edit, name='users.edit'),
url('^edit(?:/(?P<user_id>\d+))?$', views.admin_edit,
name='users.admin_edit'),
name='users.admin_edit'),
url('^browserid-login', views.browserid_login,
name='users.browserid_login'),
url('^login/modal', views.login_modal, name='users.login_modal'),

Просмотреть файл

@ -1,9 +1,7 @@
from django import forms
from django.conf import settings
from django.template import Context, loader
from django.utils.encoding import force_unicode
from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe
from tower import ugettext as _
import users.notifications as email
@ -16,12 +14,17 @@ class NotificationsSelectMultiple(forms.CheckboxSelectMultiple):
super(self.__class__, self).__init__(**kwargs)
def render(self, name, value, attrs=None):
str_values = set([int(v) for v in value]) or []
str_values = [int(v) for v in value] or []
final_attrs = self.build_attrs(attrs, name=name)
groups = {}
# Mark the mandatory fields.
mandatory = [k for k, v in
email.ALL_NOTIFICATIONS_BY_ID.iteritems() if v.mandatory]
str_values = set(mandatory + str_values)
for idx, label in sorted(self.choices):
notification = email.NOTIFICATIONS_BY_ID[idx]
notification = email.ALL_NOTIFICATIONS_BY_ID[idx]
cb_attrs = dict(final_attrs, id='%s_%s' % (attrs['id'], idx))
notes = []
@ -29,7 +32,8 @@ class NotificationsSelectMultiple(forms.CheckboxSelectMultiple):
cb_attrs = dict(cb_attrs, disabled=1)
notes.append(u'<span title="required" class="req">*</span>')
if self.form_instance.choices_status.get(idx):
if (hasattr(self.form_instance, 'choices_status') and
self.form_instance.choices_status.get(idx)):
notes.append(u'<sup class="msg">%s</sup>' % _('new'))
cb = forms.CheckboxInput(
@ -48,7 +52,7 @@ class NotificationsSelectMultiple(forms.CheckboxSelectMultiple):
for e, name in email.NOTIFICATION_GROUPS.items():
if e in groups:
context = {'title': name, 'options': groups[e]}
output.append(loader.get_template(template_url).render(Context(context)))
output.append(loader.get_template(template_url).render(
Context(context)))
return mark_safe(u'<ol class="complex">%s</ol>' % u'\n'.join(output))

Просмотреть файл

@ -0,0 +1,45 @@
@import '../mkt/lib';
.edit_with_prefix {
.border-box;
border: 1px solid @light-gray;
background: @faint-gray;
.border-radius(5px);
color: @medium-gray;
padding: 0;
width: 400px;
span, input {
.border-box;
}
span {
border-right: 0 none;
display: block;
float: left;
line-height: 1.2;
overflow: hidden;
max-width: 215px;
padding: 5px;
text-overflow: ellipsis;
white-space: nowrap;
}
input[type=text] {
.border-radius(0 5px 5px 0);
.box-shadow(none);
border: 0;
float: right;
width: 170px !important;
}
}
.error .edit_with_prefix {
border-color: @red;
}
.simple-field {
.hint {
color: @gray;
display: block;
font-size: 11px;
margin-top: 4px;
}
}

Просмотреть файл

@ -178,10 +178,6 @@ textarea {
label {
display: inline-block;
}
.hint {
font-size: 11px;
margin-top: 4px;
}
ul {
li {
display: inline-block;

Просмотреть файл

@ -0,0 +1,78 @@
@import 'lib';
#user-notifications {
line-height: 16px;
ol, ul {
list-style: none;
margin: 0;
padding: 0;
}
ol,
ol > li + li {
margin-top: 10px;
}
ul li {
padding-bottom: 5px;
}
h3 {
display: none;
}
p.note {
font-style: italic;
margin: 10px 0 0;
}
}
.avatar {
border: 5px solid @white;
.box-shadow(1px 1px 3px #B2C8E0);
}
#account-settings {
img, .button {
.border-box;
}
img {
.max-width(1.5);
}
img[src*="anon_user"], .button {
.width(1.5);
}
.button {
font-size: 12px;
padding: 0;
}
#profile-photo {
img {
display: block;
margin-top: 5px;
}
.errorlist, a {
margin-top: 10px;
}
.invisible-upload a {
margin-top: 0;
}
a {
font-size: 11px;
}
a {
display: block;
}
}
.form-footer {
.delete {
color: @red;
font-size: 12px;
&:hover {
color: darken(@red, 10%);
}
}
}
}
#account-delete {
.notification-box, form {
margin-top: 10px;
}
}

Просмотреть файл

@ -5,9 +5,13 @@
@btn-g-dark: darken(spin(@btn-g, 3), 8%); //hsl(88, 60%, 38%)
@btn-g-lite: saturate(spin(@btn-g, 4), 4%); //hsl(89, 76%, 47%)
@btn-r: lighten(spin(@btn-g, -90), 10%);
@btn-r-dark: darken(spin(@btn-r, 3), 8%);
@btn-r-lite: saturate(spin(@btn-r, 4), 60%);
@btn-b: lighten(spin(@btn-g, 130), 10%);
@btn-b-dark: darken(spin(@btn-b, 3), 8%);
@btn-b-lite: saturate(spin(@btn-b, 4), 4%);
@btn-b-lite: saturate(spin(@btn-b, 4), 60%);
@btn-y: lighten(spin(@btn-g, -50), 10%);
@btn-y-dark: darken(spin(@btn-y, 3), 8%);
@ -21,7 +25,7 @@
background-color: @gray;
color: @white;
display: inline-block;
font: 14px/48px @open-stack;
font: 14px/28px @open-stack;
font-weight: 600;
padding: 0 15px;
text-align: center;
@ -45,6 +49,14 @@
inset 0 12px 24px 2px @btn-g-lite);
}
}
&.delete {
.gradient-two-color(@btn-r, @btn-r-dark);
&:hover {
.box-shadow(0 2px 0 0 rgba(0,0,0,.1),
inset 0 -2px 0 0 rgba(0,0,0,.2),
inset 0 12px 24px 2px @btn-r-lite);
}
}
}
button {

Просмотреть файл

@ -7,6 +7,7 @@
margin-bottom: 16px;
.button {
display: block;
line-height: 48px;
}
}
.icon {

Просмотреть файл

@ -7,6 +7,25 @@ button, input, select, textarea {
vertical-align: middle;
}
input {
&[type=file] {
font-family: sans-serif;
}
&[type=checkbox],
&[type=radio] {
margin-right: 5px;
}
}
.html-rtl {
input {
&[type=checkbox],
&[type=radio] {
margin: 0 0 0 5px;
}
}
}
button, input {
line-height: normal;
}
@ -98,6 +117,7 @@ label.choice {
}
.errorlist {
list-style: none;
margin: 0;
padding: 0;
}
@ -124,3 +144,113 @@ form div[style]:first-child + p {
margin-bottom: 5px;
}
}
form {
margin: 10px 0;
.note,
.html-support {
color: @medium-gray;
font-size: 11px;
line-height: 1.4;
}
.html-support {
margin: 5px 0 0;
text-align: right;
span {
border-bottom: 1px dotted @light-gray;
cursor: help;
}
}
}
.form-grid {
.simple-field {
.border-box;
border-top: 1px solid @light-gray;
padding: 10px 0;
width: 100%;
label {
margin: 0;
}
}
.form-label,
.form-col {
.border-box;
float: left;
}
.form-label {
line-height: 27px;
font-weight: bold;
min-height: 1px;
width: 25%;
}
.form-col {
width: 75%;
li {
line-height: 1;
}
p,
.errorlist {
margin: 5px 0 0;
}
}
p,
input + a {
color: @medium-gray;
}
p,
input + a,
.errorlist {
font-size: 11px;
line-height: 13px;
}
footer {
border-top: 1px solid @light-gray;
padding-top: 15px;
}
input {
&[type=text] {
.width(4);
}
+ a {
margin-left: 10px;
}
}
.edit_with_prefix {
.width(4);
}
}
.html-rtl .form-grid {
input {
+ a {
margin: 0 10px 0 0;
}
}
}
.form-footer {
button {
margin-right: 5px;
}
a {
display: inline-block;
margin-left: 5px;
&:only-child {
margin: 0;
}
}
}
.html-rtl .form-footer {
button {
margin: 0 0 0 5px;
}
a {
margin-left: 0 5px 0 0;
&:only-child {
margin: 0;
}
}
}

Просмотреть файл

@ -31,6 +31,7 @@
}
form {
float: right;
margin: 0;
}
}

Просмотреть файл

@ -65,6 +65,11 @@ body {
float: right;
}
}
ol, ul {
&:last-child {
margin-bottom: 0;
}
}
p {
margin: .5em 0 0;
}
@ -206,7 +211,7 @@ body {
}
}
footer {
#site-footer {
border-top: 1px solid black;
margin-top: 20px;
padding: 16px;

Просмотреть файл

@ -1,21 +1,6 @@
@import 'lib';
#support {
form {
margin-top: 10px;
}
.form-footer {
button {
margin-right: 5px;
}
a {
display: inline-block;
margin-left: 5px;
&:only-child {
margin: 0;
}
}
}
p, ul {
font-size: 15px;
line-height: 20px;

Просмотреть файл

@ -34,6 +34,26 @@
file in the upload box when the "onchange" event is fired.]
*/
// Get an object URL across browsers.
$.fn.objectUrl = function(offset) {
var files = $(this)[0].files,
url = false;
if (z.capabilities.fileAPI && files.length) {
offset = offset || 0;
var f = files[offset];
if (typeof window.URL == 'object') {
url = window.URL.createObjectURL(f);
} else if (typeof window.webkitURL == 'object') {
url = window.webkitURL.createObjectURL(f);
} else if(typeof f.getAsDataURL == 'function') {
url = f.getAsDataURL();
}
}
return url;
};
(function($) {
var instance_id = 0,
boundary = "BoUnDaRyStRiNg";

50
media/js/mkt/account.js Normal file
Просмотреть файл

@ -0,0 +1,50 @@
(function() {
if (!$('#account-settings').length) {
return;
}
// Avatar handling.
var $photo = $('#profile-photo'),
$avatar = $photo.find('.avatar'),
$a = $('<a>', {'text': gettext('Use original'),
'class': 'use-original',
'href': '#'}).hide();
// Doing a POST on click because deleting on a GET is the worst thing ever.
$photo.find('.delete').click(_pd(function() {
$.post(this.getAttribute('data-posturl')).success(function() {
// Redirect back to this page.
window.location = window.location;
});
}));
$avatar.attr('data-original', $avatar.attr('src'));
function use_original() {
$photo.find('.use-original').hide();
$photo.find('#id_photo').val('');
$avatar.attr('src', $avatar.attr('data-original'));
}
$a.click(_pd(use_original));
$avatar.after($a);
$('#id_photo').change(function() {
var $this = $(this),
$parent = $this.closest('.form-col'),
file = $this[0].files[0],
file_name = file.name || file.fileName;
$parent.find('.errorlist').remove();
if (!file_name.match(/\.(jpg|png|jpeg)$/i)) {
$ul = $('<ul>', {'class': 'errorlist'});
$ul.append($('<li>',
{'text': gettext('Images must be either PNG or JPG.')}));
$parent.append($ul);
use_original();
return;
}
var img = $this.objectUrl();
if (img) {
$a.show();
$avatar.attr('src', img);
}
});
})();

Просмотреть файл

@ -21,9 +21,20 @@ var escape_ = function(s) {
};
// .exists()
// This returns true if length > 0.
$.fn.exists = function(callback, args) {
var $this = $(this),
len = $this.length;
if (len && callback) {
callback.apply(null, args);
}
return !!len;
};
// CSRF Tokens
// Hijack the AJAX requests, and insert a CSRF token as a header.
$('html').ajaxSend(function(event, xhr, ajaxSettings) {
var csrf, $meta;
// Block anything that starts with 'http:', 'https:', '://' or '//'.

Просмотреть файл

@ -575,24 +575,3 @@ $.fn.exists = function(callback, args){
}
return len > 0;
};
// Get an object URL across browsers
$.fn.objectUrl = function(offset) {
var files = $(this)[0].files,
url = false;
if (z.capabilities.fileAPI && files.length) {
offset = offset || 0;
var f = files[offset];
if (typeof window.URL == 'object') {
url = window.URL.createObjectURL(f);
} else if (typeof window.webkitURL == 'object') {
url = window.webkitURL.createObjectURL(f);
} else if(typeof f.getAsDataURL == 'function') {
url = f.getAsDataURL();
}
}
return url;
};

151
mkt/account/forms.py Normal file
Просмотреть файл

@ -0,0 +1,151 @@
import os
import re
from django import forms
from django.conf import settings
import commonware.log
from tower import ugettext as _, ugettext_lazy as _lazy
import amo
from translations.fields import TransField, TransTextarea
from users.forms import BaseAdminUserEditForm, UserRegisterForm
from users.models import UserNotification, UserProfile
import users.notifications as email
from users.tasks import resize_photo
from users.widgets import NotificationsSelectMultiple
log = commonware.log.getLogger('z.users')
admin_re = re.compile('(?=.*\d)(?=.*[a-zA-Z])')
class UserEditForm(UserRegisterForm):
photo = forms.FileField(label=_lazy(u'Profile Photo'), required=False,
help_text=_lazy(u'PNG and JPG supported. Large images will be resized '
'to fit 200 x 200 px.'))
display_name = forms.CharField(label=_lazy(u'Display Name'), max_length=50,
required=False,
help_text=_lazy(u'This will be publicly displayed next to your '
'ratings, collections, and other contributions.'))
notifications = forms.MultipleChoiceField(required=False, choices=[],
widget=NotificationsSelectMultiple,
initial=email.APP_NOTIFICATIONS_DEFAULT)
password = forms.CharField(required=False)
password2 = forms.CharField(required=False)
bio = TransField(label=_lazy(u'Bio'), required=False,
widget=TransTextarea(attrs={'rows': 4}))
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request', None)
super(UserEditForm, self).__init__(*args, **kwargs)
if self.instance:
default = dict((i, n.default_checked) for i, n
in email.APP_NOTIFICATIONS_BY_ID.items())
user = dict((n.notification_id, n.enabled) for n
in self.instance.notifications.all())
default.update(user)
# Add choices to Notification.
choices = email.APP_NOTIFICATIONS_CHOICES
if not self.instance.read_dev_agreement:
choices = email.APP_NOTIFICATIONS_CHOICES_NOT_DEV
self.fields['notifications'].choices = choices
self.fields['notifications'].initial = [i for i, v in
default.items() if v]
self.fields['notifications'].widget.form_instance = self
# TODO: We should inherit from a base form not UserRegisterForm.
if self.fields.get('recaptcha'):
del self.fields['recaptcha']
class Meta:
model = UserProfile
fields = ('username', 'display_name', 'location', 'occupation', 'bio',
'homepage')
def clean_photo(self):
photo = self.cleaned_data.get('photo')
if photo:
if photo.content_type not in ('image/png', 'image/jpeg'):
raise forms.ValidationError(
_('Images must be either PNG or JPG.'))
if photo.size > settings.MAX_PHOTO_UPLOAD_SIZE:
raise forms.ValidationError(
_('Please use images smaller than %dMB.' %
(settings.MAX_PHOTO_UPLOAD_SIZE / 1024 / 1024 - 1)))
return photo
def save(self):
u = super(UserEditForm, self).save(commit=False)
data = self.cleaned_data
photo = data['photo']
if photo:
u.picture_type = 'image/png'
tmp_destination = u.picture_path + '__unconverted'
if not os.path.exists(u.picture_dir):
os.makedirs(u.picture_dir)
fh = open(tmp_destination, 'w')
for chunk in photo.chunks():
fh.write(chunk)
fh.close()
resize_photo.delay(tmp_destination, u.picture_path,
set_modified_on=[u])
for i, n in email.APP_NOTIFICATIONS_BY_ID.iteritems():
enabled = n.mandatory or (str(i) in data['notifications'])
UserNotification.update_or_create(user=u, notification_id=i,
update={'enabled': enabled})
log.debug(u'User (%s) updated their profile' % u)
u.save()
return u
class AdminUserEditForm(BaseAdminUserEditForm, UserEditForm):
"""
This extends from the old `AdminUserEditForm` but using our new fancy
`UserEditForm`.
"""
admin_log = forms.CharField(required=True, label='Reason for change',
widget=forms.Textarea(attrs={'rows': 4}))
notes = forms.CharField(required=False, label='Notes',
widget=forms.Textarea(attrs={'rows': 4}))
anonymize = forms.BooleanField(required=False)
def save(self, *args, **kw):
profile = super(UserEditForm, self).save()
if self.cleaned_data['anonymize']:
amo.log(amo.LOG.ADMIN_USER_ANONYMIZED, self.instance,
self.cleaned_data['admin_log'])
profile.anonymize() # This also logs.
else:
amo.log(amo.LOG.ADMIN_USER_EDITED, self.instance,
self.cleaned_data['admin_log'], details=self.changes())
log.info('Admin edit user: %s changed fields: %s' %
(self.instance, self.changed_fields()))
return profile
class UserDeleteForm(forms.Form):
confirm = forms.BooleanField()
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request', None)
super(UserDeleteForm, self).__init__(*args, **kwargs)
def clean(self):
amouser = self.request.user.get_profile()
if amouser.is_developer:
# This is tampering because the form isn't shown on the page if
# the user is a developer.
log.warning(u'[Tampering] Attempt to delete developer account (%s)'
% self.request.user)
raise forms.ValidationError('Developers cannot delete their '
'accounts.')

Просмотреть файл

@ -0,0 +1,54 @@
{% extends 'mkt/base.html' %}
{% from 'includes/forms.html' import required %}
{% set title = _('Delete User Account') %}
{% block title %}{{ page_title(title) }}{% endblock %}
{% block content %}
<section id="account-delete">
<div class="primary island">
<h1>{{ title }}</h1>
{% if amouser.is_developer %}
<div class="notification-box info">
{% trans link=url('mkt.developers.apps') %}
You cannot delete your account if you are listed as an
<a href="{{ link }}">author of any apps</a>. To delete your
account, please have another person in your development group
delete you from the list of authors for your apps. Afterwards you
will be able to delete your account here.
{% endtrans %}
</div>
{% else %}
{% if form %}
<div class="notification-box error prose">
{% trans site=settings.DOMAIN %}
By clicking "delete" your account is going
to be <strong>permanently removed</strong>. That means:
<ul>
<li>You will not be able to log into {{ site }} anymore.</li>
<li>Your reviews and ratings will not be deleted, but they
will no longer be associated with you.</li>
</ul>
{% endtrans %}
</div>
<form method="post" action="">
{{ csrf() }}
<h2>{{ _('Confirm account deletion') }}</h2>
<p>
<label for="{{ form.confirm.auto_id }}" class="choice">
{{ form.confirm }}
{{ _('I understand this step cannot be undone.') }}
{{ required() }}
</label>
</p>
<footer class="form-footer">
<button type="submit" class="delete">
{{ _('Delete my user account now') }}
</button>
</footer>
</form>
{% endif %}
{% endif %}{# /amouser.is_developer #}
</div>
</section>
{% endblock %}

Просмотреть файл

@ -0,0 +1,113 @@
{% extends 'mkt/base.html' %}
{% from 'developers/includes/macros.html' import some_html_tip %}
{% set title = _('Account Settings') %}
{% block title %}{{ mkt_page_title(title) }}{% endblock %}
{% block content %}
<section id="account-settings">
{{ mkt_breadcrumbs(product, [(None, title)]) }}
<h1>{{ title }}</h1>
<form class="form-grid" enctype="multipart/form-data" method="post">
{{ csrf() }}
<div id="profile-photo" class="simple-field c">
<div class="form-label">
<label for="id_photo">{{ _('Profile Photo') }}</label>
</div>
<div class="form-col">
<div class="invisible-upload">
<a href="#" class="button">{{ _('Choose Photo&hellip;') }}</a>
<span class="hint">{{ form.photo.help_text }}</span>
<input type="file" id="id_photo" name="photo">
</div>
<img src="{{ amouser.picture_url }}" class="avatar photo"
{%- if not amouser.picture_type %}
alt="{{ _('No Photo') }}"
{%- endif %}>
{% if amouser.picture_type %}
<a href="#" data-posturl="{{ url('account.delete_photo') }}"
class="delete button">{{ _('Delete current photo') }}</a>
{% endif %}
{{ form.photo.errors }}
</div>
</div>
<div class="simple-field c">
<div class="form-label">
{{ _('PersonaID Email') }}
</div>
<div class="form-col">
<input type="text" disabled value="{{ amo_user.email }}">
<a href="https://browserid.org/signin" target="_blank">
{{ _('Change Email') }}</a>
</div>
</div>
<div class="simple-field c">
<div class="form-label">
{{ _('PersonaID Password') }}
</div>
<div class="form-col">
<input type="text" disabled id="fake-password" value="•••••">
<a href="https://browserid.org/signin" target="_blank">
{{ _('Change Password') }}</a>
</div>
</div>
{{ grid_field(form.display_name, hint=True) }}
<div class="simple-field c">
<div class="form-label">
<label for="{{ form.username.auto_id }}">
{{ _('Username') }}
</label>
</div>
<div class="form-col">
<div class="edit_with_prefix c">
<span>{{ settings.SITE_URL }}/user/</span>{{ form.username }}
</div>
{{ form.username.errors }}
</div>
</div>
{{ grid_field(form.location) }}
{{ grid_field(form.occupation) }}
{{ grid_field(form.homepage) }}
{{ grid_field(form.bio, some_html=True) }}
<div id="user-notifications" class="simple-field c">
<div class="form-label">
{{ _('Email me when') }}
</div>
<div class="form-col">
<div class="choice">
{{ form.notifications }}
<p class="note">
{{ _('Mozilla reserves the right to contact you individually '
'about specific concerns with your apps.') }}
</p>
</div>
</div>
</div>
{% if 'admin_log' in form.fields %}
<h3>Administration</h3>
{{ grid_field(form.admin_log) }}
{{ grid_field(form.notes) }}
{{ grid_field(form.anonymize) }}
{% endif %}
<footer class="form-footer">
<button type="submit">{{ _('Save Changes') }}</button>
{% if 'admin_log' in form.fields %}
<a href="{{ url('admin:users_userprofile_delete', amouser.id) }}"
class="delete">Delete Account</a>
{% else %}
<a href="{{ url('account.delete') }}" class="delete">
{{ _('Delete Account') }}</a>
{% endif %}
</footer>
</form>
</section>
{% endblock %}

Просмотреть файл

@ -1,6 +1,9 @@
from datetime import datetime, timedelta
from django.contrib.auth.models import User
from django.core import mail
from django.core.cache import cache
from django.forms.models import model_to_dict
from jingo.helpers import datetime as datetime_filter
from nose.tools import eq_
@ -10,13 +13,205 @@ import waffle
import amo
import amo.tests
from amo.urlresolvers import reverse
from addons.models import AddonPremium
from addons.models import AddonPremium, AddonUser
from market.models import Price
from mkt.developers.models import ActivityLog
from stats.models import Contribution
from users.models import UserProfile
from users.models import UserNotification, UserProfile
import users.notifications as email
from mkt.webapps.models import Installed, Webapp
class TestAccountSettings(amo.tests.TestCase):
fixtures = ['users/test_backends']
def setUp(self):
self.user = self.get_user()
self.client.login(username=self.user.email, password='foo')
self.url = reverse('account.settings')
self.data = {'username': 'jbalogh', 'email': 'jbalogh@mozilla.com',
'oldpassword': 'foo', 'password': 'longenough',
'password2': 'longenough', 'bio': 'boop'}
self.extra_data = {'homepage': 'http://omg.org/',
'occupation': 'bro', 'location': 'desk 42',
'display_name': 'Fligtar Scott'}
self.data.update(self.extra_data)
def get_user(self):
return UserProfile.objects.get(username='jbalogh')
def test_success(self):
r = self.client.post(self.url, self.data, follow=True)
self.assertRedirects(r, self.url)
doc = pq(r.content)
# Check that the values got updated appropriately.
user = self.get_user()
for field, expected in self.extra_data.iteritems():
eq_(unicode(getattr(user, field)), expected)
eq_(doc('#id_' + field).val(), expected)
def test_no_password_changes(self):
r = self.client.post(self.url, self.data)
eq_(self.user.userlog_set
.filter(activity_log__action=amo.LOG.CHANGE_PASSWORD.id)
.count(), 0)
def test_email_cant_change(self):
data = {'username': 'jbalogh',
'email': 'jbalogh.changed@mozilla.com',
'display_name': 'DJ SurfNTurf'}
r = self.client.post(self.url, data)
self.assertRedirects(r, self.url)
eq_(len(mail.outbox), 0)
eq_(self.get_user().email, self.data['email'],
'Email address should not have changed')
def test_edit_bio(self):
eq_(self.get_user().bio, None)
data = {'username': 'jbalogh',
'email': 'jbalogh.changed@mozilla.com',
'bio': 'xxx unst unst'}
r = self.client.post(self.url, data, follow=True)
self.assertRedirects(r, self.url)
self.assertContains(r, data['bio'])
eq_(unicode(self.get_user().bio), data['bio'])
data['bio'] = 'yyy unst unst'
r = self.client.post(self.url, data, follow=True)
self.assertRedirects(r, self.url)
self.assertContains(r, data['bio'])
eq_(unicode(self.get_user().bio), data['bio'])
def check_default_choices(self, choices, checked=[]):
doc = pq(self.client.get(self.url).content)
eq_(doc('input[name=notifications]:checkbox').length, len(choices))
for id, label in choices:
box = doc('input[name=notifications][value=%s]' % id)
if id in checked:
eq_(box.filter(':checked').length, 1)
else:
eq_(box.length, 1)
parent = box.parent('label')
eq_(parent.remove('.req').text(), label)
def post_notifications(self, choices):
self.check_default_choices(choices=choices, checked=choices)
self.data['notifications'] = []
r = self.client.post(self.url, self.data)
self.assertRedirects(r, self.url)
eq_(UserNotification.objects.count(), len(email.APP_NOTIFICATIONS))
eq_(UserNotification.objects.filter(enabled=True).count(),
len(filter(lambda x: x.mandatory, email.APP_NOTIFICATIONS)))
self.check_default_choices(choices=choices, checked=[])
def test_edit_notifications(self):
# Make jbalogh a developer.
self.user.update(read_dev_agreement=True)
self.check_default_choices(choices=email.APP_NOTIFICATIONS_CHOICES,
checked=[email.individual_contact.id])
self.data['notifications'] = [email.app_individual_contact.id,
email.app_surveys.id]
r = self.client.post(self.url, self.data)
self.assertRedirects(r, self.url)
mandatory = [n.id for n in email.APP_NOTIFICATIONS if n.mandatory]
total = len(set(self.data['notifications'] + mandatory))
eq_(UserNotification.objects.count(), len(email.APP_NOTIFICATIONS))
eq_(UserNotification.objects.filter(enabled=True).count(), total)
doc = pq(self.client.get(self.url).content)
eq_(doc('input[name=notifications]:checked').length, total)
def test_edit_all_notifications(self):
self.user.update(read_dev_agreement=True)
self.post_notifications(email.APP_NOTIFICATIONS_CHOICES)
def test_edit_non_dev_notifications(self):
self.post_notifications(email.APP_NOTIFICATIONS_CHOICES_NOT_DEV)
def test_edit_non_dev_notifications_error(self):
# jbalogh isn't a developer so he can't set developer notifications.
self.data['notifications'] = [email.app_surveys.id]
r = self.client.post(self.url, self.data)
assert r.context['form'].errors['notifications']
class TestAdminAccountSettings(amo.tests.TestCase):
fixtures = ['base/users']
def setUp(self):
self.client.login(username='admin@mozilla.com', password='password')
self.regular = self.get_user()
self.url = reverse('users.admin_edit', args=[self.regular.pk])
def get_data(self, **kw):
data = model_to_dict(self.regular)
data['admin_log'] = 'test'
for key in ['password', 'resetcode_expires']:
del data[key]
data.update(kw)
return data
def get_user(self):
# Using pk so that we can still get the user after anonymize.
return UserProfile.objects.get(pk=999)
def test_get(self):
eq_(self.client.get(self.url).status_code, 200)
def test_forbidden(self):
self.client.logout()
self.client.login(username='editor@mozilla.com', password='password')
eq_(self.client.get(self.url).status_code, 403)
def test_forbidden_anon(self):
self.client.logout()
r = self.client.get(self.url)
self.assertLoginRedirects(r, self.url)
def test_anonymize(self):
r = self.client.post(self.url, self.get_data(anonymize=True))
self.assertRedirects(r, reverse('zadmin.index'))
eq_(self.get_user().password, 'sha512$Anonymous$Password')
def test_anonymize_fails_with_other_changed_fields(self):
# We don't let an admin change a field whilst anonymizing.
data = self.get_data(anonymize=True, display_name='something@else.com')
r = self.client.post(self.url, data)
eq_(r.status_code, 200)
eq_(self.get_user().password, self.regular.password) # Hasn't changed.
def test_admin_logs_edit(self):
self.client.post(self.url, self.get_data(email='something@else.com'))
r = ActivityLog.objects.filter(action=amo.LOG.ADMIN_USER_EDITED.id)
eq_(r.count(), 1)
assert self.get_data()['admin_log'] in r[0]._arguments
def test_admin_logs_anonymize(self):
self.client.post(self.url, self.get_data(anonymize=True))
r = (ActivityLog.objects
.filter(action=amo.LOG.ADMIN_USER_ANONYMIZED.id))
eq_(r.count(), 1)
assert self.get_data()['admin_log'] in r[0]._arguments
def test_admin_no_password(self):
data = self.get_data(password='pass1234', password2='pass1234',
oldpassword='password')
self.client.post(self.url, data)
logs = ActivityLog.objects.filter
eq_(logs(action=amo.LOG.CHANGE_PASSWORD.id).count(), 0)
r = logs(action=amo.LOG.ADMIN_USER_EDITED.id)
eq_(r.count(), 1)
eq_(r[0].details['password'][0], u'****')
class PurchaseBase(amo.tests.TestCase):
fixtures = ['base/users']

Просмотреть файл

@ -1,10 +1,21 @@
from django.conf.urls.defaults import patterns, url
from lib.misc.urlconf_decorator import decorate
from amo.decorators import login_required
from . import views
urlpatterns = patterns('',
url(r'purchases/$', views.purchases, name='account.purchases'),
urlpatterns = decorate(login_required, patterns('',
url('purchases/$', views.purchases, name='account.purchases'),
url(r'purchases/(?P<product_id>\d+)', views.purchases,
name='account.purchases.receipt'),
)
url('settings/$', views.account_settings, name='account.settings'),
url('settings/delete$', views.delete, name='account.delete'),
url('settings/delete_photo$', views.delete_photo,
name='account.delete_photo'),
# Keeping the same URL pattern since admin pages already know about this.
url(r'user/(?:/(?P<user_id>\d+)/)?edit$', views.admin_edit,
name='users.admin_edit'),
))

Просмотреть файл

@ -1,16 +1,29 @@
from django import http
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect
from django.template import Context, loader
import commonware.log
import jingo
from tower import ugettext_lazy as _lazy
from tower import ugettext as _, ugettext_lazy as _lazy
from addons.views import BaseFilter
import amo
from amo.decorators import login_required
from amo.utils import paginate
from amo.decorators import permission_required, post_required, write
from amo.urlresolvers import reverse
from amo.utils import paginate, send_mail
from stats.models import Contribution
from translations.query import order_by_translation
from users.forms import AdminUserEditForm
from users.models import UserProfile
from users.tasks import delete_photo as delete_photo_task
from users.utils import EmailResetCode
from users.views import logout
from mkt.site import messages
from mkt.webapps.models import Webapp
from . import forms
log = commonware.log.getLogger('mkt.account')
class PurchasesFilter(BaseFilter):
@ -30,7 +43,6 @@ class PurchasesFilter(BaseFilter):
return order_by_translation(qs, 'name')
@login_required
def purchases(request, product_id=None, template=None):
"""A list of purchases that a user has made through the Marketplace."""
cs = (Contribution.objects
@ -67,3 +79,56 @@ def purchases(request, product_id=None, template=None):
'listing_filter': listing,
'contributions': contributions,
'single': bool(product_id)})
@write
def account_settings(request):
# Don't use `request.amo_user` because it's too cached.
amo_user = request.amo_user.user.get_profile()
form = forms.UserEditForm(request.POST or None, request.FILES or None,
request=request, instance=amo_user)
if request.method == 'POST':
if form.is_valid():
form.save()
messages.success(request, _('Profile Updated'))
return redirect('account.settings')
else:
messages.form_errors(request)
return jingo.render(request, 'account/settings.html',
{'form': form, 'amouser': amo_user})
@write
@permission_required('Users', 'Edit')
def admin_edit(request, user_id):
amouser = get_object_or_404(UserProfile, pk=user_id)
form = forms.AdminUserEditForm(request.POST or None, request.FILES or None,
request=request, instance=amouser)
if request.method == 'POST' and form.is_valid():
form.save()
messages.success(request, _('Profile Updated'))
return redirect('zadmin.index')
return jingo.render(request, 'account/settings.html',
{'form': form, 'amouser': amouser})
def delete(request):
amouser = request.amo_user
form = forms.UserDeleteForm(request.POST, request=request)
if request.method == 'POST' and form.is_valid():
messages.success(request, _('Profile Deleted'))
amouser.anonymize()
logout(request)
form = None
return redirect('users.login')
return jingo.render(request, 'account/delete.html',
{'form': form, 'amouser': amouser})
@post_required
def delete_photo(request):
request.amo_user.update(picture_type='')
delete_photo_task.delay(request.amo_user.picture_path)
log.debug(u'User (%s) deleted photo' % request.amo_user)
messages.success(request, _('Photo Deleted'))
return http.HttpResponse()

Просмотреть файл

@ -31,6 +31,7 @@ CSS = {
# Forms (used for tables on "Manage ..." pages).
'css/devreg/devhub-forms.less',
'css/common/forms.less',
# Landing page
'css/devreg/landing.less',
@ -69,6 +70,8 @@ CSS = {
'css/mkt/typography.less',
'css/mkt/site.less',
'css/mkt/forms.less',
'css/common/invisible-upload.less',
'css/common/forms.less',
'css/mkt/header.less',
'css/mkt/breadcrumbs.less',
'css/mkt/buttons.less',
@ -80,6 +83,8 @@ CSS = {
'css/mkt/suggestions.less',
'css/mkt/purchases.less',
'css/mkt/support.less',
'css/mkt/account.less',
'css/devreg/l10n.less',
),
'mkt/in-app-payments': (
# Temporarily re-using PayPal styles for in-app-payments UI
@ -208,10 +213,15 @@ JS = {
'js/mkt/payments.js',
'js/mkt/search.js',
'js/mkt/apps.js',
'js/zamboni/outgoing_links.js',
'js/common/upload-image.js',
# Search suggestions.
'js/impala/ajaxcache.js',
'js/impala/suggestions.js',
# Account settings.
'js/mkt/account.js',
),
'marketplace-experiments': (
'js/marketplace-experiments/jquery-1.7.1.min.js',

Просмотреть файл

@ -164,7 +164,7 @@
<tr>
<th>
{{ tip(_('Device Types'),
_('Indicate support for desktop, mobile and tablet
_('Indicate support for desktop, mobile, and tablet
devices.')) }}
{{ req_if_edit }}
</th>

Просмотреть файл

@ -61,9 +61,11 @@ SUPPORTED_NONAPPS += (
'privacy-policy',
'purchases',
'search',
'settings',
'submit',
'support',
'terms-of-use',
'user',
'users',
)

Просмотреть файл

@ -107,14 +107,22 @@ def price_label(product):
@register.function
def form_field(field, label=None, tag='div', req=None, opt=False, hint=False,
some_html=False, cc_startswith=None, cc_maxlength=None,
**attrs):
grid=False, **attrs):
c = dict(field=field, label=label, tag=tag, req=req, opt=opt, hint=hint,
some_html=some_html, cc_startswith=cc_startswith,
cc_maxlength=cc_maxlength, attrs=attrs)
cc_maxlength=cc_maxlength, grid=grid, attrs=attrs)
t = env.get_template('site/helpers/simple_field.html').render(**c)
return jinja2.Markup(t)
@register.function
def grid_field(field, label=None, tag='div', req=None, opt=False, hint=False,
some_html=False, cc_startswith=None, cc_maxlength=None,
**attrs):
return form_field(field, label, tag, req, opt, hint, some_html,
cc_startswith, cc_maxlength, grid=True, attrs=attrs)
@register.function
def admin_site_links():
return {

Просмотреть файл

@ -1,5 +1,6 @@
import jinja2
from jingo import env
from tower import ugettext as _
from amo.messages import debug, info, success, warning, error
@ -10,3 +11,9 @@ def _make_message(message=None, title=None, title_safe=False,
'title_safe': title_safe, 'message_safe': message_safe}
t = env.get_template('site/messages/content.html').render(**c)
return jinja2.Markup(t)
def form_errors(request):
return error(request, title=_('Errors Found'),
message=_('There were errors in the changes you made. '
'Please correct them and resubmit.'))

Просмотреть файл

@ -1,18 +1,41 @@
{% from 'includes/forms.html' import required, optional, tip %}
{% from 'developers/includes/macros.html' import some_html_tip %}
{% macro simple_label(field, label, opt, req, tooltip, hint) %}
<label class="{{ 'choice' if choice }}" for="{{ field.auto_id }}">
{{ label or field.label }}
</label>
{% if field.field.required and req != False %}{{ required() -}}{% endif %}
{% if opt %}{{ optional() -}}{% endif %}
{% if not hint and tooltip %}{{ tip(None, tooltip) }}{% endif %}
{% endmacro %}
{% if tag %}
<{{ tag }} class="brform simple-field c {{ class }}{{ ' error' if field.errors }}">
{% endif %}
{% set choice = field|is_choice_field %}
{% if choice %}{{ field.as_widget() }}{% endif %}
<label class="{{ 'choice' if choice }}" for="{{ field.auto_id }}">
{{ label or field.label }}
</label>
{% if field.field.required and req != False %}{{ required() -}}{% endif %}
{% if opt %}{{ optional() -}}{% endif %}
{% if not tooltip %}{% set tooltip = field.help_text %}{% endif %}
{% if not hint and tooltip %}{{ tip(None, tooltip) }}{% endif %}
{% if grid %}
<div class="form-label">
{% else %}
{% if choice %}{{ field.as_widget() }}{% endif %}
{% endif %}
{% if not choice %}
{{ simple_label(field, label, opt, req, tooltip, hint) }}
{% endif %}
{% if grid %}
</div>
<div class="form-col">
{% if choice %}
{{ field.as_widget() }}
{{ simple_label(field, label, opt, req, tooltip, hint) }}
{% endif %}
{% endif %}
{% if not choice %}
{{ field.as_widget(attrs=attrs) }}
@ -32,6 +55,8 @@
data-for-startswith="{{ cc_startswith }}"
{% endif %}></div>
{% endif %}
{% if grid %}</div>{% endif %}
{% if tag %}
</{{ tag }}>
{% endif %}

Просмотреть файл

@ -37,7 +37,9 @@
</ol>
<form method="post">
{{ csrf() }}
{{ form_field(agreement_form.newsletter, opt=True) }}
{{ form_field(agreement_form.newsletter,
label=_('Mozilla may email me with relevant App Developer '
'news and surveys'), opt=True) }}
{{ agreement_form.newsletter.errors }}
{{ agreement_form.read_dev_agreement }}
{{ agreement_form.read_dev_agreement.errors }}

Просмотреть файл

@ -53,11 +53,15 @@
</div>
{% block sitefooter %}
<footer>
<footer id="site-footer">
{% if request.user.is_authenticated() %}
signed in as {{ request.amo_user.email }}
<a href="{{ url('users.logout') }}">(log out)</a>
<p><a href="{{ url('account.purchases') }}">My Purchases</a></p>
<p>
<a href="{{ url('account.purchases') }}">My Purchases</a>
&middot;
<a href="{{ url('account.settings') }}">Account Settings</a>
</p>
{% endif %}
</footer>
{% endblock %}

Просмотреть файл

@ -37,6 +37,9 @@ urlpatterns = patterns('',
# Support (e.g., refunds, FAQs).
('^support/', include('mkt.support.urls')),
# Users (Legacy).
('', include('users.urls')),
# Account info (e.g., purchases, settings).
('', include('mkt.account.urls')),
@ -52,9 +55,6 @@ urlpatterns = patterns('',
# Services.
('', include('apps.amo.urls')),
# Users.
('', include('users.urls')),
# Javascript translations.
url('^jsi18n.js$', cache_page(60 * 60 * 24 * 365)(javascript_catalog),
{'domain': 'javascript', 'packages': ['zamboni']}, name='jsi18n'),