Commit 01bb7c62 authored by Noe Nieto's avatar Noe Nieto 💬

Merge branch 'master' of source.puri.sm:liberty/ldh_middleware

parents b019296c 1f9cb06d
Pipeline #19225 failed with stage
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, \
set_subscription_status, get_user_external_credit, get_available_bundles, \
get_user_upgrade_choices, SubscriptionParseError
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 active subscription!
if subs:
try:
parsed_sub = parse_subscription(subs[0])
except SubscriptionParseError as e:
print('Error processing bundle_change for user {}: {}'.format(
self.request.user, repr(e)))
return super(UpgradeView, self).form_valid(form)
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()
set_subscription_status(parsed_sub['external_key'],
'pending-cancel')
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
......
12345:
title: Example Basic Bundle (monthly)
short_title: Basic (monthly)
type: BASIC
period: monthly
domain: example.com
upgrades:
- 12346
- 12347
- 12348
- 12349
downgrades:
12350:
title: Example Basic Bundle (annual)
short_title: Basic (annual)
type: BASIC
period: annual
domain: example.com
upgrades:
- 12351
- 12347
- 12348
- 12349
downgrades:
12351:
title: Example Complete Bundle (monthly)
short_title: Complete (monthly)
type: COMPLETE
period: monthly
domain: example.com
upgrades:
- 12347
- 12348
- 12349
downgrades:
- 12345
- 12350
12347:
title: Example Complete Bundle (annual)
short_title: Complete (annual)
type: COMPLETE
period: annual
domain: example.com
upgrades:
- 12348
- 12349
downgrades:
- 12345
- 12350
12348:
title: Example Family Pack (monthly)
short_title: Family pack (monthly)
type: GROUP
period: monthly
domain: example.com
upgrades:
downgrades:
- 12345
- 12350
- 12347
- 12351
12349:
title: Example Family Pack (annual)
short_title: Family pack (annual)
type: GROUP
period: annual
domain: example.com
upgrades:
downgrades:
- 12345
- 12350
- 12347
- 12351
......@@ -7,10 +7,19 @@ 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__)
class SubscriptionParseError(Exception):
"""Raised when the subscription being parsed contains not expected
data.
"""
pass
def get_username_from_woo_customer_id(customer_id, woo=None):
if woo is None:
woo = get_woo_connection()
......@@ -32,14 +41,19 @@ def parse_subscription(json_entry):
# validation
if len(json_entry["line_items"]) != 1:
raise Exception("Too many line items in subscription %s" % (id, ))
raise SubscriptionParseError(
"Subscription {} does not have one line_items".format(
subscription_id)
)
line_item = json_entry["line_items"][0]
quantity = line_item["quantity"]
if quantity != 1:
raise Exception("Bad quantity %s in subscription %s"
% (quantity, id, ))
raise SubscriptionParseError(
"Bad quantity {} in subscription {}".format(
quantity, subscription_id)
)
# calculate next renewal date
if json_entry["next_payment_date"] != "":
......@@ -59,25 +73,27 @@ def parse_subscription(json_entry):
else:
bundle_key = line_item.get('product_id')
# get account_type from subscription
line_item_meta = line_item["meta"]
for meta in line_item_meta:
if meta["key"] == "service-bundle":
acct_value = meta["value"]
break
else:
acct_value = None
# get account_type and billing_period from configured bundles
account_type = AccountType.UNDEFINED
if acct_value:
acct_value = acct_value.lower()
if 'family' in acct_value:
billing_period = None
bundles = get_available_bundles()
sub_bundle = bundles.get(str(bundle_key), None)
if sub_bundle:
sub_acc_type = sub_bundle.get('type', None)
if 'GROUP' == sub_acc_type:
account_type = AccountType.GROUP
elif 'complete' in acct_value:
elif 'COMPLETE' == sub_acc_type:
account_type = AccountType.COMPLETE
elif 'basic' in acct_value:
elif 'BASIC' == sub_acc_type:
account_type = AccountType.BASIC
sub_period = sub_bundle.get('period', None)
if 'monthly' == sub_period:
billing_period = 'monthly'
elif 'annual' == sub_period:
billing_period = 'annual'
# create result
result = {
"parser": ParserContainer.WOO_SUBSCRIPTION_V1,
......@@ -89,7 +105,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
......@@ -162,7 +179,11 @@ def forced_update(user):
return
for sub in subscriptions:
parsed_sub = parse_subscription(sub)
try:
parsed_sub = parse_subscription(sub)
except SubscriptionParseError as parse_e:
logger.info(repr(parse_e))
continue
if parsed_sub['bundle_key'] not in settings.WOOSUB1_PRODUCT_LIST:
logger.info('Subscription {} for user {} does not belong '
......@@ -301,3 +322,91 @@ 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()
paid_statuses = ['completed', 'processing']
if order and order['status'] in paid_statuses:
return True
return False
def set_subscription_status(subscription_id, status):
"""Given a subscription_id and a status it set that status for the
subscription returning True when it 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.
"""
valid_statuses = ['pending', 'active', 'on-hold', 'expired', 'cancelled',
'pending-cancel']
if status not in valid_statuses:
return False
woo = get_woo_connection()
data = {
'status': status
}
query = 'subscriptions/{}'.format(subscription_id)
result = woo.put(query, data).json()
if result:
r_status = result.get('status', None)
code = result.get('code', None)
if r_status == status:
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:
r_status2 = sub.get('status', None)
if r_status2 == status:
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, set_subscription_status, \
SubscriptionParseError
logger = get_task_logger(__name__)
......@@ -16,7 +18,11 @@ def process_pending_registrations_user(user):
logger.info('User {} has no subscriptions.'.format(user.username))
return
for sub in subscriptions:
parsed_sub = parse_subscription(sub)
try:
parsed_sub = parse_subscription(sub)
except SubscriptionParseError as parse_e:
logger.info(repr(parse_e))
continue
if parsed_sub['bundle_key'] not in settings.WOOSUB1_PRODUCT_LIST:
continue # Skip this subscription as is not a product we care of
......@@ -66,7 +72,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 +83,86 @@ 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).filter(user__is_active=True).order_by('-id')
for pending_change in pending_changes:
is_active = False
is_paid = order_paid(pending_change.new_order_id)
# Check if the subscription is active
subs = get_user_subscriptions(pending_change.user)
if subs is None:
logger.info('User {} has no subscriptions. BundleChange cannot be '
'processed'.format(pending_change.user))
continue
for sub in subs:
order_id = sub.get('parent_id', -1)
sub_status = sub.get('status', '')
if (
order_id == int(pending_change.new_order_id)
and sub_status == 'active'
):
is_active = True
break
# if subscription is active and order is paid we process the
# bundle change
if is_paid and is_active:
set_subscription_status(pending_change.old_subscription_id,
'cancelled')
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'),
......