Changeset View
Changeset View
Standalone View
Standalone View
looper/views/checkout.py
| Show All 12 Lines | |||||
| from django.utils import timezone | from django.utils import timezone | ||||
| from django.views import View | from django.views import View | ||||
| from django.views.generic import FormView, DetailView, ListView | from django.views.generic import FormView, DetailView, ListView | ||||
| import requests.adapters | import requests.adapters | ||||
| from .. import exceptions, forms, middleware, models, utils | from .. import exceptions, forms, middleware, models, utils | ||||
| recaptcha_session = requests.Session() | recaptcha_session = requests.Session() | ||||
| recaptcha_session.mount('https://', requests.adapters.HTTPAdapter( | recaptcha_session.mount( | ||||
| 'https://', | |||||
| requests.adapters.HTTPAdapter( | |||||
| max_retries=requests.adapters.Retry(total=10, backoff_factor=0.2), | max_retries=requests.adapters.Retry(total=10, backoff_factor=0.2), | ||||
| )) | ), | ||||
| ) | |||||
| log = logging.getLogger(__name__) | log = logging.getLogger(__name__) | ||||
| User = get_user_model() | User = get_user_model() | ||||
| class ChooseDefaultPlanVariationView(View): | class ChooseDefaultPlanVariationView(View): | ||||
| """Chose the appropriate variation for this plan, then forward the user to checkout.""" | """Chose the appropriate variation for this plan, then forward the user to checkout.""" | ||||
| log = log.getChild('ChooseDefaultPlanVariationView') | log = log.getChild('ChooseDefaultPlanVariationView') | ||||
| def get(self, request, plan_id: int): | def get(self, request, plan_id: int): | ||||
| plan = get_object_or_404(models.Plan, pk=plan_id) | plan = get_object_or_404(models.Plan, pk=plan_id) | ||||
| # Determine the proper plan for the user's currency. | # Determine the proper plan for the user's currency. | ||||
| prefcur: str = request.session.get(middleware.PREFERRED_CURRENCY_SESSION_KEY, | prefcur: str = request.session.get( | ||||
| models.DEFAULT_CURRENCY) | middleware.PREFERRED_CURRENCY_SESSION_KEY, models.DEFAULT_CURRENCY | ||||
| ) | |||||
| default_variation = plan.variation_for_currency(prefcur) | default_variation = plan.variation_for_currency(prefcur) | ||||
| if default_variation is None and prefcur != models.DEFAULT_CURRENCY: | if default_variation is None and prefcur != models.DEFAULT_CURRENCY: | ||||
| log.warning('Default variation in %s for Plan %i not found' % (prefcur, plan_id)) | log.warning('Default variation in %s for Plan %i not found' % (prefcur, plan_id)) | ||||
| # Check if the a variation is available in the default currency | # Check if the a variation is available in the default currency | ||||
| default_variation = plan.variation_for_currency(models.DEFAULT_CURRENCY) | default_variation = plan.variation_for_currency(models.DEFAULT_CURRENCY) | ||||
| if default_variation is None: | if default_variation is None: | ||||
| log.error('No variation for for Plan %i found' % plan_id) | log.error('No variation for for Plan %i found' % plan_id) | ||||
| raise Http404('This Plan does not have variations at the moment.') | raise Http404('This Plan does not have variations at the moment.') | ||||
| return redirect('looper:checkout', | return redirect('looper:checkout', plan_id=plan_id, plan_variation_id=default_variation.id) | ||||
| plan_id=plan_id, | |||||
| plan_variation_id=default_variation.id) | |||||
| class ChoosePlanVariationView(ListView): | class ChoosePlanVariationView(ListView): | ||||
| template_name = 'checkout/choose_plan_variation.html' | template_name = 'checkout/choose_plan_variation.html' | ||||
| def get(self, request, *args, **kwargs): | def get(self, request, *args, **kwargs): | ||||
| self.plan = get_object_or_404(models.Plan, pk=kwargs['plan_id']) | self.plan = get_object_or_404(models.Plan, pk=kwargs['plan_id']) | ||||
| self.current_pv = get_object_or_404( | self.current_pv = get_object_or_404( | ||||
| self.plan.variations.active(), | self.plan.variations.active(), pk=kwargs['current_plan_variation_id'] | ||||
| pk=kwargs['current_plan_variation_id']) | ) | ||||
| return super().get(request, *args, **kwargs) | return super().get(request, *args, **kwargs) | ||||
| def get_queryset(self): | def get_queryset(self): | ||||
| # Determine the current currency. | # Determine the current currency. | ||||
| currency: str = self.request.session.get(middleware.PREFERRED_CURRENCY_SESSION_KEY, | currency: str = self.request.session.get( | ||||
| models.DEFAULT_CURRENCY) | middleware.PREFERRED_CURRENCY_SESSION_KEY, models.DEFAULT_CURRENCY | ||||
| ) | |||||
| # Fetch only active variations in the current currency. | # Fetch only active variations in the current currency. | ||||
| return self.plan.variations.active().filter(currency=currency) | return self.plan.variations.active().filter(currency=currency) | ||||
| def get_context_data(self, *, object_list=None, **kwargs) -> dict: | def get_context_data(self, *, object_list=None, **kwargs) -> dict: | ||||
| return { | return { | ||||
| **super().get_context_data(object_list=object_list, **kwargs), | **super().get_context_data(object_list=object_list, **kwargs), | ||||
| 'plan': self.plan, | 'plan': self.plan, | ||||
| 'current_plan_variation': self.current_pv, | 'current_plan_variation': self.current_pv, | ||||
| } | } | ||||
| class AbstractPaymentView(LoginRequiredMixin, FormView): | class AbstractPaymentView(LoginRequiredMixin, FormView): | ||||
| """Superclass for Braintree payment screens.""" | """Superclass for Braintree payment screens.""" | ||||
| log = log.getChild('AbstractPaymentView') | log = log.getChild('AbstractPaymentView') | ||||
| gateway: models.Gateway | gateway: models.Gateway | ||||
| customer: models.Customer | customer: models.Customer | ||||
| session_key_prefix = 'PAYMENT_GATEWAY_CLIENT_TOKEN_' | session_key_prefix = 'PAYMENT_GATEWAY_CLIENT_TOKEN_' | ||||
| def dispatch(self, request, *args, **kwargs): | def dispatch(self, request, *args, **kwargs): | ||||
| if not getattr(self, 'gateway', None): | if not getattr(self, 'gateway', None): | ||||
| Show All 24 Lines | def get_context_data(self, **kwargs) -> dict: | ||||
| return ctx | return ctx | ||||
| def get_currency(self) -> str: | def get_currency(self) -> str: | ||||
| """Returns the currency for the current page. | """Returns the currency for the current page. | ||||
| Defaults to the user's preferred currency, but can be overridden | Defaults to the user's preferred currency, but can be overridden | ||||
| in a subclass. | in a subclass. | ||||
| """ | """ | ||||
| return self.request.session.get(middleware.PREFERRED_CURRENCY_SESSION_KEY, | return self.request.session.get( | ||||
| models.DEFAULT_CURRENCY) | middleware.PREFERRED_CURRENCY_SESSION_KEY, models.DEFAULT_CURRENCY | ||||
| ) | |||||
| def client_token_session_key(self, for_currency: str) -> str: | def client_token_session_key(self, for_currency: str) -> str: | ||||
| return f'{self.session_key_prefix}{self.gateway.pk}_{for_currency}' | return f'{self.session_key_prefix}{self.gateway.pk}_{for_currency}' | ||||
| def get_client_token(self, for_currency: str) -> str: | def get_client_token(self, for_currency: str) -> str: | ||||
| """Generate a Braintree client token, caching it in the session. | """Generate a Braintree client token, caching it in the session. | ||||
| This is hard-coded to Braintree instead of using the current payment | This is hard-coded to Braintree instead of using the current payment | ||||
| Show All 15 Lines | def get_client_token(self, for_currency: str) -> str: | ||||
| if expiry_timestamp > datetime.datetime.now(): | if expiry_timestamp > datetime.datetime.now(): | ||||
| # Not yet expired, use the client token. | # Not yet expired, use the client token. | ||||
| return client_token | return client_token | ||||
| # TODO(Sybren): handle Gateway errors. | # TODO(Sybren): handle Gateway errors. | ||||
| braintree_gw = models.Gateway.objects.get(name__in={'braintree', 'mock'}) | braintree_gw = models.Gateway.objects.get(name__in={'braintree', 'mock'}) | ||||
| gateway_customer_id = self.customer.gateway_customer_id(braintree_gw) | gateway_customer_id = self.customer.gateway_customer_id(braintree_gw) | ||||
| client_token = braintree_gw.provider.generate_client_token( | client_token = braintree_gw.provider.generate_client_token( | ||||
| for_currency, gateway_customer_id) | for_currency, gateway_customer_id | ||||
| ) | |||||
| expiry_timestamp = datetime.datetime.now() + datetime.timedelta(minutes=10) | expiry_timestamp = datetime.datetime.now() + datetime.timedelta(minutes=10) | ||||
| self.request.session[sesskey_expiry] = expiry_timestamp.isoformat() | self.request.session[sesskey_expiry] = expiry_timestamp.isoformat() | ||||
| self.request.session[sesskey] = client_token | self.request.session[sesskey] = client_token | ||||
| return client_token | return client_token | ||||
| def erase_client_token(self) -> None: | def erase_client_token(self) -> None: | ||||
| """Erase the client token from the session.""" | """Erase the client token from the session.""" | ||||
| to_erase = [key for key in self.request.session.keys() | to_erase = [ | ||||
| if key.startswith(self.session_key_prefix)] | key for key in self.request.session.keys() if key.startswith(self.session_key_prefix) | ||||
| ] | |||||
| for sesskey in to_erase: | for sesskey in to_erase: | ||||
| del self.request.session[sesskey] | del self.request.session[sesskey] | ||||
| def check_recaptcha(request) -> Tuple[Optional[bool], str]: | def check_recaptcha(request) -> Tuple[Optional[bool], str]: | ||||
| """Handles recaptcha verification and sets request.recaptcha_is_valid. | """Handles recaptcha verification and sets request.recaptcha_is_valid. | ||||
| Be sure to set settings.GOOGLE_RECAPTCHA_SECRET_KEY to a non-empty string, | Be sure to set settings.GOOGLE_RECAPTCHA_SECRET_KEY to a non-empty string, | ||||
| otherwise this function does nothing. | otherwise this function does nothing. | ||||
| :returns: None when no check was performed, or True/False indicating a | :returns: None when no check was performed, or True/False indicating a | ||||
| successful resp. unsuccessful check. | successful resp. unsuccessful check. | ||||
| """ | """ | ||||
| my_log = log.getChild('check_recaptcha') | my_log = log.getChild('check_recaptcha') | ||||
| recaptcha_response = request.POST.get('g-recaptcha-response') or '' | recaptcha_response = request.POST.get('g-recaptcha-response') or '' | ||||
| if not recaptcha_response: | if not recaptcha_response: | ||||
| my_log.warning('reCaptcha response not included in request') | my_log.warning('reCaptcha response not included in request') | ||||
| return (False, 'ReCaptcha failed to check your request. Please disable script/ad ' | return ( | ||||
| 'blockers and try again.') | False, | ||||
| 'ReCaptcha failed to check your request. Please disable script/ad ' | |||||
| 'blockers and try again.', | |||||
| ) | |||||
| data = { | data = {'secret': settings.GOOGLE_RECAPTCHA_SECRET_KEY, 'response': recaptcha_response} | ||||
| 'secret': settings.GOOGLE_RECAPTCHA_SECRET_KEY, | |||||
| 'response': recaptcha_response | |||||
| } | |||||
| try: | try: | ||||
| r = recaptcha_session.post('https://www.google.com/recaptcha/api/siteverify', data=data) | r = recaptcha_session.post('https://www.google.com/recaptcha/api/siteverify', data=data) | ||||
| except IOError as ex: | except IOError as ex: | ||||
| my_log.exception('Error communicating with Google reCAPTCHA service') | my_log.exception('Error communicating with Google reCAPTCHA service') | ||||
| full_url = request.build_absolute_uri() | full_url = request.build_absolute_uri() | ||||
| mail_admins(f'reCaptcha communication error', | mail_admins( | ||||
| f'reCaptcha communication error', | |||||
| f'A request on {full_url} needed a reCaptcha check. The HTTPS request' | f'A request on {full_url} needed a reCaptcha check. The HTTPS request' | ||||
| f' failed with exception {ex}') | f' failed with exception {ex}', | ||||
| ) | |||||
| return None, 'There was a communication error checking reCAPTCHA. Please try again.' | return None, 'There was a communication error checking reCAPTCHA. Please try again.' | ||||
| if r.status_code != 200: | if r.status_code != 200: | ||||
| my_log.error("Error code %d verifying reCaptcha: %s", r.status_code, r.text) | my_log.error("Error code %d verifying reCaptcha: %s", r.status_code, r.text) | ||||
| full_url = request.build_absolute_uri() | full_url = request.build_absolute_uri() | ||||
| mail_admins(f'reCaptcha communication error', | mail_admins( | ||||
| f'reCaptcha communication error', | |||||
| f'A request on {full_url} needed a reCaptcha check. The HTTPS request' | f'A request on {full_url} needed a reCaptcha check. The HTTPS request' | ||||
| f' failed with error {r.status_code} and this message:\n{r.text}') | f' failed with error {r.status_code} and this message:\n{r.text}', | ||||
| ) | |||||
| return None, 'There was a communication error checking reCAPTCHA. Please try again.' | return None, 'There was a communication error checking reCAPTCHA. Please try again.' | ||||
| result = r.json() | result = r.json() | ||||
| if not result.get('success', False): | if not result.get('success', False): | ||||
| my_log.warning("reCaptcha failed to verify for URL %s: %s", | my_log.warning( | ||||
| request.build_absolute_uri(), r.text) | "reCaptcha failed to verify for URL %s: %s", request.build_absolute_uri(), r.text | ||||
| ) | |||||
| return False, 'ReCaptcha failed to verify that you are human being. Please try again.' | return False, 'ReCaptcha failed to verify that you are human being. Please try again.' | ||||
| # Check that nobody is trying to fake the response via some other website. | # Check that nobody is trying to fake the response via some other website. | ||||
| hostname = result.get('hostname') | hostname = result.get('hostname') | ||||
| if not validate_host(hostname, settings.ALLOWED_HOSTS): | if not validate_host(hostname, settings.ALLOWED_HOSTS): | ||||
| my_log.error("reCaptcha verified but for an unexpected hostname %r for URL %s", | my_log.error( | ||||
| hostname, request.build_absolute_uri()) | "reCaptcha verified but for an unexpected hostname %r for URL %s", | ||||
| return False, 'reCaptcha verified, but for an unexpected hostname. Please try again. If ' \ | hostname, | ||||
| 'this keeps happening, send an email to production@blender.org.' | request.build_absolute_uri(), | ||||
| ) | |||||
| return ( | |||||
| False, | |||||
| 'reCaptcha verified, but for an unexpected hostname. Please try again. If ' | |||||
| 'this keeps happening, send an email to production@blender.org.', | |||||
| ) | |||||
| my_log.debug('reCaptcha verfied') | my_log.debug('reCaptcha verfied') | ||||
| return True, '' | return True, '' | ||||
| class CheckoutView(AbstractPaymentView): | class CheckoutView(AbstractPaymentView): | ||||
| """Perform the checkout procedure for a subscription.""" | """Perform the checkout procedure for a subscription.""" | ||||
| template_name = 'checkout/checkout.html' | template_name = 'checkout/checkout.html' | ||||
| anonymous_template_name = 'checkout/checkout_anonymous.html' | anonymous_template_name = 'checkout/checkout_anonymous.html' | ||||
| form_class = forms.CheckoutForm | form_class = forms.CheckoutForm | ||||
| log = log.getChild('CheckoutView') | log = log.getChild('CheckoutView') | ||||
| plan: models.Plan | plan: models.Plan | ||||
| plan_variation: models.PlanVariation | plan_variation: models.PlanVariation | ||||
| def dispatch(self, request, *args, **kwargs): | def dispatch(self, request, *args, **kwargs): | ||||
| # Get the prerequisites for handling any request in this view. | # Get the prerequisites for handling any request in this view. | ||||
| self.plan = get_object_or_404(models.Plan, pk=kwargs['plan_id']) | self.plan = get_object_or_404(models.Plan, pk=kwargs['plan_id']) | ||||
| self.plan_variation = get_object_or_404( | self.plan_variation = get_object_or_404( | ||||
| self.plan.variations.active(), | self.plan.variations.active(), pk=kwargs['plan_variation_id'] | ||||
| pk=kwargs['plan_variation_id']) | ) | ||||
| # Show a simpler version of the view when anonymous. | # Show a simpler version of the view when anonymous. | ||||
| if request.user.is_anonymous and request.method == 'GET': | if request.user.is_anonymous and request.method == 'GET': | ||||
| return render(request, self.anonymous_template_name, { | return render( | ||||
| 'plan': self.plan, | request, | ||||
| 'plan_variation': self.plan_variation, | self.anonymous_template_name, | ||||
| }) | {'plan': self.plan, 'plan_variation': self.plan_variation}, | ||||
| ) | |||||
| return super().dispatch(request, *args, **kwargs) | return super().dispatch(request, *args, **kwargs) | ||||
| def get_context_data(self, **kwargs) -> dict: | def get_context_data(self, **kwargs) -> dict: | ||||
| ctx = { | ctx = { | ||||
| **super().get_context_data(**kwargs), | **super().get_context_data(**kwargs), | ||||
| 'plan': self.plan, | 'plan': self.plan, | ||||
| 'plan_variation': self.plan_variation, | 'plan_variation': self.plan_variation, | ||||
| ▲ Show 20 Lines • Show All 70 Lines • ▼ Show 20 Lines | def _check_recaptcha(self, form: forms.CheckoutForm) -> Optional[HttpResponse]: | ||||
| return None | return None | ||||
| form.add_error('', message) | form.add_error('', message) | ||||
| response = self.form_invalid(form) | response = self.form_invalid(form) | ||||
| response.status_code = 400 | response.status_code = 400 | ||||
| response.reason_phrase = 'CAPTCHA not valid' | response.reason_phrase = 'CAPTCHA not valid' | ||||
| return response | return response | ||||
| def _check_payment_method_nonce(self, form: forms.CheckoutForm, gateway: models.Gateway) -> Optional[ | def _check_payment_method_nonce( | ||||
| models.PaymentMethod]: | self, form: forms.CheckoutForm, gateway: models.Gateway | ||||
| ) -> Optional[models.PaymentMethod]: | |||||
| """Check the Braintree nonce, block the user if it is not valid. | """Check the Braintree nonce, block the user if it is not valid. | ||||
| :return: the Payment Method, or None if there was an issue. | :return: the Payment Method, or None if there was an issue. | ||||
| """ | """ | ||||
| nonce = form.cleaned_data['payment_method_nonce'] | nonce = form.cleaned_data['payment_method_nonce'] | ||||
| device_data = form.cleaned_data.get('device_data') | device_data = form.cleaned_data.get('device_data') | ||||
| try: | try: | ||||
| payment_method = self.customer.payment_method_add( | payment_method = self.customer.payment_method_add( | ||||
| nonce, gateway, verification_data=device_data | nonce, gateway, verification_data=device_data | ||||
| ) | ) | ||||
| except exceptions.GatewayError as e: | except exceptions.GatewayError as e: | ||||
| self.log.info('Error adding payment method: %s', e.errors) | self.log.info('Error adding payment method: %s', e.errors) | ||||
| form.add_error('', f'Error from the payment gateway: {e.with_errors()} ' | form.add_error( | ||||
| 'Please refresh the page and try again.') | '', | ||||
| f'Error from the payment gateway: {e.with_errors()} ' | |||||
| 'Please refresh the page and try again.', | |||||
| ) | |||||
| return None | return None | ||||
| return payment_method | return payment_method | ||||
| def _get_existing_subscription(self, form: forms.CheckoutForm) -> Optional[models.Subscription]: | def _get_existing_subscription(self, form: forms.CheckoutForm) -> Optional[models.Subscription]: | ||||
| subs_pk = form.cleaned_data.get('subscription_pk') | subs_pk = form.cleaned_data.get('subscription_pk') | ||||
| if not subs_pk: | if not subs_pk: | ||||
| return None | return None | ||||
| subscription: models.Subscription = get_object_or_404( | subscription: models.Subscription = get_object_or_404( | ||||
| self.user.subscription_set, pk=subs_pk) | self.user.subscription_set, pk=subs_pk | ||||
| ) | |||||
| self.log.debug('Reusing subscription pk=%r for checkout', subscription.pk) | self.log.debug('Reusing subscription pk=%r for checkout', subscription.pk) | ||||
| return subscription | return subscription | ||||
| def _create_subscription(self, gateway: models.Gateway, | def _create_subscription( | ||||
| payment_method: models.PaymentMethod) -> models.Subscription: | self, gateway: models.Gateway, payment_method: models.PaymentMethod | ||||
| ) -> models.Subscription: | |||||
| collection_method = self.plan_variation.collection_method | collection_method = self.plan_variation.collection_method | ||||
| if collection_method not in gateway.provider.supported_collection_methods: | if collection_method not in gateway.provider.supported_collection_methods: | ||||
| # TODO(Sybren): when automatic is not supported, we need to switch to manual. | # TODO(Sybren): when automatic is not supported, we need to switch to manual. | ||||
| # However, we may want to notify the user about this beforehand. | # However, we may want to notify the user about this beforehand. | ||||
| # We also may want to choose a collection method more explicitly than just | # We also may want to choose a collection method more explicitly than just | ||||
| # picking a supported one randomly. In practice this is not so random, though, | # picking a supported one randomly. In practice this is not so random, though, | ||||
| # as we only have two supported collection methods (automatic and manual), | # as we only have two supported collection methods (automatic and manual), | ||||
| # so if one is not supported this always picks the other one. | # so if one is not supported this always picks the other one. | ||||
| supported = set(gateway.provider.supported_collection_methods) | supported = set(gateway.provider.supported_collection_methods) | ||||
| collection_method = supported.pop() | collection_method = supported.pop() | ||||
| subscription = models.Subscription.objects.create( | subscription = models.Subscription.objects.create( | ||||
| plan=self.plan, | plan=self.plan, | ||||
| user=self.user, | user=self.user, | ||||
| payment_method=payment_method, | payment_method=payment_method, | ||||
| price=self.plan_variation.price, | price=self.plan_variation.price, | ||||
| currency=self.plan_variation.currency, | currency=self.plan_variation.currency, | ||||
| interval_unit=self.plan_variation.interval_unit, | interval_unit=self.plan_variation.interval_unit, | ||||
| interval_length=self.plan_variation.interval_length, | interval_length=self.plan_variation.interval_length, | ||||
| collection_method=collection_method, | collection_method=collection_method, | ||||
| ) | ) | ||||
| self.log.debug('Created new subscription pk=%r for checkout', subscription.pk) | self.log.debug('Created new subscription pk=%r for checkout', subscription.pk) | ||||
| return subscription | return subscription | ||||
| def _fetch_or_create_order(self, form: forms.CheckoutForm, subscription: models.Subscription, ) -> models.Order: | def _fetch_or_create_order( | ||||
| self, form: forms.CheckoutForm, subscription: models.Subscription, | |||||
| ) -> models.Order: | |||||
| """Get an existing order of the subscription, or create a new one.""" | """Get an existing order of the subscription, or create a new one.""" | ||||
| # Either get the existing order, or create a new one. Once the order is | # Either get the existing order, or create a new one. Once the order is | ||||
| # created we perform a Charge on a new transaction. | # created we perform a Charge on a new transaction. | ||||
| order_pk = form.cleaned_data.get('order_pk') | order_pk = form.cleaned_data.get('order_pk') | ||||
| if order_pk: | if order_pk: | ||||
| order: models.Order = get_object_or_404(subscription.order_set, pk=order_pk) | order: models.Order = get_object_or_404(subscription.order_set, pk=order_pk) | ||||
| self.log.debug('Reusing order pk=%r for checkout', order.pk) | self.log.debug('Reusing order pk=%r for checkout', order.pk) | ||||
| return order | return order | ||||
| latest_order = subscription.latest_order() | latest_order = subscription.latest_order() | ||||
| if latest_order and latest_order.status == 'created': | if latest_order and latest_order.status == 'created': | ||||
| # There already was some order created on this subscription, so try | # There already was some order created on this subscription, so try | ||||
| # and pay for that. | # and pay for that. | ||||
| return latest_order | return latest_order | ||||
| # This is the expected situation: a new subscription won't have any order | # This is the expected situation: a new subscription won't have any order | ||||
| # on it yet, so we have to create it. | # on it yet, so we have to create it. | ||||
| order = subscription.generate_order() | order = subscription.generate_order() | ||||
| self.log.debug('Using auto-created order pk=%r for checkout', order.pk) | self.log.debug('Using auto-created order pk=%r for checkout', order.pk) | ||||
| return order | return order | ||||
| def _charge_if_supported(self, form: forms.CheckoutForm, gateway: models.Gateway, | def _charge_if_supported( | ||||
| order: models.Order) -> HttpResponse: | self, form: forms.CheckoutForm, gateway: models.Gateway, order: models.Order | ||||
| ) -> HttpResponse: | |||||
| """Create a transaction on the order, and charge it. | """Create a transaction on the order, and charge it. | ||||
| If transactions are not supported by the payment gateway, just redirects to a 'done' page. | If transactions are not supported by the payment gateway, just redirects to a 'done' page. | ||||
| :return: If everything went fine, a redirect to a 'done' page, either | :return: If everything went fine, a redirect to a 'done' page, either | ||||
| 'looper:checkout_done' or 'looper:transactionless_checkout_done`. | 'looper:checkout_done' or 'looper:transactionless_checkout_done`. | ||||
| If something went wrong, an error page response is returned. | If something went wrong, an error page response is returned. | ||||
| """ | """ | ||||
| # Only follow through with a charge if it's actually supported. | # Only follow through with a charge if it's actually supported. | ||||
| if not gateway.provider.supports_transactions: | if not gateway.provider.supports_transactions: | ||||
| self.log.info('Not creating transaction for order pk=%r because gateway %r does ' | self.log.info( | ||||
| 'not support it', order.pk, gateway.name) | 'Not creating transaction for order pk=%r because gateway %r does ' | ||||
| 'not support it', | |||||
| order.pk, | |||||
| gateway.name, | |||||
| ) | |||||
| self.erase_client_token() | self.erase_client_token() | ||||
| return redirect('looper:transactionless_checkout_done', | return redirect( | ||||
| pk=order.pk, gateway_name=gateway.name) | 'looper:transactionless_checkout_done', pk=order.pk, gateway_name=gateway.name | ||||
| ) | |||||
| trans = order.generate_transaction() | trans = order.generate_transaction() | ||||
| customer_ip_address = utils.get_client_ip(self.request) | customer_ip_address = utils.get_client_ip(self.request) | ||||
| if not trans.charge(customer_ip_address=customer_ip_address): | if not trans.charge(customer_ip_address=customer_ip_address): | ||||
| form.add_error('', trans.failure_message) | form.add_error('', trans.failure_message) | ||||
| form.data = { | form.data = { | ||||
| **form.data.dict(), # form.data is a QueryDict, which is immutable. | **form.data.dict(), # form.data is a QueryDict, which is immutable. | ||||
| 'subscription_pk': order.subscription.pk, | 'subscription_pk': order.subscription.pk, | ||||
| 'order_pk': order.pk, | 'order_pk': order.pk, | ||||
| } | } | ||||
| return self.form_invalid(form) | return self.form_invalid(form) | ||||
| # The transaction, being marked as 'paid', will automatically | # The transaction, being marked as 'paid', will automatically | ||||
| # activate the subscription. | # activate the subscription. | ||||
| self.erase_client_token() | self.erase_client_token() | ||||
| return redirect('looper:checkout_done', transaction_id=trans.pk) | return redirect('looper:checkout_done', transaction_id=trans.pk) | ||||
| def _check_recently_created_subscription(self, form: forms.CheckoutForm) -> Optional[models.Subscription]: | def _check_recently_created_subscription( | ||||
| self, form: forms.CheckoutForm | |||||
| ) -> Optional[models.Subscription]: | |||||
| """Check recently created subscriptions. | """Check recently created subscriptions. | ||||
| If a subscription was created recently, AND the user did not indicate | If a subscription was created recently, AND the user did not indicate | ||||
| that creating a new subscription is okay, return that subscription. | that creating a new subscription is okay, return that subscription. | ||||
| """ | """ | ||||
| # The 'cleaned_data' attribute is only available after the form has been posted, but this | # The 'cleaned_data' attribute is only available after the form has been posted, but this | ||||
| # function is called also on creation of the form. | # function is called also on creation of the form. | ||||
| if hasattr(form, 'cleaned_data'): | if hasattr(form, 'cleaned_data'): | ||||
| if form.cleaned_data.get('subscription_pk'): | if form.cleaned_data.get('subscription_pk'): | ||||
| # Checking out an existing subscription, so there won't be a new subscription created. | # Checking out an existing subscription, so there won't be a new subscription created. | ||||
| return None | return None | ||||
| if form.cleaned_data.get('approve_new_subscription'): | if form.cleaned_data.get('approve_new_subscription'): | ||||
| # User approved the creation of a new subscription. | # User approved the creation of a new subscription. | ||||
| return None | return None | ||||
| return self._recently_created_subscription(form) | return self._recently_created_subscription(form) | ||||
| def _update_form_for_recent_subscription(self, form: forms.CheckoutForm, recent_subs: models.Subscription) -> None: | def _update_form_for_recent_subscription( | ||||
| self, form: forms.CheckoutForm, recent_subs: models.Subscription | |||||
| ) -> None: | |||||
| """Updates the form with a confirmation checkbox if the user recently obtained a subscription. | """Updates the form with a confirmation checkbox if the user recently obtained a subscription. | ||||
| :return: The recent subscription (if there is one). | :return: The recent subscription (if there is one). | ||||
| """ | """ | ||||
| help_text = f"You recently obtained a {recent_subs.plan.name} Membership already. " \ | help_text = ( | ||||
| f"You recently obtained a {recent_subs.plan.name} Membership already. " | |||||
| f"Please check this checkbox if it is your intention to obtain another Membership." | f"Please check this checkbox if it is your intention to obtain another Membership." | ||||
| ) | |||||
| form.show_approve_new_subscription(help_text) | form.show_approve_new_subscription(help_text) | ||||
| def _recently_created_subscription(self, form: forms.CheckoutForm) -> Optional[models.Subscription]: | def _recently_created_subscription( | ||||
| self, form: forms.CheckoutForm | |||||
| ) -> Optional[models.Subscription]: | |||||
| """Check for recently created subscriptions, preventing users from accidentally creating multiple. | """Check for recently created subscriptions, preventing users from accidentally creating multiple. | ||||
| :return: None if all is OK and creation can continue, and an existing subscription otherwise. | :return: None if all is OK and creation can continue, and an existing subscription otherwise. | ||||
| """ | """ | ||||
| now = timezone.now() | now = timezone.now() | ||||
| nag_threshold = now - settings.LOOPER_SUBSCRIPTION_CREATION_WARNING_THRESHOLD | nag_threshold = now - settings.LOOPER_SUBSCRIPTION_CREATION_WARNING_THRESHOLD | ||||
| try: | try: | ||||
| subscription = self.user.subscription_set \ | subscription = self.user.subscription_set.filter(created_at__gte=nag_threshold).latest( | ||||
| .filter(created_at__gte=nag_threshold) \ | 'created_at' | ||||
| .latest('created_at') | ) | ||||
| except models.Subscription.DoesNotExist: | except models.Subscription.DoesNotExist: | ||||
| return None | return None | ||||
| if subscription.status != 'active' and not subscription.can_be_activated: | if subscription.status != 'active' and not subscription.can_be_activated: | ||||
| # The subscription cannot be resurrected, so no need to ask for confirmation. | # The subscription cannot be resurrected, so no need to ask for confirmation. | ||||
| return None | return None | ||||
| return subscription | return subscription | ||||
| ▲ Show 20 Lines • Show All 75 Lines • ▼ Show 20 Lines | def form_valid(self, form: forms.CheckoutForm) -> HttpResponse: | ||||
| if form.has_changed(): | if form.has_changed(): | ||||
| form.save() | form.save() | ||||
| nonce = form.cleaned_data['payment_method_nonce'] | nonce = form.cleaned_data['payment_method_nonce'] | ||||
| try: | try: | ||||
| payment_method = self.customer.payment_method_add(nonce, gateway) | payment_method = self.customer.payment_method_add(nonce, gateway) | ||||
| except exceptions.GatewayError as e: | except exceptions.GatewayError as e: | ||||
| self.log.info('Error adding payment method: %s', e.errors) | self.log.info('Error adding payment method: %s', e.errors) | ||||
| form.add_error('', 'Cannot use a payment nonce more than once. ' | form.add_error( | ||||
| 'Please refresh the page and try again') | '', | ||||
| 'Cannot use a payment nonce more than once. ' | |||||
| 'Please refresh the page and try again', | |||||
| ) | |||||
| return self.form_invalid(form) | return self.form_invalid(form) | ||||
| # Switch to this new payment method. | # Switch to this new payment method. | ||||
| subs = self.order.subscription | subs = self.order.subscription | ||||
| if subs.payment_method is None or subs.payment_method.pk != payment_method.pk: | if subs.payment_method is None or subs.payment_method.pk != payment_method.pk: | ||||
| self.log.info('Switching subscription pk=%d to payment method pk=%d', | self.log.info( | ||||
| subs.pk, payment_method.pk) | 'Switching subscription pk=%d to payment method pk=%d', subs.pk, payment_method.pk | ||||
| ) | |||||
| subs.payment_method = payment_method | subs.payment_method = payment_method | ||||
| subs.save(update_fields={'payment_method'}) | subs.save(update_fields={'payment_method'}) | ||||
| order = self.order | order = self.order | ||||
| if order.payment_method is None or order.payment_method.pk != payment_method.pk: | if order.payment_method is None or order.payment_method.pk != payment_method.pk: | ||||
| self.log.info('Switching order pk=%d to payment method pk=%d', | self.log.info( | ||||
| order.pk, payment_method.pk) | 'Switching order pk=%d to payment method pk=%d', order.pk, payment_method.pk | ||||
| ) | |||||
| order.payment_method = payment_method | order.payment_method = payment_method | ||||
| order.save(update_fields={'payment_method'}) | order.save(update_fields={'payment_method'}) | ||||
| # If the user tries to pay by bank transfer, just show the Foundation's bank info. | # If the user tries to pay by bank transfer, just show the Foundation's bank info. | ||||
| gateway = self.gateway_from_form(form) | gateway = self.gateway_from_form(form) | ||||
| if not gateway.provider.supports_transactions: | if not gateway.provider.supports_transactions: | ||||
| self.log.info('Not creating transaction for order pk=%r because gateway %r does ' | self.log.info( | ||||
| 'not support it', order.pk, gateway.name) | 'Not creating transaction for order pk=%r because gateway %r does ' | ||||
| 'not support it', | |||||
| order.pk, | |||||
| gateway.name, | |||||
| ) | |||||
| self.erase_client_token() | self.erase_client_token() | ||||
| return redirect('looper:transactionless_checkout_done', | return redirect( | ||||
| pk=order.pk, gateway_name=gateway.name) | 'looper:transactionless_checkout_done', pk=order.pk, gateway_name=gateway.name | ||||
| ) | |||||
| # Charge a new transaction. | # Charge a new transaction. | ||||
| trans = order.generate_transaction() | trans = order.generate_transaction() | ||||
| customer_ip_address = utils.get_client_ip(self.request) | customer_ip_address = utils.get_client_ip(self.request) | ||||
| if not trans.charge(customer_ip_address=customer_ip_address): | if not trans.charge(customer_ip_address=customer_ip_address): | ||||
| form.add_error('', trans.failure_message) | form.add_error('', trans.failure_message) | ||||
| return self.form_invalid(form) | return self.form_invalid(form) | ||||
| # The transaction, being marked as 'paid', will automatically | # The transaction, being marked as 'paid', will automatically | ||||
| # activate the subscription. | # activate the subscription. | ||||
| self.erase_client_token() | self.erase_client_token() | ||||
| return redirect('looper:checkout_done', transaction_id=trans.pk) | return redirect('looper:checkout_done', transaction_id=trans.pk) | ||||