switch to a new version of payments module

up to date with payment processors, pull-focused, and as a reusable app
master
alice 3 years ago
parent 18ffc0af5f
commit 72bc2d34a4

@ -43,7 +43,6 @@ INSTALLED_APPS = (
'django.contrib.humanize',
'django_countries',
'django_lcore',
'django_celery_results',
'django_celery_beat',
'lambdainst',
'payments',
@ -117,6 +116,51 @@ DATABASES = {
}
}
LOGGING = {
'version': 1,
'disable_existing_loggers': True,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'console',
},
},
'loggers': {
'root': {
'handlers': ['console'],
'level': 'DEBUG' if DEBUG else 'INFO',
},
'urllib3.connectionpool': {
'handlers': ['console'],
#'level': 'DEBUG' if DEBUG else 'INFO',
'level': 'INFO',
'propagate': False,
},
'stripe': {
'handlers': ['console'],
#'level': 'DEBUG' if DEBUG else 'INFO',
'level': 'INFO',
'propagate': False,
},
'payments': {
'handlers': ['console'],
'level': 'DEBUG' if DEBUG else 'INFO',
'propagate': False,
},
},
'formatters': {
'console': {
'format': '%(asctime)s %(levelname).4s %(name)s: %(message)s',
},
},
}
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
'LOCATION': os.path.join(BASE_DIR, ".cache"),
}
}
# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
@ -196,8 +240,6 @@ ROOT_URL = ''
REAL_IP_HEADER_NAME = None
# Celery defaults
#CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
CELERY_RESULT_BACKEND = 'django-db'
CELERY_CACHE_BACKEND = 'django-cache'
CELERY_BROKER_URL = 'redis://localhost/1'
CELERY_TIMEZONE = 'UTC'
@ -283,10 +325,10 @@ PAYMENTS_BACKENDS = {
PAYMENTS_CURRENCY = ('eur', '')
PLANS = {
'1m': {'monthly': 3.00, 'months': 1, },
'3m': {'monthly': 9.00, 'months': 3, },
'6m': {'monthly': 18.00, 'months': 6, },
'12m': {'monthly': 36.00, 'months': 12, 'default': 1},
'1m': {'monthly': 3.00, 'months': 1, },
'3m': {'monthly': 3.00, 'months': 3, },
'6m': {'monthly': 3.00, 'months': 6, },
'12m': {'monthly': 3.00, 'months': 12, 'default': 1},
}

@ -10,25 +10,14 @@ from django.utils.html import format_html
import django_lcore
import lcoreapi
from lambdainst.models import VPNUser, GiftCode, GiftCodeUser
from lambdainst.models import VPNUser
from payments.admin import UserCouponInline, UserLedgerInline
def make_user_link(user):
change_url = resolve_url('admin:auth_user_change', user.id)
return format_html('<a href="{}">{}</a>', change_url, user.username)
class GiftCodeAdminForm(forms.ModelForm):
def clean(self):
input_code = self.cleaned_data.get('code', '')
code_charset = string.ascii_letters + string.digits
if any(c not in code_charset for c in input_code):
raise forms.ValidationError(_("Code must be [a-zA-Z0-9]"))
if not 1 <= len(input_code) <= 32:
raise forms.ValidationError(_("Code must be between 1 and 32 characters"))
return self.cleaned_data
class VPNUserInline(admin.StackedInline):
model = VPNUser
can_delete = False
@ -58,31 +47,8 @@ class VPNUserInline(admin.StackedInline):
is_paid.short_description = _("Is paid?")
class GiftCodeUserAdmin(admin.TabularInline):
model = GiftCodeUser
fields = ('user_link', 'code_link', 'date')
readonly_fields = ('user_link', 'code_link', 'date')
list_display = ('user', )
original = False
def user_link(self, object):
return make_user_link(object.user)
user_link.short_description = 'User'
def code_link(self, object):
change_url = resolve_url('admin:lambdainst_giftcode_change', object.code.id)
return format_html('<a href="{}">{}</a>', change_url, object.code.code)
code_link.short_description = 'Code'
def has_add_permission(self, request, obj):
return False
def has_delete_permission(self, request, obj=None):
return False
class UserAdmin(UserAdmin):
inlines = (VPNUserInline, GiftCodeUserAdmin)
inlines = (VPNUserInline, UserLedgerInline, UserCouponInline)
list_display = ('username', 'email', 'is_staff', 'date_joined', 'is_paid')
ordering = ('-date_joined', )
fieldsets = (
@ -124,28 +90,5 @@ class UserAdmin(UserAdmin):
super().delete_model(request, obj)
class GiftCodeAdmin(admin.ModelAdmin):
fields = ('code', 'time', 'created', 'created_by', 'single_use', 'free_only',
'available', 'comment')
readonly_fields = ('created', 'created_by')
list_display = ('code', 'time', 'comment_head', 'available')
search_fields = ('code', 'comment', 'users__username')
inlines = (GiftCodeUserAdmin,)
list_filter = ('available', 'time')
form = GiftCodeAdminForm
def comment_head(self, object):
return object.comment_head
comment_head.short_description = _("Comment")
def save_model(self, request, obj, form, change):
if not change:
obj.created_by = request.user
obj.save()
admin.site.unregister(User)
admin.site.register(User, UserAdmin)
admin.site.register(GiftCode, GiftCodeAdmin)

@ -0,0 +1,76 @@
# Generated by Django 3.2.5 on 2021-07-22 00:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lambdainst', '0004_auto_20200829_2054'),
]
operations = [
migrations.RenameField(
model_name='giftcode',
old_name='free_only',
new_name='new_clients_only',
),
migrations.RemoveField(
model_name='giftcode',
name='created_by',
),
migrations.AddField(
model_name='giftcode',
name='for_plans',
field=models.CharField(blank=True, help_text='Valid only for the following plans, separated by commas. ("1m,6m", "12m", ...) (empty: any plan allowed)', max_length=100),
),
migrations.AddField(
model_name='giftcode',
name='valid_after',
field=models.DateTimeField(blank=True, help_text='Valid only after the date. (empty: no limit)', null=True),
),
migrations.AddField(
model_name='giftcode',
name='valid_before',
field=models.DateTimeField(blank=True, help_text='Valid only before the date. (empty: no limit)', null=True),
),
migrations.AddField(
model_name='giftcode',
name='value',
field=models.IntegerField(blank=True, help_text='Integer between 0 and 100. If the total is below 0.50$, it will be free and skip the payment', null=True, verbose_name='Discount %'),
),
migrations.AlterField(
model_name='giftcode',
name='available',
field=models.BooleanField(default=True, help_text='Coupon invalid when unchecked.'),
),
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(
model_name='giftcodeuser',
name='code',
),
migrations.RemoveField(
model_name='giftcodeuser',
name='user',
),
migrations.DeleteModel(
name='GiftCode',
),
migrations.DeleteModel(
name='GiftCodeUser',
),
],
database_operations=[
migrations.AlterModelTable(
name='GiftCode',
table='payments_coupon',
),
migrations.AlterModelTable(
name='GiftCodeUser',
table='payments_couponuser',
),
],
),
]

@ -1,16 +1,10 @@
import random
from datetime import timedelta
from django.db import models
from django.db import models, IntegrityError
from django.contrib.auth.models import User
from django.utils.translation import ugettext as _
from django.db.models.signals import post_save
from django.db import IntegrityError
from django.dispatch import receiver
from constance import config as site_config
from django_lcore.core import LcoreUserProfileMethods, setup_sync_hooks, VPN_AUTH_STORAGE
from ccvpn.common import get_trial_period_duration
from payments.models import Subscription
from payments.models import BaseSubUser
prng = random.SystemRandom()
@ -20,7 +14,7 @@ def random_gift_code():
return ''.join([prng.choice(charset) for n in range(10)])
class VPNUser(models.Model, LcoreUserProfileMethods):
class VPNUser(models.Model, BaseSubUser, LcoreUserProfileMethods):
class Meta:
verbose_name = _("VPN User")
verbose_name_plural = _("VPN Users")
@ -53,39 +47,12 @@ class VPNUser(models.Model, LcoreUserProfileMethods):
self.referrer_used = False
self.campaign = None
def give_trial_period(self):
self.add_paid_time(get_trial_period_duration())
self.trial_periods_given += 1
@property
def can_have_trial(self):
if self.trial_periods_given >= site_config.TRIAL_PERIOD_MAX:
return False
if self.user.payment_set.filter(status='confirmed').count() > 0:
return False
return True
@property
def remaining_trial_periods(self):
return site_config.TRIAL_PERIOD_MAX - self.trial_periods_given
def on_payment_confirmed(self, payment):
if self.referrer and not self.referrer_used:
self.referrer.vpnuser.add_paid_time(timedelta(days=30))
self.referrer.vpnuser.add_paid_months(1, 'referrer', f"rewarded for {self.user.username} (payment #{payment.id})")
self.referrer.vpnuser.save()
self.referrer_used = True
def get_subscription(self, include_unconfirmed=False):
""" Tries to get the active (and most recent) Subscription """
s = Subscription.objects.filter(user=self.user, status='active') \
.order_by('-id') \
.first()
if s is not None or include_unconfirmed is False:
return s
s = Subscription.objects.filter(user=self.user, status='unconfirmed') \
.order_by('-id') \
.first()
return s
self.save()
def lcore_sync(self):
if VPN_AUTH_STORAGE == 'inst':
@ -93,75 +60,26 @@ class VPNUser(models.Model, LcoreUserProfileMethods):
from lambdainst.tasks import push_user
push_user.delay(user_id=self.user.id)
def __str__(self):
return self.user.username
setup_sync_hooks(User, VPNUser)
def notify_payment(self, payment):
return
@receiver(post_save, sender=User)
def create_vpnuser(sender, instance, created, **kwargs):
if created:
try:
VPNUser.objects.create(user=instance)
except IntegrityError:
pass
def notify_subscription(self, sub):
return
class GiftCode(models.Model):
class Meta:
verbose_name = _("Gift Code")
verbose_name_plural = _("Gift Codes")
code = models.CharField(max_length=32, default=random_gift_code)
time = models.DurationField(default=timedelta(days=30))
created = models.DateTimeField(auto_now_add=True, null=True, blank=True)
created_by = models.ForeignKey(User, related_name='created_giftcode_set',
on_delete=models.CASCADE, null=True, blank=True)
single_use = models.BooleanField(default=True)
free_only = models.BooleanField(default=True)
available = models.BooleanField(default=True)
comment = models.TextField(blank=True)
users = models.ManyToManyField(User, through='GiftCodeUser')
def use_on(self, user):
if not self.available:
return False
if self.free_only and user.vpnuser.is_paid:
return False
link = GiftCodeUser(user=user, code=self)
link.save()
user.vpnuser.add_paid_time(self.time)
user.vpnuser.save()
if self.single_use:
self.available = False
self.save()
return True
@property
def comment_head(self):
head = self.comment.split('\n', 1)[0]
if len(head) > 80:
head = head[:80] + "..."
return head
def notify_refund(self, payment):
return
def __str__(self):
return self.code
class GiftCodeUser(models.Model):
class Meta:
verbose_name = _("Gift Code User")
verbose_name_plural = _("Gift Code Users")
user = models.ForeignKey(User, on_delete=models.CASCADE)
code = models.ForeignKey(GiftCode, on_delete=models.CASCADE)
date = models.DateTimeField(auto_now_add=True, null=True, blank=True)
return self.user.username
def __str__(self):
return "%s (%s)" % (self.user.username, self.code.code)
setup_sync_hooks(User, VPNUser)
#from django.db.models.signals import post_save
#from django.dispatch import receiver
#@receiver(post_save, sender=User)
#def create_vpnuser(sender, instance, created, **kwargs):
# if created:
# try:
# VPNUser.objects.create(user=instance)
# except IntegrityError:
# pass

@ -2,7 +2,7 @@ import logging
import itertools
import time
from datetime import timedelta, datetime
from celery import task
from celery import shared_task
from django.db.models import Q, F
from django.db import transaction
from django.conf import settings
@ -22,7 +22,7 @@ SITE_NAME = settings.TICKETS_SITE_NAME
logger = logging.getLogger(__name__)
@task(autoretry_for=(Exception,), default_retry_delay=60 * 60)
@shared_task(autoretry_for=(Exception,), default_retry_delay=60 * 60)
def push_all_users():
count = 0
for u in User.objects.all():
@ -37,14 +37,14 @@ def push_all_users():
logger.info("pushed %d users", count)
@task(autoretry_for=(Exception,), max_retries=10, retry_backoff=True)
@shared_task(autoretry_for=(Exception,), max_retries=10, retry_backoff=True)
def push_user(user_id):
user = User.objects.get(id=user_id)
logger.info("pushing user #%d %r", user.id, user)
django_lcore.sync_user(user.vpnuser, fail_silently=False)
@task
@shared_task
def notify_vpn_auth_fails():
"""
due to the high number of regular authentication fails, we'll start sending
@ -179,7 +179,7 @@ def notify_vpn_auth_fails():
examine_users()
@task
@shared_task
def notify_account_expiration():
""" Notify users near the end of their subscription """
from_email = settings.DEFAULT_FROM_EMAIL

@ -28,10 +28,10 @@ class BaseOnlineTest(StaticLiveServerTestCase):
def setUpClass(cls):
super().setUpClass()
cls.selenium = WebDriver(firefox_binary="/usr/bin/firefox")
cls.selenium.implicitly_wait(6)
cls.selenium.implicitly_wait(12)
cls.root_url = settings.ROOT_URL
cls.wait = WebDriverWait(cls.selenium, 6)
cls.wait = WebDriverWait(cls.selenium, 20)
@classmethod
def tearDownClass(cls):
@ -49,7 +49,7 @@ class BaseOnlineTest(StaticLiveServerTestCase):
self.selenium.find_element_by_xpath('//input[@value="Sign up"]').click()
def wait(f, initial=5, between=3):
def wait(f, initial=3, between=2):
time.sleep(initial)
print("waiting for confirmation...")
while True:
@ -71,15 +71,15 @@ class OnlineStripeTests(BaseOnlineTest):
self.wait.until(
EC.visibility_of(
self.selenium.find_element_by_xpath(
'//label[@for="tab_onetime"]/..//select[@name="method"]'
'//label[@for="tab_onetime"]/..//select[@name="payment-method"]'
)
)
)
self.selenium.find_element_by_xpath(
'//label[@for="tab_onetime"]/..//select[@name="time"]/option[@value="3"]'
'//label[@for="tab_onetime"]/..//select[@name="plan"]/option[@value="3m"]'
).click()
self.selenium.find_element_by_xpath(
'//label[@for="tab_onetime"]/..//select[@name="method"]/option[@value="stripe"]'
'//label[@for="tab_onetime"]/..//select[@name="payment-method"]/option[@value="stripe"]'
).click()
self.selenium.find_element_by_xpath(
'//label[@for="tab_onetime"]/..//input[@value="Buy Now"]'
@ -102,7 +102,15 @@ class OnlineStripeTests(BaseOnlineTest):
)
self.selenium.find_element_by_xpath('//button[@type="submit"]').click()
self.wait.until(
EC.presence_of_element_located((By.XPATH, '//*[contains(text(), "Waiting for payment")]'))
)
def check_active():
# refresh payment as we dont have a worker
p = Payment.objects.order_by('id').first()
p.refresh()
self.selenium.refresh()
self.selenium.find_element_by_xpath('//h2[contains(text(),"Confirmed")]')
@ -119,15 +127,15 @@ class OnlineStripeTests(BaseOnlineTest):
self.wait.until(
EC.visibility_of(
self.selenium.find_element_by_xpath(
'//label[@for="tab_subscr"]/..//select[@name="method"]'
'//label[@for="tab_subscr"]/..//select[@name="payment-method"]'
)
)
)
self.selenium.find_element_by_xpath(
'//label[@for="tab_subscr"]/..//select[@name="time"]/option[@value="12"]'
'//label[@for="tab_subscr"]/..//select[@name="plan"]/option[@value="12m"]'
).click()
self.selenium.find_element_by_xpath(
'//label[@for="tab_subscr"]/..//select[@name="method"]/option[@value="stripe"]'
'//label[@for="tab_subscr"]/..//select[@name="payment-method"]/option[@value="stripe"]'
).click()
self.selenium.find_element_by_xpath(
'//label[@for="tab_subscr"]/..//input[@value="Subscribe"]'
@ -150,7 +158,15 @@ class OnlineStripeTests(BaseOnlineTest):
)
self.selenium.find_element_by_xpath('//button[@type="submit"]').click()
self.wait.until(
EC.presence_of_element_located((By.XPATH, '//*[contains(text(), "Your subscription is processing.")]'))
)
def check_active():
# refresh sub as we dont have a worker
p = Subscription.objects.order_by('id').first()
p.refresh()
self.selenium.refresh()
sub_status = self.selenium.find_element_by_xpath(
'//td[text()="Subscription"]//following-sibling::td'
@ -166,7 +182,6 @@ class OnlineStripeTests(BaseOnlineTest):
user = User.objects.get(username="test-user")
assert user.vpnuser.is_paid
assert user.vpnuser.expiration >= (timezone.now() + timedelta(days=359))
sub_status.find_element_by_xpath('a[text()="cancel"]').click()
self.selenium.find_element_by_xpath(
@ -180,5 +195,4 @@ class OnlineStripeTests(BaseOnlineTest):
user = User.objects.get(username="test-user")
assert user.vpnuser.is_paid
assert user.vpnuser.expiration >= (timezone.now() + timedelta(days=359))
assert not user.vpnuser.get_subscription()

@ -7,108 +7,19 @@ from io import StringIO
from constance import config as site_config
from constance.test import override_config
from .forms import SignupForm
from .models import VPNUser, User, random_gift_code, GiftCode, GiftCodeUser
from lambdainst.forms import SignupForm
from lambdainst.models import VPNUser, User
from payments.models import Payment, Subscription
class UserTestMixin:
def assertRemaining(self, vpnuser, time):
def assertRemaining(self, vpnuser, time, delta=5):
""" Check that the vpnuser will expire in time (+/- 5 seconds) """
exp = vpnuser.expiration or timezone.now()
seconds = (exp - timezone.now() - time).total_seconds()
self.assertAlmostEqual(seconds, 0, delta=5)
self.assertAlmostEqual(seconds, 0, delta=delta)
class UserModelTest(TestCase, UserTestMixin):
def setUp(self):
User.objects.create_user('aaa')
def test_add_time(self):
u = User.objects.get(username='aaa')
vu = u.vpnuser
p = timedelta(days=1)
self.assertFalse(vu.is_paid)
vu.expiration = timezone.now()
vu.add_paid_time(p)
final = vu.expiration
self.assertRemaining(vu, p)
self.assertGreater(final, timezone.now())
self.assertTrue(vu.is_paid)
def test_add_time_past(self):
u = User.objects.get(username='aaa')
vu = u.vpnuser
p = timedelta(days=1)
self.assertFalse(vu.is_paid)
vu.expiration = timezone.now() - timedelta(days=135)
vu.add_paid_time(p)
final = vu.expiration
self.assertRemaining(vu, p)
self.assertGreater(final, timezone.now())
self.assertTrue(vu.is_paid)
def test_add_time_initial(self):
u = User.objects.get(username='aaa')
vu = u.vpnuser
p = timedelta(days=1)
self.assertFalse(vu.is_paid)
vu.add_paid_time(p)
self.assertTrue(vu.is_paid)
def test_paid_between_subscr_payments(self):
u = User.objects.get(username='aaa')
vu = u.vpnuser
s = Subscription(user=u, backend_id='paypal', status='new')
s.save()
self.assertFalse(vu.is_paid)
s.status = 'active'
s.save()
self.assertTrue(vu.is_paid)
def test_grant_trial(self):
p = timedelta(days=1)
u = User.objects.get(username='aaa')
vu = u.vpnuser
with override_config(TRIAL_PERIOD_HOURS=24, TRIAL_PERIOD_MAX=2):
self.assertEqual(vu.remaining_trial_periods, 2)
self.assertTrue(vu.can_have_trial)
vu.give_trial_period()
self.assertRemaining(vu, p)
self.assertEqual(vu.remaining_trial_periods, 1)
self.assertTrue(vu.can_have_trial)
vu.give_trial_period()
self.assertRemaining(vu, p * 2)
self.assertEqual(vu.remaining_trial_periods, 0)
self.assertFalse(vu.can_have_trial)
def test_trial_refused(self):
p = timedelta(days=1)
u = User.objects.get(username='aaa')
payment = Payment.objects.create(user=u, status='confirmed', amount=300,
time=timedelta(days=30))
payment.save()
vu = u.vpnuser
with override_config(TRIAL_PERIOD_HOURS=24, TRIAL_PERIOD_MAX=2):
self.assertEqual(vu.remaining_trial_periods, 2)
self.assertFalse(vu.can_have_trial)
class UserModelReferrerTest(TestCase, UserTestMixin):
def setUp(self):
@ -129,14 +40,7 @@ class UserModelReferrerTest(TestCase, UserTestMixin):
self.with_ref.vpnuser.on_payment_confirmed(self.payment)
self.assertTrue(self.with_ref.vpnuser.referrer_used)
self.assertEqual(self.with_ref.vpnuser.referrer, self.referrer)
self.assertRemaining(self.referrer.vpnuser, timedelta(days=14))
class GCModelTest(TestCase):
def test_generator(self):
c = random_gift_code()
self.assertEqual(len(c), 10)
self.assertNotEqual(c, random_gift_code())
self.assertRemaining(self.referrer.vpnuser, timedelta(days=30), delta=24*3600*3)
class SignupViewTest(TestCase):
@ -242,54 +146,6 @@ class AccountViewsTest(TestCase, UserTestMixin):
self.assertFalse(user.check_password('new_test_pw2'))
self.assertTrue(user.check_password('test_pw'))
def test_giftcode_use_single(self):
gc = GiftCode.objects.create(time=timedelta(days=42), single_use=True)
response = self.client.post('/account/gift_code', {'code': gc.code})
self.assertRedirects(response, '/account/')
user = User.objects.get(username='test')
self.assertRemaining(user.vpnuser, timedelta(days=42))
response = self.client.post('/account/gift_code', {'code': gc.code})
self.assertRedirects(response, '/account/')
user = User.objects.get(username='test')
self.assertRemaining(user.vpnuser, timedelta(days=42)) # same expiration
def test_giftcode_use_free_only(self):
gc = GiftCode.objects.create(time=timedelta(days=42), free_only=True)
response = self.client.post('/account/gift_code', {'code': gc.code})
self.assertRedirects(response, '/account/')
user = User.objects.get(username='test')
self.assertRemaining(user.vpnuser, timedelta(days=42))
def test_giftcode_use_free_only_fail(self):
gc = GiftCode.objects.create(time=timedelta(days=42), free_only=True)
user = User.objects.get(username='test')
user.vpnuser.add_paid_time(timedelta(days=1))
user.vpnuser.save()
response = self.client.post('/account/gift_code', {'code': gc.code})
self.assertRedirects(response, '/account/')
user = User.objects.get(username='test')
self.assertRemaining(user.vpnuser, timedelta(days=1))
def test_giftcode_create_gcu(self):
gc = GiftCode.objects.create(time=timedelta(days=42))
response = self.client.post('/account/gift_code', {'code': gc.code})
self.assertRedirects(response, '/account/')
user = User.objects.get(username='test')
gcu = GiftCodeUser.objects.get(user=user, code=gc)
self.assertRemaining(user.vpnuser, timedelta(days=42))
self.assertIn(gcu, user.giftcodeuser_set.all())
class CACrtViewTest(TestCase):
def test_ca_crt(self):
@ -312,7 +168,7 @@ class ExpireNotifyTest(TestCase):
def test_notify_first(self):
out = StringIO()
u = User.objects.create_user('test_username', 'test@example.com', 'testpw')
u.vpnuser.add_paid_time(timedelta(days=2))
u.vpnuser.add_paid_time(timedelta(days=2), 'initial')
u.vpnuser.save()
call_command('expire_notify', stdout=out)
@ -328,7 +184,7 @@ class ExpireNotifyTest(TestCase):
out = StringIO()
u = User.objects.create_user('test_username', 'test@example.com', 'testpw')
u.vpnuser.last_expiry_notice = timezone.now() - timedelta(days=2)
u.vpnuser.add_paid_time(timedelta(days=1))
u.vpnuser.add_paid_time(timedelta(days=1), 'initial')
u.vpnuser.save()
call_command('expire_notify', stdout=out)
@ -343,7 +199,7 @@ class ExpireNotifyTest(TestCase):
def test_notify_subscription(self):
out = StringIO()
u = User.objects.create_user('test_username', 'test@example.com', 'testpw')
u.vpnuser.add_paid_time(timedelta(days=2))
u.vpnuser.add_paid_time(timedelta(days=2), 'initial')
u.vpnuser.save()
s = Subscription(user=u, backend_id='paypal', status='active')
@ -360,7 +216,7 @@ class ExpireNotifyTest(TestCase):
def test_notify_subscription_new(self):
out = StringIO()
u = User.objects.create_user('test_username', 'test@example.com', 'testpw')
u.vpnuser.add_paid_time(timedelta(days=2))
u.vpnuser.add_paid_time(timedelta(days=2), 'initial')
u.vpnuser.last_expiry_notice = timezone.now() - timedelta(days=5)
u.vpnuser.save()

@ -17,7 +17,5 @@ urlpatterns = [
path('config_dl', django_lcore.views.openvpn_dl),
path('wireguard', views.wireguard),
path('wireguard/new', views.wireguard_new, name='wireguard_new'),
path('gift_code', views.gift_code),
path('trial', views.trial),
path('', views.index, name='index'),
]

@ -28,7 +28,7 @@ import lcoreapi
from ccvpn.common import get_client_ip, get_price_float
from payments.models import ACTIVE_BACKENDS
from .forms import SignupForm, ReqEmailForm, WgPeerForm
from .models import GiftCode, VPNUser
from .models import VPNUser
from . import graphs
@ -96,11 +96,7 @@ def signup(request):
pass
user.vpnuser.campaign = request.session.get('campaign')
user.vpnuser.add_paid_time(timedelta(days=7))
if site_config.TRIAL_ON_SIGNUP:
trial_time = timedelta(hours=site_config.TRIAL_ON_SIGNUP)
user.vpnuser.add_paid_time(trial_time)
user.vpnuser.add_paid_time(timedelta(days=7), 'trial')
user.vpnuser.save()
user.vpnuser.lcore_sync()
@ -191,7 +187,7 @@ def index(request):
title=_("Account"),
ref_url=ref_url,
twitter_link=twitter_url + urlencode(twitter_args),
subscription=request.user.vpnuser.get_subscription(include_unconfirmed=True),
subscription=request.user.vpnuser.get_subscription(),
backends=sorted(ACTIVE_BACKENDS.values(), key=lambda x: x.backend_display_name),
subscr_backends=sorted((b for b in ACTIVE_BACKENDS.values()
if b.backend_has_recurring),
@ -207,6 +203,9 @@ def index(request):
def captcha_test(grr, request):
api_url = project_settings.HCAPTCHA_API
if not project_settings.HCAPTCHA_SITE_KEY:
return True
if api_url == 'TEST' and grr == 'TEST-TOKEN':
# FIXME: i'm sorry.
return True
@ -223,22 +222,6 @@ def captcha_test(grr, request):
return False
@login_required
def trial(request):
if request.method != 'POST' or not request.user.vpnuser.can_have_trial:
return redirect('account:index')
grr = request.POST.get('g-recaptcha-response', '')
if captcha_test(grr, request):
request.user.vpnuser.give_trial_period()
request.user.vpnuser.save()
messages.success(request, _("OK!"))
else:
messages.error(request, _("Invalid captcha"))
return redirect('account:index')
def make_export_zip(user, name):
import io
import zipfile
@ -337,7 +320,6 @@ def deactivate_user(user):
user.password = ""
user.save()
user.giftcodeuser_set.all().delete()
user.payment_set.update(backend_data="null")
user.subscription_set.update(backend_data="null")
@ -405,23 +387,6 @@ def settings(request):
))
@login_required
def gift_code(request):
try:
code = GiftCode.objects.get(code=request.POST.get('code', '').strip(), available=True)
except GiftCode.DoesNotExist:
code = None
if code is None:
messages.error(request, _("Gift code not found or already used."))
elif not code.use_on(request.user):
messages.error(request, _("Gift code only available to free accounts."))
else:
messages.success(request, _("OK!"))
return redirect('account:index')
@login_required
def config(request):
return render(request, 'lambdainst/config.html', dict(

@ -1,137 +0,0 @@
import json
from django.shortcuts import resolve_url
from django.contrib import admin
from django.utils.html import format_html
from django.utils.translation import ugettext_lazy as _
from django.utils.text import Truncator
from .models import Payment, Subscription, Feedback
def subscr_mark_as_cancelled(modeladmin, request, queryset):
queryset.update(status='cancelled')
subscr_mark_as_cancelled.short_description = _("Mark as cancelled (do not actually cancel)")
def link(text, url):
if not url:
return text
if not text:
text = url
return format_html('<a href="{}">{}</a>', url, text)
def json_format(code):
j = json.dumps(code, indent=2)
return format_html("<pre>{}</pre>", j)
class PaymentAdmin(admin.ModelAdmin):
model = Payment
list_display = ('user', 'backend', 'status', 'amount', 'paid_amount', 'created')
list_filter = ('backend_id', 'status')
fieldsets = (
(None, {
'fields': ('backend', 'user_link', 'subscription_link', 'time', 'status',
'status_message'),
}),
(_("Payment Data"), {
'fields': ('amount_fmt', 'paid_amount_fmt',
'backend_extid_link', 'backend_data_fmt'),
}),
)
readonly_fields = ('backend', 'user_link', 'time', 'status', 'status_message',
'amount_fmt', 'paid_amount_fmt', 'subscription_link',
'backend_extid_link', 'backend_data_fmt')
search_fields = ('user__username', 'user__email', 'backend_extid', 'backend_data')
def backend(self, object):
try:
return object.backend.backend_verbose_name
except KeyError:
return "#" + object.backend_id
def backend_data_fmt(self, object):
return json_format(object.backend_data)
def backend_extid_link(self, object):
try:
ext_url = object.backend.get_ext_url(object)
return link(object.backend_extid, ext_url)
except KeyError:
return "#" + object.backend_id
def amount_fmt(self, object):
return '%.2f %s' % (object.amount / 100, object.currency_name)
amount_fmt.short_description = _("Amount")
def paid_amount_fmt(self, object):
return '%.2f %s' % (object.paid_amount / 100, object.currency_name)
paid_amount_fmt.short_description = _("Paid amount")
def user_link(self, object):
change_url = resolve_url('admin:auth_user_change', object.user.id)
return link(object.user.username, change_url)
user_link.short_description = 'User'
def subscription_link(self, object):
change_url = resolve_url('admin:payments_subscription_change',
object.subscription.id)
return link(object.subscription.id, change_url)
subscription_link.short_description = 'Subscription'
class SubscriptionAdmin(admin.ModelAdmin):
model = Subscription
list_display = ('user', 'created', 'status', 'backend', 'backend_extid')
list_filter = ('backend_id', 'status')
readonly_fields = ('user_link', 'backend', 'created', 'status',
'last_confirmed_payment', 'payments_links',
'backend_extid_link', 'backend_data_fmt')
search_fields = ('user__username', 'user__email', 'backend_extid', 'backend_data')
actions = (subscr_mark_as_cancelled,)
fieldsets = (
(None, {
'fields': ('backend', 'user_link', 'payments_links', 'status',
'last_confirmed_payment'),
}),
(_("Payment Data"), {
'fields': ('backend_extid_link', 'backend_data_fmt'),
}),
)
def backend(self, object):
return object.backend.backend_verbose_name
def backend_data_fmt(self, object):
return json_format(object.backend_data)
def user_link(self, object):
change_url = resolve_url('admin:auth_user_change', object.user.id)
return link(object.user.id, change_url)
user_link.short_description = 'User'
def payments_links(self, object):
count = Payment.objects.filter(subscription=object).count()
payments_url = resolve_url('admin:payments_payment_changelist')
url = "%s?subscription__id__exact=%s" % (payments_url, object.id)
return link("%d payment(s)" % count, url)
payments_links.short_description = 'Payments'
def backend_extid_link(self, object):
ext_url = object.backend.get_subscr_ext_url(object)
return link(object.backend_extid, ext_url)
backend_extid_link.allow_tags = True
class FeedbackAdmin(admin.ModelAdmin):
model = Feedback
list_display = ('user', 'created', 'short_message')
readonly_fields = ('user', 'created', 'message', 'subscription')
def short_message(self, obj):
return Truncator(obj.message).chars(80)
admin.site.register(Payment, PaymentAdmin)
admin.site.register(Subscription, SubscriptionAdmin)
admin.site.register(Feedback, FeedbackAdmin)

@ -1,8 +0,0 @@
# flake8: noqa
from .base import BackendBase, ManualBackend
from .paypal import PaypalBackend
from .bitcoin import BitcoinBackend
from .stripe import StripeBackend
from .coinpayments import CoinPaymentsBackend

@ -1,56 +0,0 @@
from django.utils.translation import ugettext_lazy as _
class BackendBase:
backend_id = None
backend_verbose_name = ""
backend_display_name = ""
backend_enabled = False
backend_has_recurring = False
def __init__(self, settings):
pass
def new_payment(self, payment):
""" Initialize a payment and returns an URL to redirect the user.
Can return a HTML string that will be sent back to the user in a
default template (like a form) or a HTTP response (like a redirect).
"""
raise NotImplementedError()
def callback(self, payment, request):
""" Handle a callback """
raise NotImplementedError()
def callback_subscr(self, payment, request):
""" Handle a callback (recurring payments) """
raise NotImplementedError()
def cancel_subscription(self, subscr):
""" Cancel a subscription """
raise NotImplementedError()
def get_info(self):
""" Returns some status (key, value) list """
return ()
def get_ext_url(self, payment):
""" Returns URL to external payment view, or None """
return None
def get_subscr_ext_url(self, subscr):
""" Returns URL to external payment view, or None """
return None
class ManualBackend(BackendBase):
""" Manual backend used to store and display informations about a
payment processed manually.
More a placeholder than an actual payment beckend, everything raises
NotImplementedError().
"""
backend_id = 'manual'
backend_verbose_name = _("Manual")

@ -1,108 +0,0 @@
from decimal import Decimal
from django.shortcuts import redirect
from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from constance import config as site_config
from .base import BackendBase
class BitcoinBackend(BackendBase):
""" Bitcoin backend.
Connects to a bitcoind.
"""
backend_id = 'bitcoin'
backend_verbose_name = _("Bitcoin")
backend_display_name = _("Bitcoin")
COIN = 100000000
def __init__(self, settings):
from bitcoin import SelectParams
from bitcoin.rpc import Proxy
self.account = settings.get('account', 'ccvpn3')
chain = settings.get('chain')
if chain:
SelectParams(chain)
self.url = settings.get('url')
if not self.url:
return
self.make_rpc = lambda: Proxy(self.url)
self.rpc = self.make_rpc()
self.backend_enabled = True
@property
def btc_value(self):
return site_config.BTC_EUR_VALUE
def new_payment(self, payment):
rpc = self.make_rpc()
# bitcoins amount = (amount in cents) / (cents per bitcoin)
btc_price = round(Decimal(payment.amount) / self.btc_value, 5)
address = str(rpc.getnewaddress(self.account))
msg = _("Please send %(amount)s BTC to %(address)s")
payment.status_message = msg % dict(amount=str(btc_price), address=address)
payment.backend_extid = address
payment.backend_data = dict(btc_price=str(btc_price), btc_address=address)
payment.save()
return redirect(reverse('payments:view', args=(payment.id,)))
def check(self, payment):
rpc = self.make_rpc()
if payment.status != 'new':
return
btc_price = payment.backend_data.get('btc_price')
address = payment.backend_data.get('btc_address')
if not btc_price or not address:
return
btc_price = Decimal(btc_price)
received = Decimal(rpc.getreceivedbyaddress(address)) / self.COIN
payment.paid_amount = int(received * self.btc_value)
payment.backend_data['btc_paid_price'] = str(received)
if received >= btc_price:
payment.user.vpnuser.add_paid_time(payment.time)
payment.user.vpnuser.on_payment_confirmed(payment)
payment.user.vpnuser.save()
payment.status = 'confirmed'
payment.save()
def get_info(self):
rpc = self.make_rpc()
try:
info = rpc.getinfo()
if not info:
return [(_("Status"), "Error: got None")]
except Exception as e:
return [(_("Status"), "Error: " + repr(e))]
v = info.get('version', 0)
return (
(_("Bitcoin value"), "%.2f" % (self.btc_value / 100)),
(_("Testnet"), info['testnet']),
(_("Balance"), '{:f}'.format(info['balance'] / self.COIN)),
(_("Blocks"), info['blocks']),
(_("Bitcoind version"), '.'.join(str(v // 10 ** (2 * i) % 10 ** (2 * i))
for i in range(3, -1, -1))),
)
def get_ext_url(self, payment):
if not payment.backend_extid:
return None
return 'https://blockstream.info/address/%s' % payment.backend_extid

@ -1,301 +0,0 @@
import math
from decimal import Decimal
from django.shortcuts import redirect
from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from constance import config as site_config
from django.conf import settings as project_settings
from .base import BackendBase
import hmac
import hashlib
import requests
import logging
import json
from urllib.parse import urlencode
logger = logging.getLogger(__name__)
class CoinPaymentsError(Exception):
pass
class CoinPayments:
def __init__(self, pkey, skey, api_url=None):
self.public_key = pkey
self.secret_key = skey.encode('utf-8')
self.api_url = api_url or 'https://www.coinpayments.net/api.php'
def _sign(self, params):
body = urlencode(params).encode('utf-8')
mac = hmac.new(self.secret_key, body, hashlib.sha512)
return body, mac.hexdigest()
def _request(self, cmd, params):
params.update({
'cmd': cmd,
'key': self.public_key,
'format': 'json',
'version': 1,
})
print(params)
post_body, mac = self._sign(params)
headers = {
'HMAC': mac,
'Content-Type': 'application/x-www-form-urlencoded',
}
r = requests.post(self.api_url, data=post_body,
headers=headers)
try:
r.raise_for_status()
j = r.json()
except Exception as e:
raise CoinPaymentsError(str(e)) from e
if j.get('error') == 'ok':
return j.get('result')
else:
raise CoinPaymentsError(j.get('error'))
def create_transaction(self, **params):
assert 'amount' in params
assert 'currency1' in params
assert 'currency2' in params
return self._request('create_transaction', params)
def get_account_info(self, **params):
return self._request('get_basic_info', params)
def get_rates(self, **params):
return self._request('rates', params)
def get_balances(self, **params):
return self._request('balances', params)
def get_deposit_address(self, **params):
assert 'currency' in params
return self._request('get_deposit_address', params)
def get_callback_address(self, **params):
assert 'currency' in params
return self._request('get_callback_address', params)
def get_tx_info(self, **params):
assert 'txid' in params
return self._request('get_tx_info', params)
def get_tx_info_multi(self, ids=None, **params):
if ids is not None:
params['txid'] = '|'.join(str(i) for i in ids)
assert 'txid' in params
return self._request('get_tx_info_multi', params)
def get_tx_ids(self, **params):
return self._request('get_tx_ids', params)
def create_transfer(self, **params):
assert 'amount' in params
assert 'currency' in params
assert 'merchant' in params or 'pbntag' in params
return self._request('create_transfer', params)
def create_withdrawal(self, **params):
assert 'amount' in params
assert 'currency' in params
assert 'address' in params or 'pbntag' in params
return self._request('create_withdrawal', params)
def create_mass_withdrawal(self, **params):
assert 'wd' in params
return self._request('create_mass_withdrawal', params)
def convert(self, **params):
assert 'amount' in params
assert 'from' in params
assert 'to' in params
return self._request('convert', params)
def get_withdrawal_history(self, **params):
return self._request('get_withdrawal_history', params)
def get_withdrawal_info(self, **params):
assert 'id' in params
return self._request('get_withdrawal_info', params)
def get_conversion_info(self, **params):
assert 'id' in params
return self._request('get_conversion_info', params)
def get_pbn_info(self, **params):
assert 'pbntag' in params
return self._request('get_pbn_info', params)
def get_pbn_list(self, **params):
return self._request('get_pbn_list', params)
def update_pbn_tag(self, **params):
assert 'tagid' in params
return self._request('update_pbn_tag', params)
def claim_pbn_tag(self, **params):
assert 'tagid' in params
assert 'name' in params
return self._request('claim_pbn_tag', params)
class IpnError(Exception):
pass
def ipn_assert(request, remote, local, key=None, delta=None):
if (delta is None and remote != local) or (delta is not None and not math.isclose(remote, local, abs_tol=delta)):
logger.debug("Invalid IPN %r: local=%r remote=%r",
key, local, remote)
raise IpnError("Unexpected value: %s" % key)
def ipn_assert_post(request, key, local):
remote = request.POST.get(key)
ipn_assert(request, remote, local, key=key)
class CoinPaymentsBackend(BackendBase):
backend_id = 'coinpayments'
backend_verbose_name = _("CoinPayments")
backend_display_name = _("Cryptocurrencies")
backend_has_recurring = False
def __init__(self, settings):
self.merchant_id = settings.get('merchant_id')
self.currency = settings.get('currency', 'EUR')
self.api_base = settings.get('api_base', None)
self.title = settings.get('title', 'VPN Payment')
self.secret = settings.get('secret', '').encode('utf-8')
if self.merchant_id and self.secret:
self.backend_enabled = True
def new_payment(self, payment):
ROOT_URL = project_settings.ROOT_URL
params = {
'cmd': '_pay',
'reset': '1',
'want_shipping': '0',
'merchant': self.merchant_id,
'currency': self.currency,
'amountf': '%.2f' % (payment.amount / 100),
'item_name': self.title,
'ipn_url': ROOT_URL + reverse('payments:cb_coinpayments', args=(payment.id,)),
'success_url': ROOT_URL + reverse('payments:view', args=(payment.id,)),
'cancel_url': ROOT_URL + reverse('payments:cancel', args=(payment.id,)),
}
payment.status_message = _("Waiting for CoinPayments to confirm the transaction... " +
"It can take up to a few minutes...")
payment.save()
form = '<form action="https://www.coinpayments.net/index.php" method="POST" id="cp-form">'
for k, v in params.items():
form += '<input type="hidden" name="%s" value="%s" />' % (k, v)
form += '''
<img src="/static/img/spinner.gif" style="margin: auto;" alt="redirecting..." id="cp-spinner" />
<input type="submit" class="button" name="submitbutton" value="Continue" />
</form>
<script>
document.addEventListener("DOMContentLoaded", function(event) {{
var f = document.getElementById("cp-form");
f.elements["submitbutton"].style.display = "none";
document.getElementById("cp-spinner").style.display = "block";
f.submit();
}});
</script>
'''
return form
def handle_ipn(self, payment, request):
sig = request.META.get('HTTP_HMAC')
if not sig:
raise IpnError("Missing HMAC")
mac = hmac.new(self.secret, request.body, hashlib.sha512).hexdigest()
# Sanity checks, if it fails the IPN is to be ignored
ipn_assert(request, sig, mac, 'HMAC')
ipn_assert_post(request, 'ipn_mode', 'hmac')
ipn_assert_post(request, 'merchant', self.merchant_id)
try:
status = int(request.POST.get('status'))
except ValueError:
raise IpnError("Invalid status (%r)" % status)
# Some states are final (can't cancel a timeout or refund)
if payment.status not in ('new', 'confirmed', 'error'):
m = "Unexpected state change for %s: is %s, received status=%r" % (
payment.id, payment.status, status
)
raise IpnError(m)
# whatever the status, we can safely update the text and save the tx id
payment.status_text = request.POST.get('status_text') or payment.status_text
payment.backend_extid = request.POST.get('txn_id')
received_amount = request.POST.get('amount1')
if received_amount:
payment.paid_amount = float(received_amount) * 100
# And now the actual processing
if status == 1: # A payment is confirmed paid
if payment.status != 'confirmed':
if payment.paid_amount != payment.amount:
ipn_assert(request, payment.paid_amount, payment.amount, 'paid',
delta=10)
vpnuser = payment.user.vpnuser
vpnuser.add_paid_time(payment.time)
vpnuser.on_payment_confirmed(payment)
vpnuser.save()
# We save the new state *at the end*
# (it will be retried if there's an error)
payment.status = 'confirmed'
payment.status_message = None
payment.save()
elif status > 1: # Waiting (that's further confirmation about funds getting moved)
# we have nothing to do, except updating status_text
payment.save()
return
elif status == -1: # Cancel / Time out
payment.status = 'cancelled'
payment.save()
elif status == -2: # A refund
if payment.status == 'confirmed': # (paid -> refunded)
payment.status = 'refunded'
# TODO
elif status <= -3: # Unknown error
payment.status = 'error'
payment.save()
def callback(self, payment, request):
try:
self.handle_ipn(payment, request)
return True
except IpnError as e:
payment.status = 'error'
payment.status_message = ("Error processing the payment. "
"Please contact support.")
payment.backend_data['ipn_exception'] = repr(e)
payment.backend_data['ipn_last_data'] = repr(request.POST)
payment.save()
logger.warn("IPN error: %s", e)
raise

@ -1,252 +0,0 @@
from django.shortcuts import redirect
from django.utils.translation import ugettext_lazy as _
from urllib.parse import urlencode
from urllib.request import urlopen
from django.urls import reverse
from django.conf import settings as project_settings
import requests
from .base import BackendBase
def urljoin(a, b):
if b.startswith('/') and a.endswith('/'):
return a + b[1:]
if b.startswith('/') or a.endswith('/'):
return a + b
return a + "/" + b
class PaypalBackend(BackendBase):
backend_id = 'paypal'
backend_verbose_name = _("PayPal")
backend_display_name = _("PayPal")
backend_has_recurring = True
def __init__(self, settings):
self.test = settings.get('test', False)
self.header_image = settings.get('header_image', None)
self.title = settings.get('title', 'VPN Payment')
self.currency = settings.get('currency', 'EUR')
self.account_address = settings.get('address')
self.receiver_address = settings.get('receiver', self.account_address)
self.api_username = settings.get('api_username')
self.api_password = settings.get('api_password')
self.api_sig = settings.get('api_sig')
if self.test:
default_nvp = 'https://api-3t.sandbox.paypal.com/nvp'
default_api = 'https://www.sandbox.paypal.com/'
else:
default_nvp = 'https://api-3t.paypal.com/nvp'
default_api = 'https://www.paypal.com/'
self.api_base = settings.get('api_base', default_api)
self.nvp_api_base = settings.get('nvp_api_base', default_nvp)
if self.account_address and self.api_username and self.api_password and self.api_sig:
self.backend_enabled = True
def new_payment(self, payment):
ROOT_URL = project_settings.ROOT_URL
params = {
'cmd': '_xclick',
'notify_url': ROOT_URL + reverse('payments:cb_paypal', args=(payment.id,)),
'item_name': self.title,
'amount': '%.2f' % (payment.amount / 100),
'currency_code': self.currency,
'business': self.account_address,
'no_shipping': '1',
'return': ROOT_URL + reverse('payments:view', args=(payment.id,)),
'cancel_return': ROOT_URL + reverse('payments:cancel', args=(payment.id,)),
}
if self.header_image:
params['cpp_header_image'] = self.header_image
payment.status_message = _("Waiting for PayPal to confirm the transaction... " +
"It can take up to a few minutes...")
payment.save()
return redirect(urljoin(self.api_base, '/cgi-bin/webscr?' + urlencode(params)))
def new_subscription(self, rps):
months = {
'3m': 3,
'6m': 6,
'12m': 12,
}[rps.period]
ROOT_URL = project_settings.ROOT_URL
params = {
'cmd': '_xclick-subscriptions',
'notify_url': ROOT_URL + reverse('payments:cb_paypal_subscr', args=(rps.id,)),
'item_name': self.title,
'currency_code': self.currency,
'business': self.account_address,
'no_shipping': '1',
'return': ROOT_URL + reverse('payments:return_subscr', args=(rps.id,)),
'cancel_return': ROOT_URL + reverse('account:index'),
'a3': '%.2f' % (rps.period_amount / 100),
'p3': str(months),
't3': 'M',
'src': '1',
}
if self.header_image:
params['cpp_header_image'] = self.header_image
rps.save()
return redirect(urljoin(self.api_base, '/cgi-bin/webscr?' + urlencode(params)))
def handle_verified_callback(self, payment, params):
if self.test and params['test_ipn'] != '1':
raise ValueError('Test IPN')
txn_type = params.get('txn_type')
if txn_type not in (None, 'web_accept', 'express_checkout'):
# Not handled here and can be ignored
return
if params['payment_status'] == 'Refunded':
payment.status = 'refunded'
payment.status_message = None
elif params['payment_status'] == 'Completed':
self.handle_completed_payment(payment, params)
def handle_verified_callback_subscr(self, subscr, params):
if self.test and params['test_ipn'] != '1':
raise ValueError('Test IPN')
txn_type = params.get('txn_type')
if not txn_type.startswith('subscr_'):
# Not handled here and can be ignored
return
if txn_type == 'subscr_payment':
if params['payment_status'] == 'Refunded':
# FIXME: Find the payment and do something
pass
elif params['payment_status'] == 'Completed':
payment = subscr.create_payment()
if not self.handle_completed_payment(payment, params):
return
subscr.last_confirmed_payment = payment.created
subscr.backend_extid = params.get('subscr_id', '')
if subscr.status == 'new' or subscr.status == 'unconfirmed':
subscr.status = 'active'
subscr.save()
elif txn_type == 'subscr_cancel' or txn_type == 'subscr_eot':
subscr.status = 'cancelled'
subscr.save()
def handle_completed_payment(self, payment, params):
from payments.models import Payment
# Prevent making duplicate Payments if IPN is received twice
pc = Payment.objects.filter(backend_extid=params['txn_id']).count()
if pc > 0:
return False
if self.receiver_address != params['receiver_email']:
raise ValueError('Wrong receiver: ' + params['receiver_email'])
if self.currency.lower() != params['mc_currency'].lower():
raise ValueError('Wrong currency: ' + params['mc_currency'])
payment.paid_amount = int(float(params['mc_gross']) * 100)
if payment.paid_amount < payment.amount:
raise ValueError('Not fully paid.')
payment.user.vpnuser.add_paid_time(payment.time)
payment.user.vpnuser.on_payment_confirmed(payment)
payment.user.vpnuser.save()
payment.backend_extid = params['txn_id']
payment.status = 'confirmed'
payment.status_message = None
payment.save()
payment.user.vpnuser.lcore_sync()
return True
def verify_ipn(self, request):
v_url = urljoin(self.api_base, '/cgi-bin/webscr?cmd=_notify-validate')
v_req = urlopen(v_url, data=request.body, timeout=5)
v_res = v_req.read()
return v_res == b'VERIFIED'
def callback(self, payment, request):
if not self.verify_ipn(request):
return False
params = request.POST
try:
self.handle_verified_callback(payment, params)
return True
except (KeyError, ValueError) as e:
payment.status = 'error'
payment.status_message = None
payment.backend_data['ipn_exception'] = repr(e)
payment.backend_data['ipn_last_data'] = repr(request.POST)
payment.save()
raise
def callback_subscr(self, subscr, request):
if not self.verify_ipn(request):
return False
params = request.POST
try:
self.handle_verified_callback_subscr(subscr, params)
return True
except (KeyError, ValueError) as e:
subscr.status = 'error'
subscr.status_message = None
subscr.backend_data['ipn_exception'] = repr(e)
subscr.backend_data['ipn_last_data'] = repr(request.POST)
subscr.save()
raise
def cancel_subscription(self, subscr):
if not subscr.backend_extid:
return False
try:
r = requests.post(self.nvp_api_base, data={
"METHOD": "ManageRecurringPaymentsProfileStatus",
"PROFILEID": subscr.backend_extid,
"ACTION": "cancel",
"USER": self.api_username,
"PWD": self.api_password,
"SIGNATURE": self.api_sig,
"VERSION": "204.0",
})
r.raise_for_status()
print(r.text)
subscr.status = 'cancelled'
subscr.save()
return True
except Exception as e:
print(e)
return False
def get_ext_url(self, payment):
if not payment.backend_extid:
return None
url = 'https://history.paypal.com/webscr?cmd=_history-details-from-hub&id=%s'
return url % payment.backend_extid
def get_subscr_ext_url(self, subscr):
if not subscr.backend_extid:
return None
return ('https://www.paypal.com/fr/cgi-bin/webscr?cmd=_profile-recurring-payments'
'&encrypted_profile_id=%s' % subscr.backend_extid)

@ -1,304 +0,0 @@
from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from django.conf import settings as project_settings
from .base import BackendBase
class StripeBackend(BackendBase):
backend_id = 'stripe'
backend_verbose_name = _("Stripe")
backend_display_name = _("Credit Card")
backend_has_recurring = True
def get_plan_id(self, period):
return 'ccvpn_' + period
def __init__(self, settings):
self.public_key = settings.get('public_key')
self.secret_key = settings.get('secret_key')
self.wh_key = settings.get('wh_key')
if not self.public_key or not self.secret_key or not self.wh_key:
raise Exception("Missing keys for stripe backend")
import stripe
self.stripe = stripe
stripe.api_key = self.secret_key
self.header_image = settings.get('header_image', '')
self.currency = settings.get('currency', 'EUR')
self.title = settings.get('title', 'VPN Payment')
self.backend_enabled = True
def make_redirect(self, session):
return '''
<script src="https://js.stripe.com/v3/"></script>
<script type="text/javascript">
document.write("<p>Redirecting to the payment page...</p>");
var stripe = Stripe("{pk}");
stripe.redirectToCheckout({{
sessionId: "{sess}"
}});
</script>
<noscript><p>Please enable JavaScript to use the payment form.</p></noscript>
'''.format(pk=self.public_key, sess=session['id'])
def new_payment(self, payment):
root_url = project_settings.ROOT_URL
assert root_url
months = payment.time.days // 30
if months > 1:
desc = '{} months for {}'.format(months, payment.user.username)
else:
desc = 'One month for {}'.format(payment.user.username)
session = self.stripe.checkout.Session.create(
success_url=root_url + reverse('payments:view', args=(payment.id,)),
cancel_url=root_url + reverse('payments:cancel', args=(payment.id,)),
payment_method_types=['card'],
line_items=[
{
'amount': payment.amount,
'currency': self.currency.lower(),
'name': self.title,
'description': desc,
'quantity': 1,
}
],
)
payment.backend_extid = session['id']
payment.backend_data = {'session_id': session['id']}
payment.save()
return self.make_redirect(session)
def new_subscription(self, subscr):
root_url = project_settings.ROOT_URL
assert root_url
session = self.stripe.checkout.Session.create(
success_url=root_url + reverse('payments:return_subscr', args=(subscr.id,)),
cancel_url=root_url + reverse('payments:cancel_subscr', args=(subscr.id,)),
client_reference_id='sub_%d'%subscr.id,
payment_method_types=['card'],
subscription_data={
'items': [{
'plan': self.get_plan_id(subscr.period),
'quantity': 1,
}],
},
)
subscr.backend_data = {'session_id': session['id']}
subscr.save()
return self.make_redirect(session)
def cancel_subscription(self, subscr):
if subscr.status not in ('new', 'unconfirmed', 'active'):
return
if subscr.backend_extid.startswith('pi'):
# a session that didn't create a subscription yet (intent)
pass
elif subscr.backend_extid.startswith('sub_'):
# Subscription object
try:
self.stripe.Subscription.delete(subscr.backend_extid)
except self.stripe.error.InvalidRequestError:
pass
elif subscr.backend_extid.startswith('cus_'):
# Legacy Customer object
try:
cust = self.stripe.Customer.retrieve(subscr.backend_extid)
except self.stripe.error.InvalidRequestError:
return
try:
# Delete customer and cancel any active subscription
cust.delete()
except self.stripe.error.InvalidRequestError:
pass
else:
raise Exception("Failed to cancel subscription %r" % subscr.backend_extid)
subscr.status = 'cancelled'
subscr.save()
return True
def refresh_subscription(self, subscr):
if subscr.backend_extid.startswith('cus_'):
customer = self.stripe.Customer.retrieve(subscr.backend_extid)
for s in customer['subscriptions']['data']:
if s['status'] == 'active':
sub = s
break
else:
return
elif subscr.backend_extid.startswith('sub_'):
sub = self.stripe.Subscription.retrieve(subscr.backend_extid)
else:
print("unhandled subscription backend extid: {}".format(subscr.backend_extid))
return
if sub['status'] == 'canceled':
subscr.status = 'cancelled'
if sub['status'] == 'past_due':
subscr.status = 'error'
def webhook_session_completed(self, event):
session = event['data']['object']
if session['subscription']:
# Subscription creation
from payments.models import Payment, Subscription
sub_id = session['subscription']
assert sub_id
parts = session['client_reference_id'].split('_')
if len(parts) != 2 or parts[0] != 'sub':
raise Exception("invalid reference id")
sub_internal_id = int(parts[1])
# Fetch sub by ID and confirm it
subscr = Subscription.objects.get(id=sub_internal_id)
subscr.status = 'active'
subscr.backend_extid = sub_id
subscr.set_data('subscription_id', sub_id)
subscr.save()
else:
from payments.models import Payment
payment = Payment.objects.filter(backend_extid=session['id']).get()
# the amount is provided server-side, we do not have to check
payment.paid_amount = payment.amount
payment.status = 'confirmed'
payment.status_message = None
payment.save()
payment.user.vpnuser.add_paid_time(payment.time)
payment.user.vpnuser.on_payment_confirmed(payment)
payment.user.vpnuser.save()
payment.user.vpnuser.lcore_sync()
def get_subscription_from_invoice(self, invoice):
from payments.models import Subscription
subscription_id = invoice['subscription']
customer_id = invoice['customer']
# once it's confirmed, the id to the subscription is stored as extid
subscr = Subscription.objects.filter(backend_extid=subscription_id).first()
if subscr:
return subscr
# older subscriptions will have a customer id instead
subscr = Subscription.objects.filter(backend_extid=customer_id).first()
if subscr:
return subscr
return None
def webhook_payment_succeeded(self, event):
""" webhook event for a subscription's succeeded payment """
from payments.models import Payment
invoice = event['data']['object']
subscr = self.get_subscription_from_invoice(invoice)
if not subscr:
# the subscription does not exist
# checkout.confirmed event will create it and handle the initial payment
# return True
raise Exception("Unknown subscription for invoice %r" % invoice['id'])
# Prevent making duplicate Payments if event is received twice
pc = Payment.objects.filter(backend_extid=invoice['id']).count()
if pc > 0:
return
payment = subscr.create_payment()
payment.status = 'confirmed'
payment.paid_amount = payment.amount
payment.backend_extid = invoice['id']
if invoice['subscription']:
if isinstance(invoice['subscription'], str):
payment.backend_sub_id = invoice['subscription']
else:
payment.backend_sub_id = invoice['subscription']['id']
payment.set_data('event_id', event['id'])
payment.set_data('sub_id', payment.backend_sub_id)
payment.save()
payment.user.vpnuser.add_paid_time(payment.time)
payment.user.vpnuser.on_payment_confirmed(payment)
payment.user.vpnuser.save()
payment.save()
payment.user.vpnuser.lcore_sync()
def webhook_subscr_update(self, event):
from payments.models import Subscription
stripe_sub = event['data']['object']
sub = Subscription.objects.get(backend_id='stripe', backend_extid=stripe_sub['id'])
if not sub:
return
if stripe_sub['status'] == 'canceled':
sub.status = 'cancelled'
if stripe_sub['status'] == 'past_due':
sub.status = 'error'
sub.save()
def webhook(self, request):
payload = request.body
sig_header = request.META['HTTP_STRIPE_SIGNATURE']
try:
event = self.stripe.Webhook.construct_event(
payload, sig_header, self.wh_key,
)
except (ValueError, self.stripe.error.InvalidRequestError, self.stripe.error.SignatureVerificationError):
return False
if event['type'] == 'invoice.payment_succeeded':
self.webhook_payment_succeeded(event)
if event['type'] == 'checkout.session.completed':
self.webhook_session_completed(event)
if event['type'] == 'customer.subscription.deleted':
self.webhook_subscr_update(event)
return True
def get_ext_url(self, payment):
extid = payment.backend_extid
if not extid:
return None
if extid.startswith('in_'):
return 'https://dashboard.stripe.com/invoices/%s' % extid
if extid.startswith('ch_'):
return 'https://dashboard.stripe.com/payments/%s' % extid
def get_subscr_ext_url(self, subscr):
extid = subscr.backend_extid
if not extid:
return None
if extid.startswith('sub_') and self.stripe:
livemode = False
try:
sub = self.stripe.Subscription.retrieve(extid)
livemode = sub['livemode']
except Exception:
pass
if livemode:
return 'https://dashboard.stripe.com/subscriptions/' + extid
else:
return 'https://dashboard.stripe.com/test/subscriptions/' + extid
if extid.startswith('cus_'):
return 'https://dashboard.stripe.com/customers/%s' % subscr.backend_extid

