This commit is contained in:
Gregory Koberger 2011-07-18 15:49:15 -07:00
Родитель 312bccaceb
Коммит 7872975fbf
15 изменённых файлов: 675 добавлений и 9 удалений

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

@ -14,8 +14,10 @@ from tower import ugettext as _, ugettext_lazy as _lazy
import amo
from amo.utils import slug_validator
from .models import (UserProfile, BlacklistedUsername, BlacklistedEmailDomain,
BlacklistedPassword, DjangoUser)
from .models import (UserProfile, UserNotification, BlacklistedUsername,
BlacklistedEmailDomain, BlacklistedPassword, DjangoUser)
from .widgets import NotificationsSelectMultiple
import users.notifications as email
from . import tasks
log = commonware.log.getLogger('z.users')
@ -213,10 +215,32 @@ class UserEditForm(UserRegisterForm, PasswordMixin):
photo = forms.FileField(label=_lazy(u'Profile Photo'), required=False)
notifications = forms.MultipleChoiceField(
choices=email.NOTIFICATIONS_CHOICES,
widget=NotificationsSelectMultiple,
initial=email.NOTIFICATIONS_DEFAULT,
required=False)
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.NOTIFICATIONS_BY_ID.items())
user = dict((n.notification_id, n.enabled) for n
in self.instance.notifications.all())
default.update(user)
self.fields['notifications'].initial = [i for i, v
in default.items() if v]
# Mark unsaved options as "new"
user = (self.instance.notifications
.values_list('notification_id', flat=True))
for c in self.fields['notifications'].choices:
c[1].new = c[0] not in user
# TODO: We should inherit from a base form not UserRegisterForm
if self.fields.get('recaptcha'):
del self.fields['recaptcha']
@ -284,6 +308,11 @@ class UserEditForm(UserRegisterForm, PasswordMixin):
amo.log(amo.LOG.CHANGE_PASSWORD)
log.info(u'User (%s) changed their password' % u)
for (i, n) in email.NOTIFICATIONS_BY_ID.items():
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()

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

@ -317,6 +317,22 @@ class UserProfile(amo.models.ModelBase):
return c
class UserNotification(amo.models.ModelBase):
user = models.ForeignKey(UserProfile, related_name='notifications')
notification_id = models.IntegerField()
enabled = models.BooleanField(default=False)
class Meta:
db_table = 'users_notifications'
@staticmethod
def update_or_create(update={}, **kwargs):
rows = UserNotification.objects.filter(**kwargs).update(**update)
if not rows:
update.update(dict(**kwargs))
obj = UserNotification.objects.create(**update)
class RequestUserManager(amo.models.ManagerBase):
def get_query_set(self):

126
apps/users/notifications.py Normal file
Просмотреть файл

