diff --git a/.gitignore b/.gitignore index bbf9e12ab56..63ba9691694 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ whoosh_index xml_output public_cnames public_symlinks +.rope_project/ diff --git a/bower.json b/bower.json index add924b7d80..282e4f31d70 100644 --- a/bower.json +++ b/bower.json @@ -19,7 +19,7 @@ "readthedocs-client": "https://github.com/agjohnson/readthedocs-client-js.git", "sphinx-rtd-theme": "https://github.com/snide/sphinx-rtd-theme.git#0.1.9", "knockout": "~3.3.0", - "jquery.payment": "~1.2.3", + "jquery.payment": "~1.3.0", "jquery-migrate": "~1.2.1", "jquery-ui": "1.8.23" }, diff --git a/media/css/core.css b/media/css/core.css index 46640c4b722..76a3c03119f 100644 --- a/media/css/core.css +++ b/media/css/core.css @@ -836,27 +836,27 @@ ul.donate-supporters.donate-supporters-large div.supporter-name { font-size: .9em; } -div#payment-form div.cc-type { - height: 23px; - margin: 3px 0px 10px; - background: url('/static/donate/img/creditcard.png'); - background-repeat: no-repeat; -} -div#payment-form input#cc-number.visa + div.cc-type { - background-position: 0px -23px; +/* Gold */ +div.gold-subscription p.subscription-detail, +div.gold-subscription p.subscription-projects { + margin: 0em; } -div#payment-form input#cc-number.mastercard + div.cc-type { - background-position: 0px -46px; + +div.gold-subscription p.subscription-detail label { + display: inline-block; } -div#payment-form input#cc-number.amex + div.cc-type { - background-position: 0px -69px; + +div.gold-subscription p.subscription-detail-card > span { + font-family: monospace; } -div#payment-form input#cc-number.discover + div.cc-type { - background-position: 0px -92px; + +div.gold-subscription > form { + display: inline-block; } -div#payment-form input#cc-expiry { width: 150px; } -div#payment-form input#cc-cvv { width: 100px; } +div.gold-subscription > form button { + margin: 1em .3em 1.5em 0em; +} /* Form Wizards */ div.actions.wizard-actions button.action-primary, diff --git a/readthedocs/core/mixins.py b/readthedocs/core/mixins.py index 9c803dad756..a6ebdae96f1 100644 --- a/readthedocs/core/mixins.py +++ b/readthedocs/core/mixins.py @@ -2,21 +2,9 @@ Common mixin classes for views """ -from django.conf import settings - from vanilla import ListView - - -class StripeMixin(object): - - """Adds Stripe publishable key to the context data""" - - def get_context_data(self, **kwargs): - context = super(StripeMixin, self).get_context_data(**kwargs) - context.update({ - 'publishable': settings.STRIPE_PUBLISHABLE, - }) - return context +from django.contrib.auth.decorators import login_required +from django.utils.decorators import method_decorator class ListViewWithForm(ListView): @@ -27,3 +15,10 @@ def get_context_data(self, **kwargs): context = super(ListViewWithForm, self).get_context_data(**kwargs) context['form'] = self.get_form(data=None, files=None) return context + + +class LoginRequiredMixin(object): + + @method_decorator(login_required) + def dispatch(self, *args, **kwargs): + return super(LoginRequiredMixin, self).dispatch(*args, **kwargs) diff --git a/readthedocs/core/static-src/core/js/payment.js b/readthedocs/core/static-src/core/js/payment.js deleted file mode 100644 index b102bff309d..00000000000 --- a/readthedocs/core/static-src/core/js/payment.js +++ /dev/null @@ -1,99 +0,0 @@ -// Stripe payment form views - -var ko = require('knockout'), - payment = require('jquery.payment'), - $ = require('jquery'), - stripe = null; - - -// TODO stripe doesn't support loading locally very well, do they? -if (typeof(window) != 'undefined' && typeof(window.Stripe) != 'undefined') { - stripe = window.Stripe || {}; -} - -function PaymentView (config) { - var self = this, - config = config || {}; - - // Config - stripe.publishableKey = self.stripe_key = config.key; - self.form = config.form; - - // Credit card parameters - self.cc_number = ko.observable(null); - self.cc_expiry = ko.observable(null); - self.cc_cvv = ko.observable(null); - self.cc_error_number = ko.observable(null); - self.cc_error_expiry = ko.observable(null); - self.cc_error_cvv = ko.observable(null); - - // Credit card validation - self.initialize_form(); - - // Outputs - self.error = ko.observable(null); - - // Process form inputs and send API request to Stripe. Using jquery.payment - // for some validation, display field errors and some generic errors. - self.process_form = function () { - var expiry = $.payment.cardExpiryVal(self.cc_expiry()), - card = { - number: self.cc_number(), - exp_month: expiry.month, - exp_year: expiry.year, - cvc: self.cc_cvv() - }; - - self.error(null); - self.cc_error_number(null); - self.cc_error_expiry(null); - self.cc_error_cvv(null); - - if (!$.payment.validateCardNumber(card.number)) { - self.cc_error_number('Invalid card number'); - console.log(card); - return false; - } - if (!$.payment.validateCardExpiry(card.exp_month, card.exp_year)) { - self.cc_error_expiry('Invalid expiration date'); - return false; - } - if (!$.payment.validateCardCVC(card.cvc)) { - self.cc_error_cvv('Invalid security code'); - return false; - } - - stripe.createToken(card, function(status, response) { - if (status === 200) { - // Update form fields that are actually sent to - var cc_last_digits = self.form.find('#id_last_4_digits'), - token = self.form.find('#id_stripe_id,#id_stripe_token'); - cc_last_digits.val(response.card.last4); - token.val(response.id); - self.form.submit(); - } - else { - self.error(response.error.message); - } - }); - }; -} - -PaymentView.prototype.initialize_form = function () { - var cc_number = $('input#cc-number'), - cc_cvv = $('input#cc-cvv'), - cc_expiry = $('input#cc-expiry'); - - cc_number.payment('formatCardNumber'); - cc_expiry.payment('formatCardExpiry'); - cc_cvv.payment('formatCardCVC'); -}; - -PaymentView.init = function (config, obj) { - var view = new GoldView(config), - obj = obj || $('#payment-form')[0]; - ko.applyBindings(view, obj); - return view; -} - -module.exports.PaymentView = PaymentView; diff --git a/readthedocs/core/templates/core/ko_form_field.html b/readthedocs/core/templates/core/ko_form_field.html new file mode 100644 index 00000000000..111545dc3aa --- /dev/null +++ b/readthedocs/core/templates/core/ko_form_field.html @@ -0,0 +1,22 @@ +{% if field.is_hidden %} + {{ field }} +{% else %} + + {{ field.errors }} + {% if 'data-bind' in field.field.widget.attrs %} +
+ {% endif %} ++ + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} +
+ +{% endif %} diff --git a/readthedocs/donate/forms.py b/readthedocs/donate/forms.py index ad93e804abb..3348610f218 100644 --- a/readthedocs/donate/forms.py +++ b/readthedocs/donate/forms.py @@ -1,23 +1,32 @@ +"""Forms for RTD donations""" + import logging from django import forms from django.conf import settings from django.utils.translation import ugettext_lazy as _ -import stripe +from readthedocs.payments.forms import StripeModelForm, StripeResourceMixin +from readthedocs.payments.utils import stripe from .models import Supporter log = logging.getLogger(__name__) -class SupporterForm(forms.ModelForm): +class SupporterForm(StripeResourceMixin, StripeModelForm): + + """Donation support sign up form + + This extends the basic payment form, giving fields for credit card number, + expiry, and CVV. The proper Knockout data bindings are established on + :py:cls:`StripeModelForm` + """ class Meta: model = Supporter fields = ( 'last_4_digits', - 'stripe_id', 'name', 'email', 'dollars', @@ -43,38 +52,33 @@ class Meta: }), 'site_url': forms.TextInput(attrs={ 'data-bind': 'value: site_url, enable: urls_enabled' - }) + }), + 'last_4_digits': forms.TextInput(attrs={ + 'data-bind': 'valueInit: card_digits, value: card_digits' + }), } last_4_digits = forms.CharField(widget=forms.HiddenInput(), required=True) - stripe_id = forms.CharField(widget=forms.HiddenInput(), required=True) + name = forms.CharField(required=True) + email = forms.CharField(required=True) def __init__(self, *args, **kwargs): self.user = kwargs.pop('user') super(SupporterForm, self).__init__(*args, **kwargs) - def clean(self): - '''Call stripe for payment (not ideal here) and clean up logo < $200''' + def validate_stripe(self): + """Call stripe for payment (not ideal here) and clean up logo < $200""" dollars = self.cleaned_data['dollars'] if dollars < 200: self.cleaned_data['logo_url'] = None self.cleaned_data['site_url'] = None - try: - stripe.api_key = settings.STRIPE_SECRET - stripe.Charge.create( - amount=int(self.cleaned_data['dollars']) * 100, - currency='usd', - source=self.cleaned_data['stripe_id'], - description='Read the Docs Sustained Engineering', - receipt_email=self.cleaned_data['email'] - ) - except stripe.error.CardError, e: - stripe_error = e.json_body['error'] - log.error('Credit card error: %s', stripe_error['message']) - raise forms.ValidationError( - _('There was a problem processing your card: %(message)s'), - params=stripe_error) - return self.cleaned_data + stripe.Charge.create( + amount=int(self.cleaned_data['dollars']) * 100, + currency='usd', + source=self.cleaned_data['stripe_token'], + description='Read the Docs Sustained Engineering', + receipt_email=self.cleaned_data['email'] + ) def save(self, commit=True): supporter = super(SupporterForm, self).save(commit) diff --git a/readthedocs/donate/mixins.py b/readthedocs/donate/mixins.py index fdec627c5f1..f9eb4640fcd 100644 --- a/readthedocs/donate/mixins.py +++ b/readthedocs/donate/mixins.py @@ -1,6 +1,4 @@ -''' -Mixin classes for donation views -''' +"""Mixin classes for donation views""" from django.db.models import Avg, Sum @@ -8,10 +6,11 @@ class DonateProgressMixin(object): - '''Add donation progress to context data''' - def get_context_data(self): - context = super(DonateProgressMixin, self).get_context_data() + """Add donation progress to context data""" + + def get_context_data(self, **kwargs): + context = super(DonateProgressMixin, self).get_context_data(**kwargs) sums = (Supporter.objects .aggregate(dollars=Sum('dollars'))) avgs = (Supporter.objects diff --git a/readthedocs/donate/static-src/donate/js/donate.js b/readthedocs/donate/static-src/donate/js/donate.js index cbe6267fb53..4e1ba31bec1 100644 --- a/readthedocs/donate/static-src/donate/js/donate.js +++ b/readthedocs/donate/static-src/donate/js/donate.js @@ -1,18 +1,22 @@ // Donate payment views var jquery = require('jquery'), - payment = require('../../../../core/static-src/core/js/payment'), + payment = require('readthedocs/payments/static-src/payments/js/base'), ko = require('knockout'); function DonateView (config) { var self = this, config = config || {}; - ko.utils.extend(self, new payment.PaymentView(config)); + self.constructor.call(self, config); self.dollars = ko.observable(); self.logo_url = ko.observable(); self.site_url = ko.observable(); + self.error_dollars = ko.observable(); + self.error_logo_url = ko.observable(); + self.error_site_url = ko.observable(); + ko.computed(function () { var input_logo = $('input#id_logo_url').closest('p'), input_site = $('input#id_site_url').closest('p'); @@ -32,6 +36,8 @@ function DonateView (config) { }); } +DonateView.prototype = new payment.PaymentView(); + DonateView.init = function (config, obj) { var view = new DonateView(config), obj = obj || $('#donate-payment')[0]; diff --git a/readthedocs/donate/static/donate/img/creditcard.png b/readthedocs/donate/static/donate/img/creditcard.png index bc56007e8c7..0fe91a8bb17 100644 Binary files a/readthedocs/donate/static/donate/img/creditcard.png and b/readthedocs/donate/static/donate/img/creditcard.png differ diff --git a/readthedocs/donate/static/donate/js/donate.js b/readthedocs/donate/static/donate/js/donate.js index bbf90bc53b0..5faa93b1774 100644 --- a/readthedocs/donate/static/donate/js/donate.js +++ b/readthedocs/donate/static/donate/js/donate.js @@ -1 +1 @@ -require=function e(t,n,r){function o(i,u){if(!n[i]){if(!t[i]){var l="function"==typeof require&&require;if(!u&&l)return l(i,!0);if(a)return a(i,!0);var c=new Error("Cannot find module '"+i+"'");throw c.code="MODULE_NOT_FOUND",c}var s=n[i]={exports:{}};t[i][0].call(s.exports,function(e){var n=t[i][1][e];return o(n?n:e)},s,s.exports,e,t,n,r)}return n[i].exports}for(var a="function"==typeof require&&require,i=0;i- Thanks for supporting Read the Docs! It really means a lot to us. -
-- Level: {{ gold_user.get_level_display }} -
-- Card: Ends with {{ gold_user.last_4_digits }} -
- -- You can adopt {{ gold_user.num_supported_projects }} projects with your subscription. Select Projects -
- -- Cancel or Change your subscription. -
- - {% else %} -- Supporting Read the Docs lets us work more on features that people - love. Your money will go directly to maintenance and development of - the product. -
-- You can make one-time donations on our sustainability page. -
- -+ {% blocktrans %} + Are you sure you want to cancel your subscription? + {% endblocktrans %} +
+ + +{% endblock %} diff --git a/readthedocs/gold/templates/gold/subscription_detail.html b/readthedocs/gold/templates/gold/subscription_detail.html new file mode 100644 index 00000000000..a25d3391a84 --- /dev/null +++ b/readthedocs/gold/templates/gold/subscription_detail.html @@ -0,0 +1,68 @@ +{% extends "profiles/base_profile_edit.html" %} +{% load static %} +{% load i18n %} + +{% block profile-admin-gold-edit %}active{% endblock %} + +{% block title %}{% trans "Gold Subscription" %}{% endblock %} + +{% block extra_scripts %} + + + + +{% endblock %} + +{% block edit_content %} ++ {% blocktrans %} + Thanks for supporting Read the Docs! It really means a lot to us. + {% endblocktrans %} +
+ ++ + {{ golduser.get_level_display }} +
+ ++ + ****-{{ golduser.last_4_digits }} +
+ + + + + ++ {% blocktrans with projects=golduser.num_supported_projects %} + You can adopt {{ projects }} projects with your subscription. + {% endblocktrans %} +
+ + ++ {% blocktrans %} + Supporting Read the Docs lets us work more on features that people love. + Your money will go directly to maintenance and development of the + product. + {% endblocktrans %} +
++ {% blocktrans %} + You can make one-time donations on our sustainability page. + {% endblocktrans %} +
+ + {% trans "Become a Gold Member" as subscription_title %} + {% if golduser %} + {% trans "Update Your Subscription" as subscription_title %} + {% endif %} +-Thanks for contributing to Read the Docs! -
- -{% endblock %} diff --git a/readthedocs/gold/tests/__init__.py b/readthedocs/gold/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/readthedocs/gold/tests/test_forms.py b/readthedocs/gold/tests/test_forms.py new file mode 100644 index 00000000000..2c00a3bf050 --- /dev/null +++ b/readthedocs/gold/tests/test_forms.py @@ -0,0 +1,224 @@ +import mock +import django_dynamic_fixture as fixture +from django.test import TestCase +from django.contrib.auth.models import User + +from readthedocs.projects.models import Project + +from ..models import GoldUser +from ..forms import GoldSubscriptionForm + + +class GoldSubscriptionFormTests(TestCase): + + def setUp(self): + self.owner = fixture.get(User) + self.user = fixture.get(User) + self.project = fixture.get(Project, users=[self.user]) + + # Mocking + self.patches = {} + self.mocks = {} + self.patches['requestor'] = mock.patch('stripe.api_requestor.APIRequestor') + + for patch in self.patches: + self.mocks[patch] = self.patches[patch].start() + + self.mocks['request'] = self.mocks['requestor'].return_value + self.mock_request([({}, 'reskey')]) + + def mock_request(self, resp): + self.mocks['request'].request = mock.Mock(side_effect=resp) + + def test_add_subscription(self): + """Valid subscription form""" + subscription_list = { + 'object': 'list', + 'data': [], + 'has_more': False, + 'total_count': 1, + 'url': '/v1/customers/cus_12345/subscriptions', + } + customer_obj = { + 'id': 'cus_12345', + 'description': self.user.get_full_name(), + 'email': self.user.email, + 'subscriptions': subscription_list + } + subscription_obj = { + 'id': 'sub_12345', + 'object': 'subscription', + 'customer': 'cus_12345', + 'plan': { + 'id': 'v1-org-5', + 'object': 'plan', + 'amount': 1000, + 'currency': 'usd', + 'name': 'Test', + } + } + self.mock_request([ + (customer_obj, ''), + (subscription_list, ''), + (subscription_obj, ''), + ]) + + # Create user and subscription + subscription_form = GoldSubscriptionForm( + {'level': 'v1-org-5', + 'last_4_digits': '0000', + 'stripe_token': 'GARYBUSEY'}, + customer=self.user + ) + self.assertTrue(subscription_form.is_valid()) + subscription = subscription_form.save() + + self.assertEqual(subscription.level, 'v1-org-5') + self.assertEqual(subscription.stripe_id, 'cus_12345') + self.assertIsNotNone(self.user.gold) + self.assertEqual(self.user.gold.first().level, 'v1-org-5') + + self.mocks['request'].request.assert_has_calls([ + mock.call('post', + '/v1/customers', + {'description': mock.ANY, 'email': mock.ANY}, + mock.ANY), + mock.call('get', + '/v1/customers/cus_12345/subscriptions', + mock.ANY, + mock.ANY), + mock.call('post', + '/v1/customers/cus_12345/subscriptions', + {'source': mock.ANY, 'plan': 'v1-org-5'}, + mock.ANY), + ]) + + def test_add_subscription_update_user(self): + """Valid subscription form""" + subscription_list = { + 'object': 'list', + 'data': [], + 'has_more': False, + 'total_count': 1, + 'url': '/v1/customers/cus_12345/subscriptions', + } + customer_obj = { + 'id': 'cus_12345', + 'description': self.user.get_full_name(), + 'email': self.user.email, + 'subscriptions': subscription_list + } + subscription_obj = { + 'id': 'sub_12345', + 'object': 'subscription', + 'customer': 'cus_12345', + 'plan': { + 'id': 'v1-org-5', + 'object': 'plan', + 'amount': 1000, + 'currency': 'usd', + 'name': 'Test', + } + } + self.mock_request([ + (customer_obj, ''), + (customer_obj, ''), + (subscription_list, ''), + (subscription_obj, ''), + ]) + + # Create user and update the current gold subscription + golduser = fixture.get(GoldUser, user=self.user, stripe_id='cus_12345') + subscription_form = GoldSubscriptionForm( + {'level': 'v1-org-5', + 'last_4_digits': '0000', + 'stripe_token': 'GARYBUSEY'}, + customer=self.user, + instance=golduser + ) + self.assertTrue(subscription_form.is_valid()) + subscription = subscription_form.save() + + self.assertEqual(subscription.level, 'v1-org-5') + self.assertEqual(subscription.stripe_id, 'cus_12345') + self.assertIsNotNone(self.user.gold) + self.assertEqual(self.user.gold.first().level, 'v1-org-5') + + self.mocks['request'].request.assert_has_calls([ + mock.call('get', + '/v1/customers/cus_12345', + {}, + mock.ANY), + mock.call('post', + '/v1/customers/cus_12345', + {'description': mock.ANY, 'email': mock.ANY}, + mock.ANY), + mock.call('get', + '/v1/customers/cus_12345/subscriptions', + mock.ANY, + mock.ANY), + mock.call('post', + '/v1/customers/cus_12345/subscriptions', + {'source': mock.ANY, 'plan': 'v1-org-5'}, + mock.ANY), + ]) + + def test_update_subscription_plan(self): + """Update subcription plan""" + subscription_obj = { + 'id': 'sub_12345', + 'object': 'subscription', + 'customer': 'cus_12345', + 'plan': { + 'id': 'v1-org-5', + 'object': 'plan', + 'amount': 1000, + 'currency': 'usd', + 'name': 'Test', + } + } + subscription_list = { + 'object': 'list', + 'data': [subscription_obj], + 'has_more': False, + 'total_count': 1, + 'url': '/v1/customers/cus_12345/subscriptions', + } + customer_obj = { + 'id': 'cus_12345', + 'description': self.user.get_full_name(), + 'email': self.user.email, + 'subscriptions': subscription_list + } + self.mock_request([ + (customer_obj, ''), + (subscription_list, ''), + (subscription_obj, ''), + ]) + subscription_form = GoldSubscriptionForm( + {'level': 'v1-org-5', + 'last_4_digits': '0000', + 'stripe_token': 'GARYBUSEY'}, + customer=self.user + ) + self.assertTrue(subscription_form.is_valid()) + subscription = subscription_form.save() + + self.assertEqual(subscription.level, 'v1-org-5') + self.assertIsNotNone(self.user.gold) + self.assertEqual(self.user.gold.first().level, 'v1-org-5') + + self.mocks['request'].request.assert_has_calls([ + mock.call('post', + '/v1/customers', + {'description': mock.ANY, 'email': mock.ANY}, + mock.ANY), + mock.call('get', + '/v1/customers/cus_12345/subscriptions', + mock.ANY, + mock.ANY), + mock.call('post', + '/v1/customers/cus_12345/subscriptions/sub_12345', + {'source': mock.ANY, 'plan': 'v1-org-5'}, + mock.ANY), + ]) diff --git a/readthedocs/gold/tests/test_signals.py b/readthedocs/gold/tests/test_signals.py new file mode 100644 index 00000000000..f6b10a0d477 --- /dev/null +++ b/readthedocs/gold/tests/test_signals.py @@ -0,0 +1,46 @@ +import mock +import django_dynamic_fixture as fixture +from django.test import TestCase +from django.contrib.auth.models import User +from django.db.models.signals import pre_delete + +from readthedocs.projects.models import Project + +from ..models import GoldUser +from ..signals import delete_customer + + +class GoldSignalTests(TestCase): + + def setUp(self): + self.user = fixture.get(User) + + # Mocking + self.patches = {} + self.mocks = {} + self.patches['requestor'] = mock.patch('stripe.api_requestor.APIRequestor') + + for patch in self.patches: + self.mocks[patch] = self.patches[patch].start() + + self.mocks['request'] = self.mocks['requestor'].return_value + + def mock_request(self, resp=None): + if resp is None: + resp = ({}, '') + self.mocks['request'].request = mock.Mock(side_effect=resp) + + def test_delete_subscription(self): + subscription = fixture.get(GoldUser, user=self.user, stripe_id='cus_123') + self.assertIsNotNone(subscription) + self.mock_request([ + ({'id': 'cus_123', 'object': 'customer'}, ''), + ({'deleted': True, 'customer': 'cus_123'}, ''), + ]) + + subscription.delete() + + self.mocks['request'].request.assert_has_calls([ + mock.call('get', '/v1/customers/cus_123', {}, mock.ANY), + mock.call('delete', '/v1/customers/cus_123', {}, mock.ANY), + ]) diff --git a/readthedocs/gold/urls.py b/readthedocs/gold/urls.py index ea87bd3b6ef..8f0ca1e5a7d 100644 --- a/readthedocs/gold/urls.py +++ b/readthedocs/gold/urls.py @@ -1,18 +1,20 @@ -from django.conf.urls import url, patterns, include +"""Gold subscription URLs""" + +from django.conf.urls import url, patterns from readthedocs.gold import views from readthedocs.projects.constants import PROJECT_SLUG_REGEX -urlpatterns = patterns('', - url(r'^register/$', views.register, name='gold_register'), - url(r'^edit/$', views.edit, name='gold_edit'), - url(r'^cancel/$', views.cancel, name='gold_cancel'), - url(r'^thanks/$', views.thanks, name='gold_thanks'), - url(r'^projects/$', views.projects, name='gold_projects'), - url(r'^projects/remove/(?P