Commit 56dbe057 authored by Birin Sanchez's avatar Birin Sanchez
Browse files

Add bundlechange App that takes care of customers upgrades and


downgrades.

Changes:

limitmonitor.common module:
  * Add billing_period to parse_subscription
  * Add order_paid, cancel_subscription, get_user_external_credit,
    get_available_bundles and get_user_upgrade_choices

limitmonitor.task_resources.subscription:
  * Process pending_regs in reverse order
  * Add upgrade_user_subscription and process_bundle_changes

limitmonitor.views:
  * Add can_upgrade control

middleware.settings:
  * Add BundleChange app
  * Add new config variable for BundleChange

purist:
  * Rename DeleteAcccountForm for reuse
  * Add display names to AccountType
  * Move spinner animation into a single template in purist module for
   reuse

3 new config options:
  * WOO_PARENT_PROD_ID
  * WOO_UPGRADE_PAY_URL
  * WOO_BUNDLES_INFO_FILE
Signed-off-by: Birin Sanchez's avatarBirin Sanchez <birin.sanchez@puri.sm>
parent dbe229db
Pipeline #16342 passed with stage
in 45 seconds
from django.contrib import admin
# Register your models here.
from django.apps import AppConfig
class BundlechangeConfig(AppConfig):
name = 'bundlechange'
from django import forms
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from purist.forms import ConfirmAddressForm
class ChooseBundleForm(ConfirmAddressForm):
def __init__(self, choices=[(1, 'NoConf')], *args, **kwargs):
super(ChooseBundleForm, self).__init__(*args, **kwargs)
self.fields['bundle'] = forms.ChoiceField(
label=_('You can change to the following bundles'),
widget=forms.RadioSelect,
choices=choices
)
self.fields['address'].label = _(
'Type in your {} address to confirm the change'.format(
settings.SITE_TITLE))
self.order_fields(('bundle', 'address'))
# -*- coding: utf-8 -*-
# Generated by Django 1.11.20 on 2019-06-12 12:54
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BundleChange',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('from_bdl', models.CharField(max_length=100)),
('to_bdl', models.CharField(max_length=100)),
('is_pending', models.BooleanField(default=True)),
('pay_url', models.URLField(default='')),
('new_order_id', models.CharField(max_length=100)),
('old_subscription_id', models.CharField(max_length=100)),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
from django.db import models
from django.conf import settings
from django.utils import timezone
class BundleChange(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL)
from_bdl = models.CharField(max_length=100)
to_bdl = models.CharField(max_length=100)
is_pending = models.BooleanField(default=True)
pay_url = models.URLField(default='')
new_order_id = models.CharField(max_length=100)
old_subscription_id = models.CharField(max_length=100)
created_date = models.DateTimeField(default=timezone.now)
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% trans 'Manage Upgrades' %}{% endblock %}
{% block extra_css%}{% include "purist/spinner.html" %}{% endblock %}
{% block header %}{% trans 'Manage Upgrades' %}{% endblock %}
{% block byline %}
{% if can_upgrade %}
{% trans 'You are currently on' %} <strong>{{ current_bdl }}</strong> {% trans 'bundle.' %}
{% endif %}
{% endblock %}
{% block content %}
{% if has_pending_changes %}
<p>{% trans 'Your change from ' %} <strong>{{ from_bdl }}</strong> {% trans ' to ' %} <strong>{{ to_bdl }}</strong> {% trans 'was successful.' %}<br>
Use <a href="{{ pay_url }}">this link</a> to pay.</p>
<p>{% trans 'After payment is done your account will be updated in few minutes.' %}
{% elif can_upgrade %}
<form id="change_bundle" action="" method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Confirm" />
<a href="{% url 'profile' %}" class="button">No thanks</a>
</form>
<div id="spinner-wrap" class="hidden">
<div class="spinner"></div>
<p class="wait">Please wait ...</p>
</div>
{% else %}
{% trans 'There is no upgrade or downgrade options for your account.' %}
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
const frm = document.getElementById('change_bundle'),
spin = document.getElementById('spinner-wrap');
frm.addEventListener('submit', enable_spinner);
function enable_spinner() {
spin.setAttribute('class', 'spinner-wrapper');
}
</script>
{% endblock %}
from django.test import TestCase
# Create your tests here.
from django.urls import reverse_lazy
from django.conf import settings
from django.views.generic.edit import FormView
from django.contrib.auth.mixins import LoginRequiredMixin
from .forms import ChooseBundleForm
from .models import BundleChange
from limitmonitor.common import get_user_subscriptions, parse_subscription, \
get_user_upgrade_choices, get_user_external_credit, get_available_bundles
from limitmonitor.task_resources.subscription import upgrade_user_subscription
class UpgradeView(LoginRequiredMixin, FormView):
template_name = 'bundlechange/upgrade.html'
form_class = ChooseBundleForm
success_url = reverse_lazy('upgrade')
def get_form_kwargs(self):
choices = get_user_upgrade_choices(self.request.user,
add_downgrade=True)
kwargs = super(UpgradeView, self).get_form_kwargs()
kwargs['choices'] = choices
return kwargs
def get_form(self, *args, **kwargs):
form = super(UpgradeView, self).get_form(*args, **kwargs)
form.user = self.request.user
return form
def get_context_data(self, **kwargs):
context = super(UpgradeView, self).get_context_data(**kwargs)
pending_changes = self.request.user.bundlechange_set.filter(
is_pending=True
)
avl_bundles = get_available_bundles()
u_ec = get_user_external_credit(self.request.user)
if u_ec is not None:
context['current_bdl'] = (
avl_bundles[u_ec.bundle_key]['short_title'])
if len(pending_changes) > 0:
context['has_pending_changes'] = True
context['from_bdl'] = (
avl_bundles[pending_changes[0].from_bdl]['short_title'])
context['to_bdl'] = (
avl_bundles[pending_changes[0].to_bdl]['short_title'])
context['pay_url'] = pending_changes[0].pay_url
else:
context['has_pending_changes'] = False
context['can_upgrade'] = len(get_user_upgrade_choices(
self.request.user,
add_downgrade=True)) > 0
return context
def form_valid(self, form):
variation_id = form.cleaned_data['bundle']
subs = get_user_subscriptions(self.request.user)
# We assume the user has only 1 subscription!
if subs:
parsed_sub = parse_subscription(subs[0])
order = upgrade_user_subscription(self.request.user,
variation_id)
pay_url = settings.WOO_UPGRADE_PAY_URL.format(order['id'],
order['key'])
bundle_change = BundleChange(
user=self.request.user,
from_bdl=parsed_sub['bundle_key'],
to_bdl=variation_id,
is_pending=True,
pay_url=pay_url,
new_order_id=order['id'],
old_subscription_id=parsed_sub['external_key']
)
bundle_change.save()
return super(UpgradeView, self).form_valid(form)
......@@ -46,6 +46,9 @@ WOO_CART_5999 = True
WOO_CART_BILLING_PATH = add_to_cart
WOO_CART_THANKS_PATH = get-started
WOO_API_TIMEOUT = 30
WOO_PARENT_PROD_ID = 34
WOO_UPGRADE_PAY_URL = http://wp.example.com/checkout/order-pay/{}/?pay_for_order=true&key={}
WOO_BUNDLES_INFO_FILE = /etc/opt/purist/middleware/upgrades_downgrades.yml
OVPN_HOSTNAME=ssh.example.com
OVPN_PORT=22
......
......@@ -7,6 +7,7 @@ from purist.models import get_woo_connection, AccountType
from purist.limitmonitor import ParserContainer, ServicesContainer
from limitmonitor.models import ExternalCredit, ExternalBundle, Limit
from limitmonitor.tunnel import TunnelManager
import strictyaml
logger = logging.getLogger(__name__)
......@@ -59,14 +60,15 @@ def parse_subscription(json_entry):
else:
bundle_key = line_item.get('product_id')
# get account_type from subscription
# get account_type and billing_period from subscription
line_item_meta = line_item["meta"]
acct_value = None
billing_value = None
for meta in line_item_meta:
if meta["key"] == "service-bundle":
acct_value = meta["value"]
break
else:
acct_value = None
if meta["key"] == "billing-period":
billing_value = meta["value"]
account_type = AccountType.UNDEFINED
if acct_value:
......@@ -78,6 +80,13 @@ def parse_subscription(json_entry):
elif 'basic' in acct_value:
account_type = AccountType.BASIC
billing_period = None
if billing_value:
if 'monthly' == billing_value.lower():
billing_period = 'monthly'
elif 'yearly' == billing_value.lower():
billing_period = 'yearly'
# create result
result = {
"parser": ParserContainer.WOO_SUBSCRIPTION_V1,
......@@ -89,7 +98,8 @@ def parse_subscription(json_entry):
"account": username,
"next_renewal": next_renewal,
"status": json_entry["status"],
"account_type": account_type
"account_type": account_type,
"billing_period": billing_period
}
return result
......@@ -301,3 +311,85 @@ def get_users_expired_subs(date_limit=None):
):
users.append(user)
return users
def order_paid(order_id):
"""Given an order_id returns True if the order has been paid and
False if it hasn't been paid.
"""
woo = get_woo_connection()
query = 'orders/{}'.format(order_id)
order = woo.get(query).json()
if order and order['status'] == 'completed':
return True
return False
def cancel_subscription(subscription_id):
"""Given a subscription_id cancels the subscription returning True
when cancellation succeeds and False when it does not.
This has only be tested with WC instances with Stripe payment
gateway enabled. When used against a WC instance with Check
payments and not Stripe gateway configured it returns the error:
'woocommerce_rest_invalid_payment_data' but the subscription still
get cancelled.
"""
woo = get_woo_connection()
data = {
'status': 'cancelled'
}
query = 'subscriptions/{}'.format(subscription_id)
result = woo.put(query, data).json()
if result:
status = result.get('status', None)
code = result.get('code', None)
if status == 'cancelled':
return True
elif code == 'woocommerce_rest_invalid_payment_data':
# No Stripe gateway error. Let's check if the subscription
# was really cancelled.
sub = woo.get('subscriptions/{}'.format(subscription_id)).json()
if sub:
status2 = sub.get('status', None)
if status2 == 'cancelled':
return True
return False
def get_user_external_credit(user):
ecs = ExternalCredit.objects.filter(
account_name=user.get_identity()
).order_by('-updated_date')
if len(ecs) >= 1:
return ecs[0]
return None
def get_available_bundles():
with open(settings.WOO_BUNDLES_INFO_FILE, 'r') as stream:
bundles = strictyaml.load(stream.read()).data
return bundles
def get_user_upgrade_choices(user, add_downgrade=False):
choices = []
ec = get_user_external_credit(user)
if ec is None:
return choices
bundles = get_available_bundles()
cur_bundle = bundles.get(ec.bundle_key, None)
if cur_bundle is None:
return choices
for upgrade in cur_bundle['upgrades']:
choices.append((upgrade, bundles[upgrade]['title']))
if add_downgrade:
for downgrade in cur_bundle['downgrades']:
choices.append((downgrade, bundles[downgrade]['title']))
return choices
......@@ -158,6 +158,9 @@ SPDX-License-Identifier: AGPL-3.0
<li><a href="{% url 'password_change' %}">{% trans "Change password" %}</a></li>
<li><a href="{% url 'profile_configure' %}">{% trans "Profile settings" %}</a></li>
<li><a href="{% url 'delete_account' %}">{% trans "Delete Account" %}</a></li>
{% if can_upgrade %}
<li><a href="{% url 'upgrade' %}">{% trans "Manage Upgrades" %}</a></li>
{% endif %}
</ul>
</article>
......
from django.conf import settings
from celery.utils.log import get_task_logger
from cart.models import ChosenReward
from bundlechange.models import BundleChange
from limitmonitor.common import parse_subscription, get_user_subscriptions, \
subscription_was_processed, get_services_from_bundle, forced_update, \
get_users_expired_subs
get_users_expired_subs, order_paid, cancel_subscription
logger = get_task_logger(__name__)
......@@ -66,7 +67,7 @@ def process_pending_registrations_user(user):
def process_pending_registrations():
pending_regs = ChosenReward.objects.filter(is_pending=True)
pending_regs = ChosenReward.objects.filter(is_pending=True).order_by('-id')
for reg in pending_regs:
process_pending_registrations_user(reg.user)
......@@ -77,3 +78,64 @@ def process_renewals():
expired_users = get_users_expired_subs()
for user in expired_users:
forced_update(user)
def upgrade_user_subscription(user, variation_id,
product_id=settings.WOO_PARENT_PROD_ID):
"""Given a user and a variation_id creates a new WooCommerce order
with a subscriptions associated to it. The item in the
subscription will be the product variation specified by
variation_id and product_id. By default product_id is the default
product configured in LDH.
Returns a dictionary containing:
* id: This is the order id.
* key: This is the order_key for the order. This can be used to
create the payment link.
* subscription: The created subscription in JSON format.
"""
subs = get_user_subscriptions(user)
if subs is None:
return
order_data = {
"payment_method": "stripe",
"payment_method_title": "Credit Card (Stripe)",
"set_paid": False,
"line_items": [
{
"product_id": product_id,
"variation_id": variation_id,
"quantity": 1
}
],
}
order_data['billing'] = subs[0]['billing']
order_data['shipping'] = subs[0]['shipping']
order_data['customer_id'] = subs[0]['customer_id']
order = user.woo_post_json('orders', order_data)
subs_data = {}
subs_data['parent_id'] = order['id']
subs_data['customer_id'] = order['customer_id']
sub_order = user.woo_post_json('subscriptions', subs_data)
return {'id': order['id'],
'key': order['order_key'],
'subscription': sub_order}
def process_bundle_changes():
# We get all pending BundleChanges in reverse order ("-id") so we
# process the most recent ones earlier.
pending_changes = BundleChange.objects.filter(
is_pending=True).order_by('-id')
for pending_change in pending_changes:
if order_paid(pending_change.new_order_id):
cancel_subscription(pending_change.old_subscription_id)
forced_update(pending_change.user)
pending_change.is_pending = False
pending_change.save()
......@@ -5,6 +5,7 @@ from .task_resources import tunnel_credit
from .task_resources import tunnel_subscription
from .task_resources import subscription
@shared_task
def tunnel_refresh_subscription():
tunnel_subscription.monitor_woosub1_renewals()
......@@ -43,3 +44,8 @@ def process_pending_registrations():
@shared_task
def process_renewals():
subscription.process_renewals()
@shared_task
def process_bundle_changes():
subscription.process_bundle_changes()
......@@ -8,6 +8,7 @@ from .models import Limit, ExternalCredit
from invitation.models import Invitation
from purist.models import AccountType
from cart.views import CartRegistrationView
from .common import get_user_upgrade_choices
@login_required
......@@ -59,6 +60,9 @@ def userlimit(request):
ec = ExternalCredit.objects.filter(
account_name=request.user.get_identity())
haspaid = len(ec) > 0
can_upgrade = (
len(get_user_upgrade_choices(request.user, add_downgrade=True)) > 0
)
render_data = {
"DEBUG_CHANGE_PASSWORD": settings.DEBUG_CHANGE_PASSWORD,
"username": username,
......@@ -74,6 +78,7 @@ def userlimit(request):
"haschosenreward": hasattr(request.user, 'chosenreward'),
"complete_signup_url": CartRegistrationView().get_success_url(request.user),
"haspaid": haspaid,
"can_upgrade": can_upgrade
}
return render(request, 'limitmonitor/userlimit.html', render_data)
......
......@@ -59,7 +59,8 @@ INSTALLED_APPS += ["crispy_forms",
"registration",
"rest_framework",
"password_reset",
"invitation"]
"invitation",
"bundlechange"]
#
# AGPL APPLICATION
......@@ -193,6 +194,12 @@ WOO_CART_THANKS_PATH = config("WOO_CART_THANKS_PATH")
#
WOOSUB1_PRODUCT_LIST = config("WOOSUB1_PRODUCT_LIST", cast=Csv(int))
WOO_PARENT_PROD_ID = config("WOO_PARENT_PROD_ID")
WOO_UPGRADE_PAY_URL = config("WOO_UPGRADE_PAY_URL")
WOO_BUNDLES_INFO_FILE = config(
"WOO_BUNDLES_INFO_FILE",
default='/etc/opt/purist/middleware/upgrades_downgrades.yml'
)
#
# SSH CONNECTION TO OPENVPN SERVER
......
......@@ -28,6 +28,7 @@ from purist.views import Recovery, PasswordChange, \
PasswordChangeDone, ProfileConfigureView, RecoveryPasswordReset
from invitation.views import InvitationRegistrationView
from password_reset.views import reset_done, recover_done
from bundlechange.views import UpgradeView
#
# Set admin titles for this site
......@@ -68,6 +69,7 @@ urlpatterns = [
name='password_change'),
url(r'^accounts/password_change_done/$', PasswordChangeDone.as_view(),
name='password_change_done'),
url(r'^accounts/upgrade/$', UpgradeView.as_view(), name='upgrade'),
url(r'^accounts/', include('registration.backends.simple.urls')),
url(r'^download/', include('django_agpl.urls')),
url(r'^jslicense/$', purist.views.jslicense, name='jslicense'),
......
......@@ -110,7 +110,7 @@ class ProfileConfigureForm(forms.ModelForm):
return super(ProfileConfigureForm, self).save(commit)
class DeleteAccountForm(forms.Form):
class ConfirmAddressForm(forms.Form):
address = forms.EmailField(
label=_('If you are sure, please type in your {} address and '
......
......@@ -24,11 +24,11 @@ log = logging.getLogger(__name__)
class AccountType(ChoicesEnum):
UNDEFINED = 0
BASIC = 1
COMPLETE = 2
INVITED = 3
GROUP = 4
UNDEFINED = 0, 'Undefined'
BASIC = 1, 'Basic'
COMPLETE = 2, 'Complete'
INVITED = 3, 'Invited'
GROUP = 4, 'Family pack'
def get_woo_connection():
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment