diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py index 1eadb16184..cbb70a2697 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -9,12 +9,16 @@ from django.contrib import messages from django.core import signing from django.http import HttpRequest from django.template.loader import get_template +from django.urls import reverse +from django.utils.http import urlquote from django.utils.translation import ugettext as __, ugettext_lazy as _ +from paypalrestsdk.openid_connect import Tokeninfo from pretix.base.decimal import round_decimal -from pretix.base.models import OrderPayment, OrderRefund, Quota +from pretix.base.models import Event, OrderPayment, OrderRefund, Quota from pretix.base.payment import BasePaymentProvider, PaymentException from pretix.base.services.mail import SendMailException +from pretix.base.settings import SettingsSandbox from pretix.helpers.urls import build_absolute_uri as build_global_uri from pretix.multidomain.urlreverse import build_absolute_uri from pretix.plugins.paypal.models import ReferencedPayPalObject @@ -28,19 +32,26 @@ class Paypal(BasePaymentProvider): payment_form_fields = OrderedDict([ ]) + def __init__(self, event: Event): + super().__init__(event) + self.settings = SettingsSandbox('payment', 'paypal', event) + @property def settings_form_fields(self): - d = OrderedDict( - [ - ('endpoint', - forms.ChoiceField( - label=_('Endpoint'), - initial='live', - choices=( - ('live', 'Live'), - ('sandbox', 'Sandbox'), - ), - )), + if self.settings.connect_client_id and not self.settings.secret: + # PayPal connect + if self.settings.connect_user_id: + fields = [ + ('connect_user_id', + forms.CharField( + label=_('PayPal account'), + disabled=True + )), + ] + else: + return {} + else: + fields = [ ('client_id', forms.CharField( label=_('Client ID'), @@ -56,24 +67,76 @@ class Paypal(BasePaymentProvider): label=_('Secret'), max_length=80, min_length=80, - )) - ] + list(super().settings_form_fields.items()) + )), + ('endpoint', + forms.ChoiceField( + label=_('Endpoint'), + initial='live', + choices=( + ('live', 'Live'), + ('sandbox', 'Sandbox'), + ), + )), + ] + + d = OrderedDict( + fields + list(super().settings_form_fields.items()) ) + d.move_to_end('_enabled', False) return d + def get_connect_url(self, request): + request.session['payment_paypal_oauth_event'] = request.event.pk + + self.init_api() + return Tokeninfo.authorize_url({'scope': 'openid profile email'}) + def settings_content_render(self, request): - return "
%s
%s
" % ( - _('Please configure a PayPal Webhook to the following endpoint in order to automatically cancel orders ' - 'when payments are refunded externally.'), - build_global_uri('plugins:paypal:webhook') - ) + if self.settings.connect_client_id and not self.settings.secret: + # Use PayPal connect + if not self.settings.connect_user_id: + return ( + "

{}

" + "{}" + ).format( + _('To accept payments via PayPal, you will need an account at PayPal. By clicking on the ' + 'following button, you can either create a new PayPal account connect pretix to an existing ' + 'one.'), + self.get_connect_url(request), + _('Connect with {icon} PayPal').format(icon='') + ) + else: + return ( + "" + ).format( + reverse('plugins:paypal:oauth.disconnect', kwargs={ + 'organizer': self.event.organizer.slug, + 'event': self.event.slug, + }), + _('Disconnect from PayPal') + ) + else: + return "
%s
%s
" % ( + _('Please configure a PayPal Webhook to the following endpoint in order to automatically cancel orders ' + 'when payments are refunded externally.'), + build_global_uri('plugins:paypal:webhook') + ) def init_api(self): - paypalrestsdk.set_config( - mode="sandbox" if "sandbox" in self.settings.get('endpoint') else 'live', - client_id=self.settings.get('client_id'), - client_secret=self.settings.get('secret')) + if self.settings.connect_client_id: + paypalrestsdk.set_config( + mode="sandbox" if "sandbox" in self.settings.connect_endpoint else 'live', + client_id=self.settings.connect_client_id, + client_secret=self.settings.connect_secret_key, + openid_client_id=self.settings.connect_client_id, + openid_client_secret=self.settings.connect_secret_key, + openid_redirect_uri=urlquote(build_global_uri('plugins:paypal:oauth.return'))) + else: + paypalrestsdk.set_config( + mode="sandbox" if "sandbox" in self.settings.get('endpoint') else 'live', + client_id=self.settings.get('client_id'), + client_secret=self.settings.get('secret')) def payment_is_valid_session(self, request): return (request.session.get('payment_paypal_id', '') != '' @@ -90,6 +153,18 @@ class Paypal(BasePaymentProvider): if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs: kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace'] + if request.event.settings.payment_paypal_connect_user_id: + userinfo = Tokeninfo.create_with_refresh_token(request.event.settings.payment_paypal_connect_refresh_token).userinfo() + request.event.settings.payment_paypal_connect_user_id = userinfo.email + payee = { + "email": request.event.settings.payment_paypal_connect_user_id, + # If PayPal ever offers a good way to get the MerchantID via the Identifity API, + # we should use it instead of the merchant's eMail-address + # "merchant_id": request.event.settings.payment_paypal_connect_user_id, + } + else: + payee = {} + payment = paypalrestsdk.Payment({ 'intent': 'sale', 'payer': { @@ -115,7 +190,8 @@ class Paypal(BasePaymentProvider): "currency": request.event.currency, "total": self.format_price(cart['total']) }, - "description": __('Event tickets for {event}').format(event=request.event.name) + "description": __('Event tickets for {event}').format(event=request.event.name), + "payee": payee } ] }) @@ -333,6 +409,19 @@ class Paypal(BasePaymentProvider): def payment_prepare(self, request, payment_obj): self.init_api() + + if request.event.settings.payment_paypal_connect_user_id: + userinfo = Tokeninfo.create_with_refresh_token(request.event.settings.payment_paypal_connect_refresh_token).userinfo() + request.event.settings.payment_paypal_connect_user_id = userinfo.email + payee = { + "email": request.event.settings.payment_paypal_connect_user_id, + # If PayPal ever offers a good way to get the MerchantID via the Identifity API, + # we should use it instead of the merchant's eMail-address + # "merchant_id": request.event.settings.payment_paypal_connect_user_id, + } + else: + payee = {} + payment = paypalrestsdk.Payment({ 'intent': 'sale', 'payer': { @@ -362,7 +451,8 @@ class Paypal(BasePaymentProvider): "description": __('Order {order} for {event}').format( event=request.event.name, order=payment_obj.order.code - ) + ), + "payee": payee } ] }) diff --git a/src/pretix/plugins/paypal/signals.py b/src/pretix/plugins/paypal/signals.py index e2afc0ca5c..c77f2a3522 100644 --- a/src/pretix/plugins/paypal/signals.py +++ b/src/pretix/plugins/paypal/signals.py @@ -1,11 +1,14 @@ import json +from collections import OrderedDict +from django import forms from django.dispatch import receiver from django.template.loader import get_template from django.utils.translation import ugettext_lazy as _ from pretix.base.signals import ( - logentry_display, register_payment_providers, requiredaction_display, + logentry_display, register_global_settings, register_payment_providers, + requiredaction_display, ) @@ -53,3 +56,25 @@ def pretixcontrol_action_display(sender, action, request, **kwargs): ctx = {'data': data, 'event': sender, 'action': action} return template.render(ctx, request) + + +@receiver(register_global_settings, dispatch_uid='paypal_global_settings') +def register_global_settings(sender, **kwargs): + return OrderedDict([ + ('payment_paypal_connect_client_id', forms.CharField( + label=_('PayPal Connect: Client ID'), + required=False, + )), + ('payment_paypal_connect_secret_key', forms.CharField( + label=_('PayPal Connect: Secret key'), + required=False, + )), + ('payment_paypal_connect_endpoint', forms.ChoiceField( + label=_('PayPal Connect Endpoint'), + initial='live', + choices=( + ('live', 'Live'), + ('sandbox', 'Sandbox'), + ), + )), + ]) diff --git a/src/pretix/plugins/paypal/urls.py b/src/pretix/plugins/paypal/urls.py index 02042dc994..773012eae1 100644 --- a/src/pretix/plugins/paypal/urls.py +++ b/src/pretix/plugins/paypal/urls.py @@ -2,7 +2,9 @@ from django.conf.urls import include, url from pretix.multidomain import event_url -from .views import abort, redirect_view, success, webhook +from .views import ( + abort, oauth_disconnect, oauth_return, redirect_view, success, webhook, +) event_patterns = [ url(r'^paypal/', include([ @@ -19,5 +21,8 @@ event_patterns = [ urlpatterns = [ + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/paypal/disconnect/', + oauth_disconnect, name='oauth.disconnect'), url(r'^_paypal/webhook/$', webhook, name='webhook'), + url(r'^_paypal/oauth_return/$', oauth_return, name='oauth.return'), ] diff --git a/src/pretix/plugins/paypal/views.py b/src/pretix/plugins/paypal/views.py index 38af84117f..b58ae81f71 100644 --- a/src/pretix/plugins/paypal/views.py +++ b/src/pretix/plugins/paypal/views.py @@ -7,14 +7,17 @@ from django.contrib import messages from django.core import signing from django.db.models import Sum from django.http import HttpResponse, HttpResponseBadRequest -from django.shortcuts import redirect, render +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST +from paypalrestsdk.openid_connect import Tokeninfo -from pretix.base.models import Order, OrderPayment, OrderRefund, Quota +from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota from pretix.base.payment import PaymentException +from pretix.control.permissions import event_permission_required from pretix.multidomain.urlreverse import eventreverse from pretix.plugins.paypal.models import ReferencedPayPalObject from pretix.plugins.paypal.payment import Paypal @@ -37,6 +40,37 @@ def redirect_view(request, *args, **kwargs): return r +def oauth_return(request, *args, **kwargs): + if 'payment_paypal_oauth_event' not in request.session: + messages.error(request, _('An error occurred during connecting with PayPal, please try again.')) + return redirect(reverse('control:index')) + + event = get_object_or_404(Event, pk=request.session['payment_paypal_oauth_event']) + + prov = Paypal(event) + prov.init_api() + + try: + tokeninfo = Tokeninfo.create(request.GET.get('code')) + userinfo = Tokeninfo.create_with_refresh_token(tokeninfo['refresh_token']).userinfo() + except: + logger.exception('Failed to obtain OAuth token') + messages.error(request, _('An error occurred during connecting with PayPal, please try again.')) + else: + messages.success(request, + _('Your PayPal account is now connected to pretix. You can change the settings in ' + 'detail below.')) + + event.settings.payment_paypal_connect_refresh_token = tokeninfo['refresh_token'] + event.settings.payment_paypal_connect_user_id = userinfo.email + + return redirect(reverse('control:event.settings.payment.provider', kwargs={ + 'organizer': event.organizer.slug, + 'event': event.slug, + 'provider': 'paypal' + })) + + def success(request, *args, **kwargs): pid = request.GET.get('paymentId') token = request.GET.get('token') @@ -201,3 +235,18 @@ def webhook(request, *args, **kwargs): pass return HttpResponse(status=200) + + +@event_permission_required('can_change_event_settings') +@require_POST +def oauth_disconnect(request, **kwargs): + del request.event.settings.payment_paypal_connect_refresh_token + del request.event.settings.payment_paypal_connect_user_id + request.event.settings.payment_paypal__enabled = False + messages.success(request, _('Your PayPal account has been disconnected.')) + + return redirect(reverse('control:event.settings.payment.provider', kwargs={ + 'organizer': request.event.organizer.slug, + 'event': request.event.slug, + 'provider': 'paypal' + }))