@ -0,0 +1,126 @@
from inspect import isclass
from celery.datastructures import AttributeDict
from tower import ugettext_lazy as _
class _NOTIFICATION(object):
pass
class thanks(_NOTIFICATION):
id = 2
group = 'user'
short = 'dev_thanks'
label = _('an add-on developer thanks me for a contribution')
mandatory = False
default_checked = True
class reply(_NOTIFICATION):
id = 3
group = 'user'
short = 'reply'
label = _('an add-on developer replies to my review')
mandatory = False
default_checked = True
class new_features(_NOTIFICATION):
id = 4
group = 'user'
short = 'new_features'
label = _('new add-ons or Firefox features are available')
mandatory = False
default_checked = True
class upgrade_success(_NOTIFICATION):
id = 5
group = 'dev'
short = 'upgrade_success'
label = _("my add-on's compatibility is upgraded successfully")
mandatory = False
default_checked = True
class sdk_upgrade_success(_NOTIFICATION):
id = 6
group = 'dev'
short = 'sdk_upgrade_success'
label = _("my sdk-based add-on is upgraded successfully")
mandatory = False
default_checked = True
class new_review(_NOTIFICATION):
id = 7
group = 'dev'
short = 'new_review'
label = _("someone writes a review of my add-on")
mandatory = False
default_checked = True
class announcements(_NOTIFICATION):
id = 8
group = 'dev'
short = 'announcements'
label = _("add-on contests or events are announced")
mandatory = False
default_checked = True
class upgrade_fail(_NOTIFICATION):
id = 9
group = 'dev'
short = 'upgrade_fail'
label = _("my add-on's compatibility cannot be upgraded")
mandatory = True
default_checked = True
class sdk_upgrade_fail(_NOTIFICATION):
id = 10
group = 'dev'
short = 'sdk_upgrade_fail'
label = _("my sdk-based add-on cannot be ugpraded")
mandatory = True
default_checked = True
class editor_reviewed(_NOTIFICATION):
id = 11
group = 'dev'
short = 'editor_reviewed'
label = _("my add-on is reviewed by an editor")
mandatory = True
default_checked = True
class individual_contact(_NOTIFICATION):
id = 12
group = 'dev'
short = 'individual_contact'
label = _("Mozilla needs to contact me about my individual add-on")
mandatory = True
default_checked = True
NOTIFICATION_GROUPS = {'dev': _('Developer'),
'user': _('User Notifications')}
NOTIFICATIONS = [x for x in vars().values()
if isclass(x) and issubclass(x, _NOTIFICATION)
and x != _NOTIFICATION]
NOTIFICATIONS_BY_ID = dict((l.id, l) for l in NOTIFICATIONS)
NOTIFICATION = AttributeDict((l.__name__, l) for l in NOTIFICATIONS)
NOTIFICATIONS_DEV = [l.id for l in NOTIFICATIONS if l.group == 'dev']
NOTIFICATIONS_USER = [l.id for l in NOTIFICATIONS if l.group == 'user']
NOTIFICATIONS_DEFAULT = [l.id for l in NOTIFICATIONS if l.default_checked]
NOTIFICATIONS_CHOICES = [(l.id, l.label) for l in NOTIFICATIONS]

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