@ -1,16 +0,0 @@
from django import forms
from .models import BACKEND_CHOICES
class NewPaymentForm(forms.Form):
TIME_CHOICES = (
('1', '1'),
('3', '3'),
('6', '6'),
('12', '12'),
)
subscr = forms.ChoiceField(choices=(('0', 'no'), ('1', 'yes')))
time = forms.ChoiceField(choices=TIME_CHOICES)
method = forms.ChoiceField(choices=BACKEND_CHOICES)

@ -1,15 +0,0 @@
from django.core.management.base import BaseCommand, CommandError
from payments.models import ACTIVE_BACKENDS
class Command(BaseCommand):
help = "Get bitcoind info"
def handle(self, *args, **options):
if 'bitcoin' not in ACTIVE_BACKENDS:
raise CommandError("bitcoin backend not active.")
backend = ACTIVE_BACKENDS['bitcoin']
for key, value in backend.get_info():
self.stdout.write("%s: %s" % (key, value))

@ -1,28 +0,0 @@
from django.core.management.base import BaseCommand, CommandError
from payments.models import Payment, ACTIVE_BACKENDS
class Command(BaseCommand):
help = "Check bitcoin payments status"
def handle(self, *args, **options):
if 'bitcoin' not in ACTIVE_BACKENDS:
raise CommandError("bitcoin backend not active.")
backend = ACTIVE_BACKENDS['bitcoin']
payments = Payment.objects.filter(backend_id='bitcoin', status='new')
self.stdout.write("Found %d active unconfirmed payments." % len(payments))
for p in payments:
self.stdout.write("Checking payment #%d... " % p.id, ending="")
backend.check(p)
if p.status == 'confirmed':
self.stdout.write("OK.")
else:
self.stdout.write("Waiting")

