Rework all payment pieces to unify implementation
parent
b0c982510c
commit
443619495f
|
@ -35,3 +35,4 @@ whoosh_index
|
|||
xml_output
|
||||
public_cnames
|
||||
public_symlinks
|
||||
.rope_project/
|
||||
|
|
|
@ -836,27 +836,28 @@ 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;
|
||||
}
|
||||
div#payment-form input#cc-number.mastercard + div.cc-type {
|
||||
background-position: 0px -46px;
|
||||
}
|
||||
div#payment-form input#cc-number.amex + div.cc-type {
|
||||
background-position: 0px -69px;
|
||||
}
|
||||
div#payment-form input#cc-number.discover + div.cc-type {
|
||||
background-position: 0px -92px;
|
||||
/* Gold */
|
||||
div.gold-subscription p.subscription-detail,
|
||||
div.gold-subscription p.subscription-projects {
|
||||
margin: 0em;
|
||||
}
|
||||
|
||||
div#payment-form input#cc-expiry { width: 150px; }
|
||||
div#payment-form input#cc-cvv { width: 100px; }
|
||||
div.gold-subscription p.subscription-detail label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
div.gold-subscription p.subscription-detail-card > span {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
div.gold-subscription > form {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
div.gold-subscription > form button {
|
||||
margin: 1em .3em 1.5em 0em;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* Form Wizards */
|
||||
div.actions.wizard-actions button.action-primary,
|
||||
|
|
|
@ -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 @@ class ListViewWithForm(ListView):
|
|||
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)
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
{% if field.is_hidden %}
|
||||
{{ field }}
|
||||
{% else %}
|
||||
<!-- Begin: {{ field.name }} -->
|
||||
{{ field.errors }}
|
||||
{% if 'data-bind' in field.field.widget.attrs %}
|
||||
<ul
|
||||
class="errorlist"
|
||||
data-bind="visible: error_{{ field.name }}"
|
||||
style="display: none;">
|
||||
<li data-bind="text: error_{{ field.name }}"></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
<p>
|
||||
<label for="{{ field.id_for_label }}">{{ field.label }}:</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}
|
||||
<span class="helptext">{{ field.help_text }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<!-- End: {{ field.name }} -->
|
||||
{% endif %}
|
|
@ -1,23 +1,24 @@
|
|||
import logging
|
||||
|
||||
import stripe
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
import stripe
|
||||
from readthedocs.payments.forms import (StripeSubscriptionModelForm,
|
||||
StripeResourceMixin)
|
||||
|
||||
from .models import Supporter
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SupporterForm(forms.ModelForm):
|
||||
class SupporterForm(StripeResourceMixin, StripeSubscriptionModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Supporter
|
||||
fields = (
|
||||
'last_4_digits',
|
||||
'stripe_id',
|
||||
'name',
|
||||
'email',
|
||||
'dollars',
|
||||
|
@ -43,38 +44,34 @@ class SupporterForm(forms.ModelForm):
|
|||
}),
|
||||
'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):
|
||||
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.api_key = settings.STRIPE_SECRET
|
||||
charge = 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)
|
||||
|
|
|
@ -10,8 +10,8 @@ from .models import Supporter
|
|||
class DonateProgressMixin(object):
|
||||
'''Add donation progress to context data'''
|
||||
|
||||
def get_context_data(self):
|
||||
context = super(DonateProgressMixin, self).get_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
|
||||
|
|
|
@ -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];
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.6 KiB |
File diff suppressed because one or more lines are too long
|
@ -5,6 +5,10 @@
|
|||
|
||||
{% block title %}{% trans "Sustainability" %}{% endblock %}
|
||||
|
||||
{% block extra_links %}
|
||||
<link rel="stylesheet" href="{% static 'payments/css/form.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://js.stripe.com/v2/" type="text/javascript"></script>
|
||||
<script type="text/javascript" src="{% static 'vendor/knockout.js' %}"></script>
|
||||
|
@ -14,7 +18,7 @@ var donate_views = require('donate/donate');
|
|||
$(document).ready(function () {
|
||||
var key;
|
||||
//<![CDATA[
|
||||
key = '{{ publishable }}';
|
||||
key = '{{ stripe_publishable }}';
|
||||
//]]>
|
||||
|
||||
var view = donate_views.DonateView.init({
|
||||
|
@ -36,9 +40,24 @@ $(document).ready(function () {
|
|||
payment is processed directly through <a href="https://stripe.com">Stripe</a>.
|
||||
</p>
|
||||
|
||||
<form accept-charset="UTF-8" action="" method="post" id="donate-payment">
|
||||
<form action="" method="post" id="donate-payment" class="payment">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
{% include "gold/cardform.html" %}
|
||||
|
||||
{{ form.non_field_errors }}
|
||||
|
||||
{% for field in form.fields_with_cc_group %}
|
||||
{% if field.is_cc_group %}
|
||||
<div class="subscription-card">
|
||||
{% for groupfield in field.fields %}
|
||||
{% include 'core/ko_form_field.html' with field=groupfield %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% include 'core/ko_form_field.html' with field=field %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% trans "Donate" as form_submit_text %}
|
||||
<input type="submit" value="{{ form_submit_text }}" data-bind="click: process_form" />
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -4,13 +4,16 @@ Donation views
|
|||
|
||||
import logging
|
||||
|
||||
from django.views.generic import CreateView, ListView, TemplateView
|
||||
from django.views.generic import TemplateView
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from vanilla import CreateView, ListView
|
||||
|
||||
from readthedocs.payments.mixins import StripeMixin
|
||||
|
||||
from readthedocs.core.mixins import StripeMixin
|
||||
from .models import Supporter
|
||||
from .forms import SupporterForm
|
||||
from .mixins import DonateProgressMixin
|
||||
|
@ -18,7 +21,7 @@ from .mixins import DonateProgressMixin
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DonateCreateView(SuccessMessageMixin, StripeMixin, CreateView):
|
||||
class DonateCreateView(StripeMixin, CreateView):
|
||||
'''Create a donation locally and in Stripe'''
|
||||
|
||||
form_class = SupporterForm
|
||||
|
@ -31,10 +34,9 @@ class DonateCreateView(SuccessMessageMixin, StripeMixin, CreateView):
|
|||
def get_initial(self):
|
||||
return {'dollars': self.request.GET.get('dollars', 50)}
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super(DonateCreateView, self).get_form_kwargs()
|
||||
def get_form(self, data=None, files=None, **kwargs):
|
||||
kwargs['user'] = self.request.user
|
||||
return kwargs
|
||||
return super(DonateCreateView, self).get_form(data, files, **kwargs)
|
||||
|
||||
|
||||
class DonateSuccessView(TemplateView):
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
default_app_config = 'readthedocs.gold.apps.GoldAppConfig'
|
|
@ -0,0 +1,16 @@
|
|||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GoldAppConfig(AppConfig):
|
||||
name = 'readthedocs.gold'
|
||||
verbose_name = 'Read the Docs Gold'
|
||||
|
||||
def ready(self):
|
||||
if hasattr(self, 'already_run'):
|
||||
return
|
||||
self.already_run = True
|
||||
import readthedocs.gold.signals
|
|
@ -1,20 +1,25 @@
|
|||
from django import forms
|
||||
|
||||
from .models import LEVEL_CHOICES
|
||||
from stripe.error import InvalidRequestError
|
||||
from readthedocs.payments.forms import (StripeSubscriptionModelForm,
|
||||
StripeResourceMixin)
|
||||
|
||||
from .models import LEVEL_CHOICES, GoldUser
|
||||
|
||||
|
||||
class CardForm(forms.Form):
|
||||
class GoldSubscriptionForm(StripeResourceMixin, StripeSubscriptionModelForm):
|
||||
|
||||
class Meta:
|
||||
model = GoldUser
|
||||
fields = ['last_4_digits', 'level']
|
||||
|
||||
last_4_digits = forms.CharField(
|
||||
required=True,
|
||||
min_length=4,
|
||||
max_length=4,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
|
||||
stripe_token = forms.CharField(
|
||||
required=True,
|
||||
widget=forms.HiddenInput()
|
||||
widget=forms.HiddenInput(attrs={
|
||||
'data-bind': 'valueInit: card_digits, value: card_digits'
|
||||
})
|
||||
)
|
||||
|
||||
level = forms.ChoiceField(
|
||||
|
@ -22,6 +27,44 @@ class CardForm(forms.Form):
|
|||
choices=LEVEL_CHOICES,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
self.instance.user = self.customer
|
||||
return super(GoldSubscriptionForm, self).clean()
|
||||
|
||||
def validate_stripe(self):
|
||||
subscription = self.get_subscription()
|
||||
self.instance.stripe_id = subscription.customer
|
||||
self.instance.subscribed = True
|
||||
|
||||
def get_customer_kwargs(self):
|
||||
return {
|
||||
'description': self.customer.get_full_name(),
|
||||
'email': self.customer.email,
|
||||
'id': self.instance.stripe_id or None
|
||||
}
|
||||
|
||||
def get_subscription(self):
|
||||
customer = self.get_customer()
|
||||
try:
|
||||
# TODO get the first sub more intelligently
|
||||
subscriptions = customer.subscriptions.all(limit=5)
|
||||
subscription = subscriptions.data[0]
|
||||
subscription.plan = self.cleaned_data['level']
|
||||
if 'stripe_token' in self.cleaned_data:
|
||||
subscription.source = self.cleaned_data['stripe_token']
|
||||
subscription.save()
|
||||
return subscription
|
||||
except (InvalidRequestError, AttributeError, IndexError):
|
||||
subscription = customer.subscriptions.create(
|
||||
plan=self.cleaned_data['level'],
|
||||
source=self.cleaned_data['stripe_token']
|
||||
)
|
||||
return subscription
|
||||
|
||||
def clear_card_data(self):
|
||||
super(GoldSubscriptionForm, self).clear_card_data()
|
||||
self.data['last_4_digits'] = None
|
||||
|
||||
|
||||
class GoldProjectForm(forms.Form):
|
||||
project = forms.CharField(
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
from django.db.models.signals import pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from readthedocs.payments import utils
|
||||
|
||||
from .models import GoldUser
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=GoldUser)
|
||||
def delete_customer(sender, instance, **kwargs):
|
||||
"""On Gold subscription deletion, remove the customer from Stripe"""
|
||||
if instance.stripe_id is not None:
|
||||
utils.delete_customer(instance.stripe_id)
|
|
@ -1,16 +1,20 @@
|
|||
// Gold 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 GoldView (config) {
|
||||
var self = this,
|
||||
config = config || {};
|
||||
|
||||
ko.utils.extend(self, new payment.PaymentView(config));
|
||||
self.constructor.call(self, config);
|
||||
|
||||
self.last_4_digits = ko.observable(null);
|
||||
}
|
||||
|
||||
GoldView.prototype = new payment.PaymentView();
|
||||
|
||||
GoldView.init = function (config, obj) {
|
||||
var view = new GoldView(config),
|
||||
obj = obj || $('#payment-form')[0];
|
||||
|
@ -18,4 +22,10 @@ GoldView.init = function (config, obj) {
|
|||
return view;
|
||||
}
|
||||
|
||||
GoldView.prototype.submit_form = function (card_digits, token) {
|
||||
this.form.find('#id_last_4_digits').val(card_digits);
|
||||
this.form.find('#id_stripe_token').val(token);
|
||||
this.form.submit();
|
||||
};
|
||||
|
||||
module.exports.GoldView = GoldView;
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -1,27 +0,0 @@
|
|||
{% extends "profiles/base_profile_edit.html" %}
|
||||
|
||||
{% block profile-admin-gold-edit %}active{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
Cancel Gold
|
||||
{% endblock %}
|
||||
|
||||
{% block edit_content %}
|
||||
<div class="row">
|
||||
<div class="span6 columns">
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<h1>Cancel Gold Subscription</h1>
|
||||
</div>
|
||||
Are you sure you want to cancel?
|
||||
<div class="row">
|
||||
<div class="span6 columns">
|
||||
<form id="user_form" accept-charset="UTF-8" action="{% url "gold_cancel" %}" class="form-stacked" method="post">
|
||||
{% csrf_token %}
|
||||
<input type='submit' value='Yes'>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,20 +0,0 @@
|
|||
{% load i18n %}
|
||||
|
||||
<div id="payment-form">
|
||||
<label for="cc-number">{% trans "Card Number" %}:</label>
|
||||
<input id="cc-number" type="text" data-bind="value: cc_number, css: { 'field-error': cc_error_number() != null}">
|
||||
<div class="cc-type"></div>
|
||||
<p class="error" data-bind="text: cc_error_number, visible: cc_error_number() != null"></p>
|
||||
|
||||
<label for="cc-expiry">{% trans "Card Expiry" %}:</label>
|
||||
<input id="cc-expiry" type="text" data-bind="value: cc_expiry, css: { 'field-error': cc_error_expiry() != null}">
|
||||
<p class="error" data-bind="text: cc_error_expiry, visible: cc_error_expiry() != null"></p>
|
||||
|
||||
<label for="cc-cvv">{% trans "Security code (CVV)" %}:</label>
|
||||
<input id="cc-cvv" type="text" autocomplete="off" data-bind="value: cc_cvv, css: { 'field-error': cc_error_cvv() != null}">
|
||||
<p class="error" data-bind="text: cc_error_cvv, visible: cc_error_cvv() != null"></p>
|
||||
|
||||
<div class="error" data-bind="text: error"></div>
|
||||
|
||||
<input type="submit" value="Submit" data-bind="click: process_form">
|
||||
</div>
|
|
@ -1,41 +0,0 @@
|
|||
{% extends "profiles/base_profile_edit.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block profile-admin-gold-edit %}active{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
Edit Gold
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://js.stripe.com/v2/" type="text/javascript"></script>
|
||||
<script type="text/javascript" src="{% static 'vendor/knockout.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'gold/js/gold.js' %}"></script>
|
||||
<script type="text/javascript">
|
||||
var gold_views = require('gold/gold');
|
||||
$(document).ready(function () {
|
||||
var key;
|
||||
//<![CDATA[
|
||||
key = '{{ publishable }}';
|
||||
//]]>
|
||||
|
||||
var view = gold_views.GoldView.init({
|
||||
key: key,
|
||||
form: $('form#gold-register')
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block edit_content %}
|
||||
<div>
|
||||
<h2>Change Credit Card</h2>
|
||||
<form accept-charset="UTF-8" action="{% url "gold_edit" %}" method="post" id="gold-register">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
{% include "gold/cardform.html" %}
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,14 +0,0 @@
|
|||
{% if form.is_bound and not form.is_valid %}
|
||||
<div class="alert-message block-message error">
|
||||
<div class="errors">
|
||||
{% for field in form.visible_fields %}
|
||||
{% for error in field.errors %}
|
||||
<p>{{ field.label }}: {{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% for error in form.non_field_errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
|
@ -1,6 +0,0 @@
|
|||
<div class="clearfix">
|
||||
{{ field.label_tag }}
|
||||
<div class="input">
|
||||
{{ field }}
|
||||
</div>
|
||||
</div>
|
|
@ -1,84 +0,0 @@
|
|||
{% extends "profiles/base_profile_edit.html" %}
|
||||
|
||||
{% block profile-admin-gold-edit %}active{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
One-time Payment
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://js.stripe.com/v1/" type="text/javascript"></script>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
Stripe.publishableKey = '{{ publishable }}';
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(function() {
|
||||
|
||||
$("#user_form").submit(function() {
|
||||
if ( $("#credit-card").is(":visible")) {
|
||||
var form = this;
|
||||
var card = {
|
||||
number: $("#credit_card_number").val(),
|
||||
expMonth: $("#expiry_month").val(),
|
||||
expYear: $("#expiry_year").val(),
|
||||
cvc: $("#cvv").val()
|
||||
};
|
||||
|
||||
Stripe.createToken(card, function(status, response) {
|
||||
if (status === 200) {
|
||||
console.log(status, response);
|
||||
$("#credit-card-errors").hide();
|
||||
$("#last_4_digits").val(response.card.last4);
|
||||
$("#stripe_token").val(response.id);
|
||||
form.submit();
|
||||
} else {
|
||||
$("#stripe-error-message").text(response.error.message);
|
||||
$("#credit-card-errors").show();
|
||||
$("#user_submit").attr("disabled", false);
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
});
|
||||
|
||||
$("#change-card a").click(function() {
|
||||
$("#change-card").hide();
|
||||
$("#credit-card").show();
|
||||
$("#credit_card_number").focus();
|
||||
return false;
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block edit_content %}
|
||||
<div class="row">
|
||||
<div class="span6 columns">
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<h1>Give money to Read the Docs once</h1>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="span6 columns">
|
||||
<form id="user_form" accept-charset="UTF-8" action="{% url "gold_once" %}" class="form-stacked" method="post">
|
||||
{% csrf_token %}
|
||||
{% include "gold/errors.html" %}
|
||||
{% for field in form.visible_fields %}
|
||||
{% include "gold/field.html" %}
|
||||
{% endfor %}
|
||||
{% include "gold/cardform.html" %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,76 +0,0 @@
|
|||
{% extends "profiles/base_profile_edit.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block profile-admin-gold-edit %}active{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
Gold Status
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://js.stripe.com/v2/" type="text/javascript"></script>
|
||||
<script type="text/javascript" src="{% static 'vendor/knockout.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'gold/js/gold.js' %}"></script>
|
||||
<script type="text/javascript">
|
||||
var gold_views = require('gold/gold');
|
||||
$(document).ready(function () {
|
||||
var key;
|
||||
//<![CDATA[
|
||||
key = '{{ publishable }}';
|
||||
//]]>
|
||||
|
||||
var view = gold_views.GoldView.init({
|
||||
key: key,
|
||||
form: $('form#gold-register')
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block edit_content %}
|
||||
<div>
|
||||
<h2>Gold Status</h2>
|
||||
|
||||
{% if gold_user.subscribed %}
|
||||
<p>
|
||||
Thanks for supporting Read the Docs! It really means a lot to us.
|
||||
</p>
|
||||
<p>
|
||||
Level: {{ gold_user.get_level_display }}
|
||||
</p>
|
||||
<p>
|
||||
Card: Ends with {{ gold_user.last_4_digits }}
|
||||
</p>
|
||||
|
||||
<h3>Projects</h3>
|
||||
<p>
|
||||
You can adopt {{ gold_user.num_supported_projects }} projects with your subscription. <a href="{% url "gold_projects" %}">Select Projects</a>
|
||||
</p>
|
||||
|
||||
<h3>Changes</h3>
|
||||
<p>
|
||||
<a href="{% url "gold_cancel" %}">Cancel</a> or <a href="{% url "gold_edit" %}">Change</a> your subscription.
|
||||
</p>
|
||||
|
||||
{% else %}
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
You can make one-time donations on our <a href="https://readthedocs.org/sustainability/">sustainability</a> page.
|
||||
</p>
|
||||
|
||||
<h3>Become a Gold Member</h3>
|
||||
<form accept-charset="UTF-8" action="{% url "gold_register" %}" method="post" id="gold-register">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
{% include "gold/cardform.html" %}
|
||||
<em>All information is submitted directly to Stripe.</em>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,21 @@
|
|||
{% extends "profiles/base_profile_edit.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block profile-admin-gold-edit %}active{% endblock %}
|
||||
|
||||
{% block title %}Cancel Gold{% endblock %}
|
||||
|
||||
{% block edit_content %}
|
||||
<h2>Cancel Gold Subscription</h2>
|
||||
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
Are you sure you want to cancel your subscription?
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<form method="post" action="{% url "gold_cancel" %}">
|
||||
{% csrf_token %}
|
||||
<input type="submit" value="{% trans "Cancel subscription" %}">
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -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 %}
|
||||
<script src="https://js.stripe.com/v2/" type="text/javascript"></script>
|
||||
<script type="text/javascript" src="{% static 'vendor/knockout.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'gold/js/gold.js' %}"></script>
|
||||
<script type="text/javascript">
|
||||
var gold_views = require('gold/gold');
|
||||
$(document).ready(function () {
|
||||
var key;
|
||||
//<![CDATA[
|
||||
key = '{{ publishable }}';
|
||||
//]]>
|
||||
|
||||
var view = gold_views.GoldView.init({
|
||||
key: key,
|
||||
form: $('form#gold-register')
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block edit_content %}
|
||||
<div class="gold-subscription">
|
||||
<h2>{% trans "Gold Subscription" %}</h2>
|
||||
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
Thanks for supporting Read the Docs! It really means a lot to us.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<p class="subscription-detail subscription-detail-level">
|
||||
<label>{% trans "Level" %}:</label>
|
||||
<span>{{ golduser.get_level_display }}</span>
|
||||
</p>
|
||||
|
||||
<p class="subscription-detail subscription-detail-card">
|
||||
<label>{% trans "Card" %}:</label>
|
||||
<span>****-{{ golduser.last_4_digits }}</span>
|
||||
</p>
|
||||
|
||||
<form method="get" action="{% url "gold_subscription" %}" class="subscription-update">
|
||||
<button>{% trans "Update Subscription" %}</button>
|
||||
</form>
|
||||
|
||||
<form method="get" action="{% url "gold_cancel" %}" class="subscription-cancel">
|
||||
<button>{% trans "Cancel Subscription" %}</button>
|
||||
</form>
|
||||
|
||||
<h3>{% trans "Projects" %}</h3>
|
||||
<p class="subscription-projects">
|
||||
{% blocktrans with projects=golduser.num_supported_projects %}
|
||||
You can adopt {{ projects }} projects with your subscription.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<form method="get" action="{% url "gold_projects" %}" class="subscription-projects">
|
||||
<button>{% trans "Select Projects" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,104 @@
|
|||
{% extends "profiles/base_profile_edit.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block profile-admin-gold-edit %}active{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
Gold Status
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_links %}
|
||||
<link rel="stylesheet" href="{% static 'payments/css/form.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="https://js.stripe.com/v2/" type="text/javascript"></script>
|
||||
<script type="text/javascript" src="{% static 'vendor/knockout.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'gold/js/gold.js' %}"></script>
|
||||
<script type="text/javascript">
|
||||
var gold_views = require('gold/gold');
|
||||
$(document).ready(function () {
|
||||
var key;
|
||||
//<![CDATA[
|
||||
key = '{{ stripe_publishable }}';
|
||||
//]]>
|
||||
|
||||
var view = gold_views.GoldView.init({
|
||||
key: key,
|
||||
form: $('form#gold-register')
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block edit_content %}
|
||||
<div>
|
||||
<h2>Gold Status</h2>
|
||||
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
You can make one-time donations on our <a href="https://readthedocs.org/sustainability/">sustainability</a> page.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
{% trans "Become a Gold Member" as subscription_title %}
|
||||
{% if golduser %}
|
||||
{% trans "Update Your Subscription" as subscription_title %}
|
||||
{% endif %}
|
||||
<h3>{{ subscription_title }}</h3>
|
||||
|
||||
<form accept-charset="UTF-8" action="" method="post" id="gold-register" class="payment">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form.non_field_errors }}
|
||||
|
||||
{% for field in form.fields_with_cc_group %}
|
||||
{% if field.is_cc_group %}
|
||||
<p
|
||||
data-bind="visible: card_digits"
|
||||
style="display: none;"
|
||||
class="subscription-card">
|
||||
<label>{% trans "Current card" %}:</label>
|
||||
<span class="subscription-card-number">
|
||||
****-<span data-bind="text: card_digits"></span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div data-bind="visible: !show_card_form()">
|
||||
<a
|
||||
href="#"
|
||||
data-bind="click: function () { is_editing_card(true); }"
|
||||
class="subscription-edit-link">
|
||||
{% trans "Edit Card" %}
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="subscription-card"
|
||||
data-bind="visible: show_card_form"
|
||||
style="display: none;">
|
||||
{% for groupfield in field.fields %}
|
||||
{% include 'core/ko_form_field.html' with field=groupfield %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% include 'core/ko_form_field.html' with field=field %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% trans "Sign Up" as form_submit_text %}
|
||||
{% if golduser %}
|
||||
{% trans "Update Subscription" as form_submit_text %}
|
||||
{% endif %}
|
||||
<input type="submit" value="{{ form_submit_text }}" data-bind="click: process_full_form" />
|
||||
|
||||
<em>{% trans "All information is submitted directly to Stripe." %}</em>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -1,18 +0,0 @@
|
|||
{% extends "profiles/base_profile_edit.html" %}
|
||||
|
||||
{% block profile-admin-gold-edit %}active{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
Thanks!
|
||||
{% endblock %}
|
||||
|
||||
{% block edit_content %}
|
||||
<div class="page-header">
|
||||
<h1>Thanks</h1>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Thanks for contributing to Read the Docs!
|
||||
</p>
|
||||
|
||||
{% endblock %}
|
|
@ -0,0 +1,219 @@
|
|||
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, ''),
|
||||
(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('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),
|
||||
])
|
|
@ -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),
|
||||
])
|
|
@ -4,15 +4,16 @@ 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<project_slug>{project_slug})/$'.format(
|
||||
project_slug=PROJECT_SLUG_REGEX
|
||||
),
|
||||
views.projects_remove,
|
||||
name='gold_projects_remove'),
|
||||
)
|
||||
urlpatterns = patterns(
|
||||
'',
|
||||
url(r'^$', views.DetailGoldSubscription.as_view(), name='gold_detail'),
|
||||
url(r'^subscription/$', views.UpdateGoldSubscription.as_view(),
|
||||
name='gold_subscription'),
|
||||
url(r'^cancel/$', views.DeleteGoldSubscription.as_view(), name='gold_cancel'),
|
||||
url(r'^projects/$', views.projects, name='gold_projects'),
|
||||
url(r'^projects/remove/(?P<project_slug>{project_slug})/$'.format(
|
||||
project_slug=PROJECT_SLUG_REGEX
|
||||
),
|
||||
views.projects_remove,
|
||||
name='gold_projects_remove'),
|
||||
)
|
||||
|
|
|
@ -1,146 +1,90 @@
|
|||
import datetime
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
import stripe
|
||||
from django.core.urlresolvers import reverse, reverse_lazy
|
||||
from django.conf import settings
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.contrib import messages
|
||||
from django.db import IntegrityError
|
||||
from django.http import HttpResponseRedirect, Http404
|
||||
from django.shortcuts import render_to_response, get_object_or_404
|
||||
from django.template import RequestContext
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from vanilla import CreateView, DeleteView, UpdateView, DetailView
|
||||
|
||||
import stripe
|
||||
|
||||
from .forms import CardForm, GoldProjectForm
|
||||
from .models import GoldUser
|
||||
from readthedocs.core.mixins import LoginRequiredMixin
|
||||
from readthedocs.projects.models import Project
|
||||
from readthedocs.payments.mixins import StripeMixin
|
||||
|
||||
from .forms import GoldSubscriptionForm, GoldProjectForm
|
||||
from .models import GoldUser
|
||||
|
||||
stripe.api_key = settings.STRIPE_SECRET
|
||||
|
||||
|
||||
def soon():
|
||||
soon = datetime.date.today() + datetime.timedelta(days=30)
|
||||
return {'month': soon.month, 'year': soon.year}
|
||||
class GoldSubscriptionView(SuccessMessageMixin, StripeMixin, LoginRequiredMixin):
|
||||
|
||||
model = GoldUser
|
||||
form_class = GoldSubscriptionForm
|
||||
|
||||
def get_object(self):
|
||||
try:
|
||||
return self.get_queryset().get(user=self.request.user)
|
||||
except self.model.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = (super(GoldSubscriptionView, self)
|
||||
.get_context_data(**kwargs))
|
||||
context['stripe_publishable'] = settings.STRIPE_PUBLISHABLE
|
||||
return context
|
||||
|
||||
def get_form(self, data=None, files=None, **kwargs):
|
||||
"""Pass in copy of POST data to avoid read only QueryDicts"""
|
||||
kwargs['customer'] = self.request.user
|
||||
return super(GoldSubscriptionView, self).get_form(data, files, **kwargs)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse_lazy('gold_detail')
|
||||
|
||||
def get_template_names(self):
|
||||
return ('gold/subscription{0}.html'
|
||||
.format(self.template_name_suffix))
|
||||
|
||||
|
||||
@login_required
|
||||
def register(request):
|
||||
user = request.user
|
||||
try:
|
||||
gold_user = GoldUser.objects.get(user=request.user)
|
||||
except GoldUser.DoesNotExist:
|
||||
gold_user = None
|
||||
if request.method == 'POST':
|
||||
form = CardForm(request.POST)
|
||||
if form.is_valid():
|
||||
# Subscription Views
|
||||
class DetailGoldSubscription(GoldSubscriptionView, DetailView):
|
||||
|
||||
customer = stripe.Customer.create(
|
||||
description=user.username,
|
||||
email=user.email,
|
||||
card=form.cleaned_data['stripe_token'],
|
||||
plan=form.cleaned_data['level'],
|
||||
)
|
||||
|
||||
try:
|
||||
user = GoldUser.objects.get(user=user)
|
||||
except GoldUser.DoesNotExist:
|
||||
user = GoldUser(
|
||||
user=request.user,
|
||||
)
|
||||
|
||||
user.level = form.cleaned_data['level']
|
||||
user.last_4_digits = form.cleaned_data['last_4_digits']
|
||||
user.stripe_id = customer.id
|
||||
user.subscribed = True
|
||||
|
||||
try:
|
||||
user.save()
|
||||
except IntegrityError:
|
||||
form.add_error(None, user.user.username + ' is already a member')
|
||||
else:
|
||||
return HttpResponseRedirect(reverse('gold_thanks'))
|
||||
|
||||
else:
|
||||
form = CardForm()
|
||||
|
||||
return render_to_response(
|
||||
'gold/register.html',
|
||||
{
|
||||
'form': form,
|
||||
'gold_user': gold_user,
|
||||
'publishable': settings.STRIPE_PUBLISHABLE,
|
||||
'soon': soon(),
|
||||
'user': user,
|
||||
},
|
||||
context_instance=RequestContext(request)
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
resp = super(DetailGoldSubscription, self).get(request, *args, **kwargs)
|
||||
if self.object is None:
|
||||
return HttpResponseRedirect(reverse('gold_subscription'))
|
||||
return resp
|
||||
|
||||
|
||||
@login_required
|
||||
def edit(request):
|
||||
user = get_object_or_404(GoldUser, user=request.user)
|
||||
if request.method == 'POST':
|
||||
form = CardForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
customer = stripe.Customer.retrieve(user.stripe_id)
|
||||
customer.card = form.cleaned_data['stripe_token']
|
||||
customer.plan = form.cleaned_data['level']
|
||||
customer.save()
|
||||
|
||||
user.last_4_digits = form.cleaned_data['last_4_digits']
|
||||
user.stripe_id = customer.id
|
||||
user.level = form.cleaned_data['level']
|
||||
user.subscribed = True
|
||||
user.save()
|
||||
|
||||
return HttpResponseRedirect(reverse('gold_thanks'))
|
||||
|
||||
else:
|
||||
form = CardForm(initial={'level': user.level})
|
||||
|
||||
return render_to_response(
|
||||
'gold/edit.html',
|
||||
{
|
||||
'form': form,
|
||||
'publishable': settings.STRIPE_PUBLISHABLE,
|
||||
'soon': soon(),
|
||||
},
|
||||
context_instance=RequestContext(request)
|
||||
)
|
||||
class UpdateGoldSubscription(GoldSubscriptionView, UpdateView):
|
||||
success_message = _('Your subscription has been updated')
|
||||
|
||||
|
||||
@login_required
|
||||
def cancel(request):
|
||||
user = get_object_or_404(GoldUser, user=request.user)
|
||||
if request.method == 'POST':
|
||||
customer = stripe.Customer.retrieve(user.stripe_id)
|
||||
customer.delete()
|
||||
user.subscribed = False
|
||||
user.save()
|
||||
return HttpResponseRedirect(reverse('gold_register'))
|
||||
return render_to_response(
|
||||
'gold/cancel.html',
|
||||
{
|
||||
'publishable': settings.STRIPE_PUBLISHABLE,
|
||||
'soon': soon(),
|
||||
'months': range(1, 13),
|
||||
'years': range(2011, 2036)
|
||||
},
|
||||
context_instance=RequestContext(request)
|
||||
)
|
||||
class DeleteGoldSubscription(GoldSubscriptionView, DeleteView):
|
||||
|
||||
"""Delete Gold subscription view
|
||||
|
||||
def thanks(request):
|
||||
return render_to_response(
|
||||
'gold/thanks.html',
|
||||
{
|
||||
'publishable': settings.STRIPE_PUBLISHABLE,
|
||||
'soon': soon(),
|
||||
'months': range(1, 13),
|
||||
'years': range(2011, 2036)
|
||||
},
|
||||
context_instance=RequestContext(request)
|
||||
)
|
||||
On object deletion, the corresponding Stripe customer is deleted as well.
|
||||
Deletion is triggered on subscription deletion, to ensure the subscription
|
||||
is synced with Stripe.
|
||||
"""
|
||||
|
||||
success_message = _('Your subscription has been cancelled')
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Add success message to delete post"""
|
||||
resp = super(SuccessMessageMixin, self).post(request, *args, **kwargs)
|
||||
success_message = self.get_success_message({})
|
||||
if success_message:
|
||||
messages.success(self.request, success_message)
|
||||
return resp
|
||||
|
||||
|
||||
@login_required
|
||||
|
|
|
@ -0,0 +1,190 @@
|
|||
"""Payment forms"""
|
||||
|
||||
import logging
|
||||
|
||||
import stripe
|
||||
from stripe.resource import Customer, Charge
|
||||
from stripe.error import InvalidRequestError
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
stripe.api_key = getattr(settings, 'STRIPE_SECRET')
|
||||
|
||||
|
||||
class StripeResourceMixin(object):
|
||||
|
||||
"""Stripe actions for resources, available as a Form mixin class"""
|
||||
|
||||
def ensure_stripe_resource(self, resource, attrs):
|
||||
try:
|
||||
return resource.retrieve(attrs['id'])
|
||||
except (KeyError, InvalidRequestError):
|
||||
try:
|
||||
del attrs['id']
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
return resource.create(**attrs)
|
||||
except InvalidRequestError:
|
||||
return None
|
||||
|
||||
def get_customer_kwargs(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_customer(self):
|
||||
return self.ensure_stripe_resource(resource=Customer,
|
||||
attrs=self.get_customer_kwargs())
|
||||
|
||||
def get_subscription_kwargs(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_subscription(self):
|
||||
customer = self.get_customer()
|
||||
return self.ensure_stripe_resource(resource=customer.subscriptions,
|
||||
attrs=self.get_subscription_kwargs())
|
||||
|
||||
def get_charge_kwargs(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_charge(self):
|
||||
customer = self.get_customer()
|
||||
return self.ensure_stripe_resource(resource=Charge,
|
||||
attrs=self.get_charge_kwargs())
|
||||
|
||||
|
||||
class StripeSubscriptionModelForm(forms.ModelForm):
|
||||
|
||||
"""Payment form base for Stripe interaction
|
||||
|
||||
Use this as a base class for payment forms. It includes the necessary fields
|
||||
for card input and manipulates the Knockout field data bindings correctly.
|
||||
|
||||
:cvar stripe_token: Stripe token passed from Stripe.js
|
||||
:cvar cc_number: Credit card number field, used only by Stripe.js
|
||||
:cvar cc_expiry: Credit card expiry field, used only by Stripe.js
|
||||
:cvar cc_cvv: Credit card security code field, used only by Stripe.js
|
||||
"""
|
||||
|
||||
# Stripe token input from Stripe.js
|
||||
stripe_token = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.HiddenInput(attrs={
|
||||
'data-bind': 'valueInit: stripe_token',
|
||||
})
|
||||
)
|
||||
|
||||
# Fields used for fetching token with javascript, listed as form fields so
|
||||
# that data can survive validation errors
|
||||
cc_number = forms.CharField(
|
||||
label=_('Card number'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'data-bind': ('valueInit: cc_number, '
|
||||
'value: cc_number, '
|
||||
'''css: {'field-error': error_cc_number() != null}''')
|
||||
}),
|
||||
max_length=25,
|
||||
required=False)
|
||||
cc_expiry = forms.CharField(
|
||||
label=_('Card expiration'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'data-bind': ('valueInit: cc_expiry, '
|
||||
'value: cc_expiry, '
|
||||
'''css: {'field-error': error_cc_expiry() != null}''')
|
||||
}),
|
||||
max_length=10,
|
||||
required=False)
|
||||
cc_cvv = forms.CharField(
|
||||
label=_('Card CVV'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'data-bind': ('valueInit: cc_cvv, '
|
||||
'value: cc_cvv, '
|
||||
'''css: {'field-error': error_cc_cvv() != null}'''),
|
||||
'autocomplete': 'off',
|
||||
}),
|
||||
max_length=8,
|
||||
required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.customer = kwargs.pop('customer', None)
|
||||
super(StripeSubscriptionModelForm, self).__init__(*args, **kwargs)
|
||||
|
||||
def validate_stripe(self):
|
||||
"""Run validation against Stripe
|
||||
|
||||
This is what will create several objects using the Stripe API. We need
|
||||
to actually create the objects, as that is what will provide us with
|
||||
validation errors to throw back at the form.
|
||||
|
||||
Form fields can be accessed here via ``self.cleaned_data`` as this
|
||||
method is triggered from the :py:meth:`clean` method. Cleaned form data
|
||||
should already exist on the form at this point.
|
||||
"""
|
||||
raise NotImplemented('Stripe validation is not implemented')
|
||||
|
||||
def clean_stripe_token(self):
|
||||
data = self.cleaned_data['stripe_token']
|
||||
if not data:
|
||||
data = None
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
"""Clean form to add Stripe objects via API during validation phase
|
||||
|
||||
This will handle ensuring a customer and subscription exist and will
|
||||
raise any issues as validation errors. This is required because part
|
||||
of Stripe's validation happens on the API call to establish a
|
||||
subscription.
|
||||
"""
|
||||
cleaned_data = super(StripeSubscriptionModelForm, self).clean()
|
||||
|
||||
# Form isn't valid, no need to try to associate a card now
|
||||
if not self.is_valid():
|
||||
self.clear_card_data()
|
||||
return
|
||||
|
||||
try:
|
||||
self.validate_stripe()
|
||||
except stripe.error.CardError as e:
|
||||
self.clear_card_data()
|
||||
field_lookup = {
|
||||
'cvc': 'cc_cvv',
|
||||
'number': 'cc_number',
|
||||
'expiry': 'cc_expiry',
|
||||
'exp_month': 'cc_expiry',
|
||||
'exp_year': 'cc_expiry',
|
||||
}
|
||||
error_field = field_lookup.get(e.param, None)
|
||||
self.add_error(
|
||||
error_field,
|
||||
forms.ValidationError(e.message),
|
||||
)
|
||||
except stripe.error.StripeError as e:
|
||||
log.error('There was a problem communicating with Stripe: %s',
|
||||
str(e), exc_info=True)
|
||||
raise forms.ValidationError(
|
||||
_('There was a problem communicating with Stripe'))
|
||||
|
||||
def clear_card_data(self):
|
||||
"""Clear card data on validation errors
|
||||
|
||||
This requires the form was created by passing in a mutable QueryDict
|
||||
instance, see :py:cls:`readthedocs.payments.mixin.StripeMixin`
|
||||
"""
|
||||
try:
|
||||
self.data['stripe_token'] = None
|
||||
except AttributeError:
|
||||
raise AttributeError('Form was passed immutable QueryDict POST data')
|
||||
|
||||
def fields_with_cc_group(self):
|
||||
group = {
|
||||
'is_cc_group': True,
|
||||
'fields': []
|
||||
}
|
||||
for field in self:
|
||||
if field.name in ['cc_number', 'cc_expiry', 'cc_cvv']:
|
||||
group['fields'].append(field)
|
||||
else:
|
||||
yield field
|
||||
yield group
|
|
@ -0,0 +1,23 @@
|
|||
from django.conf import settings
|
||||
|
||||
|
||||
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['stripe_publishable'] = settings.STRIPE_PUBLISHABLE
|
||||
return context
|
||||
|
||||
def get_form(self, data=None, files=None, **kwargs):
|
||||
"""Pass in copy of POST data to avoid read only QueryDicts on form
|
||||
|
||||
This is used to be able to reset some important credit card fields if
|
||||
card validation fails. In this case, the Stripe token was valid, but the
|
||||
card was rejected during the charge or subscription instantiation.
|
||||
"""
|
||||
if self.request.method == 'POST':
|
||||
data = self.request.POST.copy()
|
||||
cls = self.get_form_class()
|
||||
return cls(data=data, files=files, **kwargs)
|
|
@ -5,12 +5,21 @@ var ko = require('knockout'),
|
|||
$ = 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 || {};
|
||||
}
|
||||
|
||||
/* Knockout binding to set initial observable values from HTML */
|
||||
ko.bindingHandlers.valueInit = {
|
||||
init: function(element, accessor) {
|
||||
var value = accessor();
|
||||
if (ko.isWriteableObservable(value)) {
|
||||
value(element.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function PaymentView (config) {
|
||||
var self = this,
|
||||
config = config || {};
|
||||
|
@ -23,9 +32,22 @@ function PaymentView (config) {
|
|||
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);
|
||||
self.error_cc_number = ko.observable(null);
|
||||
self.error_cc_expiry = ko.observable(null);
|
||||
self.error_cc_cvv = ko.observable(null);
|
||||
|
||||
self.stripe_token = ko.observable(null);
|
||||
self.card_digits = ko.observable(null);
|
||||
|
||||
// Form editing
|
||||
self.is_editing_card = ko.observable(false);
|
||||
self.show_card_form = ko.computed(function () {
|
||||
return (self.is_editing_card() ||
|
||||
!self.card_digits() ||
|
||||
self.cc_number() ||
|
||||
self.cc_expiry() ||
|
||||
self.cc_cvv());
|
||||
});
|
||||
|
||||
// Credit card validation
|
||||
self.initialize_form();
|
||||
|
@ -45,52 +67,64 @@ function PaymentView (config) {
|
|||
};
|
||||
|
||||
self.error(null);
|
||||
self.cc_error_number(null);
|
||||
self.cc_error_expiry(null);
|
||||
self.cc_error_cvv(null);
|
||||
self.error_cc_number(null);
|
||||
self.error_cc_expiry(null);
|
||||
self.error_cc_cvv(null);
|
||||
|
||||
if (!$.payment.validateCardNumber(card.number)) {
|
||||
self.cc_error_number('Invalid card number');
|
||||
console.log(card);
|
||||
self.error_cc_number('Invalid card number');
|
||||
return false;
|
||||
}
|
||||
if (!$.payment.validateCardExpiry(card.exp_month, card.exp_year)) {
|
||||
self.cc_error_expiry('Invalid expiration date');
|
||||
self.error_cc_expiry('Invalid expiration date');
|
||||
return false;
|
||||
}
|
||||
if (!$.payment.validateCardCVC(card.cvc)) {
|
||||
self.cc_error_cvv('Invalid security code');
|
||||
self.error_cc_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();
|
||||
self.submit_form(response.card.last4, response.id);
|
||||
}
|
||||
else {
|
||||
self.error(response.error.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
self.process_full_form = function () {
|
||||
if (self.show_card_form()) {
|
||||
self.process_form()
|
||||
}
|
||||
else {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
PaymentView.prototype.submit_form = function (card_digits, token) {
|
||||
this.form.find('#id_card_digits').val(card_digits);
|
||||
this.form.find('#id_stripe_token').val(token);
|
||||
this.form.submit();
|
||||
};
|
||||
|
||||
PaymentView.prototype.initialize_form = function () {
|
||||
var cc_number = $('input#cc-number'),
|
||||
cc_cvv = $('input#cc-cvv'),
|
||||
cc_expiry = $('input#cc-expiry');
|
||||
var cc_number = $('input#id_cc_number'),
|
||||
cc_cvv = $('input#id_cc_cvv'),
|
||||
cc_expiry = $('input#id_cc_expiry');
|
||||
|
||||
cc_number.payment('formatCardNumber');
|
||||
cc_expiry.payment('formatCardExpiry');
|
||||
cc_cvv.payment('formatCardCVC');
|
||||
|
||||
cc_number.trigger('keyup');
|
||||
};
|
||||
|
||||
PaymentView.init = function (config, obj) {
|
||||
var view = new GoldView(config),
|
||||
var view = new PaymentView(config),
|
||||
obj = obj || $('#payment-form')[0];
|
||||
ko.applyBindings(view, obj);
|
||||
return view;
|
|
@ -0,0 +1,26 @@
|
|||
/* Payment form CSS */
|
||||
|
||||
form.payment input#id_cc_number {
|
||||
height: 23px;
|
||||
margin: 3px 0px 10px;
|
||||
background: url('/static/donate/img/creditcard.png');
|
||||
background-repeat: no-repeat;
|
||||
background-position: -0px -128px;
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
form.payment input#id_cc_number.visa {
|
||||
background-position: -40px -96px;
|
||||
}
|
||||
form.payment input#id_cc_number.mastercard {
|
||||
background-position: -80px -64px;
|
||||
}
|
||||
form.payment input#id_cc_number.amex {
|
||||
background-position: -120px -32px;
|
||||
}
|
||||
form.payment input#id_cc_number.discover {
|
||||
background-position: -160px -0px;
|
||||
}
|
||||
|
||||
form.payment input#id_cc_expiry { width: 150px; }
|
||||
form.payment input#id_cc_cvv { width: 100px; }
|
|
@ -0,0 +1,30 @@
|
|||
"""Payment utility functions
|
||||
|
||||
These are mostly one-off functions. Define the bulk of Stripe operations on
|
||||
:py:cls:`readthedocs.payments.forms.StripeResourceMixin`.
|
||||
"""
|
||||
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
|
||||
stripe.api_key = getattr(settings, 'STRIPE_SECRET', None)
|
||||
|
||||
|
||||
def delete_customer(customer_id):
|
||||
"""Delete customer from Stripe, cancelling subscriptions"""
|
||||
try:
|
||||
customer = stripe.Customer.retrieve(customer_id)
|
||||
customer.delete()
|
||||
except stripe.error.InvalidRequestError:
|
||||
pass
|
||||
|
||||
|
||||
def cancel_subscription(customer_id, subscription_id):
|
||||
"""Cancel Stripe subscription, if it exists"""
|
||||
try:
|
||||
customer = stripe.Customer.retrieve(customer_id)
|
||||
if hasattr(customer, 'subscriptions'):
|
||||
subscription = customer.subscriptions.retrieve(subscription_id)
|
||||
subscription.delete()
|
||||
except stripe.error.StripeError as e:
|
||||
pass
|
|
@ -12,7 +12,6 @@ from django.db.models import Q
|
|||
from django.shortcuts import get_object_or_404, render_to_response, render
|
||||
from django.template import RequestContext
|
||||
from django.views.generic import View, TemplateView, ListView
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from formtools.wizard.views import SessionWizardView
|
||||
from allauth.socialaccount.models import SocialAccount
|
||||
|
@ -37,19 +36,12 @@ from readthedocs.projects.views.base import ProjectAdminMixin
|
|||
from readthedocs.projects import constants, tasks
|
||||
from readthedocs.projects.tasks import remove_path_from_web
|
||||
|
||||
|
||||
from readthedocs.core.mixins import LoginRequiredMixin
|
||||
from readthedocs.projects.signals import project_import
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginRequiredMixin(object):
|
||||
|
||||
@method_decorator(login_required)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
return super(LoginRequiredMixin, self).dispatch(*args, **kwargs)
|
||||
|
||||
|
||||
class PrivateViewMixin(LoginRequiredMixin):
|
||||
pass
|
||||
|
||||
|
|
|
@ -214,6 +214,7 @@ INSTALLED_APPS = [
|
|||
'readthedocs.privacy',
|
||||
'readthedocs.gold',
|
||||
'readthedocs.donate',
|
||||
'readthedocs.payments',
|
||||
|
||||
# allauth
|
||||
'allauth',
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
<li class="{% block profile-admin-social-accounts %}{% endblock %}"><a href="{% url 'socialaccount_connections' %}">{% trans "Social Accounts" %}</a></li>
|
||||
<li class="{% block profile-admin-change-password %}{% endblock %}"><a href="{% url 'account_change_password' %}">{% trans "Change Password" %}</a></li>
|
||||
<li class="{% block profile-admin-change-email %}{% endblock %}"><a href="{% url 'account_email' %}">{% trans "Change Email" %}</a></li>
|
||||
<li class="{% block profile-admin-gold-edit %}{% endblock %}"><a href="{% url 'gold_register' %}">{% trans "Gold" %}</a></li>
|
||||
<li class="{% block profile-admin-gold-edit %}{% endblock %}"><a href="{% url 'gold_detail' %}">{% trans "Gold" %}</a></li>
|
||||
</ul>
|
||||
<div>
|
||||
<h2>{% block edit_content_header %}{% endblock %}</h2>
|
||||
|
|
Loading…
Reference in New Issue