@ -0,0 +1,186 @@
{% extends "impala/base.html" %}
{% from 'includes/forms.html' import required %}
{% from 'devhub/includes/macros.html' import some_html_tip %}
{% block title %}{{ page_title(_('Account Settings')) }}{% endblock %}
{% block extrahead %}
<link rel="stylesheet" href="{{ media('css/zamboni/translations/trans.css') }}">
{% endblock %}
{% block content %}
<div class="secondary">
<h2>{{ _('Add-on Account') }}</h2>
<ul>
<li><a href="{{ request.user.get_profile().get_url_path() }}">{{ _('View Profile') }}</a></li>
<li><a href="{{ url('users.edit') }}">{{ _('Edit Profile') }}</a></li>
<li><a href="{{ url('collections.user', amo_user.username) }}">{{ _('My Collections') }}</a></li>
<li><a href="{{ url('collections.detail', amo_user.username, 'favorites') }}">{{ _('My Favorites') }}</a></li>
</ul>
<h4>{{ _('Delete Account') }}</h4>
<p class="note">
{% trans %}
We'd hate to see you go! If we're being a bit too noisy, you can
always just change <a href="#acct-notify">how often we contact you</a>.
{% endtrans %}
</p>
<p id="acct-delete" class="note">
<a href="{{ url('users.delete') }}" class="button scary"
title="{{ _('Permanently delete your account') }}">{{ _('Delete Account') }}</a>
</p>
</div>
<div class="primary prettyform" role="main">
{% include "messages.html" %}
<form method="post" action="" class="user-input island"
enctype="multipart/form-data">
{{ csrf() }}
<div id="user-edit" class="tab-wrapper">
<div id="user-account" class="tab-panel">
<fieldset id="acct-account">
<legend>{{ _('My account') }}</legend>
<ul>
<li>
<label for="id_username">{{ _('Username') }} {{ required() }}</label>
{{ form.username }}
{{ form.username.errors }}
</li>
<li>
<label for="id_email">{{ _('Email Address') }} {{ required() }}</label>
{{ form.email }}
{{ form.email.errors }}
</li>
<li>
<label>&nbsp;</label>
<label for="id_emailhidden" class="check">
{{ form.emailhidden }}
{{ _('Hide email address from other users') }}
</label>
{{ form.emailhidden.errors }}
</li>
</ul>
</fieldset>
<fieldset id="acct-password">
<legend>{{ _('Password') }}</legend>
<ol>
<li>
<label for="id_oldpassword">{{ _('Old Password') }}</label>
{{ form.oldpassword }}
{{ form.oldpassword.errors }}
</li>
<li>
{% with form_user=form.instance %}{% include "users/tougher_password.html" %}{% endwith %}
&nbsp;
</li>
<li>
<label for="id_password">{{ _('New Password') }}</label>
{{ form.password }}
{{ form.password.errors }}
</li>
<li>
<label for="id_password2">{{ _('Confirm Password') }}</label>
{{ form.password2 }}
{{ form.password2.errors }}
</li>
</ol>
</fieldset>
<fieldset id="acct-notify">
<legend>{{ _('Notifications') }}</legend>
<p>
{% trans %}
From time to time, Mozilla may send you email about upcoming
releases and add-on events. Please select the topics you are
interested in below:
{% endtrans %}
</p>
{{ form.notifications }}
<p class="note">
{% trans %}
Mozilla reserves the right to contact you individually about
specific concerns with your hosted add-ons.
{% endtrans %}
</p>
</fieldset>
</div>{# /#user-account #}
<div id="user-profile" class="tab-panel">
<fieldset id="profile-personal">
<legend>{{ _('Profile information') }}</legend>
<ol>
<li>
<label for="id_display_name">{{ _('Display Name') }}</label>
{{ form.display_name }}
{{ form.display_name.errors }}
</li>
<li>
<label for="id_location">{{ _('Location') }}</label>
{{ form.location }}
{{ form.location.errors }}
</li>
<li>
<label for="id_occupation">{{ _('Occupation') }}</label>
{{ form.occupation }}
{{ form.occupation.errors }}
</li>
<li>
<label for="id_homepage">{{ _('Homepage') }}</label>
{{ form.homepage }}
{{ form.homepage.errors }}
</li>
<li class="profile-photo">
<label for="id_photo">{{ _('Profile Photo') }}</label>
<div class="invisible-upload">
<a class="button" href="#">Choose Photo&hellip;</a>
<input type="file" id="id_photo" name="photo">
</div>
{{ form.photo.errors }}
<img src="{{ amouser.picture_url }}"
alt="{% if not amouser.picture_type %}{{ _('No Photo') }}{% endif %}"
class="avatar photo" />
{% if amouser.picture_type %}
<a href="{{ url('users.delete_photo') }}" class="delete">{{ _('delete current') }}</a>
{% endif %}
</li>
</ol>
</fieldset>
<fieldset id="profile-detail" class="clearboth">
<legend>{{ _('Details') }}</legend>
<p>
{% trans -%}
Introduce yourself to the community, if you like!
This text will appear publicly on your user info page.
{%- endtrans %}
</p>
{{ form.bio }}
{{ some_html_tip() }}
{{ form.bio.errors }}
</fieldset> {# /.profile-detail #}
<ul>
<li>
<label for="id_display_collections" class="check">
{{ form.display_collections }}
{{ _('Display the collections I have created') }}
</label>
{{ form.display_collections.errors }}
</li>
<li>
<label for="id_display_collections_fav" class="check">
{{ form.display_collections_fav }}
{{ _("Display collections I'm following") }}
</label>
{{ form.display_collections_fav.errors }}
</li>
</ul>
</div>{# /#user-profile #}
<div class="listing-footer">
<button type="submit" class="button prominent">{{ _('Update') }}</button>
</div>
</div>{# /.tab-wrapper #}
</form>
</div>{# /.primary #}
{% endblock content %}

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

@ -1,3 +1,3 @@
{% if form_user.needs_tougher_password -%}
<p>{{ _('For your account a password must contain at least 8 characters including letters and numbers.') }}</p>
<p class="note">{{ _('For your account a password must contain at least 8 characters including letters and numbers.') }}</p>
{%- endif %}

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

@ -16,10 +16,11 @@ from access.models import Group, GroupUser
from addons.models import Addon, AddonUser
import amo
from amo.helpers import urlparams
from amo.pyquery_wrapper import PyQuery
from amo.pyquery_wrapper import PyQuery as pq
from amo.urlresolvers import reverse
from amo.tests.test_helpers import AbuseBase
from users.models import BlacklistedPassword, UserProfile
from users.models import BlacklistedPassword, UserProfile, UserNotification
import users.notifications as email
from users.utils import EmailResetCode
@ -59,6 +60,7 @@ class TestEdit(UserViewBase):
self.client.login(username='jbalogh@mozilla.com', password='foo')
self.user = UserProfile.objects.get(username='jbalogh')
self.url = reverse('users.edit')
self.impala_url = reverse('users.edit_impala')
self.correct = {'username': 'jbalogh', 'email': 'jbalogh@mozilla.com',
'oldpassword': 'foo', 'password': 'longenough',
'password2': 'longenough'}
@ -134,6 +136,20 @@ class TestEdit(UserViewBase):
self.assertContains(r, data['bio'])
eq_(unicode(self.get_profile().bio), data['bio'])
def test_edit_notifications(self):
post = self.correct.copy()
post['notifications'] = [2, 4, 6]
res = self.client.post(self.impala_url, post)
eq_(res.status_code, 302)
eq_(UserNotification.objects.count(), len(email.NOTIFICATION))
eq_(UserNotification.objects.filter(enabled=True).count(), 3)
res = self.client.get(self.impala_url, post)
doc = pq(res.content)
eq_(doc('[name=notifications]:checked').length, 3)
class TestPasswordAdmin(UserViewBase):
fixtures = ['base/users']
@ -309,7 +325,7 @@ class TestRegistration(UserViewBase):
# User doesn't have a confirmation code
url = reverse('users.confirm', args=[self.user.id, 'code'])
r = self.client.get(url, follow=True)
anon = PyQuery(r.content)('body').attr('data-anonymous')
anon = pq(r.content)('body').attr('data-anonymous')
self.assertTrue(anon)
self.user_profile.confirmationcode = "code"
@ -331,7 +347,7 @@ class TestRegistration(UserViewBase):
# User doesn't have a confirmation code
url = reverse('users.confirm.resend', args=[self.user.id])
r = self.client.get(url, follow=True)
anon = PyQuery(r.content)('body').attr('data-anonymous')
anon = pq(r.content)('body').attr('data-anonymous')
self.assertTrue(anon)
self.user_profile.confirmationcode = "code"
@ -355,7 +371,7 @@ class TestProfile(UserViewBase):
"""Grab profile, return edit links."""
url = reverse('users.profile', args=[id])
r = self.client.get(url)
return PyQuery(r.content)('p.editprofile a')
return pq(r.content)('p.editprofile a')
# Anonymous user.
links = get_links(self.user.id)

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

@ -20,6 +20,10 @@ detail_patterns = patterns('',
url('^abuse', views.report_abuse, name='users.abuse'),
)
impala_users_patterns = patterns('',
url('^edit$', views.edit_impala, name='users.edit_impala'),
)
users_patterns = patterns('',
url('^ajax$', views.ajax, name='users.ajax'),
url('^delete$', views.delete, name='users.delete'),
@ -47,5 +51,6 @@ users_patterns = patterns('',
urlpatterns = patterns('',
# URLs for a single user.
('^user/(?P<user_id>\d+)/', include(detail_patterns)),
('^i/users/', include(impala_users_patterns)),
('^users/', include(users_patterns)),
)

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

@ -127,6 +127,62 @@ def delete_photo(request):
return jingo.render(request, 'users/delete_photo.html', dict(user=u))
@write
@login_required
def edit_impala(request):
# Don't use request.amo_user since it has too much caching.
amouser = UserProfile.objects.get(pk=request.user.id)
if request.method == 'POST':
# ModelForm alters the instance you pass in. We need to keep a copy
# around in case we need to use it below (to email the user)
original_email = amouser.email
form = forms.UserEditForm(request.POST, request.FILES, request=request,
instance=amouser)
if form.is_valid():
messages.success(request, _('Profile Updated'))
if amouser.email != original_email:
l = {'user': amouser,
'mail1': original_email,
'mail2': amouser.email}
log.info(u"User (%(user)s) has requested email change from"
"(%(mail1)s) to (%(mail2)s)" % l)
messages.info(request, _('Email Confirmation Sent'),
_(('An email has been sent to {0} to confirm your new ' +
'email address. For the change to take effect, you ' +
'need to click on the link provided in this email. ' +
'Until then, you can keep logging in with your ' +
'current email address.')).format(amouser.email))
domain = settings.DOMAIN
token, hash = EmailResetCode.create(amouser.id, amouser.email)
url = "%s%s" % (settings.SITE_URL,
reverse('users.emailchange', args=[amouser.id,
token, hash]))
t = loader.get_template('users/email/emailchange.ltxt')
c = {'domain': domain, 'url': url, }
send_mail(_(("Please confirm your email address "
"change at %s") % domain),
t.render(Context(c)), None, [amouser.email],
use_blacklist=False)
# Reset the original email back. We aren't changing their
# address until they confirm the new one
amouser.email = original_email
form.save()
return http.HttpResponseRedirect(reverse('users.edit_impala'))
else:
messages.error(request, _('Errors Found'),
_('There were errors in the changes '
'you made. Please correct them and '
'resubmit.'))
else:
form = forms.UserEditForm(instance=amouser)
return jingo.render(request, 'users/edit_impala.html',
{'form': form, 'amouser': amouser})
@write
@login_required
def edit(request):

52
apps/users/widgets.py Normal file
Просмотреть файл

@ -0,0 +1,52 @@
from django import forms
from django.conf import settings
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
class NotificationsSelectMultiple(forms.CheckboxSelectMultiple):
"""Widget that formats the notification checkboxes."""
def __init__(self, **kwargs):
super(self.__class__, self).__init__(**kwargs)
def render(self, name, value, attrs=None):
str_values = set([int(v) for v in value]) or []
final_attrs = self.build_attrs(attrs, name=name)
groups = {}
for c in sorted(self.choices):
notification = email.NOTIFICATIONS_BY_ID[c[0]]
cb_attrs = dict(final_attrs, id='%s_%s' % (attrs['id'], c[0]))
notes = []
if notification.mandatory:
cb_attrs = dict(cb_attrs, disabled=1)
notes.append(u'<abbr title="required" class="req">*</abbr>')
if c[1].new:
notes.append(u'<sup class="msg">%s</sup>' % _('new'))
cb = forms.CheckboxInput(
cb_attrs, check_test=lambda value: value in str_values)
rendered_cb = cb.render(name, c[0])
label_for = u' for="%s"' % cb_attrs['id']
groups.setdefault(notification.group, []).append(
u'<li><label class="check" %s>%s %s %s</label></li>' % (
label_for, rendered_cb, c[1], ''.join(notes)
))
output = []
template = u'<li><label>%s</label><ul class="checkboxes">%s</ul></li>'
for e, name in email.NOTIFICATION_GROUPS.items():
if e in groups:
output.append(template % (name, u'\n'.join(groups[e])))
return mark_safe(u'<ol class="complex">%s</ul>' % u'\n'.join(output))

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

@ -77,7 +77,7 @@
.sprite-pos(7, 64, 3px);
}
}
&.developer { // Red
&.developer, &.scary { // Red
background: #bc2b1a;
.gradient-two-color(#f84b4e, #bc2b1a);
color: #fff;
@ -175,3 +175,45 @@
.thunderbird .listing-grid .install-shell {
display: none;
}
/* Invisible Upload */
.invisible-upload {
position: relative;
display: inline-block;
overflow: hidden;
cursor: pointer;
input {
display: block;
position: absolute;
right: 0;
bottom: 0;
width: auto;
height: 110%;
opacity: 0;
cursor: pointer;
/* stack this below the navbar: */
z-index: 0;
outline: none;
}
&:hover input {
font-size: 1000px;
}
&:hover, &:focused {
a.button {
border-color: #25f;
.box-shadow(inset 0 0 2px #fff);
}
a.link {
text-decoration: underline;
}
}
a.button {
position: relative;
display: inline-block;
}
a.link {
text-decoration: none;
color: #0055EE;
}
}

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

@ -54,3 +54,107 @@ form p {
.edit_initially_hidden {
display: none;
}
.prettyform {
fieldset {
border-bottom: 1px dotted #C9DDF2;
margin: 0 0 1em;
padding: 1em;
legend {
margin-left: 2em;
color: #D0D9EB;
float: right;
font-size: 25px;
text-transform: uppercase;
}
}
label {
color: #555;
display: inline-block;
font-weight: normal;
padding-bottom: 1em;
text-transform: lowercase;
width: 120px;
text-align: right;
padding-right: 1em;
&.check {
width: auto;
text-align: left;
color: #777;
&:hover {
color: #555;
}
}
.req {
color: @red;
}
}
input[type="checkbox"] {
margin-left: 0;
}
input[type="text"], input[type="password"], select, textarea {
border: 1px solid #B2C8E0;
border-radius: 3px 3px 3px 3px;
box-shadow: 2px 2px #EFF6FE inset;
color: #5B738E;
font-size: 1em;
margin-bottom: 1em;
padding: 4px 7px;
&:focus {
color: #394D63;
border-color: #809CBA;
}
}
p.note {
color: #888888;
font-size: 0.9em;
font-style: italic;
margin-top: 0;
padding-left: 138px;
clear: both;
width: 300px;
}
sup {
bottom: 4px;
font-size: 0.7em;
position: relative;
&.msg {
color: @red;
}
}
legend + p {
margin-top: 0;
}
.checkboxes {
display: inline-block;
padding-bottom: 1em;
label {
padding: 0 0 4px;
width: auto;
text-align: left;
input {
margin: 0 8px 0 0;
}
}
}
.complex {
label, ul {
float: left;
}
label {
clear: both;
}
}
a {
&.delete {
color: @red;
padding-left: 1em;
}
}
.listing-footer {
button {
float: right;
}
}
}

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

@ -0,0 +1,15 @@
.trans {
[lang] {
display: none;
&:first-child {
display: block;
}
}
.init-trans {
display: none;
}
.cloned {
color: #ccc;
font-style: italic;
}
}

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

@ -175,6 +175,11 @@ a {
}
p {
margin-bottom: 8px;
&.note {
color: #666;
line-height: 1.2em;
padding: 10px 0 0 0;
}
}
section section {
margin: 0;

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

@ -0,0 +1,13 @@
DROP TABLE IF EXISTS `users_notifications`;
CREATE TABLE `users_notifications` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`notification_id` int(11) NOT NULL,
`created` datetime DEFAULT NULL,
`modified` datetime DEFAULT NULL,
`enabled` tinyint(1) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
ALTER TABLE `users_notifications`
ADD CONSTRAINT FOREIGN KEY (`user_id`) REFERENCES `users` (`id`);

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

@ -425,6 +425,7 @@ MINIFY_BUNDLES = {
'css/impala/addon_details.less',
'css/impala/expando.less',
'css/impala/popups.less',
'css/impala/l10n.less',
'css/impala/contributions.less',
'css/impala/lightbox.less',
'css/impala/copy.less',