@ -1,52 +0,0 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from django.utils.dateparse import parse_duration
from payments.models import Payment
class Command(BaseCommand):
help = "Manually confirm a Payment"
def add_arguments(self, parser):
parser.add_argument('id', action='store', type=int, help="Payment ID")
parser.add_argument('--paid-amount', dest='amount', action='store', type=int, help="Paid amount")
parser.add_argument('--extid', dest='extid', action='store', type=str)
parser.add_argument('-n', dest='sim', action='store_true', help="Simulate")
def handle(self, *args, **options):
try:
p = Payment.objects.get(id=options['id'])
except Payment.DoesNotExist:
self.stderr.write("Cannot find payment #%d" % options['id'])
return
print("Payment #%d by %s (amount=%d; paid_amount=%d)" % (p.id, p.user.username, p.amount, p.paid_amount))
if options['amount']:
pa = options['amount']
else:
pa = p.amount
extid = options['extid']
print("Status -> confirmed")
print("Paid amount -> %d" % pa)
if extid:
print("Ext ID -> %s" % extid)
print("Confirm? [y/n] ")
i = input()
if i.lower().strip() == 'y':
p.user.vpnuser.add_paid_time(p.time)
p.user.vpnuser.on_payment_confirmed(p)
p.user.vpnuser.save()
p.paid_amount = pa
p.status = 'confirmed'
if extid:
p.backend_extid = extid
p.save()
else:
print("aborted.")

@ -1,31 +0,0 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from django.utils.dateparse import parse_duration
from payments.models import Payment
class Command(BaseCommand):
help = "Cancels expired Payments"
def add_arguments(self, parser):
parser.add_argument('-n', dest='sim', action='store_true', help="Simulate")
parser.add_argument('-e', '--exp-time', action='store',
help="Expiration time.", default='3 00:00:00')
def handle(self, *args, **options):
now = timezone.now()
expdate = now - parse_duration(options['exp_time'])
self.stdout.write("Now: " + now.isoformat())
self.stdout.write("Exp: " + expdate.isoformat())
expired = Payment.objects.filter(created__lte=expdate, status='new',
paid_amount=0)
for p in expired:
self.stdout.write("Payment #%d (%s): %s" % (p.id, p.user.username, p.created))
if not options['sim']:
p.status = 'cancelled'
p.save()

@ -1,70 +0,0 @@
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from ccvpn.common import get_price
from payments.models import ACTIVE_BACKENDS, SUBSCR_PERIOD_CHOICES, period_months
CURRENCY_CODE, CURRENCY_NAME = settings.PAYMENTS_CURRENCY
class Command(BaseCommand):
help = "Update Stripe plans"
def add_arguments(self, parser):
parser.add_argument('--force-run', action='store_true',
help="Run even when Stripe backend is disabled")
parser.add_argument('--force-update', action='store_true',
help="Replace plans, including matching ones")
def handle(self, *args, **options):
if 'stripe' not in ACTIVE_BACKENDS and options['force-run'] is False:
raise CommandError("stripe backend not active.")
backend = ACTIVE_BACKENDS['stripe']
stripe = backend.stripe
for period_id, period_name in SUBSCR_PERIOD_CHOICES:
plan_id = backend.get_plan_id(period_id)
months = period_months(period_id)
amount = months * get_price()
kwargs = dict(
id=plan_id,
amount=amount,
interval='month',
interval_count=months,
name="VPN Subscription (%s)" % period_id,
currency=CURRENCY_CODE,
)
self.stdout.write('Plan %s: %d months for %.2f %s (%s)... ' % (
plan_id, months, amount / 100, CURRENCY_NAME, CURRENCY_CODE), ending='')
self.stdout.flush()
try:
plan = stripe.Plan.retrieve(plan_id)
except stripe.error.InvalidRequestError:
plan = None
def is_valid_plan():
if not plan:
return False
for k, v in kwargs.items():
if getattr(plan, k) != v:
return False
return True
if plan:
if is_valid_plan() and not options['force_update']:
self.stdout.write(self.style.SUCCESS('[ok]'))
continue
plan.delete()
update = True
else:
update = False
stripe.Plan.create(**kwargs)
if update:
self.stdout.write(self.style.WARNING('[updated]'))
else:
self.stdout.write(self.style.WARNING('[created]'))

@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import jsonfield.fields
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Payment',
fields=[
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
('backend_id', models.CharField(choices=[('bitcoin', 'Bitcoin'), ('coinbase', 'Coinbase'), ('manual', 'Manual'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16)),
('status', models.CharField(choices=[('new', 'Waiting for payment'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('rejected', 'Rejected by processor'), ('error', 'Payment processing failed')], max_length=16)),
('created', models.DateTimeField(auto_now_add=True)),
('modified', models.DateTimeField(auto_now=True)),
('confirmed_on', models.DateTimeField(null=True, blank=True)),
('amount', models.IntegerField()),
('paid_amount', models.IntegerField(default=0)),
('time', models.DurationField()),
('status_message', models.TextField(null=True, blank=True)),
('backend_extid', models.CharField(null=True, max_length=64, blank=True)),
('backend_data', jsonfield.fields.JSONField(blank=True, default=dict)),
],
options={
'ordering': ('-created',),
},
),
migrations.CreateModel(
name='RecurringPaymentSource',
fields=[
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
('backend', models.CharField(choices=[('bitcoin', 'Bitcoin'), ('coinbase', 'Coinbase'), ('manual', 'Manual'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16)),
('created', models.DateTimeField(auto_now_add=True)),
('modified', models.DateTimeField(auto_now=True)),
('period', models.CharField(choices=[('monthly', 'Monthly'), ('biannually', 'Bianually'), ('yearly', 'Yearly')], max_length=16)),
('last_confirmed_payment', models.DateTimeField(null=True, blank=True)),
('backend_id', models.CharField(null=True, max_length=64, blank=True)),
('backend_data', jsonfield.fields.JSONField(blank=True, default=dict)),
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
),
migrations.AddField(
model_name='payment',
name='recurring_source',
field=models.ForeignKey(null=True, to='payments.RecurringPaymentSource', blank=True, on_delete=models.CASCADE),
),
migrations.AddField(
model_name='payment',
name='user',
field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL),
),
]

@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='recurringpaymentsource',
name='period',
field=models.CharField(max_length=16, choices=[('6m', 'Every 6 months'), ('1year', 'Yearly')]),
),
]

@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9 on 2015-12-09 04:40
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0002_auto_20151204_0341'),
]
operations = [
migrations.AlterField(
model_name='payment',
name='status',
field=models.CharField(choices=[('new', 'Waiting for payment'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('rejected', 'Rejected by processor'), ('error', 'Payment processing failed')], default='new', max_length=16),
),
]

@ -1,49 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.5 on 2016-09-04 00:48
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import jsonfield.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('payments', '0003_auto_20151209_0440'),
]
operations = [
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('backend_id', models.CharField(choices=[('bitcoin', 'Bitcoin'), ('coinbase', 'Coinbase'), ('manual', 'Manual'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16)),
('created', models.DateTimeField(auto_now_add=True)),
('period', models.CharField(choices=[('3m', 'Every 3 months'), ('6m', 'Every 6 months'), ('12m', 'Every year')], max_length=16)),
('last_confirmed_payment', models.DateTimeField(blank=True, null=True)),
('status', models.CharField(choices=[('new', 'Waiting for payment'), ('active', 'Active'), ('cancelled', 'Cancelled')], default='new', max_length=16)),
('backend_extid', models.CharField(blank=True, max_length=64, null=True)),
('backend_data', jsonfield.fields.JSONField(blank=True, default=dict)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.RemoveField(
model_name='recurringpaymentsource',
name='user',
),
migrations.RemoveField(
model_name='payment',
name='recurring_source',
),
migrations.DeleteModel(
name='RecurringPaymentSource',
),
migrations.AddField(
model_name='payment',
name='subscription',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='payments.Subscription'),
),
]

@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.5 on 2016-09-07 00:18
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0004_auto_20160904_0048'),
]
operations = [
migrations.AlterField(
model_name='subscription',
name='status',
field=models.CharField(choices=[('new', 'Created'), ('unconfirmed', 'Waiting for payment'), ('active', 'Active'), ('cancelled', 'Cancelled'), ('error', 'Error')], default='new', max_length=16),
),
]

@ -1,26 +0,0 @@
# Generated by Django 2.2.1 on 2019-09-07 20:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('payments', '0005_auto_20160907_0018'),
]
operations = [
migrations.CreateModel(
name='Feedback',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True)),
('message', models.TextField()),
('subscription', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='payments.Subscription')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

@ -1,26 +0,0 @@
# Generated by Django 3.1.2 on 2020-11-14 17:30
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('payments', '0006_auto_20190907_2029'),
]
operations = [
migrations.AlterField(
model_name='payment',
name='backend_extid',
field=models.CharField(blank=True, max_length=256, null=True),
),
migrations.AlterField(
model_name='subscription',
name='backend_extid',
field=models.CharField(blank=True, max_length=256, null=True),
),
]

