From 11e4158a2e0cd034651046cea82225ac27813f5a Mon Sep 17 00:00:00 2001 From: Andy McKay Date: Fri, 16 Sep 2011 13:04:22 -0700 Subject: [PATCH] the support pages (bug 686808) --- apps/market/models.py | 3 +- apps/users/forms.py | 10 ++ apps/users/templates/users/purchases.html | 6 +- .../templates/users/support/author-sent.html | 3 + .../users/templates/users/support/author.html | 13 ++ .../users/support/emails/refund-request.txt | 9 + .../users/support/emails/support-request.txt | 12 ++ .../templates/users/support/mozilla.html | 14 ++ .../templates/users/support/refund-sent.html | 5 + .../users/templates/users/support/refund.html | 8 + .../templates/users/support/request.html | 17 ++ .../templates/users/support/resources.html | 8 + apps/users/templates/users/support/site.html | 10 ++ apps/users/templates/users/support/start.html | 5 + .../templates/users/support/wrapper.html | 9 + apps/users/tests/test_views.py | 123 +++++++++++++- apps/users/urls.py | 3 + apps/users/views.py | 157 ++++++++++++++++-- settings.py | 1 + 19 files changed, 391 insertions(+), 25 deletions(-) create mode 100644 apps/users/templates/users/support/author-sent.html create mode 100644 apps/users/templates/users/support/author.html create mode 100644 apps/users/templates/users/support/emails/refund-request.txt create mode 100644 apps/users/templates/users/support/emails/support-request.txt create mode 100644 apps/users/templates/users/support/mozilla.html create mode 100644 apps/users/templates/users/support/refund-sent.html create mode 100644 apps/users/templates/users/support/refund.html create mode 100644 apps/users/templates/users/support/request.html create mode 100644 apps/users/templates/users/support/resources.html create mode 100644 apps/users/templates/users/support/site.html create mode 100644 apps/users/templates/users/support/start.html create mode 100644 apps/users/templates/users/support/wrapper.html diff --git a/apps/market/models.py b/apps/market/models.py index 213fc005a3..51f22bbc0d 100644 --- a/apps/market/models.py +++ b/apps/market/models.py @@ -147,7 +147,8 @@ def create_addon_purchase(sender, instance, **kw): if instance.type == amo.CONTRIB_PURCHASE: log.debug('Creating addon purchase: addon %s, user %s' % (instance.addon.pk, instance.user.pk)) - AddonPurchase.objects.create(addon=instance.addon, user=instance.user) + AddonPurchase.objects.get_or_create(addon=instance.addon, + user=instance.user) elif instance.type in [amo.CONTRIB_REFUND, amo.CONTRIB_CHARGEBACK]: purchases = AddonPurchase.objects.filter(addon=instance.addon, diff --git a/apps/users/forms.py b/apps/users/forms.py index de052188e4..95ce72a9d7 100644 --- a/apps/users/forms.py +++ b/apps/users/forms.py @@ -408,3 +408,13 @@ class BlacklistedEmailDomainAddForm(forms.Form): self._errors['domains'] = ErrorList([msg]) return data + + +class ContactForm(happyforms.Form): + text = forms.CharField(widget=forms.Textarea(), required=True) + + +class RemoveForm(happyforms.Form): + remove = forms.BooleanField(required=True, + label=_('I have removed this add-on from ' + 'all of my devices.')) diff --git a/apps/users/templates/users/purchases.html b/apps/users/templates/users/purchases.html index 47f97ca5a9..51d74ea71a 100644 --- a/apps/users/templates/users/purchases.html +++ b/apps/users/templates/users/purchases.html @@ -15,7 +15,8 @@ {{ impala_addon_listing_header(url_base, filter.opts, sorting, filter.extras) }} {% endif %}
- {% for addon in purchases.object_list %} + {% for contribution in purchases.object_list %} + {% with addon=contribution.addon %}

@@ -24,12 +25,13 @@

{{ addon.description|truncate(250)|nl2br }}

- {% trans date=addon.created, amt=prices[addon.pk][1], url='' %} + {% trans date=addon.created, amt=contribution.get_amount_locale(), url=url('users.support', contribution.pk) %}
Purchased {{ date }} for {{ amt }} - Request Support
{% endtrans %}
+ {% endwith %} {% else %}

You have no purchases.

{% endfor %} diff --git a/apps/users/templates/users/support/author-sent.html b/apps/users/templates/users/support/author-sent.html new file mode 100644 index 0000000000..968700a0e1 --- /dev/null +++ b/apps/users/templates/users/support/author-sent.html @@ -0,0 +1,3 @@ +

{{ _('Support Request Sent') }}

+

{% trans %}You support request has been sent.{% endtrans %}

+

Close

diff --git a/apps/users/templates/users/support/author.html b/apps/users/templates/users/support/author.html new file mode 100644 index 0000000000..46a46fe426 --- /dev/null +++ b/apps/users/templates/users/support/author.html @@ -0,0 +1,13 @@ +

{{ _('Contact the Author') }}

+
+ {{ csrf() }} +

{% trans %}For support questions regarding this add-on's functionality, + please contact the author using the form below.{% endtrans %}

+

{% trans name=addon.name %}To: {{ name }}{% endtrans %}

+

{% trans %}Please describe the issue:{% endtrans %}

+ {{ form.text.errors }} + {{ form.text }} +

{% trans %}Your email address will be made available to the + add-on author for replies.{% endtrans %}

+ +
diff --git a/apps/users/templates/users/support/emails/refund-request.txt b/apps/users/templates/users/support/emails/refund-request.txt new file mode 100644 index 0000000000..e07b49b90b --- /dev/null +++ b/apps/users/templates/users/support/emails/refund-request.txt @@ -0,0 +1,9 @@ +Your add-on, {{ addon.name }}, has received a new refund request: +User: {{ user.display_name or user.username }} +Purchased on {{ contribution.date|datetime }} +Price: {{ contribution.get_amount_locale() }} + +Reason given: +{{ form.cleaned_data.text }} + +To approve or deny this request, click here: ... diff --git a/apps/users/templates/users/support/emails/support-request.txt b/apps/users/templates/users/support/emails/support-request.txt new file mode 100644 index 0000000000..b01fd24f2b --- /dev/null +++ b/apps/users/templates/users/support/emails/support-request.txt @@ -0,0 +1,12 @@ +Add-on, {{ addon.name }}, has received a new support request: +User: {{ user.display_name or user.username }} +Email: {{ user.email }} +Purchased on {{ contribution.date|datetime }} + +Details: + +{{ form.cleaned_data.text }} + +{{ request.HTTP_USER_AGENT }} + +Please reply to this email to respond to the user’s request. diff --git a/apps/users/templates/users/support/mozilla.html b/apps/users/templates/users/support/mozilla.html new file mode 100644 index 0000000000..a968273f3c --- /dev/null +++ b/apps/users/templates/users/support/mozilla.html @@ -0,0 +1,14 @@ +

{{ _('Contact Mozilla') }}

+
+ {{ csrf() }} +

{% trans %}Please use this form only for issues with the Mozilla + Marketplace. For other suport and question, please visit + support.mozilla.com. We are unable to respond + to misdirected support requests through this form + {% endtrans %}

+

{% trans %}To: Mozilla Marketplace Support{% endtrans %}

+

{% trans %}Please describe the issue:{% endtrans %}

+ {{ form.text.errors }} + {{ form.text }} + +
diff --git a/apps/users/templates/users/support/refund-sent.html b/apps/users/templates/users/support/refund-sent.html new file mode 100644 index 0000000000..9e88746b6b --- /dev/null +++ b/apps/users/templates/users/support/refund-sent.html @@ -0,0 +1,5 @@ +

{{ _('Request Refund') }}

+

{% trans name=addon.name %}Your request for a refund of {{ name }} has been +submitted. You will receive an email when the author of this add-on has +responded.{% endtrans %}

+

Close

diff --git a/apps/users/templates/users/support/refund.html b/apps/users/templates/users/support/refund.html new file mode 100644 index 0000000000..1aee1aa59b --- /dev/null +++ b/apps/users/templates/users/support/refund.html @@ -0,0 +1,8 @@ +

{{ _('Request Refund') }}

+
+ {{ csrf() }} +

{% trans %} Please tell us why you are requesting a refund.{% endtrans %}

+ {{ form.text.errors }} + {{ form.text }} + +
diff --git a/apps/users/templates/users/support/request.html b/apps/users/templates/users/support/request.html new file mode 100644 index 0000000000..a2d8da1728 --- /dev/null +++ b/apps/users/templates/users/support/request.html @@ -0,0 +1,17 @@ +

{{ _('Request Refund') }}

+
+ {{ csrf() }} +

{% trans url=url('users.support', contribution.pk, 'author') %} + If you are unsatisfied with your purchase, you may request + a refund from the developer for up to 60 days. Requests within 30 minutes + after purchase are automatically granted. If you are having difficulty + using the add-on, we encourage you to ask the developer for + support before requesting a refund.{% endtrans %}

+

{% trans name=addon.name %}In order to continue with the refund + process, you must completely + remove the add-on from all of your devices. Open Add-ons Manager, + select Remove next to {{ name }} and restart Firefox.{% endtrans %}

+ {{ form.remove.errors }} +

{{ form.remove }} {{ form.remove.label }}

+ +
diff --git a/apps/users/templates/users/support/resources.html b/apps/users/templates/users/support/resources.html new file mode 100644 index 0000000000..06d8ec8e38 --- /dev/null +++ b/apps/users/templates/users/support/resources.html @@ -0,0 +1,8 @@ +

{{ _('Helpful Resources') }}

+

{% trans %}Please use the articles below to find answers to some of the + most common questions and issues encountered in the Marketplace. + {% endtrans %}

+

{% trans url=url('users.support', contribution.pk, 'mozilla') %} + If you looked through the above resources and still have a + question, please contact us + {% endtrans %}.

diff --git a/apps/users/templates/users/support/site.html b/apps/users/templates/users/support/site.html new file mode 100644 index 0000000000..9cf7cccd61 --- /dev/null +++ b/apps/users/templates/users/support/site.html @@ -0,0 +1,10 @@ +

{{ _('Add-on Support') }}

+

{% trans %}For support questions regarding this add-on's functionality, + please visit the add-on author's support website to search for + a solution:{% endtrans %}

+

{{ addon.name }}

+

{% trans url=url('users.support', contribution.pk, 'author') %} + If you are unable to find the answer to your question at the + above support website, please contact the author. + {% endtrans %}

+ diff --git a/apps/users/templates/users/support/start.html b/apps/users/templates/users/support/start.html new file mode 100644 index 0000000000..99316e783e --- /dev/null +++ b/apps/users/templates/users/support/start.html @@ -0,0 +1,5 @@ +

{{ _('What do you need help with?') }}

+

{{ _("This add-on isn't working as expected »") }}

+

{{ _("I can't install my purchase »") }}

+

{{ _('I have billing or payment concerns »') }}

+

{{ _('I want to request a refund »') }}

diff --git a/apps/users/templates/users/support/wrapper.html b/apps/users/templates/users/support/wrapper.html new file mode 100644 index 0000000000..46eb93b8dd --- /dev/null +++ b/apps/users/templates/users/support/wrapper.html @@ -0,0 +1,9 @@ +{% extends "impala/base.html" %} + +{% block title %}{{ page_title(wizard.title) }}{% endblock %} + +{% block content %} +
+ {% include content %} +
+{% endblock %} diff --git a/apps/users/tests/test_views.py b/apps/users/tests/test_views.py index 51807e679a..5086a4b047 100644 --- a/apps/users/tests/test_views.py +++ b/apps/users/tests/test_views.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta import json from django.conf import settings @@ -704,9 +705,13 @@ class TestPurchases(amo.tests.TestCase): for x in range(1, 5): addon = Addon.objects.create(type=amo.ADDON_EXTENSION, name='t%s' % x) - Contribution.objects.create(user=self.user.get_profile(), - addon=addon, amount='%s.00' % x, - type=amo.CONTRIB_PURCHASE) + con = Contribution.objects.create(user=self.user.get_profile(), + addon=addon, amount='%s.00' % x, + type=amo.CONTRIB_PURCHASE) + con.created = datetime.now() - timedelta(days=10 - x) + con.save() + + self.con = con self.addon = addon def test_in_menu(self): @@ -734,7 +739,8 @@ class TestPurchases(amo.tests.TestCase): def get_order(self, order): res = self.client.get('%s?sort=%s' % (self.url, order)) - return [str(a.name) for a in res.context['purchases'].object_list] + return [str(c.addon.name) for c in + res.context['purchases'].object_list] def test_ordering(self): eq_(self.get_order('name'), ['t1', 't2', 't3', 't4']) @@ -774,3 +780,112 @@ class TestPurchases(amo.tests.TestCase): self.user.get_profile().addonpurchase_set.all().delete() doc = pq(self.client.get(self.url).content) eq_(doc('body').attr('data-purchases'), '') + + def test_refund_link(self): + doc = pq(self.client.get(self.url).content) + url = reverse('users.support', args=[self.con.pk]) + eq_(doc('div.vitals a').eq(0).attr('href'), url) + + def get_url(self, *args): + return reverse('users.support', args=[self.con.pk] + list(args)) + + def test_support_not_logged_in(self): + self.client.logout() + eq_(self.client.get(self.get_url()).status_code, 302) + + def test_support_not_mine(self): + self.client.login(username='admin@mozilla.com', password='password') + eq_(self.client.get(self.get_url()).status_code, 404) + + def test_support_page(self): + doc = pq(self.client.get(self.get_url()).content) + eq_(doc('section.primary a').attr('href'), self.get_url('author')) + + def test_support_page_other(self): + self.addon.support_url = 'http://cbc.ca' + self.addon.save() + + doc = pq(self.client.get(self.get_url()).content) + eq_(doc('section.primary a').attr('href'), self.get_url('site')) + + def test_support_site(self): + self.addon.support_url = 'http://cbc.ca' + self.addon.save() + + doc = pq(self.client.get(self.get_url('site')).content) + eq_(doc('section.primary a').attr('href'), self.addon.support_url) + + def test_contact(self): + data = {'text': 'Lorem ipsum dolor sit amet, consectetur'} + res = self.client.post(self.get_url('author'), data) + eq_(res.status_code, 302) + + def test_contact_mails(self): + self.addon.support_email = 'a@a.com' + self.addon.save() + + data = {'text': 'Lorem ipsum dolor sit amet, consectetur'} + self.client.post(self.get_url('author'), data) + eq_(len(mail.outbox), 1) + email = mail.outbox[0] + eq_(email.to, ['a@a.com']) + eq_(email.from_email, 'regular@mozilla.com') + + def test_contact_fails(self): + res = self.client.post(self.get_url('author'), {'b': 'c'}) + assert 'text' in res.context['form'].errors + + def test_contact_mozilla(self): + data = {'text': 'Lorem ipsum dolor sit amet, consectetur'} + res = self.client.post(self.get_url('mozilla'), data) + eq_(res.status_code, 302) + + def test_contact_mozilla_mails(self): + data = {'text': 'Lorem ipsum dolor sit amet, consectetur'} + self.client.post(self.get_url('mozilla'), data) + eq_(len(mail.outbox), 1) + email = mail.outbox[0] + eq_(email.to, [settings.FLIGTAR]) + eq_(email.from_email, 'regular@mozilla.com') + assert 'Lorem' in email.body + + def test_contact_mozilla_fails(self): + res = self.client.post(self.get_url('mozilla'), {'b': 'c'}) + assert 'text' in res.context['form'].errors + + def test_refund_remove(self): + res = self.client.post(self.get_url('request'), {'remove': 1}) + eq_(res.status_code, 302) + + def test_refund_remove_fails(self): + res = self.client.post(self.get_url('request'), {}) + eq_(res.status_code, 200) + + def test_skip_fails(self): + res = self.client.post(self.get_url('reason'), {}) + self.assertRedirects(res, self.get_url('request')) + + def test_request(self): + self.client.post(self.get_url('request'), {'remove': 1}) + res = self.client.post(self.get_url('reason'), {'text': 'something'}) + eq_(res.status_code, 302) + + def test_request_mails(self): + self.addon.support_email = 'a@a.com' + self.addon.save() + + self.client.post(self.get_url('request'), {'remove': 1}) + self.client.post(self.get_url('reason'), {'text': 'something'}) + eq_(len(mail.outbox), 1) + email = mail.outbox[0] + eq_(email.to, ['a@a.com']) + eq_(email.from_email, 'regular@mozilla.com') + assert '$4.00' in email.body + + def test_request_fails(self): + self.addon.support_email = 'a@a.com' + self.addon.save() + + self.client.post(self.get_url('request'), {'remove': 1}) + res = self.client.post(self.get_url('reason'), {}) + eq_(res.status_code, 200) diff --git a/apps/users/urls.py b/apps/users/urls.py index 7274741a5c..2b936794e7 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -60,6 +60,9 @@ users_patterns = patterns('', url(r'purchases/$', views.purchases, name='users.purchases'), url(r'purchases/%s$' % ADDON_ID, views.purchases, name='users.purchases.receipt'), + url(r'support/(?P\d+)(?:/(?P[\w-]+))?$', + views.SupportWizard.as_view(), + name='users.support') ) urlpatterns = patterns('', diff --git a/apps/users/views.py b/apps/users/views.py index db2a008360..5fb0de5210 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -6,12 +6,16 @@ from django.db import IntegrityError from django.shortcuts import get_object_or_404, redirect from django.contrib import auth from django.template import Context, loader +from django.utils.datastructures import SortedDict from django.views.decorators.cache import never_cache +from django.utils.decorators import method_decorator +from django.utils.encoding import smart_str from django.utils.http import base36_to_int from django.contrib.auth.tokens import default_token_generator import commonware.log import jingo +from radagast.wizard import Wizard from ratelimit.decorators import ratelimit from tower import ugettext as _, ugettext_lazy as _lazy from session_csrf import anonymous_csrf, anonymous_csrf_exempt @@ -28,10 +32,10 @@ from amo.utils import send_mail from abuse.models import send_abuse_report from addons.models import Addon from addons.views import BaseFilter +from addons.decorators import addon_view from access import acl from bandwagon.models import Collection from stats.models import Contribution -from translations.query import order_by_translation from users.models import UserNotification import users.notifications as notifications @@ -612,20 +616,14 @@ class ContributionsFilter(BaseFilter): ('price', _lazy(u'Price')), ('name', _lazy(u'Name'))) - def __init__(self, *args, **kw): - self.prices = kw.pop('prices') - super(ContributionsFilter, self).__init__(*args, **kw) - def filter(self, field): qs = self.base_queryset if field == 'date': return qs.order_by('-created') elif field == 'price': - # TODO(andym): If someone has a large number of purchases, - # this will get expensive. - return sorted(list(qs), key=lambda x: self.prices[x.pk][0]) + return qs.order_by('amount') elif field == 'name': - return order_by_translation(qs, 'name') + return qs.order_by('addon__name') @login_required @@ -634,21 +632,144 @@ def purchases(request, addon_id=None): if not waffle.switch_is_active('marketplace'): raise http.Http404 + # TODO(ashort): this is where we'll need to get cunning about refunds. cs = Contribution.objects.filter(user=request.amo_user, type=amo.CONTRIB_PURCHASE) if addon_id: cs = cs.filter(addon=addon_id) - prices = dict((p.addon_id, (p.amount, p.get_amount_locale())) for p in cs) - if addon_id and not prices: + + filter = ContributionsFilter(request, cs, key='sort', default='date') + purchases = amo.utils.paginate(request, filter.qs) + + if addon_id and not purchases.object_list: # User has requested a receipt for an addon they don't have. raise http.Http404 - - base = Addon.objects.filter(id__in=prices.keys()) - filter = ContributionsFilter(request, base, key='sort', - default='date', prices=prices) - - purchases = amo.utils.paginate(request, filter.qs, count=len(prices)) return jingo.render(request, 'users/purchases.html', {'purchases': purchases, 'filter': filter, 'url_base': reverse('users.purchases'), - 'prices': prices, 'single': bool(addon_id)}) + 'single': bool(addon_id)}) + + +# Start of the Support wizard all of these are accessed through the +# SupportWizard below. +def plain(request, contribution, wizard): + # Simple view that just shows a template matching the step. + tpl = wizard.tpl('%s.html' % wizard.step) + return wizard.render(request, tpl, {'addon': contribution.addon, + 'contribution': contribution}) + + +def support_author(request, contribution, wizard): + addon = contribution.addon + form = forms.ContactForm(request.POST) + if request.method == 'POST': + if form.is_valid(): + template = jingo.render_to_string(request, + wizard.tpl('emails/support-request.txt'), + context={'contribution': contribution, + 'addon': addon, 'form': form, + 'user': request.amo_user}) + log.info('Support request to dev. by user: %s for addon: %s' % + (request.amo_user.pk, addon.pk)) + # L10n: %s is the addon name. + send_mail(_(u'New Support Request for %s' % addon.name), + template, request.amo_user.email, + [smart_str(addon.support_email)]) + return redirect(reverse('users.support', + args=[contribution.pk, 'author-sent'])) + + return wizard.render(request, wizard.tpl('author.html'), + {'addon': addon, 'form': form}) + + +def support_mozilla(request, contribution, wizard): + addon = contribution.addon + form = forms.ContactForm(request.POST) + if request.method == 'POST': + if form.is_valid(): + template = jingo.render_to_string(request, + wizard.tpl('emails/support-request.txt'), + context={'addon': addon, 'form': form, + 'contribution': contribution, + 'user': request.amo_user}) + log.info('Support request to mozilla by user: %s for addon: %s' % + (request.amo_user.pk, addon.pk)) + # L10n: %s is the addon name. + send_mail(_(u'New Support Request for %s' % addon.name), + template, request.amo_user.email, [settings.FLIGTAR]) + return redirect(reverse('users.support', + args=[contribution.pk, 'mozilla-sent'])) + + return wizard.render(request, wizard.tpl('mozilla.html'), + {'addon': addon, 'form': form}) + + +def refund_request(request, contribution, wizard): + addon = contribution.addon + form = forms.RemoveForm(request.POST or None) + if request.method == 'POST': + if form.is_valid(): + return redirect(reverse('users.support', + args=[contribution.pk, 'reason'])) + + return wizard.render(request, wizard.tpl('request.html'), + {'addon': addon, 'form': form, + 'contribution': contribution}) + + +def refund_reason(request, contribution, wizard): + addon = contribution.addon + if not 'request' in wizard.get_progress(): + return redirect(reverse('users.support', + args=[contribution.pk, 'request'])) + + form = forms.ContactForm(request.POST or None) + if request.method == 'POST': + if form.is_valid(): + # if under 30 minutes, refund + # TODO(ashort): add in the logic for under 30 minutes. + template = jingo.render_to_string(request, + wizard.tpl('emails/refund-request.txt'), + context={'addon': addon, 'form': form, + 'user': request.amo_user, + 'contribution': contribution}) + log.info('Refund request sent by user: %s for addon: %s' % + (request.amo_user.pk, addon.pk)) + # L10n: %s is the addon name. + send_mail(_(u'New Refund Request for %s' % addon.name), + template, request.amo_user.email, + [smart_str(addon.support_email)]) + return redirect(reverse('users.support', + args=[contribution.pk, 'refund-sent'])) + + return wizard.render(request, wizard.tpl('refund.html'), + {'contribut': addon, 'form': form}) + + +class SupportWizard(Wizard): + title = _lazy('Support') + steps = SortedDict((('start', plain), + ('site', plain), + ('resources', plain), + ('mozilla', support_mozilla), + ('mozilla-sent', plain), + ('author', support_author), + ('author-sent', plain), + ('request', refund_request), + ('reason', refund_reason), + ('refund-sent', plain))) + + def tpl(self, x): + return 'users/support/%s' % x + + @property + def wrapper(self): + return self.tpl('wrapper.html') + + @method_decorator(login_required) + def dispatch(self, request, contribution_id, step='', *args, **kw): + contribution = get_object_or_404(Contribution, pk=contribution_id) + if contribution.user.pk != request.amo_user.pk: + raise http.Http404 + args = [contribution] + list(args) + return super(SupportWizard, self).dispatch(request, step, *args, **kw) diff --git a/settings.py b/settings.py index 9b25f24cce..5fd0e5bdaa 100644 --- a/settings.py +++ b/settings.py @@ -373,6 +373,7 @@ INSTALLED_APPS = ( # Has to load after auth 'django_browserid', + 'radagast', ) # These apps will be removed from INSTALLED_APPS in a production environment.