Allow accountless membership cancellation via the token #96851

Merged
Oleg-Komarov merged 3 commits from cancel-with-token into main 2024-07-11 19:38:36 +02:00
6 changed files with 103 additions and 15 deletions
Showing only changes of commit e0547d1edb - Show all commits

View File

@ -4,16 +4,31 @@
{% block admin_tools %}{# don't show any admin links here #}{% endblock admin_tools %} {% block admin_tools %}{# don't show any admin links here #}{% endblock admin_tools %}
{% block content_settings %} {% block after_membership_info %}
{% if user.is_anonymous %}
<div class="ml-4 mt-2 mb-2"> <div class="ml-4 mt-2 mb-2">
<h2>Link Membership</h2> <div class="row">
<div class="col-md-12">
You need a Blender ID account to manage this membership.
You can create a new Blender ID account if you don't have it yet.
<a class="btn" href="{{ settings.LOGIN_URL }}">Sign in</a>
</div>
</div>
<div class="row mt-2">
<div class="col-md-12">
To directly cancel your membership, please use the button below.
<form class="form cancel-membership-form" method="post" action="{{ cancel_url }}">
{% csrf_token %}
<button class="btn-danger form-submit" id="submit-button" type="submit"><span>Cancel membership</span></button>
</form>
</div>
</div>
</div>
{% else %}
<div class="ml-4 mt-2 mb-2">
<h3>Link Membership</h3>
<div class="row"> <div class="row">
<div class="{% if membership.level.badge %}col-md-9{% else %}col-md-12{% endif %}"> <div class="{% if membership.level.badge %}col-md-9{% else %}col-md-12{% endif %}">
<p class="mt-2">
You are going to link this <strong>{{ membership.level.name }}</strong> membership
(created on <abbr title="{{ membership.created_at }}">{{ membership.created_at | date:"Y-m-d" }}</abbr>,
currently {{ membership.status }}) to your account.
</p>
{{ membership.level.badge }} {{ membership.level.badge }}
</div> </div>
{% if membership.level.badge %} {% if membership.level.badge %}
@ -55,4 +70,5 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -25,14 +25,21 @@ class LinkMembershipTest(AbstractLooperTestCase):
self.subscription = self.create_active_accountless_subscription() self.subscription = self.create_active_accountless_subscription()
self.url = reverse('link_membership', kwargs={'token': self.link_customer_token.token}) self.url = reverse('link_membership', kwargs={'token': self.link_customer_token.token})
def test_get_redirects_if_not_logged_in(self): def test_shows_cancelviatoken_if_not_logged_in(self):
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 200)
self.assertEqual( self.assertContains(response, '<form class="form cancel-membership-form" method="post"')
response['Location'],
f'/oauth/login?next=/link-membership/{self.link_customer_token.token}/', def test_cancelviatoken(self):
url = reverse(
'settings_membership_cancel_via_token',
kwargs={'token': self.link_customer_token.token}
) )
response = self.client.post(url, data={'confirm': 'True'})
self.assertEqual(response.status_code, 302)
self.subscription.refresh_from_db()
self.assertEqual(self.subscription.status, 'pending-cancellation')
def test_get_displays_a_form(self): def test_get_displays_a_form(self):
some_user = User.objects.create(email='joe@example.com') some_user = User.objects.create(email='joe@example.com')

View File

