diff --git a/backend/handlers.py b/backend/handlers.py index 3bed89a..778a09b 100644 --- a/backend/handlers.py +++ b/backend/handlers.py @@ -1,21 +1,25 @@ """Handlers for MayOne.US.""" from collections import namedtuple +import datetime import json import logging -import webapp2 +from google.appengine.ext import deferred import validictory +import webapp2 + +import model # Immutable environment with both configuration variables, and backends to be # mocked out in tests. Environment = namedtuple( 'Environment', [ - # AppEngine app name. + # App engine app name, or 'local' for dev_appserver, or 'unittest' for unit + # tests. 'app_name', - # Stripe creds to export. 'stripe_public_key', # PaymentProcessor @@ -23,49 +27,75 @@ # MailingListSubscriber 'mailing_list_subscriber', + + # MailSender + 'mail_sender', ]) class PaymentProcessor(object): """Interface which processes payments.""" - def CreateCustomer(self, payment_params, pledge_model): + + STRIPE = 'STRIPE' + + def CreateCustomer(self, params): """Does whatever the payment processor needs to do in order to be able to charge the customer later. Args: - payment_params: dict with keys like 'paypal' or 'stripe', with values - which are dicts with parameters specific to that payment platform. - pledge_model: A not-yet-committed pledge model for us to modify to include - a record of the customer. + params: dict representing the JSON object send to the pledge creation + handler. + + Returns: A dict giving the fields that will need to be included in the + Pledge model. + """ raise NotImplementedError() class MailingListSubscriber(object): """Interface which signs folks up for emails.""" - def Subscribe(self, first_name, last_name, amount_cents, ip_addr, time, + def Subscribe(self, email, first_name, last_name, amount_cents, ip_addr, time, source): raise NotImplementedError() +class MailSender(object): + """Interface which sends mail.""" + def Send(self, to, subject, text_body, html_body): + raise NotImplementedError() + + _STR = dict(type='string') class PledgeHandler(webapp2.RequestHandler): + """RESTful handler for pledge objects.""" + CREATE_SCHEMA = dict( type='object', properties=dict( email=_STR, phone=dict(type='string', blank=True), - firstName=_STR, - lastName=_STR, + name=_STR, occupation=_STR, employer=_STR, target=_STR, subscribe=dict(type='boolean'), - amountCents=dict(type='integer', minimum=100) + amountCents=dict(type='integer', minimum=100), + payment=dict(type='object', + properties=dict( + STRIPE=dict(type='object', + required=False, + properties=dict(token=_STR)), + # TODO: Paypal + ) + ), ) ) def post(self): + """Create a new pledge, and update user info.""" + env = self.app.config['env'] + try: data = json.loads(self.request.body) except ValueError, e: @@ -82,10 +112,73 @@ def post(self): self.response.write('Invalid request') return + # Do any server-side processing the payment processor needs. + stripe_customer_id = None + if PaymentProcessor.STRIPE in data['payment']: + customer = env.payment_processor.CreateCustomer(data) + stripe_customer_id = customer['customer_id'] + else: + logging.warning('No payment processor specified: %s', data) + pledge = model.addPledge(email=data['email'], + stripe_customer_id=stripe_customer_id, + amount_cents=data['amountCents'], + occupation=data['occupation'], + employer=data['employer'], + phone=data['phone'], + fundraisingRound='1', + target=data['target']) + + # Split apart the name into first and last. Yes, this sucks, but adding the + # name fields makes the form look way more daunting. We may reconsider this. + name_parts = data['name'].split(None, 1) + first_name = name_parts[0] + if len(name_parts) == 1: + last_name = '' + logging.warning('Could not determine last name: %s', data['name']) + else: + last_name = name_parts[1] + + if data['subscribe']: + env.mailing_list_subscriber.Subscribe( + email=data['email'], + first_name=first_name, last_name=last_name, + amount_cents=data['amountCents'], + ip_addr=self.request.remote_addr, + time=datetime.datetime.now(), + source='pledged') + + format_kwargs = { + 'name': data['name'].encode('utf-8'), + 'url_nonce': pledge.url_nonce, + 'total': '$%d' % int(data['amountCents'] / 100) + } + + text_body = open('email/thank-you.txt').read().format(**format_kwargs) + html_body = open('email/thank-you.html').read().formata(**format_kwargs) + + env.mail_sender.Send(to=data['email'].encode('utf-8'), + subject='Thank you for your pledge', + text_body=text_body, + html_body=html_body) + + self.response.headers['Content-Type'] = 'application/json' + json.dump(dict(id=str(pledge.key()), + auth_token=pledge.url_nonce), self.response) + + +class PaymentConfigHandler(webapp2.RequestHandler): + def get(self): + env = self.app.config['env'] + if not env.stripe_public_key: + raise Error('No stripe public key in DB') + params = dict(testMode=(env.app_name == u'local'), + stripePublicKey=env.stripe_public_key) + self.response.headers['Content-Type'] = 'application/json' - json.dump(dict(id='2'), self.response) + json.dump(params, self.response) HANDLERS = [ ('/r/pledge', PledgeHandler), + ('/r/payment_config', PaymentConfigHandler), ] diff --git a/backend/main.py b/backend/main.py index 5020a4c..8cfb628 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,20 +1,19 @@ import datetime -import jinja2 import json import logging import urlparse -import webapp2 from google.appengine.api import mail from google.appengine.api import memcache from google.appengine.ext import db from google.appengine.ext import deferred - from mailchimp import mailchimp +import jinja2 +import stripe +import webapp2 import handlers import model -import stripe import wp_import # These get added to every pledge calculation @@ -32,25 +31,14 @@ class Error(Exception): pass autoescape=True) -def send_thank_you(name, email, url_nonce, amount_cents): +def send_mail(to, subject, text_body, html_body): """Deferred email task""" sender = ('MayOne no-reply ' % model.Config.get().app_name) - subject = 'Thank you for your pledge' message = mail.EmailMessage(sender=sender, subject=subject) - message.to = email - - format_kwargs = { - # TODO: Figure out how to set the outgoing email content encoding. - # once we can set the email content encoding to utf8, we can change this - # to name.encode('utf-8') and not drop fancy characters. :( - 'name': name.encode('ascii', errors='ignore'), - 'url_nonce': url_nonce, - 'total': '$%d' % int(amount_cents/100) - } - - message.body = open('email/thank-you.txt').read().format(**format_kwargs) - message.html = open('email/thank-you.html').read().format(**format_kwargs) + message.to = to + message.body = text_body + message.html = html_body message.send() @@ -133,7 +121,8 @@ def get(self): self.response.headers['Content-Type'] = 'application/javascript' self.response.write('%s(%d)' % (self.request.get('callback'), total)) - +# DEPRECATED. Replaced by handlers.PaymentConfigHandler +# TODO(hjfreyer): Remove class GetStripePublicKeyHandler(webapp2.RequestHandler): def get(self): if not model.Config.get().stripe_public_key: @@ -149,6 +138,8 @@ def get(self): self.redirect('/') +# DEPRECATED. Replaced by handlers.PledgeHandler +# TODO(hjfreyer): Remove class PledgeHandler(webapp2.RequestHandler): def post(self): try: @@ -273,6 +264,66 @@ def post(self, url_nonce): return +class ProdPaymentProcessor(handlers.PaymentProcessor): + def __init__(self, stripe_private_key): + self.stripe_private_key = stripe_private_key + + def CreateCustomer(self, request): + if 'STRIPE' not in request['payment']: + raise Error('STRIPE is the only supported payment platform') + customer = stripe.Customer.create( + card=request['payment']['STRIPE']['token'], + email=request['email']) + return dict(customer_id=customer.id) + + +class FakePaymentProcessor(handlers.PaymentProcessor): + def CreateCustomer(self, request): + logging.error('USING FAKE PAYMENT PROCESSOR') + return dict(customer_id='fake_1234') + + +class MailchimpSubscriber(handlers.MailingListSubscriber): + def Subscribe(self, email, first_name, last_name, amount_cents, ip_addr, time, + source): + deferred.defer(subscribe_to_mailchimp, + email, first_name, last_name, + amount_cents, ip_addr, 'pledge') + + +class FakeSubscriber(handlers.MailingListSubscriber): + def Subscribe(self, **kwargs): + logging.info('Subscribing %s', kwargs) + + +class ProdMailSender(handlers.MailSender): + def Send(self, to, subject, text_body, html_body): + deferred.defer(send_mail, to, subject, text_body, html_body) + + +def GetEnv(): + j = json.load(open('config.json')) + s = model.Secrets.get() + + stripe.api_key = model.Config.get().stripe_private_key + + payment_processor = None + mailing_list_subscriber = None + if j['appName'] == 'local': + payment_processor = FakePaymentProcessor() + mailing_list_subscriber = FakeSubscriber() + else: + payment_processor = ProdPaymentProcessor(s.stripe_private_key) + mailing_list_subscriber = MailchimpSubscriber() + + return handlers.Environment( + app_name=j['appName'], + stripe_public_key=s.stripe_public_key, + payment_processor=payment_processor, + mailing_list_subscriber=mailing_list_subscriber, + mail_sender=ProdMailSender()) + + app = webapp2.WSGIApplication([ ('/total', GetTotalHandler), ('/stripe_public_key', GetStripePublicKeyHandler), @@ -282,4 +333,4 @@ def post(self, url_nonce): ('/contact.do', ContactHandler), # See wp_import # ('/import.do', wp_import.ImportHandler), -] + handlers.HANDLERS, debug=False) +] + handlers.HANDLERS, debug=False, config=dict(env=GetEnv())) diff --git a/backend/model.py b/backend/model.py index 1448bbb..450b4a8 100644 --- a/backend/model.py +++ b/backend/model.py @@ -28,6 +28,8 @@ class Error(Exception): pass # # Note that this isn't really a "model", it's built up from config.json # and the "Secrets" model. +# +# TODO(hjfreyer): Deprecate this and replace it with handlers.Environment. class Config(object): ConfigType = namedtuple('ConfigType', ['app_name', diff --git a/markup/pledge.jade b/markup/pledge.jade index 0ab7721..418499c 100644 --- a/markup/pledge.jade +++ b/markup/pledge.jade @@ -11,37 +11,44 @@ html myApp.controller('PledgeController', ['$scope', '$http', function($scope, $http) { $scope.ctrl = { + paymentParams: null, stripeHandler: null, - amount: 0, - userinfo: { + form: { email: '', phone: '', occupation: '', employer: '', target: 'Whatever Helps', + amount: 0, subscribe: true, }, error: '', cents: function() { - return Math.floor($scope.ctrl.amount * 100); + return Math.floor($scope.ctrl.form.amount * 100); }, pledge: function() { var cents = $scope.ctrl.cents(); $scope.ctrl.stripeHandler.open({ - email: $scope.ctrl.userinfo.email, + email: $scope.ctrl.form.email, amount: cents, }); }, onTokenRecv: function(token, args) { - $http.post('/pledge.do', { - // TODO(hjfreyer): Remove redundant fields. - email: $scope.ctrl.userinfo.email, - - token: token.id, - amount: $scope.ctrl.cents(), - userinfo: $scope.ctrl.userinfo, - name: args.billing_name, + $scope.ctrl.createPledge(args.billing_name, + { STRIPE: { token: token.id } }); + }, + createPledge: function(name, payment) { + $http.post('/r/pledge', { + email: $scope.ctrl.form.email, + phone: $scope.ctrl.form.phone, + name: name, + occupation: $scope.ctrl.form.occupation, + employer: $scope.ctrl.form.employer, + target: $scope.ctrl.form.target, + subscribe: $scope.ctrl.form.subscribe, + amountCents: $scope.ctrl.cents(), + payment: payment, }).success(function(data) { ga('ecommerce:addTransaction', { id: data.id, @@ -56,16 +63,17 @@ html }, }; - $http.get('/stripe_public_key').success(function(key) { + $http.get('/r/payment_params').success(function(params) { + $scope.ctrl.paymentParams = params; $scope.ctrl.stripeHandler = StripeCheckout.configure({ - key: key, + key: params.stripePublicKey, name: 'MayOne.US', panelLabel: 'Pledge', billingAddress: true, image: '/static/flag.jpg', token: function(token, args) { $scope.ctrl.onTokenRecv(token, args); - } + }, }); }); }]); @@ -101,26 +109,26 @@ html .form-group.col-sm-6 label.control-label Email input.form-control.input-lg(type="email", required, - placeholder="smith@example.com", ng-model="ctrl.userinfo.email") + placeholder="smith@example.com", ng-model="ctrl.form.email") .form-group.col-sm-6 label.control-label Phone Number Optional input.form-control.input-lg(type="tel", - placeholder="(212) 867-5309", ng-model="ctrl.userinfo.phone") + placeholder="(212) 867-5309", ng-model="ctrl.form.phone") .form-group.col-sm-6 label.control-label Occupation input.form-control.input-lg(type="text", required, placeholder="Gardener, Railroad Worker, etc", - ng-model="ctrl.userinfo.occupation") + ng-model="ctrl.form.occupation") .form-group.col-sm-6 label.control-label Employer input.form-control.input-lg(type="text", required, placeholder="Nintendo, UPS, Self, etc", - ng-model="ctrl.userinfo.employer") + ng-model="ctrl.form.employer") .form-group.col-sm-6 label.control-label Who to Fund? - select.form-control.input-lg(ng-model="ctrl.userinfo.target") + select.form-control.input-lg(ng-model="ctrl.form.target") option Whatever Helps option Democrats Only option Republicans Only @@ -129,13 +137,15 @@ html .input-group.input-group-lg span.input-group-addon $ input.form-control(type="number", required, min="1", - ng-model="ctrl.amount") + ng-model="ctrl.form.amount") .form-group.col-sm-9 .checkbox - label Want to know how we're using the money? Sign up for emails from us. + label Want to know how we're using the money? Sign up for emails from us. .form-group.col-sm-3 - button.form-control.input-lg.btn.btn-lg.btn-danger Pledge + button.form-control.input-lg.btn.btn-lg.btn-danger + span(ng-show="ctrl.paymentParams.testMode") TEST  + span Pledge .row .col-md-12 h3 Donate Directly diff --git a/testrunner.py b/testrunner.py index 20a2818..276a709 100755 --- a/testrunner.py +++ b/testrunner.py @@ -1,4 +1,5 @@ #!/usr/bin/python +import os import sys import unittest @@ -6,10 +7,11 @@ Run unit tests for App Engine apps.""" SDK_PATH_manual = '/usr/local/google_appengine' -TEST_PATH_manual = 'unittests' +TEST_PATH_manual = '../unittests' def main(sdk_path, test_path): - sys.path.extend([sdk_path, 'backend', 'lib', 'testlib']) + os.chdir('backend') + sys.path.extend([sdk_path, '../lib', '../testlib']) import dev_appserver dev_appserver.fix_sys_path() suite = unittest.loader.TestLoader().discover(test_path) diff --git a/unittests/test_Main.py b/unittests/test_Main.py deleted file mode 100644 index 3c7e2dc..0000000 --- a/unittests/test_Main.py +++ /dev/null @@ -1,69 +0,0 @@ -import unittest -import logging -#from datetime import datetime, timedelta - -#from google.appengine.api import memcache -from google.appengine.ext import ndb -from google.appengine.ext import testbed - -from backend import main - -class TestGetTotalHandler(unittest.TestCase): - def setUp(self): - # First, create an instance of the Testbed class. - self.testbed = testbed.Testbed() - # Then activate the testbed, which prepares the service stubs for use. - self.testbed.activate() - # Next, declare which service stubs you want to use. - self.testbed.init_datastore_v3_stub() - - def tearDown(self): - self.testbed.deactivate() - -class TestGetStripePublicKeyHandler(unittest.TestCase): - def setUp(self): - # First, create an instance of the Testbed class. - self.testbed = testbed.Testbed() - # Then activate the testbed, which prepares the service stubs for use. - self.testbed.activate() - # Next, declare which service stubs you want to use. - self.testbed.init_datastore_v3_stub() - - def tearDown(self): - self.testbed.deactivate() - -class TestEmbedHandler(unittest.TestCase): - def setUp(self): - # First, create an instance of the Testbed class. - self.testbed = testbed.Testbed() - # Then activate the testbed, which prepares the service stubs for use. - self.testbed.activate() - # Next, declare which service stubs you want to use. - self.testbed.init_datastore_v3_stub() - - def tearDown(self): - self.testbed.deactivate() - -class TestPledgeHandler(unittest.TestCase): - def setUp(self): - # First, create an instance of the Testbed class. - self.testbed = testbed.Testbed() - # Then activate the testbed, which prepares the service stubs for use. - self.testbed.activate() - # Next, declare which service stubs you want to use.#self.testbed.init_datastore_v3_stub() - self.testbed.init_urlfetch_stub() - - def tearDown(self): - self.testbed.deactivate() - -class TestFunctions(unittest.TestCase): - def setUp(self): - # First, create an instance of the Testbed class. - self.testbed = testbed.Testbed() - # Then activate the testbed, which prepares the service stubs for use. - self.testbed.activate() - # Next, declare which service stubs you want to use. - self.testbed.init_datastore_v3_stub() - - def tearDown(self): - self.testbed.deactivate() diff --git a/unittests/test_e2e.py b/unittests/test_e2e.py index a94ee72..aa1627d 100644 --- a/unittests/test_e2e.py +++ b/unittests/test_e2e.py @@ -1,16 +1,17 @@ import unittest import logging -#from datetime import datetime, timedelta +import datetime -#from google.appengine.api import memcache -from google.appengine.ext import ndb +from google.appengine.ext import db from google.appengine.ext import testbed - -import webtest +import mox import webapp2 +import webtest + from backend import handlers from backend import model + class BaseTest(unittest.TestCase): def setUp(self): self.testbed = testbed.Testbed() @@ -18,22 +19,44 @@ def setUp(self): self.testbed.init_datastore_v3_stub() - self.app = webtest.TestApp(webapp2.WSGIApplication(handlers.HANDLERS)) + self.mockery = mox.Mox() + self.payment_processor = self.mockery.CreateMock(handlers.PaymentProcessor) + self.mailing_list_subscriber = self.mockery.CreateMock( + handlers.MailingListSubscriber) + self.mail_sender = self.mockery.CreateMock(handlers.MailSender) + + self.env = handlers.Environment( + app_name='unittest', + stripe_public_key='pubkey1234', + payment_processor=self.payment_processor, + mailing_list_subscriber=self.mailing_list_subscriber, + mail_sender=self.mail_sender) + self.wsgi_app = webapp2.WSGIApplication(handlers.HANDLERS, + config=dict(env=self.env)) + + self.app = webtest.TestApp(self.wsgi_app) def tearDown(self): - self.testbed.deactivate() + self.mockery.VerifyAll() + self.testbed.deactivate() + class PledgeTest(BaseTest): - SAMPLE_USER = dict( - email='pika@pokedex.biz', - phone='212-234-5432', - firstName='Pika', - lastName='Chu', - occupation=u'Pok\u00E9mon', - employer='Nintendo', - target='Republicans Only', - subscribe=False, - amountCents=4200) + def samplePledge(self): + return dict( + email='pika@pokedex.biz', + phone='212-234-5432', + name=u'Pik\u00E1 Chu', + occupation=u'Pok\u00E9mon', + employer='Nintendo', + target='Republicans Only', + subscribe=True, + amountCents=4200, + payment=dict( + STRIPE=dict( + token='tok_1234', + ) + )) def testBadJson(self): self.app.post('/r/pledge', '{foo', status=400) @@ -42,7 +65,190 @@ def testNotEnoughJson(self): self.app.post_json('/r/pledge', dict(email='foo@bar.com'), status=400) def testCreateAddsPledge(self): - resp = self.app.post_json('/r/pledge', PledgeTest.SAMPLE_USER) + self.payment_processor \ + .CreateCustomer(self.samplePledge()) \ + .AndReturn(dict(customer_id='cust_4321')) + + self.mailing_list_subscriber \ + .Subscribe(email='pika@pokedex.biz', + first_name=u'Pik\u00E1', last_name='Chu', + amount_cents=4200, ip_addr=None, # Not sure why this is None + # in unittests. + time=mox.IsA(datetime.datetime), source='pledged') + + self.mail_sender.Send(to=mox.IsA(str), subject=mox.IsA(str), + text_body=mox.IsA(str), + html_body=mox.IsA(str)) + + self.mockery.ReplayAll() + + resp = self.app.post_json('/r/pledge', self.samplePledge()) + + pledge = db.get(resp.json['id']) + self.assertEquals(4200, pledge.amountCents) + self.assertEquals(resp.json['auth_token'], pledge.url_nonce) + self.assertEquals('cust_4321', pledge.stripeCustomer) + + user = model.User.get_by_key_name('pika@pokedex.biz') + + sample = self.samplePledge() + def assertEqualsSampleProperty(prop_name, actual): + self.assertEquals(sample[prop_name], actual) + assertEqualsSampleProperty('email', user.email) + assertEqualsSampleProperty('occupation', user.occupation) + assertEqualsSampleProperty('employer', user.employer) + assertEqualsSampleProperty('phone', user.phone) + assertEqualsSampleProperty('target', user.target) + assert user.url_nonce + assert not user.from_import + + def testSubscribes(self): + self.payment_processor \ + .CreateCustomer(self.samplePledge()) \ + .AndReturn(dict(customer_id='cust_4321')) + + self.mailing_list_subscriber \ + .Subscribe(email='pika@pokedex.biz', + first_name=u'Pik\u00E1', last_name='Chu', + amount_cents=4200, ip_addr=None, # Not sure why this is None + # in unittests. + time=mox.IsA(datetime.datetime), source='pledged') + + self.mail_sender.Send(to=mox.IsA(str), subject=mox.IsA(str), + text_body=mox.IsA(str), + html_body=mox.IsA(str)) + + self.mockery.ReplayAll() + + self.app.post_json('/r/pledge', self.samplePledge()) + + def testSubscribeOptOut(self): + sample = self.samplePledge() + sample['subscribe'] = False + + self.payment_processor \ + .CreateCustomer(sample) \ + .AndReturn(dict(customer_id='cust_4321')) + + self.mail_sender.Send(to=mox.IsA(str), subject=mox.IsA(str), + text_body=mox.IsA(str), + html_body=mox.IsA(str)) + + # Don't subscribe. + + self.mockery.ReplayAll() + + self.app.post_json('/r/pledge', sample) + + def testNoPhone(self): + sample = self.samplePledge() + sample['phone'] = '' + + self.payment_processor \ + .CreateCustomer(sample) \ + .AndReturn(dict(customer_id='cust_4321')) + + self.mailing_list_subscriber \ + .Subscribe(email='pika@pokedex.biz', + first_name=u'Pik\u00E1', last_name='Chu', + amount_cents=4200, ip_addr=None, # Not sure why this is None + # in unittests. + time=mox.IsA(datetime.datetime), source='pledged') + + self.mail_sender.Send(to=mox.IsA(str), subject=mox.IsA(str), + text_body=mox.IsA(str), + html_body=mox.IsA(str)) + + self.mockery.ReplayAll() + + self.app.post_json('/r/pledge', sample) + + def testNoName(self): + sample = self.samplePledge() + sample['name'] = '' + + self.mockery.ReplayAll() + + self.app.post_json('/r/pledge', sample, status=400) + + def testMail(self): + sample = self.samplePledge() + sample['subscribe'] = False + + self.payment_processor \ + .CreateCustomer(sample) \ + .AndReturn(dict(customer_id='cust_4321')) + + self.mail_sender.Send(to='pika@pokedex.biz', subject='Thank you for your pledge', + text_body="""Dear Pik\xc3\xa1 Chu: + +Thank you for your pledge to the MaydayPAC. We are grateful for the support to make it possible for us to win back our democracy. + +But may I ask for one more favor? + +We will only win if we find 100 people for every person like you. It would be incredibly helpful if you could help us recruit them, ideally by sharing the link to the MayOne.US site. We've crafted something simple to copy and paste below. Or you can like us on our Facebook Page[1], or follow @MayOneUS[2] on Twitter. + +We'd be grateful for your feedback and ideas for how we can spread this message broadly. We're watching the social media space for #MaydayPAC, or you can email your ideas to info@mayone.us. + +This is just the beginning. But if we can succeed as we have so far, then by 2016, we will have taken the first critical step to getting our democracy back. + +This email serves as your receipt for your pledge of: $42 + +Thank you again, + +Lessig +lessig@mayone.us + +Suggested text: + +I just supported a SuperPAC to end all SuperPACs \xe2\x80\x94 the #MaydayPAC, citizen-funded through a crowd-funded campaign. You can check it out here: http://mayone.us. + +[1] https://www.facebook.com/mayonedotus +[2] https://twitter.com/MayOneUS + +---------------------- +Paid for by MayDay PAC +Not authorized by any candidate or candidate\xe2\x80\x99s committee +www.MayOne.us +""", + html_body=''' + +

Dear Pik\xc3\xa1 Chu,

+ +

Thank you for your pledge to the MaydayPAC. We are grateful for the support to make it possible for us to win back our democracy.

+ +

But may I ask for one more favor?

+ +

We will only win if we find 100 people for every person like you. It would be incredibly helpful if you could help us recruit them, ideally by sharing the link to the MayOne.US site. We\'ve crafted something simple to copy and paste below. Or you can like us on our Facebook Page, or follow @MayOneUS on Twitter.

+ +

We\'d be grateful for your feedback and ideas for how we can spread this message broadly. We\'re watching the social media space for #MaydayPAC, or you can email your ideas to info@mayone.us.

+ +

This is just the beginning. But if we can succeed as we have so far, then by 2016, we will have taken the first critical step to getting our democracy back.

+ +

This email serves as your receipt for your pledge of: $42

+ +

Thank you again,

+ +

+ Lessig
+ lessig@mayone.us +

+ +

Suggested text:

+

I just supported a SuperPAC to end all SuperPACs – the #MaydayPAC, citizen-funded through a crowd-funded campaign. You can check it out here: http://mayone.us.

+ +

+ ----------------------
+ Paid for by MayDay PAC
+ Not authorized by any candidate or candidate\xe2\x80\x99s committee
+ www.MayOne.us +

+ + +''') + + # Don't subscribe. + + self.mockery.ReplayAll() - pledge = model.Pledge.get_by_key_name(resp.json['id']) - self.assertIsNotNone(pledge) + self.app.post_json('/r/pledge', sample)