@ -1,99 +0,0 @@
# Generated by Django 3.2.4 on 2021-07-21 19:31
from datetime import timedelta
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import jsonfield.fields
def field_to_plan_id(model_name, field_name):
def fun(apps, schema_editor):
keys = settings.PLANS.keys()
model = apps.get_model('payments', model_name)
for s in model.objects.all():
d = getattr(s, field_name)
if s.plan_id is not None:
continue
s.plan_id = str(round(d / timedelta(days=30))) + 'm'
if s.plan_id not in keys:
raise Exception(f"unknown plan: {s.plan_id}")
s.save()
return fun
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('payments', '0007_auto_20201114_1730'),
]
operations = [
migrations.RenameField("Subscription", 'period', 'plan_id'),
# Add plan_id to payments and convert from the time field
migrations.AddField(
model_name='payment',
name='plan_id',
field=models.CharField(choices=[('1m', 'Every 1 month'), ('3m', 'Every 3 months'), ('6m', 'Every 6 months'), ('12m', 'Every 12 months')], max_length=16, null=True),
),
migrations.RunPython(field_to_plan_id('Payment', 'time'), lambda x, y: ()),
# Make those two columns non-null once converted
migrations.AlterField(
model_name='payment',
name='plan_id',
field=models.CharField(choices=[('1m', 'Every 1 month'), ('3m', 'Every 3 months'), ('6m', 'Every 6 months'), ('12m', 'Every 12 months')], max_length=16),
),
migrations.AlterField(
model_name='subscription',
name='plan_id',
field=models.CharField(choices=[('1m', 'Every 1 month'), ('3m', 'Every 3 months'), ('6m', 'Every 6 months'), ('12m', 'Every 12 months')], max_length=16),
),
migrations.AddField(
model_name='payment',
name='ip_address',
field=models.GenericIPAddressField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='refund_date',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='refund_text',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='subscription',
name='next_payment_date',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='payment',
name='backend_data',
field=jsonfield.fields.JSONField(blank=True),
),
migrations.AlterField(
model_name='payment',
name='backend_id',
field=models.CharField(choices=[('bitcoin', 'Bitcoin'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16),
),
migrations.AlterField(
model_name='payment',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='subscription',
name='backend_data',
field=jsonfield.fields.JSONField(blank=True),
),
migrations.AlterField(
model_name='subscription',
name='backend_id',
field=models.CharField(choices=[('bitcoin', 'Bitcoin'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16),
),
]

@ -1,272 +0,0 @@
import logging
from django.db import models
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from jsonfield import JSONField
from datetime import timedelta
import json
from ccvpn.common import get_price
from .backends import BackendBase
logger = logging.getLogger(__name__)
backends_settings = settings.PAYMENTS_BACKENDS
assert isinstance(backends_settings, dict)
CURRENCY_CODE, CURRENCY_NAME = settings.PAYMENTS_CURRENCY
STATUS_CHOICES = (
('new', _("Waiting for payment")),
('confirmed', _("Confirmed")),
('cancelled', _("Cancelled")),
('rejected', _("Rejected by processor")),
('error', _("Payment processing failed")),
)
# A Subscription is created with status='new'. When getting back from PayPal,
# it may get upgraded to 'unconfirmed'. It will be set 'active' with the first
# confirmed payment.
# 'unconfirmed' exists to prevent creation of a second Subscription while
# waiting for the first one to be confirmed.
SUBSCR_STATUS_CHOICES = (
('new', _("Created")),
('unconfirmed', _("Waiting for payment")),
('active', _("Active")),
('cancelled', _("Cancelled")),
('error', _("Error")),
)
SUBSCR_PERIOD_CHOICES = (
('3m', _("Every 3 months")),
('6m', _("Every 6 months")),
('12m', _("Every year")),
)
BACKEND_CLASSES = BackendBase.__subclasses__()
# All known backends (classes)
BACKENDS = {}
BACKEND_CHOICES = []
# All enabled backends (configured instances)
ACTIVE_BACKENDS = {}
ACTIVE_BACKEND_CHOICES = []
logger.info("loading payment backends...")
for cls in BACKEND_CLASSES:
name = cls.backend_id
assert isinstance(name, str)
if name not in backends_settings:
logger.info("payments: ☐ %s disabled (no settings)", name)
continue
backend_settings = backends_settings.get(name, {})
for k, v in backend_settings.items():
if hasattr(v, '__call__'):
backend_settings[k] = v()
if not backend_settings.get('enabled'):
logger.info("payments: ☐ %s disabled (by settings)", name)
continue
obj = cls(backend_settings)
BACKENDS[name] = obj
BACKEND_CHOICES.append((name, cls.backend_verbose_name))
if obj.backend_enabled:
ACTIVE_BACKENDS[name] = obj
ACTIVE_BACKEND_CHOICES.append((name, cls.backend_verbose_name))
logger.info("payments: ☑ %s initialized", name)
else:
logger.info("payments: ☒ %s disabled (initialization failed)", name)
BACKEND_CHOICES = sorted(BACKEND_CHOICES, key=lambda x: x[0])
ACTIVE_BACKEND_CHOICES = sorted(ACTIVE_BACKEND_CHOICES, key=lambda x: x[0])
logger.info("payments: finished. %d/%d backends active", len(ACTIVE_BACKENDS), len(BACKEND_CLASSES))
class PlanBase(object):
def __init__(self, name, months, monthly, saves="", default=False):
self.name = name
self.months = months
self.monthly = monthly
self.saves = saves
self.default = default
@property
def due_amount(self):
return round(self.months * self.monthly, 2)
@property
def time_display(self):
return "%d month%s" % (self.months, 's' if self.months > 1 else '')
def json(self):
return {'total': self.due_amount}
PLANS = {k: PlanBase(name=k, **settings.PLANS[k]) for k, v in settings.PLANS.items()}
PLAN_CHOICES = [(k, "Every " + p.time_display) for k, p in PLANS.items()]
SUBSCR_PLAN_CHOICES = PLAN_CHOICES
def period_months(p):
return {
'3m': 3,
'6m': 6,
'12m': 12,
}[p]
class BackendData:
backend_data = None
def set_data(self, key, value):
""" adds a backend data key to this instance's dict """
if not self.backend_data:
self.backend_data = {}
if isinstance(self.backend_data, str):
self.backend_data = json.loads(self.backend_data) or {}
if not isinstance(self.backend_data, dict):
raise Exception("self.backend_data is not a dict (%r)" % self.backend_data)
self.backend_data[key] = value
class Payment(models.Model, BackendData):
""" Just a payment.
If subscription is not null, it has been automatically issued.
backend_extid is the external transaction ID, backend_data is other
things that should only be used by the associated backend.
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
backend_id = models.CharField(max_length=16, choices=BACKEND_CHOICES)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='new')
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
confirmed_on = models.DateTimeField(null=True, blank=True)
amount = models.IntegerField()
paid_amount = models.IntegerField(default=0)
time = models.DurationField()
plan_id = models.CharField(max_length=16, choices=SUBSCR_PLAN_CHOICES)
subscription = models.ForeignKey('Subscription', null=True, blank=True, on_delete=models.CASCADE)
status_message = models.TextField(blank=True, null=True)
ip_address = models.GenericIPAddressField(blank=True, null=True)
backend_extid = models.CharField(max_length=256, null=True, blank=True)
backend_data = JSONField(blank=True)
refund_date = models.DateTimeField(blank=True, null=True)
refund_text = models.CharField(max_length=200, blank=True)
@property
def currency_code(self):
return CURRENCY_CODE
@property
def currency_name(self):
return CURRENCY_NAME
@property
def backend(self):
""" Returns a global instance of the backend
:rtype: BackendBase
"""
return BACKENDS[self.backend_id]
def get_amount_display(self):
return '%.2f %s' % (self.amount / 100, CURRENCY_NAME)
@property
def is_confirmed(self):
return self.status == 'confirmed'
def confirm(self):
self.user.vpnuser.add_paid_time(self.time)
self.user.vpnuser.on_payment_confirmed(self)
self.user.vpnuser.save()
self.update_status('confirmed')
def refund(self):
self.user.vpnuser.remove_paid_time(self.time)
self.user.vpnuser.save()
self.update_status('refunded')
def update_status(self, status, message=None):
assert any(c[0] == status for c in STATUS_CHOICES)
self.status = status
self.status_message = message
class Meta:
ordering = ('-created', )
@classmethod
def create_payment(self, backend_id, user, months):
payment = Payment(
user=user,
backend_id=backend_id,
status='new',
time=timedelta(days=30 * months),
amount=get_price() * months
)
return payment
class Subscription(models.Model, BackendData):
""" Recurring payment subscription. """
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
backend_id = models.CharField(max_length=16, choices=BACKEND_CHOICES)
created = models.DateTimeField(auto_now_add=True)
plan_id = models.CharField(max_length=16, choices=SUBSCR_PLAN_CHOICES)
next_payment_date = models.DateTimeField(blank=True, null=True)
last_confirmed_payment = models.DateTimeField(blank=True, null=True)
status = models.CharField(max_length=16, choices=SUBSCR_STATUS_CHOICES, default='new')
backend_extid = models.CharField(max_length=256, null=True, blank=True)
backend_data = JSONField(blank=True)
@property
def backend(self):
""" Returns a global instance of the backend
:rtype: BackendBase
"""
return BACKENDS[self.backend_id]
@property
def months(self):
return period_months(self.period)
@property
def period_amount(self):
return self.months * get_price()
@property
def next_renew(self):
""" Approximate date of the next payment """
if self.last_confirmed_payment:
return self.last_confirmed_payment + timedelta(days=self.months * 30)
return self.created + timedelta(days=self.months * 30)
@property
def monthly_amount(self):
return get_price()
def create_payment(self):
payment = Payment(
user=self.user,
backend_id=self.backend_id,
status='new',
time=timedelta(days=30 * self.months),
amount=get_price() * self.months,
subscription=self,
)
return payment
class Feedback(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
subscription = models.ForeignKey('Subscription', null=True, blank=True, on_delete=models.SET_NULL)
created = models.DateTimeField(auto_now_add=True)
message = models.TextField()

@ -1,33 +0,0 @@
import logging
from datetime import timedelta
from django.utils import timezone
from celery import task
from payments.models import Payment
from .models import Payment, Subscription, ACTIVE_BACKENDS
logger = logging.getLogger(__name__)
@task
def check_subscriptions():
logger.debug("checking subscriptions")
subs = Subscription.objects.filter(status='active', backend_id='stripe').all()
for sub in subs:
logger.debug("checking subscription #%s on %s", sub.id, sub.backend_id)
sub.refresh_from_db()
ACTIVE_BACKENDS['stripe'].refresh_subscription(sub)
sub.save()
@task
def cancel_old_payments():
expdate = timezone.now() - timedelta(days=3)
expired = Payment.objects.filter(created__lte=expdate, status='new',
paid_amount=0)
logger.info("cancelling %d pending payments older than 3 days (%s)", len(expired), expdate.isoformat())
for p in expired:
logger.debug("cancelling payment #%d (%s): created on %s", p.id, p.user.username, p.created)
p.status = 'cancelled'
p.save()

@ -1,10 +0,0 @@
# flake8: noqa
from .bitcoin import *
from .paypal import *
from .coingate import *
from django.conf import settings
if settings.RUN_ONLINE_TESTS:
from .online.stripe import *

@ -1,118 +0,0 @@
from datetime import timedelta
from django.test import TestCase
from django.http import HttpResponseRedirect
from django.contrib.auth.models import User
from constance.test import override_config
from payments.models import Payment
from payments.backends import BitcoinBackend
from decimal import Decimal
class FakeBTCRPCNew:
def getnewaddress(self, account):
return 'TEST_ADDRESS'
class FakeBTCRPCUnpaid:
def getreceivedbyaddress(self, address):
assert address == 'TEST_ADDRESS'
return Decimal('0')
class FakeBTCRPCPartial:
def getreceivedbyaddress(self, address):
assert address == 'TEST_ADDRESS'
return Decimal('0.5') * 100000000
class FakeBTCRPCPaid:
def getreceivedbyaddress(self, address):
assert address == 'TEST_ADDRESS'
return Decimal('1') * 100000000
class BitcoinBackendTest(TestCase):
def setUp(self):
self.user = User.objects.create_user('test', 'test_user@example.com', None)
self.p = Payment.objects.create(
user=self.user, time=timedelta(days=30), backend_id='bitcoin',
amount=300)
@override_config(BTC_EUR_VALUE=300)
def test_new(self):
backend = BitcoinBackend(dict(URL=''))
backend.make_rpc = FakeBTCRPCNew
backend.new_payment(self.p)
redirect = backend.new_payment(self.p)
self.assertEqual(self.p.backend_extid, 'TEST_ADDRESS')
self.assertEqual(self.p.status, 'new')
self.assertIn('btc_price', self.p.backend_data)
self.assertIn('btc_address', self.p.backend_data)
self.assertEqual(self.p.backend_data['btc_address'], 'TEST_ADDRESS')
self.assertIsInstance(redirect, HttpResponseRedirect)
self.assertEqual(redirect.url, '/payments/view/%d' % self.p.id)
self.assertEqual(self.p.status_message, "Please send 1.00000 BTC to TEST_ADDRESS")
def test_rounding(self):
""" Rounding test
300 / 300 = 1 => 1.00000 BTC
300 / 260 = Decimal('1.153846153846153846153846154') => 1.15385 BTC
"""
with override_config(BTC_EUR_VALUE=300):
backend = BitcoinBackend(dict(URL=''))
backend.make_rpc = FakeBTCRPCNew
backend.new_payment(self.p)
self.assertEqual(self.p.status_message, "Please send 1.00000 BTC to TEST_ADDRESS")
with override_config(BTC_EUR_VALUE=260):
backend = BitcoinBackend(dict(URL=''))
backend.make_rpc = FakeBTCRPCNew
backend.new_payment(self.p)
self.assertEqual(self.p.status_message, "Please send 1.15385 BTC to TEST_ADDRESS")
class BitcoinBackendConfirmTest(TestCase):
@override_config(BTC_EUR_VALUE=300)
def setUp(self):
self.user = User.objects.create_user('test', 'test_user@example.com', None)
self.p = Payment.objects.create(
user=self.user, time=timedelta(days=30), backend_id='bitcoin',
amount=300)
# call new_payment
backend = BitcoinBackend(dict(URL=''))
backend.make_rpc = FakeBTCRPCNew
backend.new_payment(self.p)
@override_config(BTC_EUR_VALUE=300)
def test_check_unpaid(self):
backend = BitcoinBackend(dict(URL=''))
backend.make_rpc = FakeBTCRPCUnpaid
backend.check(self.p)
self.assertEqual(self.p.status, 'new')
self.assertEqual(self.p.paid_amount, 0)
@override_config(BTC_EUR_VALUE=300)
def test_check_partially_paid(self):
backend = BitcoinBackend(dict(URL=''))
backend.make_rpc = FakeBTCRPCPartial
backend.check(self.p)
self.assertEqual(self.p.status, 'new')
self.assertEqual(self.p.paid_amount, 150)
@override_config(BTC_EUR_VALUE=300)
def test_check_paid(self):
backend = BitcoinBackend(dict(URL=''))
backend.make_rpc = FakeBTCRPCPaid
backend.check(self.p)
self.assertEqual(self.p.paid_amount, 300)
self.assertEqual(self.p.status, 'confirmed')

@ -1,75 +0,0 @@
from datetime import timedelta
from django.test import TestCase, RequestFactory
from django.http import HttpResponseRedirect
from django.contrib.auth.models import User
from payments.models import Payment
from payments.backends import CoinGateBackend
class CoinGateBackendTest(TestCase):
def setUp(self):
self.user = User.objects.create_user('test', 'test_user@example.com', None)
self.backend_settings = dict(
api_token='test',
title='Test Title',
currency='EUR',
)
def test_payment(self):
payment = Payment.objects.create(
user=self.user,
time=timedelta(days=30),
backend_id='coingate',
amount=300
)
def fake_post(_backend, *, data={}):
self.assertEqual(data['order_id'], '1')
self.assertEqual(data['price_amount'], 3.0)
self.assertEqual(data['price_currency'], 'EUR')
self.assertEqual(data['receive_currency'], 'EUR')
return {'id': 42, 'payment_url': 'http://testtoken/'}
with self.settings(ROOT_URL='root'):
backend = CoinGateBackend(self.backend_settings)
backend._post = fake_post
redirect = backend.new_payment(payment)
self.assertIsInstance(redirect, HttpResponseRedirect)
self.assertEqual(redirect.url, 'http://testtoken/')
self.assertEqual(payment.backend_data.get('coingate_id'), 42)
# Test a standard successful payment callback flow
def post_callback(status):
callback_data = {
'token': payment.backend_data['coingate_token'],
'order_id': str(payment.id),
'status': status,
}
ipn_url = '/payments/callback/coingate/%d' % payment.id
ipn_request = RequestFactory().post(
ipn_url,
data=callback_data)
return backend.callback(payment, ipn_request)
r = post_callback('pending')
self.assertTrue(r)
self.assertEqual(payment.status, 'new')
r = post_callback('confirming')
self.assertTrue(r)
self.assertEqual(payment.status, 'new')
r = post_callback('paid')
self.assertTrue(r)
self.assertEqual(payment.status, 'confirmed')
self.assertEqual(payment.paid_amount, 300)
time_left_s = self.user.vpnuser.time_left.total_seconds()
self.assertAlmostEqual(time_left_s, payment.time.total_seconds(), delta=60)

@ -1,325 +0,0 @@
from datetime import timedelta
from urllib.parse import parse_qs
from django.test import TestCase, RequestFactory
from django.http import HttpResponseRedirect
from django.contrib.auth.models import User
from payments.models import Payment, Subscription
from payments.backends import PaypalBackend
PAYPAL_IPN_TEST = '''\
mc_gross=3.00&\
protection_eligibility=Eligible&\
address_status=confirmed&\
payer_id=LPLWNMTBWMFAY&\
tax=0.00&\
address_street=1+Main+St&\
payment_date=20%3A12%3A59+Jan+13%2C+2009+PST&\
payment_status=Completed&\
charset=windows-1252&\
address_zip=95131&\
first_name=Test&\
mc_fee=0.88&\
address_country_code=US&\
address_name=Test+User&\
notify_version=2.6&\
custom=&\
payer_status=verified&\
address_country=United+States&\
address_city=San+Jose&\
quantity=1&\
payer_email=test_user@example.com&\
txn_id=61E67681CH3238416&\
payment_type=instant&\
last_name=User&\
address_state=CA&\
receiver_email=test_business@example.com&\
payment_fee=0.88&\
receiver_id=S8XGHLYDW9T3S&\
txn_type=express_checkout&\
item_name=&\
mc_currency=EUR&\
item_number=&\
residence_country=US&\
test_ipn=1&\
handling_amount=0.00&\
transaction_subject=&\
payment_gross=3.00&\
shipping=0.00'''
PAYPAL_IPN_SUBSCR_PAYMENT = '''\
transaction_subject=VPN+Payment&\
payment_date=11%3A19%3A00+Sep+04%2C+2016+PDT&\
txn_type=subscr_payment&\
subscr_id=I-1S262863X133&\
last_name=buyer&\
residence_country=FR&\
item_name=VPN+Payment&\
payment_gross=&\
mc_currency=EUR&\
business=test_business@example.com&\
payment_type=instant&\
protection_eligibility=Ineligible&\
payer_status=verified&\
test_ipn=1&\
payer_email=test_user@example.com&\
txn_id=097872679P963871Y&\
receiver_email=test_business@example.com&\
first_name=test&\
payer_id=APYYVSFLNPWUU&\
receiver_id=MGT8TQ8GC4944&\
payment_status=Completed&\
payment_fee=&\
mc_fee=0.56&\
mc_gross=9.00&\
charset=windows-1252&\
notify_version=3.8&\
ipn_track_id=546a4aa4300a0'''
PAYPAL_IPN_SUBSCR_CANCEL = '''\
txn_type=subscr_cancel&\
subscr_id=I-E5SCT6936H40&\
last_name=buyer&\
residence_country=FR&\
mc_currency=EUR&\
item_name=VPN+Payment&\
business=test_business@example.com&\
recurring=1&\
payer_status=verified&\
test_ipn=1&\
payer_email=test_user@example.com&\
first_name=test&\
receiver_email=test_business@example.com&\
payer_id=APYYVSFLNPWUU&\
reattempt=1&\
subscr_date=17%3A35%3A14+Sep+04%2C+2016+PDT&\
charset=windows-1252&\
notify_version=3.8&\
period3=3+M&\
mc_amount3=9.00&\
ipn_track_id=474870d13b375'''
PAYPAL_IPN_SUBSCR_SIGNUP = '''\
txn_type=subscr_signup&\
subscr_id=I-1S262863X133&\
last_name=buyer&\
residence_country=FR&\
mc_currency=EUR&\
item_name=VPN+Payment&\
business=test_business@example.com&\
recurring=1&\
payer_status=verified&\
test_ipn=1&\
payer_email=test_user@example.com&\
first_name=test&\
receiver_email=test_business@example.com&\
payer_id=APYYVSFLNPWUU&\
reattempt=1&\
subscr_date=11%3A18%3A57+Sep+04%2C+2016+PDT&\
charset=windows-1252&\
notify_version=3.8&\
period3=3+M&\
mc_amount3=9.00&\
ipn_track_id=546a4aa4300a0'''
class PaypalBackendTest(TestCase):
def setUp(self):
self.user = User.objects.create_user('test', 'test_user@example.com', None)
def test_paypal(self):
# TODO: This checks the most simple and perfect payment that could
# happen, but not errors or other/invalid IPN
payment = Payment.objects.create(
user=self.user,
time=timedelta(days=30),
backend_id='paypal',
amount=300
)
settings = dict(
test=True,
title='Test Title',
currency='EUR',
address='test_business@example.com',
)
with self.settings(ROOT_URL='root'):
backend = PaypalBackend(settings)
redirect = backend.new_payment(payment)
self.assertIsInstance(redirect, HttpResponseRedirect)
host, params = redirect.url.split('?', 1)
params = parse_qs(params)
expected_notify_url = 'root/payments/callback/paypal/%d' % payment.id
expected_return_url = 'root/payments/view/%d' % payment.id
expected_cancel_url = 'root/payments/cancel/%d' % payment.id
self.assertEqual(params['cmd'][0], '_xclick')
self.assertEqual(params['notify_url'][0], expected_notify_url)
self.assertEqual(params['return'][0], expected_return_url)
self.assertEqual(params['cancel_return'][0], expected_cancel_url)
self.assertEqual(params['business'][0], 'test_business@example.com')
self.assertEqual(params['currency_code'][0], 'EUR')
self.assertEqual(params['amount'][0], '3.00')
self.assertEqual(params['item_name'][0], 'Test Title')
# Replace PaypalBackend.verify_ipn to not call the PayPal API
# we will assume the IPN is authentic
backend.verify_ipn = lambda request: True
ipn_url = '/payments/callback/paypal/%d' % payment.id
ipn_request = RequestFactory().post(
ipn_url,
content_type='application/x-www-form-urlencoded',
data=PAYPAL_IPN_TEST)
r = backend.callback(payment, ipn_request)
self.assertTrue(r)
self.assertEqual(payment.status, 'confirmed')
self.assertEqual(payment.paid_amount, 300)
self.assertEqual(payment.backend_extid, '61E67681CH3238416')
def test_paypal_ipn_error(self):
payment = Payment.objects.create(
user=self.user,
time=timedelta(days=30),
backend_id='paypal',
amount=300
)
settings = dict(
test=True,
title='Test Title',
currency='EUR',
address='test_business@example.com',
)
with self.settings(ROOT_URL='root'):
backend = PaypalBackend(settings)
redirect = backend.new_payment(payment)
self.assertIsInstance(redirect, HttpResponseRedirect)
host, params = redirect.url.split('?', 1)
params = parse_qs(params)
# Replace PaypalBackend.verify_ipn to not call the PayPal API
# we will assume the IPN is authentic
backend.verify_ipn = lambda request: True
ipn_url = '/payments/callback/paypal/%d' % payment.id
ipn_request = RequestFactory().post(
ipn_url,
content_type='application/x-www-form-urlencoded',
data=PAYPAL_IPN_TEST)
r = backend.callback(payment, ipn_request)
self.assertTrue(r)
self.assertEqual(payment.status, 'confirmed')
self.assertEqual(payment.paid_amount, 300)
self.assertEqual(payment.backend_extid, '61E67681CH3238416')
def test_paypal_subscr(self):
subscription = Subscription.objects.create(
user=self.user,
backend_id='paypal',
period='3m'
)
settings = dict(
test=True,
title='Test Title',
currency='EUR',
address='test_business@example.com',
)
with self.settings(ROOT_URL='root'):
backend = PaypalBackend(settings)
redirect = backend.new_subscription(subscription)
self.assertIsInstance(redirect, HttpResponseRedirect)
host, params = redirect.url.split('?', 1)
params = parse_qs(params)
expected_notify_url = 'root/payments/callback/paypal_subscr/%d' % subscription.id
expected_return_url = 'root/payments/return_subscr/%d' % subscription.id
expected_cancel_url = 'root/account/'
self.assertEqual(params['cmd'][0], '_xclick-subscriptions')
self.assertEqual(params['notify_url'][0], expected_notify_url)
self.assertEqual(params['return'][0], expected_return_url)
self.assertEqual(params['cancel_return'][0], expected_cancel_url)
self.assertEqual(params['business'][0], 'test_business@example.com')
self.assertEqual(params['currency_code'][0], 'EUR')
self.assertEqual(params['a3'][0], '9.00')
self.assertEqual(params['p3'][0], '3')
self.assertEqual(params['t3'][0], 'M')
self.assertEqual(params['item_name'][0], 'Test Title')
# Replace PaypalBackend.verify_ipn to not call the PayPal API
# we will assume the IPN is authentic
backend.verify_ipn = lambda request: True
self.assertEqual(subscription.status, 'new')
# 1. the subscr_payment IPN
ipn_url = '/payments/callback/paypal_subscr/%d' % subscription.id
ipn_request = RequestFactory().post(
ipn_url,
content_type='application/x-www-form-urlencoded',
data=PAYPAL_IPN_SUBSCR_PAYMENT)
r = backend.callback_subscr(subscription, ipn_request)
self.assertTrue(r)
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.backend_extid, 'I-1S262863X133')
payments = Payment.objects.filter(subscription=subscription).all()
self.assertEqual(len(payments), 1)
self.assertEqual(payments[0].amount, 900)
self.assertEqual(payments[0].paid_amount, 900)
self.assertEqual(payments[0].backend_extid, '097872679P963871Y')
# 2. the subscr_signup IPN
# We don't expect anything to happen here
ipn_url = '/payments/callback/paypal_subscr/%d' % subscription.id
ipn_request = RequestFactory().post(
ipn_url,
content_type='application/x-www-form-urlencoded',
data=PAYPAL_IPN_SUBSCR_SIGNUP)
r = backend.callback_subscr(subscription, ipn_request)
self.assertTrue(r)
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.backend_extid, 'I-1S262863X133')
payments = Payment.objects.filter(subscription=subscription).all()
self.assertEqual(len(payments), 1)
self.assertEqual(payments[0].amount, 900)
self.assertEqual(payments[0].paid_amount, 900)
self.assertEqual(payments[0].backend_extid, '097872679P963871Y')
# 3. the subscr_cancel IPN
ipn_url = '/payments/callback/paypal_subscr/%d' % subscription.id
ipn_request = RequestFactory().post(
ipn_url,
content_type='application/x-www-form-urlencoded',
data=PAYPAL_IPN_SUBSCR_CANCEL)
r = backend.callback_subscr(subscription, ipn_request)
self.assertTrue(r)
self.assertEqual(subscription.status, 'cancelled')
self.assertEqual(subscription.backend_extid, 'I-1S262863X133')
payments = Payment.objects.filter(subscription=subscription).all()
self.assertEqual(len(payments), 1)

@ -1,23 +0,0 @@
from django.conf.urls import url
from . import views
app_name = 'payments'
urlpatterns = [
url(r'^new$', views.new),
url(r'^view/(?P<id>[0-9]+)$', views.view, name='view'),
url(r'^cancel/(?P<id>[0-9]+)$', views.cancel, name='cancel'),
url(r'^cancel_subscr/(?P<id>[0-9]+)?$', views.cancel_subscr, name='cancel_subscr'),
url(r'^return_subscr/(?P<id>[0-9]+)$', views.return_subscr, name='return_subscr'),
url(r'^callback/paypal/(?P<id>[0-9]+)$', views.payment_callback('paypal'), name='cb_paypal'),
url(r'^callback/coingate/(?P<id>[0-9]+)$', views.payment_callback('coingate'), name='cb_coingate'),
url(r'^callback/stripe/(?P<id>[0-9]+)$', views.payment_callback('stripe'), name='cb_stripe'),
url(r'^callback/coinbase/$', views.plain_callback('coinbase'), name='cb_coinbase'),
url(r'^callback/coinpayments/(?P<id>[0-9]+)$', views.payment_callback('coinpayments'), name='cb_coinpayments'),
url(r'^callback/paypal_subscr/(?P<id>[0-9]+)$', views.sub_callback('paypal'), name='cb_paypal_subscr'),
url(r'^callback/stripe_hook$', views.stripe_hook, name='stripe_hook'),
url(r'^$', views.list_payments),
]

@ -1,189 +0,0 @@
from datetime import timedelta
from django.shortcuts import render, redirect
from django.urls import reverse
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound, Http404
from django.views.decorators.csrf import csrf_exempt
from django.utils import timezone
from django.contrib import messages
from django.utils.translation import ugettext_lazy as _
from .forms import NewPaymentForm
from .models import Payment, Subscription, BACKENDS, ACTIVE_BACKENDS, Feedback
def require_backend(name):
backend = BACKENDS.get(name)
if not backend:
raise Http404()
if not backend.backend_enabled:
raise Http404()
return backend
@login_required
def new(request):
if request.method != 'POST':
return redirect('account:index')
if Payment.objects.filter(user=request.user, status='new').count() > 10:
messages.error(request, "Too many open payments.")
return redirect('account:index')
form = NewPaymentForm(request.POST)
if not form.is_valid():
return redirect('account:index')
if request.user.vpnuser.get_subscription() is not None:
return redirect('account:index')
subscr = form.cleaned_data['subscr'] == '1'
backend_id = form.cleaned_data['method']
months = int(form.cleaned_data['time'])
if backend_id not in ACTIVE_BACKENDS:
return HttpResponseNotFound()
if subscr:
if months not in (3, 6, 12):
return redirect('account:index')
rps = Subscription(
user=request.user,
backend_id=backend_id,
period=str(months) + 'm',
)
rps.save()
r = rps.backend.new_subscription(rps)
else:
payment = Payment.create_payment(backend_id, request.user, months)
payment.save()
r = payment.backend.new_payment(payment)
if not r:
payment.status = 'error'
payment.save()
raise Exception("Failed to initialize payment #%d" % payment.id)
if isinstance(r, str):
return render(request, 'payments/form.html', dict(html=r))
elif r is None:
return redirect('payments:view', payment.id)
return r
def plain_callback(backend_name, method='callback'):
@csrf_exempt
def callback(request):
backend = require_backend(backend_name)
m = getattr(backend, method)
if m and m(Payment, request):
return HttpResponse()
else:
return HttpResponseBadRequest()
return callback
def payment_callback(backend_name):
@csrf_exempt
def callback(request, id):
backend = require_backend(backend_name)
p = Payment.objects.get(id=id)
if backend.callback(p, request):
return HttpResponse()
else:
return HttpResponseBadRequest()
return callback
def sub_callback(backend_name):
@csrf_exempt
def callback(request, id):
backend = require_backend(backend_name)
p = Subscription.objects.get(id=id)
if backend.callback_subscr(p, request):
return HttpResponse()
else:
return HttpResponseBadRequest()
return callback
@csrf_exempt
def stripe_hook(request):
backend = require_backend('stripe')
if backend.webhook(request):
return HttpResponse()
else:
return HttpResponseBadRequest()
@login_required
@csrf_exempt
def view(request, id):
p = Payment.objects.get(id=id, user=request.user)
return render(request, 'payments/view.html', dict(payment=p))
@login_required
def cancel(request, id):
p = Payment.objects.get(id=id, user=request.user)
if p.status == 'new':
p.status = 'cancelled'
p.save()
return render(request, 'payments/view.html', dict(payment=p))
@login_required
def cancel_subscr(request, id=None):
if request.method == 'POST' and id:
p = Subscription.objects.get(id=id, user=request.user)
# Saving any feedback note
feedback = request.POST.get('feedback')
if feedback:
feedback = feedback[:10000]
f = Feedback(user=request.user, subscription=p, message=feedback)
f.save()
try:
if p.backend.cancel_subscription(p):
messages.add_message(request, messages.INFO, _("Subscription cancelled!"))
else:
messages.add_message(request, messages.ERROR, _("Could not cancel the subscription. It may have already been cancelled or caused an error."))
except NotImplementedError:
pass
return redirect('account:index')
subscription = request.user.vpnuser.get_subscription(include_unconfirmed=True)
return render(request, 'payments/cancel_subscr.html', {
'subscription': subscription,
})
@login_required
def return_subscr(request, id):
p = Subscription.objects.get(id=id, user=request.user)
if p.status == 'new':
p.status = 'unconfirmed'
p.save()
return redirect('account:index')
@login_required
def list_payments(request):
# Only show recent cancelled payments
cancelled_limit = timezone.now() - timedelta(days=3)
objects = request.user.payment_set.exclude(status='cancelled',
created__lte=cancelled_limit)
return render(request, 'payments/list.html', dict(payments=objects))

511
poetry.lock generated

@ -1,13 +1,13 @@
[[package]]
name = "amqp"
version = "2.6.1"
version = "5.0.6"
description = "Low-level AMQP client for Python (fork of amqplib)."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
python-versions = ">=3.6"
[package.dependencies]
vine = ">=1.1.3,<5.0.0a1"
vine = "5.0.0"
[[package]]
name = "appdirs"
@ -19,7 +19,7 @@ python-versions = "*"
[[package]]
name = "asgiref"
version = "3.3.4"
version = "3.4.1"
description = "ASGI specs, helper code, and adapters"
category = "main"
optional = false
@ -33,7 +33,7 @@ tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
[[package]]
name = "astroid"
version = "2.5.6"
version = "2.6.5"
description = "An abstract syntax tree for Python with inference support."
category = "dev"
optional = false
@ -42,6 +42,7 @@ python-versions = "~=3.6"
[package.dependencies]
lazy-object-proxy = ">=1.4.0"
typed-ast = {version = ">=1.4.0,<1.5", markers = "implementation_name == \"cpython\" and python_version < \"3.8\""}
typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
wrapt = ">=1.11,<1.13"
[[package]]
@ -75,44 +76,55 @@ typing-extensions = ">=3.7.4"
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
[[package]]
name = "cached-property"
version = "1.5.2"
description = "A decorator for caching properties in classes."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "celery"
version = "4.4.7"
version = "5.1.2"
description = "Distributed Task Queue."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
python-versions = ">=3.6,"
[package.dependencies]
billiard = ">=3.6.3.0,<4.0"
kombu = ">=4.6.10,<4.7"
billiard = ">=3.6.4.0,<4.0"
click = ">=7.0,<8.0"
click-didyoumean = ">=0.0.3"
click-plugins = ">=1.1.1"
click-repl = ">=0.1.6"
kombu = ">=5.1.0,<6.0"
pytz = ">0.0-dev"
vine = "1.3.0"
vine = ">=5.0.0,<6.0"
[package.extras]
arangodb = ["pyArango (>=1.3.2)"]
auth = ["cryptography"]
azureblockblob = ["azure-storage (==0.36.0)", "azure-common (==1.1.5)", "azure-storage-common (==1.1.0)"]
azureblockblob = ["azure-storage-blob (==12.6.0)"]
brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"]
cassandra = ["cassandra-driver (<3.21.0)"]
consul = ["python-consul"]
consul = ["python-consul2"]
cosmosdbsql = ["pydocumentdb (==2.3.2)"]
couchbase = ["couchbase-cffi (<3.0.0)", "couchbase (<3.0.0)"]
couchbase = ["couchbase (>=3.0.0)"]
couchdb = ["pycouchdb"]
django = ["Django (>=1.11)"]
dynamodb = ["boto3 (>=1.9.178)"]
elasticsearch = ["elasticsearch"]
eventlet = ["eventlet (>=0.24.1)"]
gevent = ["gevent"]
eventlet = ["eventlet (>=0.26.1)"]
gevent = ["gevent (>=1.0.0)"]
librabbitmq = ["librabbitmq (>=1.5.0)"]
lzma = ["backports.lzma"]
memcache = ["pylibmc"]
mongodb = ["pymongo[srv] (>=3.3.0)"]
msgpack = ["msgpack"]
pymemcache = ["python-memcached"]
pyro = ["pyro4"]
pytest = ["pytest-celery"]
redis = ["redis (>=3.2.0)"]
riak = ["riak (>=2.0)"]
s3 = ["boto3 (>=1.9.125)"]
slmq = ["softlayer-messaging (>=1.0.3)"]
solar = ["ephem"]
@ -132,24 +144,61 @@ optional = false
python-versions = "*"
[[package]]
name = "chardet"
version = "4.0.0"
description = "Universal encoding detector for Python 2 and 3"
name = "charset-normalizer"
version = "2.0.3"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
python-versions = ">=3.5.0"
[package.extras]
unicode_backport = ["unicodedata2"]
[[package]]
name = "click"
version = "8.0.1"
version = "7.1.2"
description = "Composable command line interface toolkit"
category = "dev"
category = "main"
optional = false
python-versions = ">=3.6"
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "click-didyoumean"
version = "0.0.3"
description = "Enable git-like did-you-mean feature in click."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
click = "*"
[[package]]
name = "click-plugins"
version = "1.1.1"
description = "An extension module for click to enable registering CLI commands via setuptools entry-points."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
click = ">=4.0"
[package.extras]
dev = ["pytest (>=3.6)", "pytest-cov", "wheel", "coveralls"]
[[package]]
name = "click-repl"
version = "0.2.0"
description = "REPL plugin for Click"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
click = "*"
prompt-toolkit = "*"
six = "*"
[[package]]
name = "colorama"
@ -169,7 +218,7 @@ python-versions = ">=3.6, <3.7"
[[package]]
name = "django"
version = "3.2.4"
version = "3.2.5"
description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
category = "main"
optional = false
@ -194,29 +243,18 @@ python-versions = "*"
[[package]]
name = "django-celery-beat"
version = "2.2.0"
version = "2.2.1"
description = "Database-backed Periodic Tasks."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
celery = ">=4.4,<6.0"
celery = ">=5.0,<6.0"
Django = ">=2.2,<4.0"
django-timezone-field = ">=4.1.0,<5.0"
python-crontab = ">=2.3.4"
[[package]]
name = "django-celery-results"
version = "1.2.1"
description = "Celery result backends for Django."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
celery = ">=4.4,<5.0"
[[package]]
name = "django-constance"
version = "2.8.0"
@ -246,6 +284,18 @@ maintainer = ["transifex-client", "zest.releaser", "django"]
pyuca = ["pyuca"]
test = ["pytest", "pytest-django", "pytest-cov", "graphene-django"]
[[package]]
name = "django-jsonfield"
version = "1.4.1"
description = "JSONField for django models"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
Django = ">=1.11"
six = "*"
[[package]]
name = "django-lcore"
version = "1.7.1"
@ -279,9 +329,33 @@ Django = ">=2.2"
[package.extras]
tests = ["tox"]
[[package]]
name = "django-pipayments"
version = "0.2.0"
description = ""
category = "main"
optional = false
python-versions = "^3.6"
develop = false
[package.dependencies]
celery = "^5"
Django = "^3.1"
django-constance = "^2.4"
django-jsonfield = "^1.3"
python-dateutil = "^2.8.1"
requests = "^2.22"
stripe = "^2.40"
[package.source]
type = "git"
url = "git@git.packetimpact.net:lvpn/django-pipayments.git"
reference = "main"
resolved_reference = "31315e7f3e6e0ea84b00e1cdfa6a7cd6550b4fed"
[[package]]
name = "django-timezone-field"
version = "4.1.2"
version = "4.2.1"
description = "A Django app providing database and form fields for pytz timezone objects."
category = "main"
optional = false
@ -308,23 +382,25 @@ jsmin = "*"
[[package]]
name = "flower"
version = "0.9.7"
version = "0.9.5"
description = "Celery Flower"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
celery = {version = ">=4.3.0,<5.0.0", markers = "python_version >= \"3.7\""}
celery = [
{version = ">=3.1.0", markers = "python_version < \"3.7\""},
{version = ">=4.3.0", markers = "python_version >= \"3.7\""},
]
humanize = "*"
prometheus-client = "0.8.0"
pytz = "*"
tornado = {version = ">=5.0.0,<7.0.0", markers = "python_version >= \"3.5.2\""}
vine = "1.3.0"
[[package]]
name = "humanize"
version = "3.7.0"
version = "3.10.0"
description = "Python humanize utilities"
category = "main"
optional = false
@ -335,15 +411,15 @@ tests = ["freezegun", "pytest", "pytest-cov"]
[[package]]
name = "idna"
version = "2.10"
version = "3.2"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
python-versions = ">=3.5"
[[package]]
name = "importlib-metadata"
version = "4.4.0"
version = "4.6.1"
description = "Read metadata from Python packages"
category = "main"
optional = false
@ -355,7 +431,8 @@ zipp = ">=0.5"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
perf = ["ipython"]
testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
[[package]]
name = "isort"
@ -391,18 +468,20 @@ Django = ">=2.2"
[[package]]
name = "kombu"
version = "4.6.11"
version = "5.1.0"
description = "Messaging library for Python."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
python-versions = ">=3.6"
[package.dependencies]
amqp = ">=2.6.0,<2.7"
amqp = ">=5.0.6,<6.0.0"
cached-property = {version = "*", markers = "python_version < \"3.8\""}
importlib-metadata = {version = ">=0.18", markers = "python_version < \"3.8\""}
vine = "*"
[package.extras]
azureservicebus = ["azure-servicebus (>=0.21.1)"]
azureservicebus = ["azure-servicebus (>=7.0.0)"]
azurestoragequeues = ["azure-storage-queue"]
consul = ["python-consul (>=0.6.0)"]
librabbitmq = ["librabbitmq (>=1.5.2)"]
@ -413,7 +492,7 @@ qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"]
redis = ["redis (>=3.3.11)"]
slmq = ["softlayer-messaging (>=1.0.3)"]
sqlalchemy = ["sqlalchemy"]
sqs = ["boto3 (>=1.4.4)", "pycurl (==7.43.0.2)"]
sqs = ["boto3 (>=1.4.4)", "pycurl (==7.43.0.2)", "urllib3 (<1.26)"]
yaml = ["PyYAML (>=3.10)"]
zookeeper = ["kazoo (>=1.3.1)"]
@ -475,11 +554,11 @@ python-versions = "*"
[[package]]
name = "pathspec"
version = "0.8.1"
version = "0.9.0"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[[package]]
name = "prometheus-client"
@ -492,13 +571,24 @@ python-versions = "*"
[package.extras]
twisted = ["twisted"]
[[package]]
name = "prompt-toolkit"
version = "3.0.3"
description = "Library for building powerful interactive command lines in Python"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
wcwidth = "*"
[[package]]
name = "psycopg2-binary"
version = "2.8.6"
version = "2.9.1"
description = "psycopg2 - Python-PostgreSQL Database Adapter"
category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
python-versions = ">=3.6"
[[package]]
name = "pygal"
@ -524,14 +614,14 @@ python-versions = ">=3.5"
[[package]]
name = "pylint"
version = "2.8.3"
version = "2.9.5"
description = "python code static checker"
category = "dev"
optional = false
python-versions = "~=3.6"
[package.dependencies]
astroid = "2.5.6"
astroid = ">=2.6.5,<2.7"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
isort = ">=4.2.5,<6"
mccabe = ">=0.6,<0.7"
@ -589,7 +679,7 @@ cron-schedule = ["croniter"]
[[package]]
name = "python-dateutil"
version = "2.8.1"
version = "2.8.2"
description = "Extensions to the standard Python datetime module"
category = "main"
optional = false
@ -642,7 +732,7 @@ hiredis = ["hiredis (>=0.1.3)"]
[[package]]
name = "regex"
version = "2021.4.4"
version = "2021.7.6"
description = "Alternative regular expression module, to replace re."
category = "dev"
optional = false
@ -650,21 +740,21 @@ python-versions = "*"
[[package]]
name = "requests"
version = "2.25.1"
version = "2.26.0"
description = "Python HTTP for Humans."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[package.dependencies]
certifi = ">=2017.4.17"
chardet = ">=3.0.2,<5"
idna = ">=2.5,<3"
charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
urllib3 = ">=1.21.1,<1.27"
[package.extras]
security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]]
name = "selenium"
@ -695,7 +785,7 @@ python-versions = ">=3.5"
[[package]]
name = "stripe"
version = "2.57.0"
version = "2.60.0"
description = "Python bindings for the Stripe API"
category = "main"
optional = false
@ -738,7 +828,7 @@ python-versions = "*"
[[package]]
name = "urllib3"
version = "1.26.5"
version = "1.26.6"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
@ -751,11 +841,19 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "vine"
version = "1.3.0"
version = "5.0.0"
description = "Promises, promises, promises."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
python-versions = ">=3.6"
[[package]]
name = "wcwidth"
version = "0.2.5"
description = "Measures the displayed width of unicode strings in a terminal"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "wrapt"
@ -767,7 +865,7 @@ python-versions = "*"
[[package]]
name = "zipp"
version = "3.4.1"
version = "3.5.0"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main"
optional = false
@ -775,29 +873,29 @@ python-versions = ">=3.6"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
[metadata]
lock-version = "1.1"
python-versions = "^3.6"
content-hash = "1eb80ae7794ce4aa0c8fb6f938102718032bfe55f28677213e3c04adf78b7ed5"
content-hash = "3684444cfa9713769dfe63acf8eed5bfe8a25066e7ca312461498c82c444de9f"
[metadata.files]
amqp = [
{file = "amqp-2.6.1-py2.py3-none-any.whl", hash = "sha256:aa7f313fb887c91f15474c1229907a04dac0b8135822d6603437803424c0aa59"},
{file = "amqp-2.6.1.tar.gz", hash = "sha256:70cdb10628468ff14e57ec2f751c7aa9e48e7e3651cfd62d431213c0c4e58f21"},
{file = "amqp-5.0.6-py3-none-any.whl", hash = "sha256:493a2ac6788ce270a2f6a765b017299f60c1998f5a8617908ee9be082f7300fb"},
{file = "amqp-5.0.6.tar.gz", hash = "sha256:03e16e94f2b34c31f8bf1206d8ddd3ccaa4c315f7f6a1879b7b1210d229568c2"},
]
appdirs = [
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
]
asgiref = [
{file = "asgiref-3.3.4-py3-none-any.whl", hash = "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee"},
{file = "asgiref-3.3.4.tar.gz", hash = "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78"},
{file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"},
{file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"},
]
astroid = [
{file = "astroid-2.5.6-py3-none-any.whl", hash = "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e"},
{file = "astroid-2.5.6.tar.gz", hash = "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975"},
{file = "astroid-2.6.5-py3-none-any.whl", hash = "sha256:7b963d1c590d490f60d2973e57437115978d3a2529843f160b5003b721e1e925"},
{file = "astroid-2.6.5.tar.gz", hash = "sha256:83e494b02d75d07d4e347b27c066fd791c0c74fc96c613d1ea3de0c82c48168f"},
]
billiard = [
{file = "billiard-3.6.4.0-py3-none-any.whl", hash = "sha256:87103ea78fa6ab4d5c751c4909bcff74617d985de7fa8b672cf8618afd5a875b"},
@ -807,21 +905,36 @@ black = [
{file = "black-20.8b1-py3-none-any.whl", hash = "sha256:70b62ef1527c950db59062cda342ea224d772abdf6adc58b86a45421bab20a6b"},
{file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"},
]
cached-property = [
{file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"},
{file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"},
]
celery = [
{file = "celery-4.4.7-py2.py3-none-any.whl", hash = "sha256:a92e1d56e650781fb747032a3997d16236d037c8199eacd5217d1a72893bca45"},
{file = "celery-4.4.7.tar.gz", hash = "sha256:d220b13a8ed57c78149acf82c006785356071844afe0b27012a4991d44026f9f"},
{file = "celery-5.1.2-py3-none-any.whl", hash = "sha256:9dab2170b4038f7bf10ef2861dbf486ddf1d20592290a1040f7b7a1259705d42"},
{file = "celery-5.1.2.tar.gz", hash = "sha256:8d9a3de9162965e97f8e8cc584c67aad83b3f7a267584fa47701ed11c3e0d4b0"},
]
certifi = [
{file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"},
{file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"},
]
chardet = [
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
charset-normalizer = [
{file = "charset-normalizer-2.0.3.tar.gz", hash = "sha256:c46c3ace2d744cfbdebceaa3c19ae691f53ae621b39fd7570f59d14fb7f2fd12"},
{file = "charset_normalizer-2.0.3-py3-none-any.whl", hash = "sha256:88fce3fa5b1a84fdcb3f603d889f723d1dd89b26059d0123ca435570e848d5e1"},
]
click = [
{file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
{file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"},
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
]
click-didyoumean = [
{file = "click-didyoumean-0.0.3.tar.gz", hash = "sha256:112229485c9704ff51362fe34b2d4f0b12fc71cc20f6d2b3afabed4b8bfa6aeb"},
]
click-plugins = [
{file = "click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b"},
{file = "click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8"},
]
click-repl = [
{file = "click-repl-0.2.0.tar.gz", hash = "sha256:cd12f68d745bf6151210790540b4cb064c7b13e571bc64b6957d98d120dacfd8"},
{file = "click_repl-0.2.0-py3-none-any.whl", hash = "sha256:94b3fbbc9406a236f176e0506524b2937e4b23b6f4c0c0b2a0a83f8a64e9194b"},
]
colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
@ -832,20 +945,16 @@ dataclasses = [
{file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"},
]
django = [
{file = "Django-3.2.4-py3-none-any.whl", hash = "sha256:ea735cbbbb3b2fba6d4da4784a0043d84c67c92f1fdf15ad6db69900e792c10f"},
{file = "Django-3.2.4.tar.gz", hash = "sha256:66c9d8db8cc6fe938a28b7887c1596e42d522e27618562517cc8929eb7e7f296"},
{file = "Django-3.2.5-py3-none-any.whl", hash = "sha256:c58b5f19c5ae0afe6d75cbdd7df561e6eb929339985dbbda2565e1cabb19a62e"},
{file = "Django-3.2.5.tar.gz", hash = "sha256:3da05fea54fdec2315b54a563d5b59f3b4e2b1e69c3a5841dda35019c01855cd"},
]
django-admin-list-filter-dropdown = [
{file = "django-admin-list-filter-dropdown-1.0.3.tar.gz", hash = "sha256:07cd37b6a9be1b08f11d4a92957c69b67bc70b1f87a2a7d4ae886c93ea51eb53"},
{file = "django_admin_list_filter_dropdown-1.0.3-py3-none-any.whl", hash = "sha256:bf1b48bab9772dad79db71efef17e78782d4f2421444d5e49bb10e0da71cd6bb"},
]
django-celery-beat = [
{file = "django-celery-beat-2.2.0.tar.gz", hash = "sha256:b8a13afb15e7c53fc04f4f847ac71a6d32088959aba701eb7c4a59f0c28ba543"},
{file = "django_celery_beat-2.2.0-py2.py3-none-any.whl", hash = "sha256:c4c72a9579f20eff4c4ccf1b58ebdca5ef940f4210065057db1754ea5f8dffdc"},
]
django-celery-results = [
{file = "django_celery_results-1.2.1-py2.py3-none-any.whl", hash = "sha256:a29ab580f0e38c66c39f51cc426bbdbb2a391b8cc0867df9dea748db2c961db2"},
{file = "django_celery_results-1.2.1.tar.gz", hash = "sha256:e390f70cc43bbc2cd7c8e4607dc29ab6211a2ab968f93677583f0160921f670c"},
{file = "django-celery-beat-2.2.1.tar.gz", hash = "sha256:97ae5eb309541551bdb07bf60cc57cadacf42a74287560ced2d2c06298620234"},
{file = "django_celery_beat-2.2.1-py2.py3-none-any.whl", hash = "sha256:ab43049634fd18dc037927d7c2c7d5f67f95283a20ebbda55f42f8606412e66c"},
]
django-constance = [
{file = "django-constance-2.8.0.tar.gz", hash = "sha256:0a492454acc78799ce7b9f7a28a00c53427d513f34f8bf6fdc90a46d8864b2af"},
@ -855,34 +964,39 @@ django-countries = [
{file = "django-countries-7.2.1.tar.gz", hash = "sha256:26878b54d36bedff30b4535ceefcb8af6784741a8b30b1b8a662fb14a936a4ab"},
{file = "django_countries-7.2.1-py3-none-any.whl", hash = "sha256:adc965f1d348124274b7d918fc1aad5e29609758af999e1822baa9f2cc06d1b8"},
]
django-jsonfield = [
{file = "django-jsonfield-1.4.1.tar.gz", hash = "sha256:f789a0ea1f80b48aff7d6c36dd356ce125dbf1b7cd97a82d315607ac758f50ff"},
{file = "django_jsonfield-1.4.1-py2.py3-none-any.whl", hash = "sha256:ccb2fe623e1bf7799e49c593b0a89a85084ef8d3debbf26d92a54e27b5305d72"},
]
django-lcore = []
django-picklefield = [
{file = "django-picklefield-3.0.1.tar.gz", hash = "sha256:15ccba592ca953b9edf9532e64640329cd47b136b7f8f10f2939caa5f9ce4287"},
{file = "django_picklefield-3.0.1-py3-none-any.whl", hash = "sha256:3c702a54fde2d322fe5b2f39b8f78d9f655b8f77944ab26f703be6c0ed335a35"},
]
django-pipayments = []
django-timezone-field = [
{file = "django-timezone-field-4.1.2.tar.gz", hash = "sha256:cffac62452d060e365938aa9c9f7b72d70d8b26b9c60243bce227b35abd1b9df"},
{file = "django_timezone_field-4.1.2-py3-none-any.whl", hash = "sha256:897c06e40b619cf5731a30d6c156886a7c64cba3a90364832148da7ef32ccf36"},
{file = "django-timezone-field-4.2.1.tar.gz", hash = "sha256:97780cde658daa5094ae515bb55ca97c1352928ab554041207ad515dee3fe971"},
{file = "django_timezone_field-4.2.1-py3-none-any.whl", hash = "sha256:6dc782e31036a58da35b553bd00c70f112d794700025270d8a6a4c1d2e5b26c6"},
]
django-tinymce4-lite = [
{file = "django-tinymce4-lite-1.8.0.tar.gz", hash = "sha256:eb0ee7eda19970d06484f9e121871de01287b5345c4007ea2582d6f80ec3edb3"},
{file = "django_tinymce4_lite-1.8.0-py3-none-any.whl", hash = "sha256:2d53510ddb5fe20f25e525d4eaf7c8f8a567b85c9cc29f8ab2964419d9352d88"},
]
flower = [
{file = "flower-0.9.7-py2.py3-none-any.whl", hash = "sha256:8d6d6ac03e60b3a4227d156da489eb435e2442d82e89922d413df9054b9221eb"},
{file = "flower-0.9.7.tar.gz", hash = "sha256:cf27a254268bb06fd4972408d0518237fcd847f7da4b4cd8055e228150ace8f3"},
{file = "flower-0.9.5-py2.py3-none-any.whl", hash = "sha256:71be02bff7b2f56b0a07bd947fb3c748acba7f44f80ae88125d8954ce1a89697"},
{file = "flower-0.9.5.tar.gz", hash = "sha256:56916d1d2892e25453d6023437427fc04706a1308e0bd4822321da34e1643f9c"},
]
humanize = [
{file = "humanize-3.7.0-py3-none-any.whl", hash = "sha256:5eaafcd584fd01ab9a59f040e20b3daa35a24e8125d4c84788392d799be2012a"},
{file = "humanize-3.7.0.tar.gz", hash = "sha256:8b1463a17bf722c06712ac9d31f7e46efd048dd4e76fafeac9f3b8f972b0b8e3"},
{file = "humanize-3.10.0-py3-none-any.whl", hash = "sha256:aab7625d62dd5e0a054c8413a47d1fa257f3bdd8e9a2442c2fe36061bdd1d9bf"},
{file = "humanize-3.10.0.tar.gz", hash = "sha256:b2413730ce6684f85e0439a5b80b8f402e09f03e16ab8023d1da758c6ff41148"},
]
idna = [
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
{file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
{file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"},
]
importlib-metadata = [
{file = "importlib_metadata-4.4.0-py3-none-any.whl", hash = "sha256:960d52ba7c21377c990412aca380bf3642d734c2eaab78a2c39319f67c6a5786"},
{file = "importlib_metadata-4.4.0.tar.gz", hash = "sha256:e592faad8de1bda9fe920cf41e15261e7131bcf266c30306eec00e8e225c1dd5"},
{file = "importlib_metadata-4.6.1-py3-none-any.whl", hash = "sha256:9f55f560e116f8643ecf2922d9cd3e1c7e8d52e683178fecd9d08f6aa357e11e"},
{file = "importlib_metadata-4.6.1.tar.gz", hash = "sha256:079ada16b7fc30dfbb5d13399a5113110dab1aa7c2bc62f66af75f0b717c8cac"},
]
isort = [
{file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"},
@ -896,8 +1010,8 @@ jsonfield = [
{file = "jsonfield-3.1.0.tar.gz", hash = "sha256:7e4e84597de21eeaeeaaa7cc5da08c61c48a9b64d0c446b2d71255d01812887a"},
]
kombu = [
{file = "kombu-4.6.11-py2.py3-none-any.whl", hash = "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a"},
{file = "kombu-4.6.11.tar.gz", hash = "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74"},
{file = "kombu-5.1.0-py3-none-any.whl", hash = "sha256:e2dedd8a86c9077c350555153825a31e456a0dc20c15d5751f00137ec9c75f0a"},
{file = "kombu-5.1.0.tar.gz", hash = "sha256:01481d99f4606f6939cdc9b637264ed353ee9e3e4f62cfb582324142c41a572d"},
]
lazy-object-proxy = [
{file = "lazy-object-proxy-1.6.0.tar.gz", hash = "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726"},
@ -937,46 +1051,47 @@ mypy-extensions = [
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
pathspec = [
{file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"},
{file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"},
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
]
prometheus-client = [
{file = "prometheus_client-0.8.0-py2.py3-none-any.whl", hash = "sha256:983c7ac4b47478720db338f1491ef67a100b474e3bc7dafcbaefb7d0b8f9b01c"},
{file = "prometheus_client-0.8.0.tar.gz", hash = "sha256:c6e6b706833a6bd1fd51711299edee907857be10ece535126a158f911ee80915"},
]
prompt-toolkit = [
{file = "prompt_toolkit-3.0.3-py3-none-any.whl", hash = "sha256:c93e53af97f630f12f5f62a3274e79527936ed466f038953dfa379d4941f651a"},
{file = "prompt_toolkit-3.0.3.tar.gz", hash = "sha256:a402e9bf468b63314e37460b68ba68243d55b2f8c4d0192f85a019af3945050e"},
]
psycopg2-binary = [
{file = "psycopg2-binary-2.8.6.tar.gz", hash = "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0"},
{file = "psycopg2_binary-2.8.6-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4"},
{file = "psycopg2_binary-2.8.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db"},
{file = "psycopg2_binary-2.8.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5"},
{file = "psycopg2_binary-2.8.6-cp27-cp27m-win32.whl", hash = "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25"},
{file = "psycopg2_binary-2.8.6-cp27-cp27m-win_amd64.whl", hash = "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c"},
{file = "psycopg2_binary-2.8.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c"},
{file = "psycopg2_binary-2.8.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1"},
{file = "psycopg2_binary-2.8.6-cp34-cp34m-win32.whl", hash = "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2"},
{file = "psycopg2_binary-2.8.6-cp34-cp34m-win_amd64.whl", hash = "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152"},
{file = "psycopg2_binary-2.8.6-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449"},
{file = "psycopg2_binary-2.8.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859"},
{file = "psycopg2_binary-2.8.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550"},
{file = "psycopg2_binary-2.8.6-cp35-cp35m-win32.whl", hash = "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd"},
{file = "psycopg2_binary-2.8.6-cp35-cp35m-win_amd64.whl", hash = "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71"},
{file = "psycopg2_binary-2.8.6-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4"},
{file = "psycopg2_binary-2.8.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb"},
{file = "psycopg2_binary-2.8.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da"},
{file = "psycopg2_binary-2.8.6-cp36-cp36m-win32.whl", hash = "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2"},
{file = "psycopg2_binary-2.8.6-cp36-cp36m-win_amd64.whl", hash = "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a"},
{file = "psycopg2_binary-2.8.6-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679"},
{file = "psycopg2_binary-2.8.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf"},
{file = "psycopg2_binary-2.8.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b"},
{file = "psycopg2_binary-2.8.6-cp37-cp37m-win32.whl", hash = "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67"},
{file = "psycopg2_binary-2.8.6-cp37-cp37m-win_amd64.whl", hash = "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66"},
{file = "psycopg2_binary-2.8.6-cp38-cp38-macosx_10_9_x86_64.macosx_10_9_intel.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f"},
{file = "psycopg2_binary-2.8.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77"},
{file = "psycopg2_binary-2.8.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94"},
{file = "psycopg2_binary-2.8.6-cp38-cp38-win32.whl", hash = "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729"},
{file = "psycopg2_binary-2.8.6-cp38-cp38-win_amd64.whl", hash = "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77"},
{file = "psycopg2_binary-2.8.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52"},
{file = "psycopg2_binary-2.8.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd"},
{file = "psycopg2-binary-2.9.1.tar.gz", hash = "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773"},
{file = "psycopg2_binary-2.9.1-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76"},
{file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aef9aee84ec78af51107181d02fe8773b100b01c5dfde351184ad9223eab3698"},
{file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123c3fb684e9abfc47218d3784c7b4c47c8587951ea4dd5bc38b6636ac57f616"},
{file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:995fc41ebda5a7a663a254a1dcac52638c3e847f48307b5416ee373da15075d7"},
{file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:fbb42a541b1093385a2d8c7eec94d26d30437d0e77c1d25dae1dcc46741a385e"},
{file = "psycopg2_binary-2.9.1-cp36-cp36m-win32.whl", hash = "sha256:20f1ab44d8c352074e2d7ca67dc00843067788791be373e67a0911998787ce7d"},
{file = "psycopg2_binary-2.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f6fac64a38f6768e7bc7b035b9e10d8a538a9fadce06b983fb3e6fa55ac5f5ce"},
{file = "psycopg2_binary-2.9.1-cp37-cp37m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:1e3a362790edc0a365385b1ac4cc0acc429a0c0d662d829a50b6ce743ae61b5a"},
{file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f8559617b1fcf59a9aedba2c9838b5b6aa211ffedecabca412b92a1ff75aac1a"},
{file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a36c7eb6152ba5467fb264d73844877be8b0847874d4822b7cf2d3c0cb8cdcb0"},
{file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:2f62c207d1740b0bde5c4e949f857b044818f734a3d57f1d0d0edc65050532ed"},
{file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:cfc523edecddaef56f6740d7de1ce24a2fdf94fd5e704091856a201872e37f9f"},
{file = "psycopg2_binary-2.9.1-cp37-cp37m-win32.whl", hash = "sha256:1e85b74cbbb3056e3656f1cc4781294df03383127a8114cbc6531e8b8367bf1e"},
{file = "psycopg2_binary-2.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:1473c0215b0613dd938db54a653f68251a45a78b05f6fc21af4326f40e8360a2"},
{file = "psycopg2_binary-2.9.1-cp38-cp38-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:35c4310f8febe41f442d3c65066ca93cccefd75013df3d8c736c5b93ec288140"},
{file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c13d72ed6af7fd2c8acbd95661cf9477f94e381fce0792c04981a8283b52917"},
{file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14db1752acdd2187d99cb2ca0a1a6dfe57fc65c3281e0f20e597aac8d2a5bd90"},
{file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:aed4a9a7e3221b3e252c39d0bf794c438dc5453bc2963e8befe9d4cd324dff72"},
{file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:da113b70f6ec40e7d81b43d1b139b9db6a05727ab8be1ee559f3a69854a69d34"},
{file = "psycopg2_binary-2.9.1-cp38-cp38-win32.whl", hash = "sha256:4235f9d5ddcab0b8dbd723dca56ea2922b485ea00e1dafacf33b0c7e840b3d32"},
{file = "psycopg2_binary-2.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:988b47ac70d204aed01589ed342303da7c4d84b56c2f4c4b8b00deda123372bf"},
{file = "psycopg2_binary-2.9.1-cp39-cp39-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:7360647ea04db2e7dff1648d1da825c8cf68dc5fbd80b8fb5b3ee9f068dcd21a"},
{file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca86db5b561b894f9e5f115d6a159fff2a2570a652e07889d8a383b5fae66eb4"},
{file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ced67f1e34e1a450cdb48eb53ca73b60aa0af21c46b9b35ac3e581cf9f00e31"},
{file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:0f2e04bd2a2ab54fa44ee67fe2d002bb90cee1c0f1cc0ebc3148af7b02034cbd"},
{file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:3242b9619de955ab44581a03a64bdd7d5e470cc4183e8fcadd85ab9d3756ce7a"},
{file = "psycopg2_binary-2.9.1-cp39-cp39-win32.whl", hash = "sha256:0b7dae87f0b729922e06f85f667de7bf16455d411971b2043bbd9577af9d1975"},
{file = "psycopg2_binary-2.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68"},
]
pygal = [
{file = "pygal-2.4.0-py2.py3-none-any.whl", hash = "sha256:27abab93cbc31e21f3c6bdecc05bda6cd3570cbdbd8297b7caa6904051b50d72"},
@ -987,8 +1102,8 @@ pygments = [
{file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"},
]
pylint = [
{file = "pylint-2.8.3-py3-none-any.whl", hash = "sha256:792b38ff30903884e4a9eab814ee3523731abd3c463f3ba48d7b627e87013484"},
{file = "pylint-2.8.3.tar.gz", hash = "sha256:0a049c5d47b629d9070c3932d13bff482b12119b6a241a93bc460b0be16953c8"},
{file = "pylint-2.9.5-py3-none-any.whl", hash = "sha256:748f81e5776d6273a6619506e08f1b48ff9bcb8198366a56821cf11aac14fc87"},
{file = "pylint-2.9.5.tar.gz", hash = "sha256:1f333dc72ef7f5ea166b3230936ebcfb1f3b722e76c980cb9fe6b9f95e8d3172"},
]
pylint-django = [
{file = "pylint-django-2.4.4.tar.gz", hash = "sha256:f63f717169b0c2e4e19c28f1c32c28290647330184fcb7427805ae9b6994f3fc"},
@ -1006,8 +1121,8 @@ python-crontab = [
{file = "python-crontab-2.5.1.tar.gz", hash = "sha256:4bbe7e720753a132ca4ca9d4094915f40e9d9dc8a807a4564007651018ce8c31"},
]
python-dateutil = [
{file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
{file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},
{file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
{file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
]
python-frontmatter = [
{file = "python-frontmatter-1.0.0.tar.gz", hash = "sha256:e98152e977225ddafea6f01f40b4b0f1de175766322004c826ca99842d19a7cd"},
@ -1053,51 +1168,51 @@ redis = [
{file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"},
]
regex = [
{file = "regex-2021.4.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000"},
{file = "regex-2021.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711"},
{file = "regex-2021.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11"},
{file = "regex-2021.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968"},
{file = "regex-2021.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0"},
{file = "regex-2021.4.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4"},
{file = "regex-2021.4.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a"},
{file = "regex-2021.4.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7"},
{file = "regex-2021.4.4-cp36-cp36m-win32.whl", hash = "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29"},
{file = "regex-2021.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79"},
{file = "regex-2021.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8"},
{file = "regex-2021.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31"},
{file = "regex-2021.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a"},
{file = "regex-2021.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5"},
{file = "regex-2021.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82"},
{file = "regex-2021.4.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765"},
{file = "regex-2021.4.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e"},
{file = "regex-2021.4.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439"},
{file = "regex-2021.4.4-cp37-cp37m-win32.whl", hash = "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d"},
{file = "regex-2021.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3"},
{file = "regex-2021.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500"},
{file = "regex-2021.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14"},
{file = "regex-2021.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480"},
{file = "regex-2021.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc"},
{file = "regex-2021.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093"},
{file = "regex-2021.4.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10"},
{file = "regex-2021.4.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f"},
{file = "regex-2021.4.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87"},
{file = "regex-2021.4.4-cp38-cp38-win32.whl", hash = "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac"},
{file = "regex-2021.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2"},
{file = "regex-2021.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17"},
{file = "regex-2021.4.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605"},
{file = "regex-2021.4.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9"},
{file = "regex-2021.4.4-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7"},
{file = "regex-2021.4.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8"},
{file = "regex-2021.4.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed"},
{file = "regex-2021.4.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c"},
{file = "regex-2021.4.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042"},
{file = "regex-2021.4.4-cp39-cp39-win32.whl", hash = "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6"},
{file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"},
{file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"},
{file = "regex-2021.7.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e6a1e5ca97d411a461041d057348e578dc344ecd2add3555aedba3b408c9f874"},
{file = "regex-2021.7.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:6afe6a627888c9a6cfbb603d1d017ce204cebd589d66e0703309b8048c3b0854"},
{file = "regex-2021.7.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ccb3d2190476d00414aab36cca453e4596e8f70a206e2aa8db3d495a109153d2"},
{file = "regex-2021.7.6-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:ed693137a9187052fc46eedfafdcb74e09917166362af4cc4fddc3b31560e93d"},
{file = "regex-2021.7.6-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:99d8ab206a5270c1002bfcf25c51bf329ca951e5a169f3b43214fdda1f0b5f0d"},
{file = "regex-2021.7.6-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:b85ac458354165405c8a84725de7bbd07b00d9f72c31a60ffbf96bb38d3e25fa"},
{file = "regex-2021.7.6-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:3f5716923d3d0bfb27048242a6e0f14eecdb2e2a7fac47eda1d055288595f222"},
{file = "regex-2021.7.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5983c19d0beb6af88cb4d47afb92d96751fb3fa1784d8785b1cdf14c6519407"},
{file = "regex-2021.7.6-cp36-cp36m-win32.whl", hash = "sha256:c92831dac113a6e0ab28bc98f33781383fe294df1a2c3dfd1e850114da35fd5b"},
{file = "regex-2021.7.6-cp36-cp36m-win_amd64.whl", hash = "sha256:791aa1b300e5b6e5d597c37c346fb4d66422178566bbb426dd87eaae475053fb"},
{file = "regex-2021.7.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:59506c6e8bd9306cd8a41511e32d16d5d1194110b8cfe5a11d102d8b63cf945d"},
{file = "regex-2021.7.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:564a4c8a29435d1f2256ba247a0315325ea63335508ad8ed938a4f14c4116a5d"},
{file = "regex-2021.7.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:59c00bb8dd8775473cbfb967925ad2c3ecc8886b3b2d0c90a8e2707e06c743f0"},
{file = "regex-2021.7.6-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:9a854b916806c7e3b40e6616ac9e85d3cdb7649d9e6590653deb5b341a736cec"},
{file = "regex-2021.7.6-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:db2b7df831c3187a37f3bb80ec095f249fa276dbe09abd3d35297fc250385694"},
{file = "regex-2021.7.6-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:173bc44ff95bc1e96398c38f3629d86fa72e539c79900283afa895694229fe6a"},
{file = "regex-2021.7.6-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:15dddb19823f5147e7517bb12635b3c82e6f2a3a6b696cc3e321522e8b9308ad"},
{file = "regex-2021.7.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ddeabc7652024803666ea09f32dd1ed40a0579b6fbb2a213eba590683025895"},
{file = "regex-2021.7.6-cp37-cp37m-win32.whl", hash = "sha256:f080248b3e029d052bf74a897b9d74cfb7643537fbde97fe8225a6467fb559b5"},
{file = "regex-2021.7.6-cp37-cp37m-win_amd64.whl", hash = "sha256:d8bbce0c96462dbceaa7ac4a7dfbbee92745b801b24bce10a98d2f2b1ea9432f"},
{file = "regex-2021.7.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:edd1a68f79b89b0c57339bce297ad5d5ffcc6ae7e1afdb10f1947706ed066c9c"},
{file = "regex-2021.7.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:422dec1e7cbb2efbbe50e3f1de36b82906def93ed48da12d1714cabcd993d7f0"},
{file = "regex-2021.7.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cbe23b323988a04c3e5b0c387fe3f8f363bf06c0680daf775875d979e376bd26"},
{file = "regex-2021.7.6-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:0eb2c6e0fcec5e0f1d3bcc1133556563222a2ffd2211945d7b1480c1b1a42a6f"},
{file = "regex-2021.7.6-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:1c78780bf46d620ff4fff40728f98b8afd8b8e35c3efd638c7df67be2d5cddbf"},
{file = "regex-2021.7.6-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bc84fb254a875a9f66616ed4538542fb7965db6356f3df571d783f7c8d256edd"},
{file = "regex-2021.7.6-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:598c0a79b4b851b922f504f9f39a863d83ebdfff787261a5ed061c21e67dd761"},
{file = "regex-2021.7.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875c355360d0f8d3d827e462b29ea7682bf52327d500a4f837e934e9e4656068"},
{file = "regex-2021.7.6-cp38-cp38-win32.whl", hash = "sha256:e586f448df2bbc37dfadccdb7ccd125c62b4348cb90c10840d695592aa1b29e0"},
{file = "regex-2021.7.6-cp38-cp38-win_amd64.whl", hash = "sha256:2fe5e71e11a54e3355fa272137d521a40aace5d937d08b494bed4529964c19c4"},
{file = "regex-2021.7.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6110bab7eab6566492618540c70edd4d2a18f40ca1d51d704f1d81c52d245026"},
{file = "regex-2021.7.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4f64fc59fd5b10557f6cd0937e1597af022ad9b27d454e182485f1db3008f417"},
{file = "regex-2021.7.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:89e5528803566af4df368df2d6f503c84fbfb8249e6631c7b025fe23e6bd0cde"},
{file = "regex-2021.7.6-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2366fe0479ca0e9afa534174faa2beae87847d208d457d200183f28c74eaea59"},
{file = "regex-2021.7.6-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f9392a4555f3e4cb45310a65b403d86b589adc773898c25a39184b1ba4db8985"},
{file = "regex-2021.7.6-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:2bceeb491b38225b1fee4517107b8491ba54fba77cf22a12e996d96a3c55613d"},
{file = "regex-2021.7.6-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:f98dc35ab9a749276f1a4a38ab3e0e2ba1662ce710f6530f5b0a6656f1c32b58"},
{file = "regex-2021.7.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:319eb2a8d0888fa6f1d9177705f341bc9455a2c8aca130016e52c7fe8d6c37a3"},
{file = "regex-2021.7.6-cp39-cp39-win32.whl", hash = "sha256:eaf58b9e30e0e546cdc3ac06cf9165a1ca5b3de8221e9df679416ca667972035"},
{file = "regex-2021.7.6-cp39-cp39-win_amd64.whl", hash = "sha256:4c9c3155fe74269f61e27617529b7f09552fbb12e44b1189cebbdb24294e6e1c"},
{file = "regex-2021.7.6.tar.gz", hash = "sha256:8394e266005f2d8c6f0bc6780001f7afa3ef81a7a2111fa35058ded6fce79e4d"},
]
requests = [
{file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"},
{file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"},
{file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
{file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
]
selenium = [
{file = "selenium-3.141.0-py2.py3-none-any.whl", hash = "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c"},
@ -1112,8 +1227,8 @@ sqlparse = [
{file = "sqlparse-0.4.1.tar.gz", hash = "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"},
]
stripe = [
{file = "stripe-2.57.0-py2.py3-none-any.whl", hash = "sha256:178d15444d1364bca1a91f3d167409aedeb21a01835a5f9968a88bba3f79b606"},
{file = "stripe-2.57.0.tar.gz", hash = "sha256:965a7531c1e64253f0efbd4b31d028a66bb6e5b9352504fbcfdc72a7ba745aff"},
{file = "stripe-2.60.0-py2.py3-none-any.whl", hash = "sha256:0050763cb67df6745973bd9757f7a765bed1b82b5d5261fb8908cfc6ec9e5200"},
{file = "stripe-2.60.0.tar.gz", hash = "sha256:8966b7793014380f60c6f121ba333d6f333a55818edaf79c8d70464ce0a7a808"},
]
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
@ -1200,17 +1315,21 @@ typing-extensions = [
{file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"},
]
urllib3 = [
{file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"},
{file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"},
{file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"},
{file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"},
]
vine = [
{file = "vine-1.3.0-py2.py3-none-any.whl", hash = "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af"},
{file = "vine-1.3.0.tar.gz", hash = "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87"},
{file = "vine-5.0.0-py2.py3-none-any.whl", hash = "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30"},
{file = "vine-5.0.0.tar.gz", hash = "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e"},
]
wcwidth = [
{file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
]
wrapt = [
{file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"},
]
zipp = [
{file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"},
{file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"},
{file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"},
{file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"},
]

@ -23,9 +23,10 @@ python-frontmatter = "^1"
django-tinymce4-lite = "^1.7"
django-admin-list-filter-dropdown = "^1.0"
django-lcore = {git = "https://git.packetimpact.net/lvpn/django-lcore.git", tag = "v1.7.1"}
celery = "^4.4.7"
django-pipayments = {git = "git@git.packetimpact.net:lvpn/django-pipayments.git", branch = "main"}
#django-pipayments = {path = "../lvpn/django-pipayments", develop=true}
celery = "^5"
django-celery-beat = "^2.0.0"
django-celery-results = "^1.2.1"
redis = "^3.5.3"
flower = "^0.9.5"

@ -67,8 +67,8 @@ div#captcha > div {
}
.button.thin {
border-width: 1px;
padding: 0.20em 0.50em;
border-radius: 5px;
padding: 0.15em 0.50em;
border-radius: 7px;
font-size: 0.85em;
}
@ -446,7 +446,7 @@ footer .language-selector ul {
color: black;
}
.message p.info, .message p.success {
background-color: #062D4D;
background-color: #2EA658;
}
.message p.error, .message p.critical, .message .warning {
background-color: #A7332F;

@ -34,7 +34,7 @@
{% trans 'Used to recover your password and confirm stuff.' %}
</p>
{% if not request.session.signup_captcha_pass %}
{% if HCAPTCHA_SITE_KEY and not request.session.signup_captcha_pass %}
<div class="h-captcha" data-sitekey="{{ HCAPTCHA_SITE_KEY }}" data-callback="onCaptcha"></div>
<script src="https://hcaptcha.com/1/api.js" async defer></script>
{% endif %}

@ -34,7 +34,7 @@
<td>{% trans "Subscription" %}</td>
<td>
{% if subscription.status == 'active' %}
{% blocktrans trimmed with until=subscription.next_renew|date:'Y-m-d' backend=subscription.backend.backend_verbose_name %}
{% blocktrans trimmed with until=subscription.next_renew|default:user.vpnuser.expiration|date:'Y-m-d' backend=subscription.backend.backend_verbose_name %}
<b>ACTIVE</b>. Renews on {{until}} via {{backend}}.
{% endblocktrans %}
(<a href="{% url 'payments:cancel_subscr' %}">{% trans "cancel" %}</a>)
@ -43,6 +43,7 @@
{% endif %}
</td>
</tr>
{% if not subscription or subscription.status != 'active' %}
<tr>
<td>{% trans "Expiration" %}</td>
<td>
@ -54,6 +55,7 @@
{% endif %}
</td>
</tr>
{% endif %}
</table>
</div>
@ -67,21 +69,21 @@
<div class="tab-content">
<form action="/payments/new" method="post" class="pure-form pure-form-aligned centered-form">
{% csrf_token %}
<input type="hidden" name="subscr" value="1" />
<input type="hidden" name="recurring" value="1" />
<fieldset>
<div class="pure-control-group">
<label for="ino_time">{% trans 'Pay every' %}</label>
<select id="ino_time" name="time" class="pure-input-1-2">
<option value="3">3 {% trans 'months' %} ({{price.3}})</option>
<option value="6">6 {% trans 'months' %} ({{price.6}})</option>
<option value="12">12 {% trans 'months' %} ({{price.12}})</option>
<select id="ino_time" name="plan" class="pure-input-1-2">
<option value="3m">3 {% trans 'months' %} ({{price.3}})</option>
<option value="6m">6 {% trans 'months' %} ({{price.6}})</option>
<option value="12m">12 {% trans 'months' %} ({{price.12}})</option>
</select>
</div>
<div class="pure-control-group">
<label for="ino_method">{% trans 'by' %}</label>
<select id="ino_method" name="method" class="pure-input-1-2">
<select id="ino_method" name="payment-method" class="pure-input-1-2">
{% for backend in subscr_backends %}
<option value="{{ backend.backend_id }}" {% if backend.backend_id == default_backend %}selected{% endif %}>
{{ backend.backend_display_name }}
@ -110,17 +112,17 @@
<fieldset>
<div class="pure-control-group">
<label for="ino_time">{% trans 'Add' %}</label>
<select id="ino_time" name="time" class="pure-input-1-2">
<option value="1">1 {% trans 'month' %} ({{price.1}})</option>
<option value="3">3 {% trans 'months' %} ({{price.3}})</option>
<option value="6">6 {% trans 'months' %} ({{price.6}})</option>
<option value="12">12 {% trans 'months' %} ({{price.12}})</option>
<select id="ino_time" name="plan" class="pure-input-1-2">
<option value="1m">1 {% trans 'month' %} ({{price.1}})</option>
<option value="3m">3 {% trans 'months' %} ({{price.3}})</option>
<option value="6m">6 {% trans 'months' %} ({{price.6}})</option>
<option value="12m">12 {% trans 'months' %} ({{price.12}})</option>
</select>
</div>
<div class="pure-control-group">
<label for="ino_method">{% trans 'by' %}</label>
<select id="ino_method" name="method" class="pure-input-1-2">
<select id="ino_method" name="payment-method" class="pure-input-1-2">
{% for backend in backends %}
<option value="{{ backend.backend_id }}" {% if backend.backend_id == default_backend %}selected{% endif %}>
{{ backend.backend_display_name }}
@ -133,7 +135,7 @@
<input type="submit" class="pure-button pure-button-primary"
value="{% trans 'Buy Now' %}" />
</div>
<p>{% trans 'If you still have time, it will be added.' %}</p>
<p>{% trans 'If you still have time, the first payment will be delayed.' %}</p>
</fieldset>
</form>
</div>
@ -142,7 +144,7 @@
<input type="radio" name="type" id="tab_coupon" value="coupon" />
<label for="tab_coupon"><span>{% trans 'Coupon' %}</span></label>
<div class="tab-content">
<form action="/account/gift_code" method="post" class="pure-form pure-form-aligned centered-form">
<form action="{% url 'payments:redeem_coupon' %}" method="post" class="pure-form pure-form-aligned centered-form">
{% csrf_token %}
<fieldset>

@ -7,7 +7,7 @@
<div class="payments-cancel-page">
{% if subscription %}
<form action="/payments/cancel_subscr/{{subscription.id}}" method="post" class="pure-form centered-form" id="cancel-form">
<form action="{% url 'payments:cancel_subscr' subscription.id %}" method="post" class="pure-form centered-form" id="cancel-form">
{% csrf_token %}
<fieldset>
<p>

@ -5,6 +5,8 @@
{% block pagetitle %}{% trans 'Payment' %}{% endblock %}
{% block account_content %}
<section id="account">
<p>Please continue with our trusted payment processor:</p>
<noscript>You may need to enable JavaScript.</noscript>
{{ html | safe }}
</section>
Loading…
Cancel
Save