@ -40,6 +40,9 @@ urlpatterns = [
name='settings_membership_edit'), name='settings_membership_edit'),
path('settings/membership/<int:membership_id>/cancel', settings.CancelMembershipView.as_view(), path('settings/membership/<int:membership_id>/cancel', settings.CancelMembershipView.as_view(),
name='settings_membership_cancel'), name='settings_membership_cancel'),
path('settings/membership/cancelviatoken/<token>',
settings.CancelMembershipViaTokenView.as_view(),
name='settings_membership_cancel_via_token'),
path( path(
'settings/billing/payment-methods/change/<int:subscription_id>', 'settings/billing/payment-methods/change/<int:subscription_id>',

View File

@ -4,7 +4,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction from django.db import transaction
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy from django.urls import reverse, reverse_lazy
from django.views.generic import FormView from django.views.generic import FormView
from looper.models import ( from looper.models import (
@ -77,7 +77,7 @@ def merge_customer_and_grant_badges(token: str, old_customer, user):
campaign_order.campaign.grant_badges({campaign_order.order_id}) campaign_order.campaign.grant_badges({campaign_order.order_id})
class LinkMembershipView(LoginRequiredMixin, FormView): class LinkMembershipView(FormView):
template_name = 'blender_fund_main/link_membership.html' template_name = 'blender_fund_main/link_membership.html'
form_class = forms.LinkMembershipForm form_class = forms.LinkMembershipForm
success_url = reverse_lazy('settings_home') success_url = reverse_lazy('settings_home')
@ -113,6 +113,9 @@ class LinkMembershipView(LoginRequiredMixin, FormView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data() context = super().get_context_data()
context['cancel_url'] = reverse(
'settings_membership_cancel_via_token', kwargs={'token': self.kwargs['token']}
)
context['membership'] = self._get_membership(self.kwargs['token']) context['membership'] = self._get_membership(self.kwargs['token'])
return context return context
@ -120,6 +123,8 @@ class LinkMembershipView(LoginRequiredMixin, FormView):
token = self.kwargs['token'] token = self.kwargs['token']
membership = self._get_membership(token) membership = self._get_membership(token)
user = self.request.user user = self.request.user
if not user.is_authenticated:
return self.handle_no_permission()
old_customer = membership.customer old_customer = membership.customer
assert old_customer.user is None assert old_customer.user is None

View File

@ -209,6 +209,14 @@ class CancelMembershipView(SingleMembershipMixin, FormView):
_log = log.getChild('CancelMembershipView') _log = log.getChild('CancelMembershipView')
def get_context_data(self, **kwargs):
return {
**super().get_context_data(**kwargs),
'back_url': reverse(
'settings_membership_edit', kwargs={'membership_id': self.membership_id}
),
}
def get_success_url(self) -> str: def get_success_url(self) -> str:
return reverse('settings_membership_edit', return reverse('settings_membership_edit',
kwargs={'membership_id': self.membership_id}) kwargs={'membership_id': self.membership_id})
@ -221,6 +229,51 @@ class CancelMembershipView(SingleMembershipMixin, FormView):
return super().form_valid(form) return super().form_valid(form)
class CancelMembershipViaTokenView(FormView):
template_name = 'settings/membership_cancel.html'
form_class = forms.CancelMembershipForm
initial = {'confirm': False}
_log = log.getChild('CancelMembershipViaTokenView')
def get_membership(self, token):
linktoken = get_object_or_404(looper.models.LinkCustomerToken, token=token)
memberships = linktoken.customer.memberships
memberships_count = memberships.count()
if memberships_count != 1:
self._log.error(
'Expected exactly one membership, found %s, customer pk=%s',
memberships_count,
linktoken.customer_id,
)
return memberships.first()
def get_context_data(self, **kwargs):
token = self.kwargs['token']
membership = self.get_membership(token)
return {
**super().get_context_data(**kwargs),
'back_url': reverse('link_membership', kwargs={'token': token}),
'membership': membership,
'subscription': membership.subscription,
}
def form_valid(self, form):
token = self.kwargs['token']
membership = self.get_membership(token)
self._log.info('Cancelling membership pk=%d using token=%s',
membership.pk, token)
membership.cancel()
return super().form_valid(form)
def get_success_url(self) -> str:
# land on the same page, the template will hide the form
# and confirm that cancellation has happened
return reverse('settings_membership_cancel_via_token',
kwargs={'token': self.kwargs['token']})
class ExtendMembershipView(SingleMembershipMixin, ExpectReadableIPAddressMixin, FormView): class ExtendMembershipView(SingleMembershipMixin, ExpectReadableIPAddressMixin, FormView):
"""Allow users to extend their membership by paying any amount.""" """Allow users to extend their membership by paying any amount."""
# TODO(Sybren): maybe move this into Looper, or at least some of the code. # TODO(Sybren): maybe move this into Looper, or at least some of the code.

View File

@ -4,6 +4,9 @@
{% block after_membership_info %} {% block after_membership_info %}
<h2>Cancellation</h2> <h2>Cancellation</h2>
{% if subscription.status == 'cancelled' or subscription.status == 'pending-cancellation' %}
<p>Your membership has been cancelled.</p>
{% else %}
<p>Are you sure you want to cancel your Blender Development Fund membership?</p> <p>Are you sure you want to cancel your Blender Development Fund membership?</p>
{% if subscription.status == 'active' and subscription.next_payment_in_future %} {% if subscription.status == 'active' and subscription.next_payment_in_future %}
<p> <p>
@ -15,7 +18,8 @@
{% csrf_token %} {% csrf_token %}
{% include "blender_fund_main/components/form.html" %} {% include "blender_fund_main/components/form.html" %}
<hr/> <hr/>
<a class="btn" href="{% url 'settings_membership_edit' membership_id=membership.id %}"><span>← Keep Membership and Go Back</span></a> <a class="btn" href="{{ back_url }}"><span>← Keep Membership and Go Back</span></a>
<button class="btn-danger form-submit ml-3" id="submit-button" type="submit"><span>Confirm Cancellation of Membership</span></button> <button class="btn-danger form-submit ml-3" id="submit-button" type="submit"><span>Confirm Cancellation of Membership</span></button>
</form> </form>
{% endif %}
{% endblock after_membership_info %} {% endblock after_membership_info %}