From 78d1de9031ca56288692d5676134053948cc5d13 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 8 Aug 2020 22:37:00 +0200 Subject: [PATCH 01/93] Remove orderrecord Signed-off-by: Nico Schottelius --- .../migrations/0008_delete_orderrecord.py | 16 ++++++++ uncloud_pay/models.py | 41 ++----------------- uncloud_pay/serializers.py | 6 --- uncloud_pay/tests.py | 19 ++++++++- 4 files changed, 37 insertions(+), 45 deletions(-) create mode 100644 uncloud_pay/migrations/0008_delete_orderrecord.py diff --git a/uncloud_pay/migrations/0008_delete_orderrecord.py b/uncloud_pay/migrations/0008_delete_orderrecord.py new file mode 100644 index 0000000..074210a --- /dev/null +++ b/uncloud_pay/migrations/0008_delete_orderrecord.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1 on 2020-08-08 20:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0007_remove_bill_bill_records'), + ] + + operations = [ + migrations.DeleteModel( + name='OrderRecord', + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 9de1631..2cbfd0f 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -412,6 +412,7 @@ class Bill(models.Model): # Calculate the start date if last_bill: + # TODO: check that last bill is finished/closed, if not continue using it starting_date = last_bill.end_date + datetime.timedelta(seconds=1) else: if first_order: @@ -436,7 +437,9 @@ class Bill(models.Model): order=order, starting_date=starting_date, ending_date=ending_date) - pass + + # Bill all active, recurring orders + #if order. return bill @@ -544,42 +547,6 @@ class BillRecord(models.Model): return f"{self.bill}: {self.quantity} x {self.order}" -class OrderRecord(models.Model): - """ - Order records store billing informations for products: the actual product - might be mutated and/or moved to another order but we do not want to loose - the details of old orders. - - Used as source of trust to dynamically generate bill entries. - """ - - order = models.ForeignKey(Order, on_delete=models.CASCADE) - - one_time_price = models.DecimalField(default=0.0, - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)]) - recurring_price = models.DecimalField(default=0.0, - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)]) - - description = models.TextField() - - - @property - def recurring_period(self): - return self.order.recurring_period - - @property - def starting_date(self): - return self.order.starting_date - - @property - def ending_date(self): - return self.order.ending_date - - ### # Products diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py index 5ee5ad5..e00541c 100644 --- a/uncloud_pay/serializers.py +++ b/uncloud_pay/serializers.py @@ -37,12 +37,6 @@ class CreatePaymentMethodSerializer(serializers.ModelSerializer): ### # Orders & Products. -class OrderRecordSerializer(serializers.ModelSerializer): - class Meta: - model = OrderRecord - fields = ['one_time_price', 'recurring_price', 'description'] - - class OrderSerializer(serializers.ModelSerializer): owner = serializers.PrimaryKeyRelatedField(queryset=get_user_model().objects.all()) diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 6dc0aeb..b9fb23e 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -12,6 +12,14 @@ class ProductActivationTestCase(TestCase): username='jdoe', email='john.doe@domain.tld') + self.recurring_user = get_user_model().objects.create( + username='recurrent_product_user', + email='jane.doe@domain.tld') + + self.user_without_address = get_user_model().objects.create( + username='no_home_person', + email='far.away@domain.tld') + self.billing_address = BillingAddress.objects.create( owner=self.user, organization = 'Test org', @@ -19,6 +27,13 @@ class ProductActivationTestCase(TestCase): city="unknown", postal_code="unknown") + self.billing_address = BillingAddress.objects.create( + owner=self.recurring_user, + organization = 'Test org', + street="Somewhere", + city="Else", + postal_code="unknown") + self.order_meta = {} self.order_meta[1] = { 'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)), @@ -46,9 +61,9 @@ class ProductActivationTestCase(TestCase): self.assertEqual(self.one_time_order.billrecord_set.count(), 1) - def test_bill_sum(self): + def test_bill_sum_onetime(self): """ - Ensure there is only 1 bill record per order + Check the bill sum for a single one time order """ bill = Bill.create_next_bill_for_user(self.user) From fd395263506139303bd0560ce09cdf35b4878a23 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 8 Aug 2020 23:02:24 +0200 Subject: [PATCH 02/93] Improve billing address testing --- uncloud_pay/models.py | 13 ++++-- uncloud_pay/tests.py | 104 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 2cbfd0f..57a3d07 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -422,6 +422,7 @@ class Bill(models.Model): ending_date = end_of_month(starting_date) + # FIXME above: maybe even use different date / active / open bill bill, created = cls.objects.get_or_create( owner=owner, starting_date=starting_date, @@ -438,8 +439,13 @@ class Bill(models.Model): starting_date=starting_date, ending_date=ending_date) - # Bill all active, recurring orders - #if order. + else: + # Bill all recurring orders -- filter in the next iteration :-) + + br = BillRecord.objects.create(bill=bill, + order=order, + starting_date=starting_date, + ending_date=ending_date) return bill @@ -450,8 +456,7 @@ class Bill(models.Model): # maxtime = time of last order # iterate month based through it - cls.assign_orders_to_bill(owner, year, month) - pass + cls.create_next_bill_for_user(owner) def assign_orders_to_bill(self, owner, year, month): """ diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index b9fb23e..fbf249d 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -5,9 +5,77 @@ from datetime import datetime, date, timedelta from .models import * from uncloud_service.models import GenericServiceProduct +class BillingAddressTestCase(TestCase): + def setUp(self): + self.user = get_user_model().objects.create( + username='random_user', + email='jane.random@domain.tld') + + + def test_user_only_inactive_address(self): + """ + Raise an error, when there is no active address + """ + + ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="somewhere else", + active=False) + + self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, + BillingAddress.get_address_for, + self.user) + + def test_user_only_active_address(self): + """ + Find the active address + """ + + ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="unknown", + active=True) + + + self.assertEqual(BillingAddress.get_address_for(self.user), ba) + + def test_multiple_addresses(self): + """ + Find the active address only, skip inactive + """ + + ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="unknown", + active=True) + + ba2 = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="somewhere else", + active=False) + + + self.assertEqual(BillingAddress.get_address_for(self.user), ba) + class ProductActivationTestCase(TestCase): def setUp(self): + self.user_without_address = get_user_model().objects.create( + username='no_home_person', + email='far.away@domain.tld') + self.user = get_user_model().objects.create( username='jdoe', email='john.doe@domain.tld') @@ -16,23 +84,21 @@ class ProductActivationTestCase(TestCase): username='recurrent_product_user', email='jane.doe@domain.tld') - self.user_without_address = get_user_model().objects.create( - username='no_home_person', - email='far.away@domain.tld') - - self.billing_address = BillingAddress.objects.create( + BillingAddress.objects.create( owner=self.user, organization = 'Test org', street="unknown", city="unknown", - postal_code="unknown") + postal_code="unknown", + active=True) - self.billing_address = BillingAddress.objects.create( + BillingAddress.objects.create( owner=self.recurring_user, organization = 'Test org', street="Somewhere", city="Else", - postal_code="unknown") + postal_code="unknown", + active=True) self.order_meta = {} self.order_meta[1] = { @@ -49,7 +115,18 @@ class ProductActivationTestCase(TestCase): recurring_period=RecurringPeriod.ONE_TIME, price=self.order_meta[1]['price'], description=self.order_meta[1]['description'], - billing_address=self.billing_address) + billing_address=BillingAddress.get_address_for(self.user)) + + self.recurring_order = Order.objects.create( + owner=self.recurring_user, + starting_date=timezone.make_aware(datetime.datetime(2020,3,3)), + recurring_period=RecurringPeriod.PER_30D, + price=15, + description="A pretty VM", + billing_address=BillingAddress.get_address_for(self.recurring_user) + ) + + def test_bill_one_time_one_bill_record(self): @@ -70,6 +147,15 @@ class ProductActivationTestCase(TestCase): self.assertEqual(bill.sum, self.order_meta[1]['price']) + def test_bill_creates_record_for_recurring_order(self): + """ + Ensure there is only 1 bill record per order + """ + + bill = Bill.create_next_bill_for_user(self.recurring_user) + + self.assertEqual(self.recurring_order.billrecord_set.count(), 1) + self.assertEqual(bill.billrecord_set.count(), 1) # class NotABillingTC(TestCase): # #class BillingTestCase(TestCase): From d7c0c40926afc85b9ccf6d344aa1c9a81378fbc0 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Aug 2020 00:37:27 +0200 Subject: [PATCH 03/93] first bill generation works Signed-off-by: Nico Schottelius --- uncloud_pay/admin.py | 13 +- .../commands/add-opennebula-vm-orders.py | 5 +- .../migrations/0009_auto_20200808_2113.py | 22 ++++ uncloud_pay/models.py | 116 ++++++++++++------ uncloud_pay/templates/bill.html.j2 | 23 ++-- uncloud_pay/tests.py | 22 +++- 6 files changed, 136 insertions(+), 65 deletions(-) create mode 100644 uncloud_pay/migrations/0009_auto_20200808_2113.py diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index 464ff1b..9c1b809 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -27,8 +27,6 @@ class BillRecordInline(admin.TabularInline): class BillAdmin(admin.ModelAdmin): inlines = [ BillRecordInline ] -# change_list_template = "uncloud_pay/change_list.html" - def get_urls(self): """ Create URLs for PDF view @@ -43,15 +41,6 @@ class BillAdmin(admin.ModelAdmin): return url_patterns - # def changelist_view(self, request, extra_context=None): - # extra_context = extra_context or {} - - # print("view exec") - # return super().changelist_view( - # request, extra_context=extra_context, - # ) - - def my_view(self, request, object_id): bill = self.get_object(request, object_id=object_id) print(bill) @@ -61,7 +50,7 @@ class BillAdmin(admin.ModelAdmin): output_file = NamedTemporaryFile() bill_html = render_to_string("bill.html.j2", {'bill': bill, - 'bill_records': bill.bill_records.all() + 'bill_records': bill.billrecord_set.all() }) bytestring_to_pdf(bill_html.encode('utf-8'), output_file) diff --git a/uncloud_pay/management/commands/add-opennebula-vm-orders.py b/uncloud_pay/management/commands/add-opennebula-vm-orders.py index 8febc21..658404a 100644 --- a/uncloud_pay/management/commands/add-opennebula-vm-orders.py +++ b/uncloud_pay/management/commands/add-opennebula-vm-orders.py @@ -5,8 +5,8 @@ from django.utils import timezone from datetime import datetime, timedelta from uncloud_pay.models import * -#import opennebula.models as one from uncloud_vm.models import * + import sys def vm_price_2020(cpu=1, ram=2, v6only=False): @@ -35,6 +35,7 @@ class Command(BaseCommand): def handle(self, *args, **options): user = get_user_model().objects.get(username=options['username']) + addr, created = BillingAddress.objects.get_or_create( owner=user, active=True, @@ -72,6 +73,8 @@ class Command(BaseCommand): vm25206.save() vm25206.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,4,17))) + Bill.create_next_bill_for_user(user) + sys.exit(0) diff --git a/uncloud_pay/migrations/0009_auto_20200808_2113.py b/uncloud_pay/migrations/0009_auto_20200808_2113.py new file mode 100644 index 0000000..e5090ef --- /dev/null +++ b/uncloud_pay/migrations/0009_auto_20200808_2113.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1 on 2020-08-08 21:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0008_delete_orderrecord'), + ] + + operations = [ + migrations.RemoveField( + model_name='bill', + name='valid', + ), + migrations.AddField( + model_name='bill', + name='is_final', + field=models.BooleanField(default=False), + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 57a3d07..6d35b17 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -382,9 +382,7 @@ class Bill(models.Model): starting_date = models.DateTimeField(default=start_of_this_month) ending_date = models.DateTimeField() due_date = models.DateField(default=default_payment_delay) - - # what is valid for? should this be "final"? - valid = models.BooleanField(default=True) + is_final = models.BooleanField(default=False) class Meta: constraints = [ @@ -409,29 +407,36 @@ class Bill(models.Model): all_orders = Order.objects.filter(owner=owner).order_by('id') first_order = all_orders.first() + bill = None + ending_date = None - # Calculate the start date + # Get date & bill from previous bill if last_bill: - # TODO: check that last bill is finished/closed, if not continue using it - starting_date = last_bill.end_date + datetime.timedelta(seconds=1) + if not last_bill.is_final: + bill = last_bill + starting_date = last_bill.starting_date + ending_date = bill.ending_date + else: + starting_date = last_bill.end_date + datetime.timedelta(seconds=1) else: if first_order: starting_date = first_order.starting_date else: starting_date = timezone.now() - ending_date = end_of_month(starting_date) - # FIXME above: maybe even use different date / active / open bill - bill, created = cls.objects.get_or_create( - owner=owner, - starting_date=starting_date, - ending_date=ending_date) + if not ending_date: + ending_date = end_of_month(starting_date) + + # create new bill, if previous is closed/does not exist + if not bill: + + bill = cls.objects.create( + owner=owner, + starting_date=starting_date, + ending_date=ending_date) for order in all_orders: - # check if order needs to be billed - # check if order has previous billing record - if order.is_one_time: if order.billrecord_set.count() == 0: br = BillRecord.objects.create(bill=bill, @@ -447,6 +452,10 @@ class Bill(models.Model): starting_date=starting_date, ending_date=ending_date) + # Filtering ideas: + # If order is replaced, it should not be added anymore if it has been billed "last time" + # If order has ended and finally charged, do not charge anymore + return bill @classmethod @@ -546,7 +555,7 @@ class BillRecord(models.Model): @property def sum(self): - return self.order.price * self.quantity + return self.order.price * Decimal(self.quantity) def __str__(self): return f"{self.bill}: {self.quantity} x {self.order}" @@ -575,34 +584,54 @@ class Product(UncloudModel): # Default period for all products default_recurring_period = RecurringPeriod.PER_30D - def create_order_at(self, when_to_start, *args, **kwargs): + def create_order_at(self, when_to_start=None, recurring_period=None): billing_address = BillingAddress.get_address_for(self.owner) - order = Order.objects.create(owner=self.owner, - billing_address=billing_address, - starting_date=when_to_start, - one_time_price=self.one_time_price, - recurring_period=self.default_recurring_period, - recurring_price=self.recurring_price, - description=str(self)) + if not billing_address: + raise ValidationError("Cannot order without a billing address") - def create_or_update_order(self, when_to_start=None): + if not when_to_start: + when_to_start = timezone.now() + + if not recurring_period: + recurring_period = self.default_recurring_period + + one_time_order = None + + if self.one_time_price > 0: + + one_time_order = Order.objects.create(owner=self.owner, + billing_address=billing_address, + starting_date=when_to_start, + price=self.one_time_price, + recurring_period=RecurringPeriod.ONE_TIME, + description=str(self)) + + + if recurring_period != RecurringPeriod.ONE_TIME: + if one_time_order: + recurring_order = Order.objects.create(owner=self.owner, + billing_address=billing_address, + starting_date=when_to_start, + price=self.recurring_price, + recurring_period=recurring_period, + depends_on=one_time_order, + description=str(self)) + else: + recurring_order = Order.objects.create(owner=self.owner, + billing_address=billing_address, + starting_date=when_to_start, + price=self.recurring_price, + recurring_period=recurring_period, + description=str(self)) + + + def create_or_update_order(self, when_to_start=None, recurring_period=None): if not when_to_start: when_to_start = timezone.now() if not self.order: - billing_address = BillingAddress.get_address_for(self.owner) - - if not billing_address: - raise ValidationError("Cannot order without a billing address") - - self.order = Order.objects.create(owner=self.owner, - billing_address=billing_address, - starting_date=when_to_start, - one_time_price=self.one_time_price, - recurring_period=self.default_recurring_period, - recurring_price=self.recurring_price, - description=str(self)) + self.create_order_at(when_to_start, recurring_period) else: previous_order = self.order @@ -611,9 +640,8 @@ class Product(UncloudModel): new_order = Order.objects.create(owner=self.owner, billing_address=self.order.billing_address, starting_date=when_to_start, - one_time_price=self.one_time_price, - recurring_period=self.default_recurring_period, - recurring_price=self.recurring_price, + price=self.recurring_price, + recurring_period=recurring_period, description=str(self), replaces=self.order) @@ -641,6 +669,14 @@ class Product(UncloudModel): """ return 0 + + @property + def is_recurring(self): + return self.recurring_price > 0 + + # on is_one_time as this should be has_one_time which is the same as > 0 again... + + @property def billing_address(self): return self.order.billing_address diff --git a/uncloud_pay/templates/bill.html.j2 b/uncloud_pay/templates/bill.html.j2 index 4f186c2..10ae1e2 100644 --- a/uncloud_pay/templates/bill.html.j2 +++ b/uncloud_pay/templates/bill.html.j2 @@ -673,7 +673,7 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7
{% if bill.billing_address.organization != "" %} - {{ bill.billing_address.organization }} + ORG{{ bill.billing_address.organization }}
{{ bill.billing_address.name }} {% else %} {{ bill.billing_address.name }} @@ -709,19 +709,24 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7 - - - + + {% for record in bill_records %} - - + + + {% endfor %} @@ -733,13 +738,13 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7

VAT - {{ bill.vat_amount }} + {{ bill.vat_amount|floatformat:2 }}

- Gesamtbetrag - {{ bill.total }} + Total + {{ bill.sum|floatformat:2 }}

- Rechnungsdatum: -
Rechnungsnummer -
Zahlbar bis + {{ bill.starting_date|date:"c" }} - + {{ bill.ending_date|date:"c" }} +
Bill id: {{ bill }} +
Due: {{ bill.due_date }}
-
- {{ bill.creation_date.date }}
- {% if bill.billing_address.vat_number != "" %} - {{ bill.billing_address.vat_number - }}
- {% else %} - None
- {% endif %} - {{ bill.billing_address.vat_number }}
- {{ bill.due_date }} -
-

RECHNUNG

+

Invoice

Beschreibung DetailAmountVATQuantityPrice/Unit Total
A{{ record.quantity }} + {{ record.starting_date|date:"c" }} + {% if record.ending_date %} + - {{ record.ending_date|date:"c" }} + {% endif %} + {{ record.order.description }} {{ record.quantity|floatformat:2 }}{{ record.order.price|floatformat:2 }}{{ record.sum|floatformat:2 }}
- + - + @@ -733,17 +723,17 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7
DetailQuantityUnits Price/UnitTotalTotal price

- Total + Total (excl. VAT) {{ bill.amount }}

- VAT + VAT 7.7% {{ bill.vat_amount|floatformat:2 }}

- Total + Total amount to be paid {{ bill.sum|floatformat:2 }}

diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 2c1182b..9a26e2b 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -144,6 +144,13 @@ class BillAndOrderTestCase(TestCase): billing_address=BillingAddress.get_address_for(self.recurring_user) ) + # used for generating multiple bills + self.bill_dates = [ + timezone.make_aware(datetime.datetime(2020,3,31)), + timezone.make_aware(datetime.datetime(2020,4,30)), + timezone.make_aware(datetime.datetime(2020,5,31)), + ] + def test_bill_one_time_one_bill_record(self): """ @@ -173,6 +180,21 @@ class BillAndOrderTestCase(TestCase): self.assertEqual(self.recurring_order.billrecord_set.count(), 1) self.assertEqual(bill.billrecord_set.count(), 1) + + def test_new_bill_after_closing(self): + """ + After closing a bill and the user has a recurring product, + the next bill run should create e new bill + """ + + for ending_date in self.bill_dates: + Bill.create_next_bill_for_user(self.recurring_user, ending_date) + + bills_count = Bill.objects.filter(owner=self.recurring_user).count() + + self.assertEqual(len(self.bill_dates), bill_count) + + # class NotABillingTC(TestCase): # #class BillingTestCase(TestCase): # def setUp(self): diff --git a/uncloud_vm/models.py b/uncloud_vm/models.py index 8d9b35f..4033ba0 100644 --- a/uncloud_vm/models.py +++ b/uncloud_vm/models.py @@ -164,9 +164,7 @@ class VMDiskProduct(Product): default=VMDiskType.CEPH_SSD) def __str__(self): - return "{} disk for VM '{}': {}GB".format(self.disk_type, - self.vm.name, - self.size_in_gb) + return f"Disk {self.size_in_gb}GB ({self.disk_type}) for VM '{self.vm.name}'" @property def recurring_price(self): From 6a928a2b2a9ee9e2a24edd3db23f0202e5939d9c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Aug 2020 10:18:15 +0200 Subject: [PATCH 06/93] Fix tests for billing --- uncloud_pay/models.py | 11 +++++++++-- uncloud_pay/tests.py | 5 +++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 5f2b5d3..7232cd3 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -327,7 +327,7 @@ class Order(models.Model): One time orders have a recurring period of 0, so this work universally """ - return self.starting_date + timedelta(seconds=self.recurring_period) + return self.starting_date + datetime.timedelta(seconds=self.recurring_period) @property @@ -438,7 +438,7 @@ class Bill(models.Model): starting_date = last_bill.starting_date ending_date = bill.ending_date else: - starting_date = last_bill.end_date + datetime.timedelta(seconds=1) + starting_date = last_bill.ending_date + datetime.timedelta(seconds=1) else: if first_order: starting_date = first_order.starting_date @@ -566,6 +566,13 @@ class Bill(models.Model): # For each recurring order get the usage and bill it + def close(self): + """ + Close/finish a bill + """ + + self.is_final = True + self.save() class BillRecord(models.Model): """ diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 9a26e2b..7acd7d4 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -188,9 +188,10 @@ class BillAndOrderTestCase(TestCase): """ for ending_date in self.bill_dates: - Bill.create_next_bill_for_user(self.recurring_user, ending_date) + b = Bill.create_next_bill_for_user(self.recurring_user, ending_date) + b.close() - bills_count = Bill.objects.filter(owner=self.recurring_user).count() + bill_count = Bill.objects.filter(owner=self.recurring_user).count() self.assertEqual(len(self.bill_dates), bill_count) From 0dd109381261ebdc74191c7539df013786bced54 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Aug 2020 11:02:45 +0200 Subject: [PATCH 07/93] add sample products and improve testing for Product --- .../migrations/0010_auto_20200809_0856.py | 65 +++++++++++++++++++ uncloud_pay/models.py | 41 ++++++++++-- uncloud_pay/tests.py | 46 ++++++++++++- uncloud_vm/models.py | 21 +++--- 4 files changed, 156 insertions(+), 17 deletions(-) create mode 100644 uncloud_pay/migrations/0010_auto_20200809_0856.py diff --git a/uncloud_pay/migrations/0010_auto_20200809_0856.py b/uncloud_pay/migrations/0010_auto_20200809_0856.py new file mode 100644 index 0000000..db2f7d7 --- /dev/null +++ b/uncloud_pay/migrations/0010_auto_20200809_0856.py @@ -0,0 +1,65 @@ +# Generated by Django 3.1 on 2020-08-09 08:56 + +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), + ('uncloud_pay', '0009_auto_20200808_2113'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='depends_on', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_of', to='uncloud_pay.order'), + ), + migrations.AlterField( + model_name='order', + name='replaces', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replaced_by', to='uncloud_pay.order'), + ), + migrations.CreateModel( + name='SampleRecurringProductOneTimeFee', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extra_data', models.JSONField(blank=True, editable=False, null=True)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), + ('order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.order')), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SampleRecurringProduct', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extra_data', models.JSONField(blank=True, editable=False, null=True)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), + ('order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.order')), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SampleOneTimeProduct', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extra_data', models.JSONField(blank=True, editable=False, null=True)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), + ('order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.order')), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 7232cd3..8023bf7 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -596,7 +596,6 @@ class BillRecord(models.Model): return record_delta.total_seconds()/self.order.recurring_period - @property def sum(self): return self.order.price * Decimal(self.quantity) @@ -604,6 +603,12 @@ class BillRecord(models.Model): def __str__(self): return f"{self.bill}: {self.quantity} x {self.order}" + def save(self, *args, **kwargs): + if self.ending_date < self.starting_date: + raise ValidationError("End date cannot be before starting date") + + super().save(*args, **kwargs) + ### # Products @@ -703,13 +708,12 @@ class Product(UncloudModel): @property def recurring_price(self): - pass # To be implemented in child. + """ implement correct values in the child class """ + return 0 @property def one_time_price(self): - """ - Default is 0 CHF - """ + """ implement correct values in the child class """ return 0 @@ -799,3 +803,30 @@ class Product(UncloudModel): else: # FIXME: use the right type of exception here! raise Exception("Did not implement the discounter for this case") + +# Sample products included into uncloud +class SampleOneTimeProduct(Product): + + default_recurring_period = RecurringPeriod.ONE_TIME + + @property + def one_time_price(self): + return 5 + +class SampleRecurringProduct(Product): + default_recurring_period = RecurringPeriod.PER_30D + + @property + def recurring_price(self): + return 10 + +class SampleRecurringProductOneTimeFee(Product): + default_recurring_period = RecurringPeriod.PER_30D + + @property + def one_time_price(self): + return 5 + + @property + def recurring_price(self): + return 1 diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 7acd7d4..21ac730 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -5,6 +5,7 @@ from datetime import datetime, date, timedelta from .models import * from uncloud_service.models import GenericServiceProduct + class ProductOrderTestCase(TestCase): """ Test products and products <-> order interaction @@ -15,12 +16,53 @@ class ProductOrderTestCase(TestCase): username='random_user', email='jane.random@domain.tld') - def test_update_one_time_product(self): + ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="somewhere else", + active=True) + + def test_create_one_time_product(self): """ One time payment products cannot be updated - can they? """ - pass + p = SampleOneTimeProduct.objects.create(owner=self.user) + + self.assertEqual(p.one_time_price, 5) + self.assertEqual(p.recurring_price, 0) + + + def test_create_order_creates_correct_order_count(self): + """ + Ensure creating orders from product only creates 1 order + """ + + # One order + p = SampleOneTimeProduct(owner=self.user) + p.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) + p.save() + + order_count = Order.objects.filter(owner=self.user).count() + self.assertEqual(order_count, 1) + + # One more order + p = SampleRecurringProduct(owner=self.user) + p.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) + p.save() + + order_count = Order.objects.filter(owner=self.user).count() + self.assertEqual(order_count, 2) + + # Should create 2 orders + p = SampleRecurringProductOneTimeFee(owner=self.user) + p.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) + p.save() + + order_count = Order.objects.filter(owner=self.user).count() + self.assertEqual(order_count, 4) class BillingAddressTestCase(TestCase): diff --git a/uncloud_vm/models.py b/uncloud_vm/models.py index 4033ba0..dc2369e 100644 --- a/uncloud_vm/models.py +++ b/uncloud_vm/models.py @@ -69,16 +69,6 @@ class VMProduct(Product): def recurring_price(self): return self.cores * 3 + self.ram_in_gb * 4 - def __str__(self): - if self.name: - name = f"{self.name} ({self.id})" - else: - name = self.id - - return "VM {}: {} cores {} gb ram".format(name, - self.cores, - self.ram_in_gb) - @property def description(self): return "Virtual machine '{}': {} core(s), {}GB memory".format( @@ -92,6 +82,17 @@ class VMProduct(Product): RecurringPeriod.choices)) + def __str__(self): + name = f"{self.id}" + + if self.name: + name = f"{self.id} ({self.name})" + + return "VM {}: {} cores {} gb ram".format(name, + self.cores, + self.ram_in_gb) + + class VMWithOSProduct(VMProduct): primary_disk = models.ForeignKey('VMDiskProduct', on_delete=models.CASCADE, null=True) From 70c450afc8984bab59b8eb622bb99e19d9b59306 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Aug 2020 11:44:22 +0200 Subject: [PATCH 08/93] fix tests for Product() Signed-off-by: Nico Schottelius --- uncloud_pay/models.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 8023bf7..4005908 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -645,18 +645,20 @@ class Product(UncloudModel): if not recurring_period: recurring_period = self.default_recurring_period - one_time_order = None if self.one_time_price > 0: one_time_order = Order.objects.create(owner=self.owner, - billing_address=billing_address, - starting_date=when_to_start, - price=self.one_time_price, - recurring_period=RecurringPeriod.ONE_TIME, - description=str(self)) - + billing_address=billing_address, + starting_date=when_to_start, + price=self.one_time_price, + recurring_period=RecurringPeriod.ONE_TIME, + description=str(self)) + else: + one_time_order = None if recurring_period != RecurringPeriod.ONE_TIME: + print("not one time") + if one_time_order: recurring_order = Order.objects.create(owner=self.owner, billing_address=billing_address, @@ -699,12 +701,13 @@ class Product(UncloudModel): self.order = new_order - def save(self, *args, **kwargs): - # Create order if there is none already - if not self.order: - self.create_or_update_order() +# def save(self, *args, **kwargs): + # if not self.order: + # raise ValidationError("Cannot create product without order") - super().save(*args, **kwargs) +# self.create_or_update_order() + +# super().save(*args, **kwargs) @property def recurring_price(self): From ef02cb61fd2744e5ba48605f46ee7f3beefd8935 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Aug 2020 12:34:25 +0200 Subject: [PATCH 09/93] Refine tests for bills, multiple bills --- .../migrations/0011_bill_billing_address.py | 19 +++ .../migrations/0012_auto_20200809_1026.py | 19 +++ uncloud_pay/models.py | 77 ++++++------- uncloud_pay/templates/bill.html.j2 | 2 +- uncloud_pay/tests.py | 108 ++++++++++++++++-- 5 files changed, 177 insertions(+), 48 deletions(-) create mode 100644 uncloud_pay/migrations/0011_bill_billing_address.py create mode 100644 uncloud_pay/migrations/0012_auto_20200809_1026.py diff --git a/uncloud_pay/migrations/0011_bill_billing_address.py b/uncloud_pay/migrations/0011_bill_billing_address.py new file mode 100644 index 0000000..e13e7b3 --- /dev/null +++ b/uncloud_pay/migrations/0011_bill_billing_address.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2020-08-09 10:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0010_auto_20200809_0856'), + ] + + operations = [ + migrations.AddField( + model_name='bill', + name='billing_address', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress'), + ), + ] diff --git a/uncloud_pay/migrations/0012_auto_20200809_1026.py b/uncloud_pay/migrations/0012_auto_20200809_1026.py new file mode 100644 index 0000000..dff0e78 --- /dev/null +++ b/uncloud_pay/migrations/0012_auto_20200809_1026.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2020-08-09 10:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0011_bill_billing_address'), + ] + + operations = [ + migrations.AlterField( + model_name='bill', + name='billing_address', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress'), + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 4005908..5d69523 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -347,6 +347,11 @@ class Order(models.Model): def is_one_time(self): return not self.is_recurring + def replace_with(self, new_order): + new_order.replaces = self + self.ending_date = new_order.starting_date - datetime.timedelta(seconds=1) + self.save() + def save(self, *args, **kwargs): if self.ending_date and self.ending_date < self.starting_date: @@ -395,6 +400,13 @@ class Bill(models.Model): ending_date = models.DateTimeField() due_date = models.DateField(default=default_payment_delay) + + billing_address = models.ForeignKey(BillingAddress, + on_delete=models.CASCADE, + editable=True, + null=False) + # FIXME: editable=True -> is in the admin, but also editable in DRF + is_final = models.BooleanField(default=False) class Meta: @@ -408,10 +420,13 @@ class Bill(models.Model): def __str__(self): return f"Bill {self.owner}-{self.id}" - @property - def billing_address(self): - pass -# if self.order_set.all + def close(self): + """ + Close/finish a bill + """ + + self.is_final = True + self.save() @property def sum(self): @@ -420,12 +435,26 @@ class Bill(models.Model): @classmethod - def create_next_bill_for_user(cls, owner, ending_date=None): - last_bill = cls.objects.filter(owner=owner).order_by('id').last() + def create_next_bills_for_user(cls, owner, ending_date=None): + """ + Create one bill per billing address, as the VAT rates might be different + for each address + """ + + bills = [] + + for billing_address in BillingAddress.objects.filter(owner=owner): + bills.append(cls.create_next_bill_for_user_address(owner, billing_address, ending_date)) + + return bills + + @classmethod + def create_next_bill_for_user_address(cls, owner, billing_address, ending_date=None): + last_bill = cls.objects.filter(owner=owner, billing_address=billing_address).order_by('id').last() # it is important to sort orders here, as bill records will be # created (and listed) in this order - all_orders = Order.objects.filter(owner=owner).order_by('id') + all_orders = Order.objects.filter(owner=owner, billing_address=billing_address).order_by('id') first_order = all_orders.first() bill = None @@ -452,7 +481,8 @@ class Bill(models.Model): bill = cls.objects.create( owner=owner, starting_date=starting_date, - ending_date=ending_date) + ending_date=ending_date, + billing_address=billing_address) for order in all_orders: if order.is_one_time: @@ -557,23 +587,6 @@ class Bill(models.Model): cls.create_next_bill_for_user(owner) - # for order in Order.objects.filter(Q(starting_date__gte=self.starting_date), - # Q(starting_date__lte=self.ending_date), - - #for order in Order.objects.filter(owner=owner, - # bill_records=None): - - # For each recurring order get the usage and bill it - - - def close(self): - """ - Close/finish a bill - """ - - self.is_final = True - self.save() - class BillRecord(models.Model): """ Entry of a bill, dynamically generated from an order. @@ -657,8 +670,6 @@ class Product(UncloudModel): one_time_order = None if recurring_period != RecurringPeriod.ONE_TIME: - print("not one time") - if one_time_order: recurring_order = Order.objects.create(owner=self.owner, billing_address=billing_address, @@ -700,15 +711,6 @@ class Product(UncloudModel): self.order = new_order - -# def save(self, *args, **kwargs): - # if not self.order: - # raise ValidationError("Cannot create product without order") - -# self.create_or_update_order() - -# super().save(*args, **kwargs) - @property def recurring_price(self): """ implement correct values in the child class """ @@ -724,9 +726,6 @@ class Product(UncloudModel): def is_recurring(self): return self.recurring_price > 0 - # on is_one_time as this should be has_one_time which is the same as > 0 again... - - @property def billing_address(self): return self.order.billing_address diff --git a/uncloud_pay/templates/bill.html.j2 b/uncloud_pay/templates/bill.html.j2 index 456e4a6..d5993ae 100644 --- a/uncloud_pay/templates/bill.html.j2 +++ b/uncloud_pay/templates/bill.html.j2 @@ -674,7 +674,7 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7
{% if bill.billing_address.organization != "" %} ORG{{ bill.billing_address.organization }} -
{{ bill.billing_address.name }} +
{{ bill.billing_address }} {% else %} {{ bill.billing_address.name }} {% endif %} diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 21ac730..3ae8a65 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -72,6 +72,15 @@ class BillingAddressTestCase(TestCase): email='jane.random@domain.tld') + def test_user_no_address(self): + """ + Raise an error, when there is no address + """ + + self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, + BillingAddress.get_address_for, + self.user) + def test_user_only_inactive_address(self): """ Raise an error, when there is no active address @@ -89,7 +98,7 @@ class BillingAddressTestCase(TestCase): BillingAddress.get_address_for, self.user) - def test_user_only_active_address(self): + def test_find_active_address(self): """ Find the active address """ @@ -105,7 +114,7 @@ class BillingAddressTestCase(TestCase): self.assertEqual(BillingAddress.get_address_for(self.user), ba) - def test_multiple_addresses(self): + def test_find_right_address_with_multiple_addresses(self): """ Find the active address only, skip inactive """ @@ -129,6 +138,35 @@ class BillingAddressTestCase(TestCase): self.assertEqual(BillingAddress.get_address_for(self.user), ba) + def test_change_addresses(self): + """ + Switch the active address + """ + + ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="unknown", + active=True) + + self.assertEqual(BillingAddress.get_address_for(self.user), ba) + + ba.active=False + ba.save() + + ba2 = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="somewhere else", + active=True) + + self.assertEqual(BillingAddress.get_address_for(self.user), ba2) + + class BillAndOrderTestCase(TestCase): def setUp(self): @@ -144,7 +182,7 @@ class BillAndOrderTestCase(TestCase): username='recurrent_product_user', email='jane.doe@domain.tld') - BillingAddress.objects.create( + self.user_addr = BillingAddress.objects.create( owner=self.user, organization = 'Test org', street="unknown", @@ -152,7 +190,7 @@ class BillAndOrderTestCase(TestCase): postal_code="unknown", active=True) - BillingAddress.objects.create( + self.recurring_user_addr = BillingAddress.objects.create( owner=self.recurring_user, organization = 'Test org', street="Somewhere", @@ -194,12 +232,13 @@ class BillAndOrderTestCase(TestCase): ] + def test_bill_one_time_one_bill_record(self): """ Ensure there is only 1 bill record per order """ - bill = Bill.create_next_bill_for_user(self.user) + bill = Bill.create_next_bill_for_user_address(self.user, self.user_addr) self.assertEqual(self.one_time_order.billrecord_set.count(), 1) @@ -208,7 +247,7 @@ class BillAndOrderTestCase(TestCase): Check the bill sum for a single one time order """ - bill = Bill.create_next_bill_for_user(self.user) + bill = Bill.create_next_bill_for_user_address(self.user, self.user_addr) self.assertEqual(bill.sum, self.order_meta[1]['price']) @@ -217,7 +256,8 @@ class BillAndOrderTestCase(TestCase): Ensure there is only 1 bill record per order """ - bill = Bill.create_next_bill_for_user(self.recurring_user) + bill = Bill.create_next_bill_for_user_address(self.recurring_user, + self.recurring_user_addr) self.assertEqual(self.recurring_order.billrecord_set.count(), 1) self.assertEqual(bill.billrecord_set.count(), 1) @@ -230,13 +270,65 @@ class BillAndOrderTestCase(TestCase): """ for ending_date in self.bill_dates: - b = Bill.create_next_bill_for_user(self.recurring_user, ending_date) + b = Bill.create_next_bill_for_user_address(self.recurring_user, self.recurring_user_addr, ending_date) b.close() bill_count = Bill.objects.filter(owner=self.recurring_user).count() self.assertEqual(len(self.bill_dates), bill_count) + def test_multi_addr_multi_bill(self): + """ + Ensure multiple bills are created if orders exist with different billing addresses + """ + + username="lotsofplaces" + multi_addr_user = get_user_model().objects.create( + username=username, + email=f"{username}@example.org") + + user_addr1 = BillingAddress.objects.create( + owner=multi_addr_user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="unknown", + active=True) + + order1 = Order.objects.create( + owner=multi_addr_user, + starting_date=self.order_meta[1]['starting_date'], + ending_date=self.order_meta[1]['ending_date'], + recurring_period=RecurringPeriod.ONE_TIME, + price=self.order_meta[1]['price'], + description=self.order_meta[1]['description'], + billing_address=BillingAddress.get_address_for(self.user)) + + # Make this address inactive + user_addr1.active = False + user_addr1.save() + + user_addr2 = BillingAddress.objects.create( + owner=multi_addr_user, + organization = 'Test2 org', + street="unknown2", + city="unknown2", + postal_code="unknown2", + active=True) + + order2 = Order.objects.create( + owner=multi_addr_user, + starting_date=self.order_meta[1]['starting_date'], + ending_date=self.order_meta[1]['ending_date'], + recurring_period=RecurringPeriod.ONE_TIME, + price=self.order_meta[1]['price'], + description=self.order_meta[1]['description'], + billing_address=BillingAddress.get_address_for(self.user)) + + + bills = Bill.create_next_bills_for_user(multi_addr_user) + + self.assertEqual(len(bills), 2) # class NotABillingTC(TestCase): # #class BillingTestCase(TestCase): From 8df1d8dc7ca2d8d52d03a20e80736c69d3df7e29 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Aug 2020 14:38:10 +0200 Subject: [PATCH 10/93] begin refactor product to user orders instead of single order Signed-off-by: Nico Schottelius --- .../migrations/0004_auto_20200809_1237.py | 23 ++++++ .../migrations/0004_auto_20200809_1237.py | 23 ++++++ .../commands/add-opennebula-vm-orders.py | 59 +++++++-------- .../migrations/0013_auto_20200809_1237.py | 40 ++++++++++ uncloud_pay/models.py | 74 +++++++++---------- uncloud_pay/templates/bill.html.j2 | 4 +- uncloud_pay/tests.py | 4 +- .../migrations/0004_auto_20200809_1237.py | 32 ++++++++ .../migrations/0004_auto_20200809_1237.py | 41 ++++++++++ uncloud_vm/models.py | 14 +--- 10 files changed, 224 insertions(+), 90 deletions(-) create mode 100644 opennebula/migrations/0004_auto_20200809_1237.py create mode 100644 uncloud_net/migrations/0004_auto_20200809_1237.py create mode 100644 uncloud_pay/migrations/0013_auto_20200809_1237.py create mode 100644 uncloud_service/migrations/0004_auto_20200809_1237.py create mode 100644 uncloud_vm/migrations/0004_auto_20200809_1237.py diff --git a/opennebula/migrations/0004_auto_20200809_1237.py b/opennebula/migrations/0004_auto_20200809_1237.py new file mode 100644 index 0000000..ac4ac86 --- /dev/null +++ b/opennebula/migrations/0004_auto_20200809_1237.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1 on 2020-08-09 12:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0013_auto_20200809_1237'), + ('opennebula', '0003_auto_20200808_1953'), + ] + + operations = [ + migrations.RemoveField( + model_name='vm', + name='order', + ), + migrations.AddField( + model_name='vm', + name='orders', + field=models.ManyToManyField(to='uncloud_pay.Order'), + ), + ] diff --git a/uncloud_net/migrations/0004_auto_20200809_1237.py b/uncloud_net/migrations/0004_auto_20200809_1237.py new file mode 100644 index 0000000..7d500c2 --- /dev/null +++ b/uncloud_net/migrations/0004_auto_20200809_1237.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1 on 2020-08-09 12:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0013_auto_20200809_1237'), + ('uncloud_net', '0003_auto_20200808_1953'), + ] + + operations = [ + migrations.RemoveField( + model_name='vpnnetwork', + name='order', + ), + migrations.AddField( + model_name='vpnnetwork', + name='orders', + field=models.ManyToManyField(to='uncloud_pay.Order'), + ), + ] diff --git a/uncloud_pay/management/commands/add-opennebula-vm-orders.py b/uncloud_pay/management/commands/add-opennebula-vm-orders.py index 6ca1b63..1d66790 100644 --- a/uncloud_pay/management/commands/add-opennebula-vm-orders.py +++ b/uncloud_pay/management/commands/add-opennebula-vm-orders.py @@ -49,15 +49,12 @@ class Command(BaseCommand): } ) - # 25206 + SSD - vm25206 = VMProduct(name="25206", cores=1, ram_in_gb=4, owner=user) + vm25206 = VMProduct.objects.create(name="one-25206", cores=1, ram_in_gb=4, owner=user) vm25206.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) - vm25206.save() - vm25206_ssd = VMDiskProduct(vm=vm25206, owner=user, size_in_gb=30) - vm25206_ssd.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) - vm25206_ssd.save() + # vm25206_ssd = VMDiskProduct.objects.create(vm=vm25206, owner=user, size_in_gb=30) + # vm25206_ssd.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) # change 1 vm25206.cores = 2 @@ -65,55 +62,53 @@ class Command(BaseCommand): vm25206.save() vm25206.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,4,17))) + sys.exit(0) + # change 2 - vm25206_ssd.size_in_gb = 50 - vm25206_ssd.save() - vm25206_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5))) + # vm25206_ssd.size_in_gb = 50 + # vm25206_ssd.save() + # vm25206_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5))) # 25206 done. # 25615 - vm25615 = VMProduct(name="25615", cores=1, ram_in_gb=4, owner=user) + vm25615 = VMProduct.objects.create(name="one-25615", cores=1, ram_in_gb=4, owner=user) vm25615.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) - vm25615.save() - - vm25615_ssd = VMDiskProduct(vm=vm25615, owner=user, size_in_gb=30) - vm25615_ssd.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) - vm25615_ssd.save() - - - Bill.create_next_bill_for_user(user) - - sys.exit(0) - - - - + # Change 2020-04-17 vm25615.cores = 2 vm25615.ram_in_gb = 8 vm25615.save() vm25615.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,4,17))) + # vm25615_ssd = VMDiskProduct(vm=vm25615, owner=user, size_in_gb=30) + # vm25615_ssd.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) + # vm25615_ssd.save() + + vm25208 = VMProduct.objects.create(name="one-25208", cores=1, ram_in_gb=4, owner=user) + vm25208.create_order_at(timezone.make_aware(datetime.datetime(2020,3,5))) + + vm25208.cores = 2 + vm25208.ram_in_gb = 8 + vm25208.save() + vm25208.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,4,17))) + + Bill.create_next_bills_for_user(user, ending_date=end_of_month(timezone.make_aware(datetime.datetime(2020,7,31)))) + + sys.exit(0) + + vm25615_ssd.size_in_gb = 50 vm25615_ssd.save() vm25615_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5))) - vm25208 = VMProduct.objects.create(name="OpenNebula 25208", - cores=1, - ram_in_gb=4, - owner=user) vm25208_ssd = VMDiskProduct.objects.create(vm=vm25208, owner=user, size_in_gb=30) - vm25208.cores = 2 - vm25208.ram_in_gb = 8 - vm25208.save() - vm25208.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,4,17))) vm25208_ssd.size_in_gb = 50 vm25208_ssd.save() diff --git a/uncloud_pay/migrations/0013_auto_20200809_1237.py b/uncloud_pay/migrations/0013_auto_20200809_1237.py new file mode 100644 index 0000000..7f1ed91 --- /dev/null +++ b/uncloud_pay/migrations/0013_auto_20200809_1237.py @@ -0,0 +1,40 @@ +# Generated by Django 3.1 on 2020-08-09 12:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0012_auto_20200809_1026'), + ] + + operations = [ + migrations.RemoveField( + model_name='sampleonetimeproduct', + name='order', + ), + migrations.RemoveField( + model_name='samplerecurringproduct', + name='order', + ), + migrations.RemoveField( + model_name='samplerecurringproductonetimefee', + name='order', + ), + migrations.AddField( + model_name='sampleonetimeproduct', + name='orders', + field=models.ManyToManyField(to='uncloud_pay.Order'), + ), + migrations.AddField( + model_name='samplerecurringproduct', + name='orders', + field=models.ManyToManyField(to='uncloud_pay.Order'), + ), + migrations.AddField( + model_name='samplerecurringproductonetimefee', + name='orders', + field=models.ManyToManyField(to='uncloud_pay.Order'), + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 5d69523..1eb2071 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -53,6 +53,10 @@ def end_of_this_month(): _, last_day = monthrange(a_day.year, a_day.month) return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0) +def end_before(a_date): + """ Return suitable datetimefield for ending just before a_date """ + return a_date - datetime.timedelta(seconds=1) + def default_payment_delay(): return timezone.now() + BILL_PAYMENT_DELAY @@ -363,30 +367,7 @@ class Order(models.Model): super().save(*args, **kwargs) def __str__(self): - return f"{self.description} (order {self.id})" - - - # def active_before(self, ending_date): - # # Was this order started before the specified ending date? - # if self.starting_date <= ending_date: - # if self.ending_date: - # if self.ending_date > ending_date: - # pass - - - # Termination needs to be verified, maybe also include checking depending orders - # @property - # def is_terminated_now(self): - # return self.is_terminated_at(timezone.now()) - - # def is_terminated_at(self, a_date): - # return self.ending_date != None and self.ending_date <= a_date - - # def terminate(self): - # if not self.is_terminated: - # self.ending_date = timezone.now() - # self.save() - + return f"{self.description} (order={self.id})" class Bill(models.Model): """ @@ -433,6 +414,14 @@ class Bill(models.Model): bill_records = BillRecord.objects.filter(bill=self) return sum([ br.sum for br in bill_records ]) + @classmethod + def create_bills_for_all_users(cls): + """ + Create bills for all users + """ + + for owner in get_user_model().objects.all(): + cls.create_next_bills_for_user(owner) @classmethod def create_next_bills_for_user(cls, owner, ending_date=None): @@ -578,14 +567,6 @@ class Bill(models.Model): return bill - @classmethod - def create_all_bills(cls): - for owner in get_user_model().objects.all(): - # mintime = time of first order - # maxtime = time of last order - # iterate month based through it - - cls.create_next_bill_for_user(owner) class BillRecord(models.Model): """ @@ -633,10 +614,19 @@ class Product(UncloudModel): description = "Generic Product" - order = models.ForeignKey(Order, - on_delete=models.CASCADE, - editable=True, - null=True) + orders = models.ManyToManyField(Order) + # one_time_order = models.ForeignKey(Order, + # on_delete=models.CASCADE, + # editable=True, + # null=True, + # related_name='product_one_time') + + # recurring_order = models.ForeignKey(Order, + # on_delete=models.CASCADE, + # editable=True, + # null=True, + # related_name='product_recurring') + # FIXME: editable=True -> is in the admin, but also editable in DRF status = models.CharField(max_length=32, @@ -691,12 +681,10 @@ class Product(UncloudModel): if not when_to_start: when_to_start = timezone.now() - if not self.order: - self.create_order_at(when_to_start, recurring_period) - - else: + # Update order = create new order + if self.order: previous_order = self.order - when_to_end = when_to_start - datetime.timedelta(seconds=1) + when_to_end = end_before(when_to_start) new_order = Order.objects.create(owner=self.owner, billing_address=self.order.billing_address, @@ -706,11 +694,15 @@ class Product(UncloudModel): description=str(self), replaces=self.order) + print(new_order) self.order.end_date = when_to_end self.order.save() self.order = new_order + else: + return self.create_order_at(when_to_start, recurring_period) + @property def recurring_price(self): """ implement correct values in the child class """ diff --git a/uncloud_pay/templates/bill.html.j2 b/uncloud_pay/templates/bill.html.j2 index d5993ae..5baa7ca 100644 --- a/uncloud_pay/templates/bill.html.j2 +++ b/uncloud_pay/templates/bill.html.j2 @@ -709,10 +709,8 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7 {% for record in bill_records %} {{ record.starting_date|date:"c" }} - {% if record.ending_date %} - {{ record.ending_date|date:"c" }} - {% endif %} - {{ record.order.description }} + {{ record.order }} {{ record.quantity|floatformat:2 }} {{ record.order.price|floatformat:2 }} diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 3ae8a65..dbbedf4 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -6,7 +6,7 @@ from .models import * from uncloud_service.models import GenericServiceProduct -class ProductOrderTestCase(TestCase): +class ProductTestCase(TestCase): """ Test products and products <-> order interaction """ @@ -168,7 +168,7 @@ class BillingAddressTestCase(TestCase): -class BillAndOrderTestCase(TestCase): +class BillTestCase(TestCase): def setUp(self): self.user_without_address = get_user_model().objects.create( username='no_home_person', diff --git a/uncloud_service/migrations/0004_auto_20200809_1237.py b/uncloud_service/migrations/0004_auto_20200809_1237.py new file mode 100644 index 0000000..743514f --- /dev/null +++ b/uncloud_service/migrations/0004_auto_20200809_1237.py @@ -0,0 +1,32 @@ +# Generated by Django 3.1 on 2020-08-09 12:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0013_auto_20200809_1237'), + ('uncloud_service', '0003_auto_20200808_1953'), + ] + + operations = [ + migrations.RemoveField( + model_name='genericserviceproduct', + name='order', + ), + migrations.RemoveField( + model_name='matrixserviceproduct', + name='order', + ), + migrations.AddField( + model_name='genericserviceproduct', + name='orders', + field=models.ManyToManyField(to='uncloud_pay.Order'), + ), + migrations.AddField( + model_name='matrixserviceproduct', + name='orders', + field=models.ManyToManyField(to='uncloud_pay.Order'), + ), + ] diff --git a/uncloud_vm/migrations/0004_auto_20200809_1237.py b/uncloud_vm/migrations/0004_auto_20200809_1237.py new file mode 100644 index 0000000..b89a920 --- /dev/null +++ b/uncloud_vm/migrations/0004_auto_20200809_1237.py @@ -0,0 +1,41 @@ +# Generated by Django 3.1 on 2020-08-09 12:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0013_auto_20200809_1237'), + ('uncloud_vm', '0003_auto_20200808_1953'), + ] + + operations = [ + migrations.RemoveField( + model_name='vmdiskproduct', + name='order', + ), + migrations.RemoveField( + model_name='vmproduct', + name='order', + ), + migrations.RemoveField( + model_name='vmsnapshotproduct', + name='order', + ), + migrations.AddField( + model_name='vmdiskproduct', + name='orders', + field=models.ManyToManyField(to='uncloud_pay.Order'), + ), + migrations.AddField( + model_name='vmproduct', + name='orders', + field=models.ManyToManyField(to='uncloud_pay.Order'), + ), + migrations.AddField( + model_name='vmsnapshotproduct', + name='orders', + field=models.ManyToManyField(to='uncloud_pay.Order'), + ), + ] diff --git a/uncloud_vm/models.py b/uncloud_vm/models.py index dc2369e..a625555 100644 --- a/uncloud_vm/models.py +++ b/uncloud_vm/models.py @@ -58,13 +58,10 @@ class VMProduct(Product): VMCluster, on_delete=models.CASCADE, editable=False, blank=True, null=True ) - # VM-specific. The name is only intended for customers: it's a pain to - # remember IDs (speaking from experience as ungleich customer)! name = models.CharField(max_length=32, blank=True, null=True) cores = models.IntegerField() ram_in_gb = models.FloatField() - # Default recurring price is PER_MONTH, see uncloud_pay.models.Product. @property def recurring_price(self): return self.cores * 3 + self.ram_in_gb * 4 @@ -83,14 +80,7 @@ class VMProduct(Product): def __str__(self): - name = f"{self.id}" - - if self.name: - name = f"{self.id} ({self.name})" - - return "VM {}: {} cores {} gb ram".format(name, - self.cores, - self.ram_in_gb) + return f"VM id={self.id},name={self.name},cores={self.cores},ram_in_gb={self.ram_in_gb}" class VMWithOSProduct(VMProduct): @@ -165,7 +155,7 @@ class VMDiskProduct(Product): default=VMDiskType.CEPH_SSD) def __str__(self): - return f"Disk {self.size_in_gb}GB ({self.disk_type}) for VM '{self.vm.name}'" + return f"Disk {self.size_in_gb}GB ({self.disk_type}) for {self.vm}" @property def recurring_price(self): From 2b29e300dd7e101f0dedacd3f56d0c61fc8f255a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Aug 2020 14:44:29 +0200 Subject: [PATCH 11/93] [product] migrate orders to ManyToManyField --- uncloud_pay/models.py | 15 ++------------- uncloud_pay/tests.py | 9 +++------ 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 1eb2071..d1c03e7 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -615,19 +615,6 @@ class Product(UncloudModel): description = "Generic Product" orders = models.ManyToManyField(Order) - # one_time_order = models.ForeignKey(Order, - # on_delete=models.CASCADE, - # editable=True, - # null=True, - # related_name='product_one_time') - - # recurring_order = models.ForeignKey(Order, - # on_delete=models.CASCADE, - # editable=True, - # null=True, - # related_name='product_recurring') - - # FIXME: editable=True -> is in the admin, but also editable in DRF status = models.CharField(max_length=32, choices=UncloudStatus.choices, @@ -656,6 +643,7 @@ class Product(UncloudModel): price=self.one_time_price, recurring_period=RecurringPeriod.ONE_TIME, description=str(self)) + self.orders.add(one_time_order) else: one_time_order = None @@ -675,6 +663,7 @@ class Product(UncloudModel): price=self.recurring_price, recurring_period=recurring_period, description=str(self)) + self.orders.add(recurring_order) def create_or_update_order(self, when_to_start=None, recurring_period=None): diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index dbbedf4..9aa86ef 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -41,25 +41,22 @@ class ProductTestCase(TestCase): """ # One order - p = SampleOneTimeProduct(owner=self.user) + p = SampleOneTimeProduct.objects.create(owner=self.user) p.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) - p.save() order_count = Order.objects.filter(owner=self.user).count() self.assertEqual(order_count, 1) # One more order - p = SampleRecurringProduct(owner=self.user) + p = SampleRecurringProduct.objects.create(owner=self.user) p.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) - p.save() order_count = Order.objects.filter(owner=self.user).count() self.assertEqual(order_count, 2) # Should create 2 orders - p = SampleRecurringProductOneTimeFee(owner=self.user) + p = SampleRecurringProductOneTimeFee.objects.create(owner=self.user) p.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) - p.save() order_count = Order.objects.filter(owner=self.user).count() self.assertEqual(order_count, 4) From 5ceaaf7c901932ca80b2f9b3f43985b597019e4b Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Aug 2020 14:52:42 +0200 Subject: [PATCH 12/93] bill cleanup, note next step --- uncloud_pay/models.py | 1 + uncloud_pay/templates/bill.html.j2 | 16 ++++++---------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index d1c03e7..6169d4f 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -670,6 +670,7 @@ class Product(UncloudModel): if not when_to_start: when_to_start = timezone.now() + # NEXT: find the latest order, use that one... # Update order = create new order if self.order: previous_order = self.order diff --git a/uncloud_pay/templates/bill.html.j2 b/uncloud_pay/templates/bill.html.j2 index 5baa7ca..6fdfca8 100644 --- a/uncloud_pay/templates/bill.html.j2 +++ b/uncloud_pay/templates/bill.html.j2 @@ -672,16 +672,12 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7
- {% if bill.billing_address.organization != "" %} - ORG{{ bill.billing_address.organization }} -
{{ bill.billing_address }} - {% else %} - {{ bill.billing_address.name }} - {% endif %} -
{{ bill.billing_address.street }} -
{{ bill.billing_address.postal_code }} {{ bill.billing_address.city }} -
{{ bill.billing_address.country }} -
+ {{ bill.billing_address.organization }}
+ {{ bill.billing_address.name }}
+ {{ bill.owner.email }}
+ {{ bill.billing_address.street }}
+ {{ bill.billing_address.country }} {{ bill.billing_address.postal_code }} {{ bill.billing_address.city }}
+
From f693dd3d18ab046232609a99b26a7796e7c6cd2c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Aug 2020 21:10:43 +0200 Subject: [PATCH 13/93] ++notes --- doc/uncloud-manual-2020-08-01.org | 1 - uncloud_pay/models.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index 6e8e4be..cead06e 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -119,7 +119,6 @@ python manage.py migrate vpn_hostname=vpn-2a0ae5c1200.ungleich.ch wireguard_private_key=$(wg genkey) **** Creating a new vpn network -** VPN *** Creating a VPN pool #+BEGIN_SRC sh diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 6169d4f..60a3401 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -666,10 +666,11 @@ class Product(UncloudModel): self.orders.add(recurring_order) - def create_or_update_order(self, when_to_start=None, recurring_period=None): + def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None): if not when_to_start: when_to_start = timezone.now() + current_recurring_order = Order.objects.filter( # NEXT: find the latest order, use that one... # Update order = create new order if self.order: From 4d5ca58b2a4102335a79358835246192c3beeb5d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 Aug 2020 20:40:33 +0200 Subject: [PATCH 14/93] [tests] cleanup old tests Finally manage.py tests runs through --- uncloud_net/tests.py | 51 ++++++++++++++++++++++--------------------- uncloud_pay/models.py | 2 +- uncloud_vm/tests.py | 16 -------------- 3 files changed, 27 insertions(+), 42 deletions(-) diff --git a/uncloud_net/tests.py b/uncloud_net/tests.py index b6700cd..540bc6a 100644 --- a/uncloud_net/tests.py +++ b/uncloud_net/tests.py @@ -57,36 +57,37 @@ class VPNTests(TestCase): # No assert needed pool = VPNPool.objects.get(network=self.pool_network2) - def test_create_vpn(self): - url = reverse("vpnnetwork-list") - view = VPNNetworkViewSet.as_view({'post': 'create'}) - request = self.factory.post(url, { 'network_size': self.pool_subnetwork_size, - 'wireguard_public_key': self.vpn_wireguard_public_key + # def test_create_vpn(self): + # url = reverse("vpnnetwork-list") + # view = VPNNetworkViewSet.as_view({'post': 'create'}) + # request = self.factory.post(url, { 'network_size': self.pool_subnetwork_size, + # 'wireguard_public_key': self.vpn_wireguard_public_key - }) - force_authenticate(request, user=self.user) + # }) + # force_authenticate(request, user=self.user) - # we don't have a billing address -> should raise an error - with self.assertRaises(ValidationError): - response = view(request) - addr = BillingAddress.objects.get_or_create( - owner=self.user, - active=True, - defaults={'organization': 'ungleich', - 'name': 'Nico Schottelius', - 'street': 'Hauptstrasse 14', - 'city': 'Luchsingen', - 'postal_code': '8775', - 'country': 'CH' } - ) + # # we don't have a billing address -> should raise an error + # # with self.assertRaises(ValidationError): + # # response = view(request) - # This should work now - response = view(request) + # addr = BillingAddress.objects.get_or_create( + # owner=self.user, + # active=True, + # defaults={'organization': 'ungleich', + # 'name': 'Nico Schottelius', + # 'street': 'Hauptstrasse 14', + # 'city': 'Luchsingen', + # 'postal_code': '8775', + # 'country': 'CH' } + # ) - # Verify that an order was created successfully - there should only be one order at - # this point in time - order = Order.objects.get(owner=self.user) + # # This should work now + # response = view(request) + + # # Verify that an order was created successfully - there should only be one order at + # # this point in time + # order = Order.objects.get(owner=self.user) def tearDown(self): diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 60a3401..fd9eab8 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -670,7 +670,7 @@ class Product(UncloudModel): if not when_to_start: when_to_start = timezone.now() - current_recurring_order = Order.objects.filter( +# current_recurring_order = Order.objects.filter( # NEXT: find the latest order, use that one... # Update order = create new order if self.order: diff --git a/uncloud_vm/tests.py b/uncloud_vm/tests.py index 1f47001..e5d403f 100644 --- a/uncloud_vm/tests.py +++ b/uncloud_vm/tests.py @@ -79,22 +79,6 @@ class VMTestCase(TestCase): # msg='VMDiskProduct created with disk image whose status is not active.' # ) - def test_vm_disk_product_creation(self): - """Ensure that a user can only create a VMDiskProduct for an existing VM""" - - disk_image = VMDiskImageProduct.objects.create( - owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10, - status='active' - ) - - with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for non-existing VM'): - # Create VMProduct object but don't save it in database - vm = VMProduct() - - vm_disk_product = VMDiskProduct.objects.create( - owner=self.user, vm=vm, image=disk_image, size_in_gb=10 - ) - # TODO: the logic tested by this test is not implemented yet. # def test_vm_disk_product_creation_for_someone_else(self): # """Ensure that a user can only create a VMDiskProduct for his/her own VM""" From 7b83efe995206b85b68bff0cfac4a404e17413aa Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 Aug 2020 21:11:28 +0200 Subject: [PATCH 15/93] [pay] make sample products more modular --- uncloud_pay/models.py | 15 +++++++++++---- uncloud_pay/tests.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index fd9eab8..f4d4c52 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -794,24 +794,31 @@ class SampleOneTimeProduct(Product): default_recurring_period = RecurringPeriod.ONE_TIME + ot_price = models.IntegerField(default=5) + @property def one_time_price(self): - return 5 + return self.ot_price class SampleRecurringProduct(Product): default_recurring_period = RecurringPeriod.PER_30D + rc_price = models.IntegerField(default=10) + @property def recurring_price(self): - return 10 + return self.rc_price class SampleRecurringProductOneTimeFee(Product): default_recurring_period = RecurringPeriod.PER_30D + ot_price = models.IntegerField(default=5) + rc_price = models.IntegerField(default=10) + @property def one_time_price(self): - return 5 + return self.ot_price @property def recurring_price(self): - return 1 + return self.rc_price diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 9aa86ef..69af3cc 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -327,6 +327,38 @@ class BillTestCase(TestCase): self.assertEqual(len(bills), 2) + +class ModifyProductTestCase(TestCase): + def setUp(self): + self.user = get_user_model().objects.create( + username='random_user', + email='jane.random@domain.tld') + + self.ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="somewhere else", + active=False) + + def test_user_no_address(self): + """ + Raise an error, when there is no address + """ + + + self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, + BillingAddress.get_address_for, + self.user) + + def test_user_only_inactive_address(self): + """ + Raise an error, when there is no active address + """ + + + # class NotABillingTC(TestCase): # #class BillingTestCase(TestCase): # def setUp(self): From ab412cb877266ef7259712e13c550b321eeefac3 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 Aug 2020 21:31:12 +0200 Subject: [PATCH 16/93] Test that creating products w/o correct billing address fails --- .../migrations/0014_auto_20200825_1915.py | 33 +++++++++++++++ uncloud_pay/models.py | 27 ++++++++++++ uncloud_pay/tests.py | 42 +++++++++++++------ 3 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 uncloud_pay/migrations/0014_auto_20200825_1915.py diff --git a/uncloud_pay/migrations/0014_auto_20200825_1915.py b/uncloud_pay/migrations/0014_auto_20200825_1915.py new file mode 100644 index 0000000..97c4b7a --- /dev/null +++ b/uncloud_pay/migrations/0014_auto_20200825_1915.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1 on 2020-08-25 19:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0013_auto_20200809_1237'), + ] + + operations = [ + migrations.AddField( + model_name='sampleonetimeproduct', + name='ot_price', + field=models.IntegerField(default=5), + ), + migrations.AddField( + model_name='samplerecurringproduct', + name='rc_price', + field=models.IntegerField(default=10), + ), + migrations.AddField( + model_name='samplerecurringproductonetimefee', + name='ot_price', + field=models.IntegerField(default=5), + ), + migrations.AddField( + model_name='samplerecurringproductonetimefee', + name='rc_price', + field=models.IntegerField(default=10), + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index f4d4c52..1f9b61a 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -789,8 +789,25 @@ class Product(UncloudModel): # FIXME: use the right type of exception here! raise Exception("Did not implement the discounter for this case") + + def save(self, *args, **kwargs): + try: + ba = BillingAddress.get_address_for(self.owner) + except BillingAddress.DoesNotExist: + raise ValidationError("User does not have a billing address") + + if not ba.active: + raise ValidationError("User does not have an active billing address") + + super().save(*args, **kwargs) + + # Sample products included into uncloud class SampleOneTimeProduct(Product): + """ + Products are usually more complex, but this product shows how easy + it can be to create your own one time product. + """ default_recurring_period = RecurringPeriod.ONE_TIME @@ -801,6 +818,11 @@ class SampleOneTimeProduct(Product): return self.ot_price class SampleRecurringProduct(Product): + """ + Products are usually more complex, but this product shows how easy + it can be to create your own recurring fee product. + """ + default_recurring_period = RecurringPeriod.PER_30D rc_price = models.IntegerField(default=10) @@ -810,6 +832,11 @@ class SampleRecurringProduct(Product): return self.rc_price class SampleRecurringProductOneTimeFee(Product): + """ + Products are usually more complex, but this product shows how easy + it can be to create your own one time + recurring fee product. + """ + default_recurring_period = RecurringPeriod.PER_30D ot_price = models.IntegerField(default=5) diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 69af3cc..6a59531 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -16,7 +16,7 @@ class ProductTestCase(TestCase): username='random_user', email='jane.random@domain.tld') - ba = BillingAddress.objects.create( + self.ba = BillingAddress.objects.create( owner=self.user, organization = 'Test org', street="unknown", @@ -34,6 +34,29 @@ class ProductTestCase(TestCase): self.assertEqual(p.one_time_price, 5) self.assertEqual(p.recurring_price, 0) + def test_create_product_without_active_billing_address(self): + """ + Fail to create a product without an active billing address + """ + + self.ba.active = False + self.ba.save() + + with self.assertRaises(ValidationError): + p = SampleOneTimeProduct.objects.create(owner=self.user) + + def test_create_product_without_billing_address(self): + """ + Fail to create a product without a billing address + """ + + user2 = get_user_model().objects.create( + username='random_user2', + email='jane.randomly@domain.tld') + + with self.assertRaises(ValidationError): + p = SampleOneTimeProduct.objects.create(owner=user2) + def test_create_order_creates_correct_order_count(self): """ @@ -329,6 +352,7 @@ class BillTestCase(TestCase): class ModifyProductTestCase(TestCase): + def setUp(self): self.user = get_user_model().objects.create( username='random_user', @@ -340,23 +364,15 @@ class ModifyProductTestCase(TestCase): street="unknown", city="unknown", postal_code="somewhere else", - active=False) + active=True) - def test_user_no_address(self): - """ - Raise an error, when there is no address + def test_modify_recurring_product(self): """ - - self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, - BillingAddress.get_address_for, - self.user) - - def test_user_only_inactive_address(self): - """ - Raise an error, when there is no active address """ + product = SampleRecurringProduct.objects.create(owner=self.user) + # class NotABillingTC(TestCase): From b8b15704a32087bc9fda02627345196c7ad959e0 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 Aug 2020 21:53:25 +0200 Subject: [PATCH 17/93] begin testing bill sums Signed-off-by: Nico Schottelius --- uncloud_pay/tests.py | 138 ++++++++----------------------------------- 1 file changed, 26 insertions(+), 112 deletions(-) diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 6a59531..0ee5abc 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -352,7 +352,6 @@ class BillTestCase(TestCase): class ModifyProductTestCase(TestCase): - def setUp(self): self.user = get_user_model().objects.create( username='random_user', @@ -366,12 +365,35 @@ class ModifyProductTestCase(TestCase): postal_code="somewhere else", active=True) - def test_modify_recurring_product(self): + def test_no_pro_rata_first_bill(self): + """ + The bill should NOT contain a partial amount """ - """ + price = 5 + + # Standard 30d recurring product + product = SampleRecurringProduct.objects.create(owner=self.user, + rc_price=price) + + starting_date = timezone.make_aware(datetime.datetime(2020,3,3)) + ending_date = timezone.make_aware(datetime.datetime(2020,3,31)) + time_diff = (ending_date - starting_date).total_seconds() + + product.create_order_at(starting_date) + + bills = Bill.create_next_bills_for_user(self.user, + ending_date=ending_date) + + + # We expect 1 bill for 1 billing address and 1 time frame + self.assertEqual(len(bills), 1) + + pro_rata_amount = time_diff / product.default_recurring_period.value + + self.assertNotEqual(bills[0].sum, pro_rata_amount * price) + self.assertEqual(bills[0].sum, price) - product = SampleRecurringProduct.objects.create(owner=self.user) @@ -500,111 +522,3 @@ class ModifyProductTestCase(TestCase): # self.assertEqual(len(second_month_bills), 1) # self.assertEqual(float(second_month_bills[0].amount), # round(12 * recurring_price, AMOUNT_DECIMALS)) - -# class ProductActivationTestCase(TestCase): -# def setUp(self): -# self.user = get_user_model().objects.create( -# username='jdoe', -# email='john.doe@domain.tld') - -# self.billing_address = BillingAddress.objects.create( -# owner=self.user, -# street="unknown", -# city="unknown", -# postal_code="unknown") - -# def test_product_activation(self): -# starting_date = datetime.fromisoformat('2020-03-01') -# one_time_price = 0 -# recurring_price = 1 -# description = "Test Product" - -# order = Order.objects.create( -# owner=self.user, -# starting_date=starting_date, -# recurring_period=RecurringPeriod.PER_30D, -# recurring_price=recurring_price, -# one_time_price=one_time_price, -# description=description, -# billing_address=self.billing_address) - -# product = GenericServiceProduct( -# custom_description=description, -# custom_one_time_price=one_time_price, -# custom_recurring_price=recurring_price, -# owner=self.user, -# order=order) -# product.save() - -# # Validate initial state: must be awaiting payment. -# self.assertEqual(product.status, UncloudStatus.AWAITING_PAYMENT) - -# # Pay initial bill, check that product is activated. -# order.generate_initial_bill() -# amount = product.order.bills[0].amount -# payment = Payment(owner=self.user, amount=amount) -# payment.save() -# self.assertEqual( -# GenericServiceProduct.objects.get(uuid=product.uuid).status, -# UncloudStatus.PENDING -# ) - -# class BillingAddressTestCase(TestCase): -# def setUp(self): -# self.user = get_user_model().objects.create( -# username='jdoe', -# email='john.doe@domain.tld') - -# self.billing_address_01 = BillingAddress.objects.create( -# owner=self.user, -# street="unknown1", -# city="unknown1", -# postal_code="unknown1", -# country="CH") - -# self.billing_address_02 = BillingAddress.objects.create( -# owner=self.user, -# street="unknown2", -# city="unknown2", -# postal_code="unknown2", -# country="CH") - -# def test_billing_with_single_address(self): -# # Create new orders somewhere in the past so that we do not encounter -# # auto-created initial bills. -# starting_date = datetime.fromisoformat('2020-03-01') - -# order_01 = Order.objects.create( -# owner=self.user, -# starting_date=starting_date, -# recurring_period=RecurringPeriod.PER_30D, -# billing_address=self.billing_address_01) -# order_02 = Order.objects.create( -# owner=self.user, -# starting_date=starting_date, -# recurring_period=RecurringPeriod.PER_30D, -# billing_address=self.billing_address_01) - -# # We need a single bill since we work with a single address. -# bills = Bill.generate_for(2020, 4, self.user) -# self.assertEqual(len(bills), 1) - -# def test_billing_with_multiple_addresses(self): -# # Create new orders somewhere in the past so that we do not encounter -# # auto-created initial bills. -# starting_date = datetime.fromisoformat('2020-03-01') - -# order_01 = Order.objects.create( -# owner=self.user, -# starting_date=starting_date, -# recurring_period=RecurringPeriod.PER_30D, -# billing_address=self.billing_address_01) -# order_02 = Order.objects.create( -# owner=self.user, -# starting_date=starting_date, -# recurring_period=RecurringPeriod.PER_30D, -# billing_address=self.billing_address_02) - -# # We need different bills since we work with different addresses. -# bills = Bill.generate_for(2020, 4, self.user) -# self.assertEqual(len(bills), 2) From 9211894b2320001462702c8b0a620e42a53ff0bb Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Aug 2020 14:45:37 +0200 Subject: [PATCH 18/93] implement basic logic for updating a recurring order Signed-off-by: Nico Schottelius --- uncloud_pay/models.py | 63 ++++++++++++++++++++++++++++++++----------- uncloud_pay/tests.py | 61 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 103 insertions(+), 21 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 1f9b61a..1d95ae1 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -608,6 +608,20 @@ class BillRecord(models.Model): # Products class Product(UncloudModel): + """ + A product is something a user orders. To record the pricing, we + create order that define a state in time. + + A product can *depend* on other products. + + A product can have *one* one_time_order and/or *one* + recurring_order. + + If either of them needs to be updated, a new order of the same + type will be created and links to the previous order. + + """ + owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False) @@ -623,7 +637,23 @@ class Product(UncloudModel): # Default period for all products default_recurring_period = RecurringPeriod.PER_30D - def create_order_at(self, when_to_start=None, recurring_period=None): + @property + def recurring_orders(self): + return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME) + + @property + def last_recurring_order(self): + return self.recurring_orders.last() + + @property + def one_time_orders(self): + return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.ONE_TIME) + + @property + def last_one_time_order(self): + return self.one_time_orders.last() + + def create_order(self, when_to_start=None, recurring_period=None): billing_address = BillingAddress.get_address_for(self.owner) if not billing_address: @@ -636,7 +666,8 @@ class Product(UncloudModel): recurring_period = self.default_recurring_period - if self.one_time_price > 0: + # Create one time order if we did not create one already + if self.one_time_price > 0 and not self.last_one_time_order: one_time_order = Order.objects.create(owner=self.owner, billing_address=billing_address, starting_date=when_to_start, @@ -667,32 +698,32 @@ class Product(UncloudModel): def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None): + + if not self.recurring_price: + return + + if not recurring_period: + recurring_period = self.default_recurring_period + if not when_to_start: when_to_start = timezone.now() -# current_recurring_order = Order.objects.filter( - # NEXT: find the latest order, use that one... - # Update order = create new order - if self.order: - previous_order = self.order + if self.last_recurring_order: when_to_end = end_before(when_to_start) new_order = Order.objects.create(owner=self.owner, - billing_address=self.order.billing_address, + billing_address=self.last_recurring_order.billing_address, starting_date=when_to_start, price=self.recurring_price, recurring_period=recurring_period, description=str(self), - replaces=self.order) - - print(new_order) - self.order.end_date = when_to_end - self.order.save() - - self.order = new_order + replaces=self.last_recurring_order) + self.last_recurring_order.end_date = when_to_end + self.orders.add(new_order) else: - return self.create_order_at(when_to_start, recurring_period) + # This might be a bug as it might (re-)create the one time order + self.create_order(when_to_start, recurring_period) @property def recurring_price(self): diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 0ee5abc..6586f3a 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -65,26 +65,37 @@ class ProductTestCase(TestCase): # One order p = SampleOneTimeProduct.objects.create(owner=self.user) - p.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) + p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) order_count = Order.objects.filter(owner=self.user).count() self.assertEqual(order_count, 1) # One more order p = SampleRecurringProduct.objects.create(owner=self.user) - p.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) + p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) order_count = Order.objects.filter(owner=self.user).count() self.assertEqual(order_count, 2) # Should create 2 orders p = SampleRecurringProductOneTimeFee.objects.create(owner=self.user) - p.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3))) + p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) order_count = Order.objects.filter(owner=self.user).count() self.assertEqual(order_count, 4) + def test_update_recurring_order(self): + """ + Ensure creating orders from product only creates 1 order + """ + + p = SampleRecurringProduct.objects.create(owner=self.user) + p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) + + p.create_or_update_recurring_order(timezone.make_aware(datetime.datetime(2020,3,3))) + + class BillingAddressTestCase(TestCase): def setUp(self): self.user = get_user_model().objects.create( @@ -351,6 +362,15 @@ class BillTestCase(TestCase): self.assertEqual(len(bills), 2) + # TO BE IMPLEMENTED -- once orders can be marked as "done" / "inactive" / "not for billing" + # def test_skip_disabled_orders(self): + # """ + # Ensure that a bill only considers "active" orders + # """ + + # self.assertEqual(1, 2) + + class ModifyProductTestCase(TestCase): def setUp(self): self.user = get_user_model().objects.create( @@ -367,7 +387,7 @@ class ModifyProductTestCase(TestCase): def test_no_pro_rata_first_bill(self): """ - The bill should NOT contain a partial amount + The bill should NOT contain a partial amount -- this is a BILL TEST :-) """ price = 5 @@ -380,7 +400,7 @@ class ModifyProductTestCase(TestCase): ending_date = timezone.make_aware(datetime.datetime(2020,3,31)) time_diff = (ending_date - starting_date).total_seconds() - product.create_order_at(starting_date) + product.create_order(starting_date) bills = Bill.create_next_bills_for_user(self.user, ending_date=ending_date) @@ -394,6 +414,37 @@ class ModifyProductTestCase(TestCase): self.assertNotEqual(bills[0].sum, pro_rata_amount * price) self.assertEqual(bills[0].sum, price) + def test_bill_for_modified_product(self): + """ + The bill should NOT contain a partial amount -- this is a BILL TEST :-) + """ + + price = 5 + + # Standard 30d recurring product + product = SampleRecurringProduct.objects.create(owner=self.user, + rc_price=price) + + starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) + ending_date = timezone.make_aware(datetime.datetime(2019,3,31)) + change_date = timezone.make_aware(datetime.datetime(2019,4,17)) + + product.create_order(starting_date) + + bills = Bill.create_next_bills_for_user(self.user, + ending_date=ending_date) + + product.rc_price = 10 + product.save() + + product.create_or_update_recurring_order(when_to_start=change_date) + + # expeted result: + # 1x 5 chf bill record + # 1x 5 chf bill record + # 1x 10 partial bill record + + From 18f9a3848aa3a14a17b281dc935c25175793a45a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Aug 2020 22:00:54 +0200 Subject: [PATCH 19/93] Implement ending/replacing date logic --- uncloud_pay/models.py | 30 +++++++++++++++++++++++++++++- uncloud_pay/tests.py | 24 ++++++++++++++++++++---- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 1d95ae1..3f8498d 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -57,6 +57,10 @@ def end_before(a_date): """ Return suitable datetimefield for ending just before a_date """ return a_date - datetime.timedelta(seconds=1) +def start_after(a_date): + """ Return suitable datetimefield for starting just after a_date """ + return a_date + datetime.timedelta(seconds=1) + def default_payment_delay(): return timezone.now() + BILL_PAYMENT_DELAY @@ -333,6 +337,24 @@ class Order(models.Model): return self.starting_date + datetime.timedelta(seconds=self.recurring_period) + @property + def next_ending_date(self): + """ + Return the next proper ending date after n times the + recurring_period, where n is an integer. + """ + + if self.recurring_period > 0: + now = timezone.now() + delta = now - self.starting_date + + num_times = math.ceil(delta.total_seconds() / self.recurring_period) + + next_date = self.starting_date + datetime.timedelta(seconds= num_times * self.recurring_period) + else: + next_date = self.starting_date + + return next_date @property def count_billed(self): @@ -698,7 +720,6 @@ class Product(UncloudModel): def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None): - if not self.recurring_price: return @@ -709,6 +730,13 @@ class Product(UncloudModel): when_to_start = timezone.now() if self.last_recurring_order: + # If the new order is less in value than the previous + # order, the previous order needs to be finished first + if self.recurring_price < self.last_recurring_order.price: + + if when_to_start < self.last_recurring_order.next_ending_date: + when_to_start = start_after(self.last_recurring_order.next_ending_date) + when_to_end = end_before(when_to_start) new_order = Order.objects.create(owner=self.owner, diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 6586f3a..d9feed0 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -416,7 +416,7 @@ class ModifyProductTestCase(TestCase): def test_bill_for_modified_product(self): """ - The bill should NOT contain a partial amount -- this is a BILL TEST :-) + Modify a product, see one pro rata entry """ price = 5 @@ -429,16 +429,32 @@ class ModifyProductTestCase(TestCase): ending_date = timezone.make_aware(datetime.datetime(2019,3,31)) change_date = timezone.make_aware(datetime.datetime(2019,4,17)) - product.create_order(starting_date) + bill_ending_date = timezone.make_aware(datetime.datetime(2019,4,30)) - bills = Bill.create_next_bills_for_user(self.user, - ending_date=ending_date) + product.create_order(starting_date) product.rc_price = 10 product.save() product.create_or_update_recurring_order(when_to_start=change_date) + bills = Bill.create_next_bills_for_user(self.user, + ending_date=bill_ending_date) + + # Sum: + # recurring1 = 5 CHF -> for 30days + # recurring2 = 5 CHF -> from 3rd of April to 3rd of May + # recurring3 = 10 CHF -> from 17th of April to ... 17th of May? + + # If replacing lower order with higher: + # - close the lower order NOW + # - start higher order NOW+1s + # If replacing higher order with lower order: + # higher order continues until its end + # lower order starts after higher order+1s + + self.assertEqual(bills[0].sum, price) + # expeted result: # 1x 5 chf bill record # 1x 5 chf bill record From 1c7d81762d13fae5d3fef76fee29bacb32380acc Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 2 Sep 2020 16:02:28 +0200 Subject: [PATCH 20/93] begin splitting bill record creation function --- uncloud_pay/admin.py | 4 +- uncloud_pay/models.py | 127 +++++++++++++++++++++++++++++++++++------- uncloud_pay/tests.py | 106 ++++++++++++++++++++++++----------- 3 files changed, 185 insertions(+), 52 deletions(-) diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index df2cdde..45714d6 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -11,7 +11,7 @@ from django.http import FileResponse from django.template.loader import render_to_string -from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress +from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress, SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee class BillRecordInline(admin.TabularInline): @@ -91,5 +91,7 @@ admin.site.register(Order) admin.site.register(BillRecord) admin.site.register(BillingAddress) +for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]: + admin.site.register(m) #admin.site.register(Order, OrderAdmin) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 3f8498d..e8e7ac2 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -459,19 +459,121 @@ class Bill(models.Model): return bills + @classmethod - def create_next_bill_for_user_address(cls, owner, billing_address, ending_date=None): + def create_bill_records_for_recurring_orders(cls, bill): + """ + Create or update bill records for recurring orders for the + given bill + """ + + owner = bill.owner + billing_address = bill.billing_address + + all_orders = Order.objects.filter(owner=owner, + billing_address=billing_address).order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME) + + for order in all_orders: + bill_record_for_this_bill = BillRecord.objects.filter(bill=bill, + order=order).first() + if bill_record_for_this_bill: + cls.update_bill_record_for_this_bill(bill_record_for_this_bill, + order, + bill) + + else: + cls.create_new_bill_record_for_this_bill(order, bill) + + + @staticmethod + def create_new_bill_record_for_this_bill(order, bill): + last_bill_record = BillRecord.objects.filter(order=order).order_by('id').last() + + this_starting_date=order.starting_date + + if last_bill_record: + if last_bill_record.ending_date >= bill.ending_date: + return + + if order.ending_date: + if last_bill_record.ending_date == order.ending_date: + return + + this_starting_date = start_after(last_bill_record.ending_date) + + + ending_date = cls.get_bill_record_ending_date(order, bill) + + return BillRecord.objects.create(bill=bill, + order=order, + starting_date=this_starting_date, + ending_date=this_ending_date) + + + @staticmethod + def update_bill_record_for_this_bill(bill_record, + order, + bill): + + # we may need to adjust it, but let's do this logic another time + + # If the order has an ending date set, we might need to adjust the bill_record + if order.ending_date: + if bill_record_for_this_bill.ending_date != order.ending_date: + bill_record_for_this_bill.ending_date = order.ending_date + + else: + # recurring, not terminated, should go until at least end of bill + if bill_record_for_this_bill.ending_date < bill.ending_date: + bill_record_for_this_bill.ending_date = bill.ending_date + + + bill_record_for_this_bill.save() + + @staticmethod + def get_bill_record_ending_date(order, bill): + """ + Determine the ending date of the billing record + + + """ + + # If the order is quit, charge the final amount (?) + if order.ending_date: + this_ending_date = order.ending_date + else: + if order.earliest_ending_date > bill.ending_date: + this_ending_date = order.earliest_ending_date + else: + this_ending_date = bill.ending_date + + return this_ending_date + + @classmethod + def create_next_bill_for_user_address(cls, + owner, + billing_address, + ending_date=None): + """ + Filtering ideas (TBD): + If order is replaced, it should not be added anymore if it has been billed "last time" + If order has ended and finally charged, do not charge anymore + + Find out the last billrecord for the order, if there is none, bill from the starting date + """ + last_bill = cls.objects.filter(owner=owner, billing_address=billing_address).order_by('id').last() # it is important to sort orders here, as bill records will be # created (and listed) in this order - all_orders = Order.objects.filter(owner=owner, billing_address=billing_address).order_by('id') + all_orders = Order.objects.filter(owner=owner, + billing_address=billing_address).order_by('id') first_order = all_orders.first() bill = None ending_date = None - # Get date & bill from previous bill + # Get date & bill from previous bill, if it exists if last_bill: if not last_bill.is_final: bill = last_bill @@ -504,12 +606,10 @@ class Bill(models.Model): starting_date=order.starting_date, ending_date=order.ending_date) - else: - # Bill all recurring orders + else: # recurring orders bill_record_for_this_bill = BillRecord.objects.filter(bill=bill, order=order).first() - # This bill already has a bill record for the order # We potentially need to update the ending_date if the ending_date # of the bill changed. @@ -529,11 +629,8 @@ class Bill(models.Model): bill_record_for_this_bill.save() - else: - # No bill record in this bill for the order yet - - # Find out whether it was already billed for the billing period of - # this bill + else: # No bill record in this bill for this order yet + # Find out when billed last time last_bill_record = BillRecord.objects.filter(order=order).order_by('id').last() # Default starting date @@ -580,12 +677,6 @@ class Bill(models.Model): - # Filtering ideas: - # If order is replaced, it should not be added anymore if it has been billed "last time" - # If order has ended and finally charged, do not charge anymore - - # Find out the last billrecord for the order, if there is none, bill from the starting date - return bill @@ -730,8 +821,6 @@ class Product(UncloudModel): when_to_start = timezone.now() if self.last_recurring_order: - # If the new order is less in value than the previous - # order, the previous order needs to be finished first if self.recurring_price < self.last_recurring_order.price: if when_to_start < self.last_recurring_order.next_ending_date: diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index d9feed0..3d805b2 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -414,53 +414,95 @@ class ModifyProductTestCase(TestCase): self.assertNotEqual(bills[0].sum, pro_rata_amount * price) self.assertEqual(bills[0].sum, price) - def test_bill_for_modified_product(self): + def test_bill_for_increasing_product_easy(self): """ - Modify a product, see one pro rata entry + Modify product, check general logi """ - price = 5 - - # Standard 30d recurring product - product = SampleRecurringProduct.objects.create(owner=self.user, - rc_price=price) - + # Create product starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) - ending_date = timezone.make_aware(datetime.datetime(2019,3,31)) - change_date = timezone.make_aware(datetime.datetime(2019,4,17)) - - bill_ending_date = timezone.make_aware(datetime.datetime(2019,4,30)) - + starting_price = 10 + product = SampleRecurringProduct.objects.create(owner=self.user, + rc_price=starting_price) product.create_order(starting_date) - product.rc_price = 10 + change1_date = timezone.make_aware(datetime.datetime(2019,4,17)) + product.rc_price = 20 product.save() + product.create_or_update_recurring_order(when_to_start=change1_date) - product.create_or_update_recurring_order(when_to_start=change_date) - + bill_ending_date = timezone.make_aware(datetime.datetime(2019,6,30)) bills = Bill.create_next_bills_for_user(self.user, ending_date=bill_ending_date) - # Sum: - # recurring1 = 5 CHF -> for 30days - # recurring2 = 5 CHF -> from 3rd of April to 3rd of May - # recurring3 = 10 CHF -> from 17th of April to ... 17th of May? + bill = bills[0] + bill_records = BillRecord.objects.filter(bill=bill) - # If replacing lower order with higher: - # - close the lower order NOW - # - start higher order NOW+1s - # If replacing higher order with lower order: - # higher order continues until its end - # lower order starts after higher order+1s + self.assertEqual(len(bill_records), 3) + self.assertEqual(int(bill.sum), 35) - self.assertEqual(bills[0].sum, price) - - # expeted result: - # 1x 5 chf bill record - # 1x 5 chf bill record - # 1x 10 partial bill record + # Expected bill sum & records: + # 2019-03-03 - 2019-04-02 +30d: 10 + # 2019-04-02 - 2019-04-17: +15d: 5 + # 2019-04-17 - 2019-05-17: +30d: 20 + # total: 35 + # def test_bill_for_increasing_product(self): + # """ + # Modify a product, see one pro rata entry + # """ + + # # Create product + # starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) + # starting_price = 30.5 + # product = SampleRecurringProduct.objects.create(owner=self.user, + # rc_price=starting_price) + # product.create_order(starting_date) + + # recurring_period = product.default_recurring_period.value + + # # First change + # change1_date = timezone.make_aware(datetime.datetime(2019,4,17)) + # product.rc_price = 49.5 + # product.save() + # product.create_or_update_recurring_order(when_to_start=change1_date) + + # # Second change + # change2_date = timezone.make_aware(datetime.datetime(2019,5,8)) + # product.rc_price = 56.5 + # product.save() + # product.create_or_update_recurring_order(when_to_start=change2_date) + + # # Create bill one month after 2nd change + # bill_ending_date = timezone.make_aware(datetime.datetime(2019,6,30)) + # bills = Bill.create_next_bills_for_user(self.user, + # ending_date=bill_ending_date) + + # # only one bill in this test case + # bill = bills[0] + + # expected_amount = starting_price + + # d2 = starting_date + recurring_period + # duration2 = change1_date - d2 + + # expected_amount = 0 + + # # Expected bill sum & records: + # # 2019-03-03 - 2019-04-02 +30d: 30.5 + # # 2019-04-02 - 2019-04-17: +15d: 15.25 + # # 2019-04-17 - 2019-05-08: +21d: (21/30) * 49.5 + # # 2019-05-08 - 2019-06-07: +30d: 56.5 + # # 2019-06-07 - 2019-07-07: +30d: 56.5 + + + # self.assertEqual(bills[0].sum, price) + + # # expeted result: + # # 1x 5 chf bill record + # # 1x 5 chf bill record + # # 1x 10 partial bill record From f7274fe967f7d1976b7bec3fc2adb2af8efd9edb Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 3 Sep 2020 16:38:51 +0200 Subject: [PATCH 21/93] Adding logic to order to find out whether its closed --- uncloud_pay/models.py | 73 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index e8e7ac2..d1cf6a3 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -290,6 +290,22 @@ class Order(models.Model): """ Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating bills. Do **NOT** mutate then! + + + An one time order is "closed" (does not need to be billed anymore) + if it has one bill record. Having more than one is a programming + error. + + A recurring order is closed if it has been replaced + (replaces__isnull=False) AND the ending_date is set AND it was + billed the last time it needed to be billed (how to check the last + item?) + + BOTH are closed, if they are ended/closed AND have been fully + charged. + + Fully charged == fully billed: sum_of_order_usage == sum_of_bill_records + """ owner = models.ForeignKey(get_user_model(), @@ -365,6 +381,34 @@ class Order(models.Model): return sum([ br.quantity for br in self.bill_records.all() ]) + @property + def count_used(self, when=None): + """ + How many times this order was billed so far. + This logic is mainly thought to be for recurring bills, but also works for one time bills + """ + + if self.is_one_time: + return 1 + + if not when: + when = timezone.now() + + # Cannot be used after it ended + if self.ending_date and when > self.ending_date: + when = self.ending_date + + return (when - self.starting_date) / self.default_recurring_period + + @property + def fully_billed(self, when=None): + """ + Returns true if this order does not need any further billing + ever. In other words: is this order "closed"? + """ + + if self.count_billed == + @property def is_recurring(self): return not self.recurring_period == RecurringPeriod.ONE_TIME @@ -375,10 +419,9 @@ class Order(models.Model): def replace_with(self, new_order): new_order.replaces = self - self.ending_date = new_order.starting_date - datetime.timedelta(seconds=1) + self.ending_date = end_before(new_order.starting_date) self.save() - def save(self, *args, **kwargs): if self.ending_date and self.ending_date < self.starting_date: raise ValidationError("End date cannot be before starting date") @@ -445,6 +488,28 @@ class Bill(models.Model): for owner in get_user_model().objects.all(): cls.create_next_bills_for_user(owner) + @classmethod + def create_next_bill_for_user_address(cls, + owner, + billing_address, + ending_date=None): + + """ + Check the existing orders of the billing address + and generate a bill if there is at least one active order + """ + + all_orders = Order.objects.filter(owner=owner, + billing_address=billing_address).order_by('id') + + + # a bill. If there + # for order in all_orders: + # if order.is_one_time: + # cls.create_one_time_record + + # pass + @classmethod def create_next_bills_for_user(cls, owner, ending_date=None): """ @@ -534,8 +599,6 @@ class Bill(models.Model): def get_bill_record_ending_date(order, bill): """ Determine the ending date of the billing record - - """ # If the order is quit, charge the final amount (?) @@ -550,7 +613,7 @@ class Bill(models.Model): return this_ending_date @classmethod - def create_next_bill_for_user_address(cls, + def create_next_bill_for_user_address_old(cls, owner, billing_address, ending_date=None): From 077c665c53a2869716cd81990777d81fb96f4a16 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 3 Sep 2020 17:16:18 +0200 Subject: [PATCH 22/93] ++update --- uncloud_pay/models.py | 52 ++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index d1cf6a3..d65c7d2 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -372,6 +372,24 @@ class Order(models.Model): return next_date + def get_ending_date_for_bill(bill): + """ + Determine the ending date given a specific bill + """ + + # If the order is quit, charge the final amount (?) + if order.ending_date: + this_ending_date = order.ending_date + else: + if order.earliest_ending_date > bill.ending_date: + this_ending_date = order.earliest_ending_date + else: + this_ending_date = bill.ending_date + + return this_ending_date + + + @property def count_billed(self): """ @@ -381,7 +399,6 @@ class Order(models.Model): return sum([ br.quantity for br in self.bill_records.all() ]) - @property def count_used(self, when=None): """ How many times this order was billed so far. @@ -401,13 +418,23 @@ class Order(models.Model): return (when - self.starting_date) / self.default_recurring_period @property - def fully_billed(self, when=None): + def all_usage_billed(self, when=None): """ Returns true if this order does not need any further billing ever. In other words: is this order "closed"? """ - if self.count_billed == + if self.count_billed == self.count_used(when): + return True + else: + return False + + @property + def is_closed(self): + if self.all_usage_billed and self.ending_date: + return True + else: + return False @property def is_recurring(self): @@ -567,7 +594,7 @@ class Bill(models.Model): this_starting_date = start_after(last_bill_record.ending_date) - ending_date = cls.get_bill_record_ending_date(order, bill) + ending_date = order.get_ending_date_for_bill(bill) return BillRecord.objects.create(bill=bill, order=order, @@ -595,23 +622,6 @@ class Bill(models.Model): bill_record_for_this_bill.save() - @staticmethod - def get_bill_record_ending_date(order, bill): - """ - Determine the ending date of the billing record - """ - - # If the order is quit, charge the final amount (?) - if order.ending_date: - this_ending_date = order.ending_date - else: - if order.earliest_ending_date > bill.ending_date: - this_ending_date = order.earliest_ending_date - else: - this_ending_date = bill.ending_date - - return this_ending_date - @classmethod def create_next_bill_for_user_address_old(cls, owner, From d8a7964fed9d081ece0325947d6f744ceb954d46 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 9 Sep 2020 00:35:55 +0200 Subject: [PATCH 23/93] Continue to refactor for shifting logic into the order --- doc/uncloud-manual-2020-08-01.org | 17 ++- uncloud_pay/models.py | 226 +++++++++++++++++++----------- uncloud_pay/tests.py | 9 +- 3 files changed, 161 insertions(+), 91 deletions(-) diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index cead06e..4bd2876 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -95,8 +95,8 @@ python manage.py migrate **** Add it to DNS as vpn-XXX.ungleich.ch **** Route a /40 network to its IPv6 address **** Install wireguard on it -**** TODO Enable wireguard on boot -**** TODO Create a new VPNPool on uncloud with +**** TODO [#C] Enable wireguard on boot +**** TODO [#C] Create a new VPNPool on uncloud with ***** the network address (selecting from our existing pool) ***** the network size (/...) ***** the vpn host that provides the network (selecting the created VM) @@ -210,3 +210,16 @@ VPNNetworks can be managed by all authenticated users. *** Decision We use integers, because they are easy. + +** Milestones :uncloud: +*** 1.1 (cleanup 1) +**** +*** 1.0 (initial release) +**** TODO Initial Generic product support + - Product +***** TODO Recurring product support +****** TODO Support replacing orders for updates +****** TODO [#A] Finish split of bill creation +****** TODO [#A] Test the new functions in the Order class +***** TODO Generating bill for admins/staff + - diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index d65c7d2..932888d 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -372,24 +372,23 @@ class Order(models.Model): return next_date - def get_ending_date_for_bill(bill): + def get_ending_date_for_bill(self, bill): """ Determine the ending date given a specific bill """ # If the order is quit, charge the final amount (?) - if order.ending_date: - this_ending_date = order.ending_date + if self.ending_date: + this_ending_date = self.ending_date else: - if order.earliest_ending_date > bill.ending_date: - this_ending_date = order.earliest_ending_date + if self.earliest_ending_date > bill.ending_date: + this_ending_date = self.earliest_ending_date else: this_ending_date = bill.ending_date return this_ending_date - @property def count_billed(self): """ @@ -458,6 +457,74 @@ class Order(models.Model): super().save(*args, **kwargs) + + def create_bill_record(self, bill): + br = None + + if self.is_one_time: + if self.billrecord_set.count() == 0: + br = BillRecord.objects.create(bill=bill, + order=self, + starting_date=self.starting_date, + ending_date=self.ending_date) + else: + br = BillRecord.objects.filter(bill=bill, order=self).first() + if br: + self.update_bill_record_for_recurring_order(br, bill) + + else: + br = self.create_new_bill_record_for_recurring_order(bill) + + return br + + def update_bill_record_for_recurring_order(self, + bill_record, + bill): + """ + Possibly update a bill record according to the information in the bill + """ + + # If the order has an ending date set, we might need to adjust the bill_record + if self.ending_date: + if bill_record_for_this_bill.ending_date != self.ending_date: + bill_record_for_this_bill.ending_date = self.ending_date + + else: + # recurring, not terminated, should go until at least end of bill + if bill_record_for_this_bill.ending_date < bill.ending_date: + bill_record_for_this_bill.ending_date = bill.ending_date + + bill_record_for_this_bill.save() + + def create_new_bill_record_for_recurring_order(self, bill): + """ + Create a new bill record + """ + + last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last() + + starting_date=self.starting_date + + if last_bill_record: + # We already charged beyond the end of this bill's period + if last_bill_record.ending_date >= bill.ending_date: + return + + # This order is terminated or replaced + if self.ending_date: + # And the last bill record already covered us -> nothing to be done anymore + if last_bill_record.ending_date == self.ending_date: + return + + starting_date = start_after(last_bill_record.ending_date) + + ending_date = self.get_ending_date_for_bill(bill) + + return BillRecord.objects.create(bill=bill, + order=self, + starting_date=starting_date, + ending_date=ending_date) + def __str__(self): return f"{self.description} (order={self.id})" @@ -509,34 +576,12 @@ class Bill(models.Model): @classmethod def create_bills_for_all_users(cls): """ - Create bills for all users + Create next bill for each user """ for owner in get_user_model().objects.all(): cls.create_next_bills_for_user(owner) - @classmethod - def create_next_bill_for_user_address(cls, - owner, - billing_address, - ending_date=None): - - """ - Check the existing orders of the billing address - and generate a bill if there is at least one active order - """ - - all_orders = Order.objects.filter(owner=owner, - billing_address=billing_address).order_by('id') - - - # a bill. If there - # for order in all_orders: - # if order.is_one_time: - # cls.create_one_time_record - - # pass - @classmethod def create_next_bills_for_user(cls, owner, ending_date=None): """ @@ -547,80 +592,93 @@ class Bill(models.Model): bills = [] for billing_address in BillingAddress.objects.filter(owner=owner): - bills.append(cls.create_next_bill_for_user_address(owner, billing_address, ending_date)) + bills.append(cls.create_next_bill_for_user_address(billing_address, ending_date)) return bills + @classmethod + def get_or_create_bill(cls, billing_address): + last_bill = cls.objects.filter(billing_address=billing_address).order_by('id').last() + + all_orders = Order.objects.filter(billing_address=billing_address).order_by('id') + first_order = all_orders.first() + + bill = None + ending_date = None + + # Get date & bill from previous bill, if it exists + if last_bill: + if not last_bill.is_final: + bill = last_bill + starting_date = last_bill.starting_date + ending_date = bill.ending_date + else: + starting_date = last_bill.ending_date + datetime.timedelta(seconds=1) + else: + # Might be an idea to make this the start of the month, too + if first_order: + starting_date = first_order.starting_date + else: + starting_date = timezone.now() + + if not ending_date: + ending_date = end_of_month(starting_date) + + if not bill: + bill = cls.objects.create( + owner=billing_address.owner, + starting_date=starting_date, + ending_date=ending_date, + billing_address=billing_address) + + + return bill @classmethod - def create_bill_records_for_recurring_orders(cls, bill): + def create_next_bill_for_user_address(cls, + billing_address, + ending_date=None): + """ - Create or update bill records for recurring orders for the - given bill + Create the next bill for a specific billing address of a user """ - owner = bill.owner - billing_address = bill.billing_address + owner = billing_address.owner all_orders = Order.objects.filter(owner=owner, - billing_address=billing_address).order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME) + billing_address=billing_address).order_by('id') + + bill = cls.get_or_create_bill(billing_address) for order in all_orders: - bill_record_for_this_bill = BillRecord.objects.filter(bill=bill, - order=order).first() - if bill_record_for_this_bill: - cls.update_bill_record_for_this_bill(bill_record_for_this_bill, - order, - bill) + order.create_bill_record(bill) - else: - cls.create_new_bill_record_for_this_bill(order, bill) + return bill + # @classmethod + # def create_bill_records_for_recurring_orders(cls, bill): + # """ + # Create or update bill records for recurring orders for the + # given bill + # """ - @staticmethod - def create_new_bill_record_for_this_bill(order, bill): - last_bill_record = BillRecord.objects.filter(order=order).order_by('id').last() + # owner = bill.owner + # billing_address = bill.billing_address - this_starting_date=order.starting_date + # all_orders = Order.objects.filter(owner=owner, + # billing_address=billing_address).order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME) - if last_bill_record: - if last_bill_record.ending_date >= bill.ending_date: - return + # for order in all_orders: + # bill_record_for_this_bill = BillRecord.objects.filter(bill=bill, + # order=order).first() + # if bill_record_for_this_bill: + # cls.update_bill_record_for_this_bill(bill_record_for_this_bill, + # order, + # bill) - if order.ending_date: - if last_bill_record.ending_date == order.ending_date: - return + # else: + # cls.create_new_bill_record_for_this_bill(order, bill) - this_starting_date = start_after(last_bill_record.ending_date) - - - ending_date = order.get_ending_date_for_bill(bill) - - return BillRecord.objects.create(bill=bill, - order=order, - starting_date=this_starting_date, - ending_date=this_ending_date) - - - @staticmethod - def update_bill_record_for_this_bill(bill_record, - order, - bill): - - # we may need to adjust it, but let's do this logic another time - - # If the order has an ending date set, we might need to adjust the bill_record - if order.ending_date: - if bill_record_for_this_bill.ending_date != order.ending_date: - bill_record_for_this_bill.ending_date = order.ending_date - - else: - # recurring, not terminated, should go until at least end of bill - if bill_record_for_this_bill.ending_date < bill.ending_date: - bill_record_for_this_bill.ending_date = bill.ending_date - - - bill_record_for_this_bill.save() @classmethod def create_next_bill_for_user_address_old(cls, diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 3d805b2..3099ed3 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -269,7 +269,7 @@ class BillTestCase(TestCase): Ensure there is only 1 bill record per order """ - bill = Bill.create_next_bill_for_user_address(self.user, self.user_addr) + bill = Bill.create_next_bill_for_user_address(self.user_addr) self.assertEqual(self.one_time_order.billrecord_set.count(), 1) @@ -278,7 +278,7 @@ class BillTestCase(TestCase): Check the bill sum for a single one time order """ - bill = Bill.create_next_bill_for_user_address(self.user, self.user_addr) + bill = Bill.create_next_bill_for_user_address(self.user_addr) self.assertEqual(bill.sum, self.order_meta[1]['price']) @@ -287,8 +287,7 @@ class BillTestCase(TestCase): Ensure there is only 1 bill record per order """ - bill = Bill.create_next_bill_for_user_address(self.recurring_user, - self.recurring_user_addr) + bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr) self.assertEqual(self.recurring_order.billrecord_set.count(), 1) self.assertEqual(bill.billrecord_set.count(), 1) @@ -301,7 +300,7 @@ class BillTestCase(TestCase): """ for ending_date in self.bill_dates: - b = Bill.create_next_bill_for_user_address(self.recurring_user, self.recurring_user_addr, ending_date) + b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date) b.close() bill_count = Bill.objects.filter(owner=self.recurring_user).count() From 1aead501703aaefb7cf2a3e43291b1851d113155 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 28 Sep 2020 20:44:50 +0200 Subject: [PATCH 24/93] remove big mistake: orders from product Signed-off-by: Nico Schottelius --- doc/uncloud-manual-2020-08-01.org | 90 ++++++++++++++++- .../migrations/0005_remove_vm_orders.py | 17 ++++ .../0005_remove_vpnnetwork_orders.py | 17 ++++ .../migrations/0015_auto_20200928_1844.py | 25 +++++ uncloud_pay/models.py | 28 +++--- uncloud_pay/tests.py | 99 +++++++++++++++---- .../migrations/0005_auto_20200928_1844.py | 21 ++++ .../migrations/0005_auto_20200928_1844.py | 25 +++++ 8 files changed, 287 insertions(+), 35 deletions(-) create mode 100644 opennebula/migrations/0005_remove_vm_orders.py create mode 100644 uncloud_net/migrations/0005_remove_vpnnetwork_orders.py create mode 100644 uncloud_pay/migrations/0015_auto_20200928_1844.py create mode 100644 uncloud_service/migrations/0005_auto_20200928_1844.py create mode 100644 uncloud_vm/migrations/0005_auto_20200928_1844.py diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index 4bd2876..d316d18 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -84,6 +84,14 @@ python manage.py migrate * URLs - api/ - the rest API * uncloud Products +** Product features + - Dependencies on other products + - Minimum parameters (min cpu, min ram, etc). + - Can also realise the dcl vm + - dualstack vm = VM + IPv4 + SSD + - Need to have a non-misguiding name for the "bare VM" + - Should support network boot (?) + ** VPN *** How to add a new VPN Host **** Install wireguard to the host @@ -219,7 +227,85 @@ VPNNetworks can be managed by all authenticated users. - Product ***** TODO Recurring product support ****** TODO Support replacing orders for updates -****** TODO [#A] Finish split of bill creation -****** TODO [#A] Test the new functions in the Order class +****** DONE [#A] Finish split of bill creation + CLOSED: [2020-09-11 Fri 23:19] +****** TODO Test the new functions in the Order class +****** Define the correct order replacement logic + Assumption: + - recurringperiods are 30days +******* Case 1: downgrading + - User commits to 10 CHF for 30 days + - Wants to downgrade after 15 days to 5 CHF product + - Expected result: + - order 1: 10 CHF until +30days + - order 2: 5 CHF starting 30days + 1s + - Sum of the two orders is 15 CHF + - Question is + - when is the VM shutdown? + - a) instantly + - b) at the end of the cycle + - best solution + - user can choose between a ... b any time +******* Duration + - You cannot cancel the duration + - You can upgrade and with that cancel the duration + - The idea of a duration is that you commit for it + - If you want to commit lower (daily basis for instance) you + have higher per period prices +******* Case X + - User has VM with 2 Core / 2 GB RAM + - User modifies with to 1 core / 3 GB RAM + - We treat it as down/upgrade independent of the modifications + +******* Case 2: upgrading after 1 day + - committed for 30 days + - upgrade after 1 day + - so first order will be charged for 1/30ths + +******* Case 2: upgrading + - User commits to 10 CHF for 30 days + - Wants to upgrade after 15 days to 20 CHF product + - Order 1 : 1 VM with 2 Core / 2 GB / 10 SSD -- 10 CHF + - 30days period, stopped after 15, so quantity is 0.5 = 5 CHF + - Order 2 : 1 VM with 2 Core / 6 GB / 10 SSD -- 20 CHF + - after 15 days + - VM is upgraded instantly + - Expected result: + - order 1: 10 CHF until +15days = 0.5 units = 5 CHF + - order 2: 20 CHF starting 15days + 1s ... +30 days after + the 15 days -> 45 days = 1 unit = 20 CHF + - Total on bill: 25 CHF + +******* Case 2: upgrading + - User commits to 10 CHF for 30 days + - Wants to upgrade after 15 days to 20 CHF product + - Expected result: + - order 1: 10 CHF until +30days = 1 units = 10 CHF + + - order 2: 20 CHF starting 15days + 1s = 1 unit = 20 CHF + - Total on bill: 30 CHF + + +****** TODO Note: ending date not set if replaced by default (implicit!) + - Should the new order modify the old order on save()? +****** DONE Fix totally wrong bill dates in our test case + CLOSED: [2020-09-09 Wed 01:00] + - 2020 used instead of 2019 + - Was due to existing test data ... +***** TODO Bill logic is still wrong + - Bill starting_date is the date of the first order + - However first encountered order does not have to be the + earliest in the bill! + - Bills should not have a duration + - Bills should only have a (unique) issue date + - We charge based on bill_records + - Last time charged issue date of the bill OR earliest date + after that + - Every bill generation checks all (relevant) orders + - add a flag "not_for_billing" or "closed" + - query on that flag + - verify it every time + + ***** TODO Generating bill for admins/staff - diff --git a/opennebula/migrations/0005_remove_vm_orders.py b/opennebula/migrations/0005_remove_vm_orders.py new file mode 100644 index 0000000..8426aec --- /dev/null +++ b/opennebula/migrations/0005_remove_vm_orders.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-09-28 18:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('opennebula', '0004_auto_20200809_1237'), + ] + + operations = [ + migrations.RemoveField( + model_name='vm', + name='orders', + ), + ] diff --git a/uncloud_net/migrations/0005_remove_vpnnetwork_orders.py b/uncloud_net/migrations/0005_remove_vpnnetwork_orders.py new file mode 100644 index 0000000..f7b607a --- /dev/null +++ b/uncloud_net/migrations/0005_remove_vpnnetwork_orders.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-09-28 18:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0004_auto_20200809_1237'), + ] + + operations = [ + migrations.RemoveField( + model_name='vpnnetwork', + name='orders', + ), + ] diff --git a/uncloud_pay/migrations/0015_auto_20200928_1844.py b/uncloud_pay/migrations/0015_auto_20200928_1844.py new file mode 100644 index 0000000..4aecb6e --- /dev/null +++ b/uncloud_pay/migrations/0015_auto_20200928_1844.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1 on 2020-09-28 18:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0014_auto_20200825_1915'), + ] + + operations = [ + migrations.RemoveField( + model_name='sampleonetimeproduct', + name='orders', + ), + migrations.RemoveField( + model_name='samplerecurringproduct', + name='orders', + ), + migrations.RemoveField( + model_name='samplerecurringproductonetimefee', + name='orders', + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 932888d..5b6299c 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -354,17 +354,18 @@ class Order(models.Model): return self.starting_date + datetime.timedelta(seconds=self.recurring_period) @property - def next_ending_date(self): + def next_cancel_or_downgrade_date(self): """ Return the next proper ending date after n times the - recurring_period, where n is an integer. + recurring_period, where n is an integer that applies for downgrading + or cancelling. """ if self.recurring_period > 0: now = timezone.now() delta = now - self.starting_date - num_times = math.ceil(delta.total_seconds() / self.recurring_period) + num_times = ceil(delta.total_seconds() / self.recurring_period) next_date = self.starting_date + datetime.timedelta(seconds= num_times * self.recurring_period) else: @@ -452,8 +453,9 @@ class Order(models.Model): if self.ending_date and self.ending_date < self.starting_date: raise ValidationError("End date cannot be before starting date") - if self.ending_date and self.ending_date < self.earliest_ending_date: - raise ValidationError("Ending date is before minimum duration (starting_date + recurring period)") + # do not check this if we upgrade + # if self.ending_date and self.ending_date < self.earliest_ending_date: + # raise ValidationError("Ending date is before minimum duration (starting_date + recurring period)") super().save(*args, **kwargs) @@ -853,11 +855,9 @@ class BillRecord(models.Model): class Product(UncloudModel): """ - A product is something a user orders. To record the pricing, we + A product is something a user can order. To record the pricing, we create order that define a state in time. - A product can *depend* on other products. - A product can have *one* one_time_order and/or *one* recurring_order. @@ -872,12 +872,12 @@ class Product(UncloudModel): description = "Generic Product" - orders = models.ManyToManyField(Order) - status = models.CharField(max_length=32, choices=UncloudStatus.choices, default=UncloudStatus.AWAITING_PAYMENT) +# config = models.JSONField() + # Default period for all products default_recurring_period = RecurringPeriod.PER_30D @@ -941,6 +941,7 @@ class Product(UncloudModel): self.orders.add(recurring_order) + # FIXME: this could/should be part of Order (?) def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None): if not self.recurring_price: return @@ -954,8 +955,8 @@ class Product(UncloudModel): if self.last_recurring_order: if self.recurring_price < self.last_recurring_order.price: - if when_to_start < self.last_recurring_order.next_ending_date: - when_to_start = start_after(self.last_recurring_order.next_ending_date) + if when_to_start < self.last_recurring_order.next_cancel_or_downgrade_date: + when_to_start = start_after(self.last_recurring_order.next_cancel_or_downgrade_date) when_to_end = end_before(when_to_start) @@ -967,10 +968,9 @@ class Product(UncloudModel): description=str(self), replaces=self.last_recurring_order) - self.last_recurring_order.end_date = when_to_end + self.last_recurring_order.replace_with(new_order) self.orders.add(new_order) else: - # This might be a bug as it might (re-)create the one time order self.create_order(when_to_start, recurring_period) @property diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 3099ed3..bee3545 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -1,6 +1,7 @@ from django.test import TestCase from django.contrib.auth import get_user_model from datetime import datetime, date, timedelta +from django.utils import timezone from .models import * from uncloud_service.models import GenericServiceProduct @@ -93,7 +94,9 @@ class ProductTestCase(TestCase): p = SampleRecurringProduct.objects.create(owner=self.user) p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - p.create_or_update_recurring_order(timezone.make_aware(datetime.datetime(2020,3,3))) + p.create_or_update_recurring_order(timezone.make_aware(datetime.datetime(2020,3,4))) + + # FIXME: where is the assert? class BillingAddressTestCase(TestCase): @@ -413,38 +416,96 @@ class ModifyProductTestCase(TestCase): self.assertNotEqual(bills[0].sum, pro_rata_amount * price) self.assertEqual(bills[0].sum, price) - def test_bill_for_increasing_product_easy(self): + + def test_downgrade_product(self): """ - Modify product, check general logi + Test downgrading behaviour: + + We create a recurring product (recurring time: 30 days) and downgrade after 15 days. + + We create the bill right AFTER the end of the first order. + + Expected result: + + - First bill record for 30 days + - Second bill record starting after 30 days + - Bill contains two bill records + """ - # Create product - starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) + user = self.user + + + starting_price = 10 - product = SampleRecurringProduct.objects.create(owner=self.user, - rc_price=starting_price) + downgrade_price = 5 + + + starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) + first_order_should_end_at = starting_date + datetime.timedelta(days=30) + change1_date = start_after(starting_date + datetime.timedelta(days=15)) + bill_ending_date = change1_date + datetime.timedelta(days=1) + + + product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) product.create_order(starting_date) - change1_date = timezone.make_aware(datetime.datetime(2019,4,17)) - product.rc_price = 20 + product.rc_price = downgrade_price product.save() product.create_or_update_recurring_order(when_to_start=change1_date) - bill_ending_date = timezone.make_aware(datetime.datetime(2019,6,30)) - bills = Bill.create_next_bills_for_user(self.user, - ending_date=bill_ending_date) + bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) bill = bills[0] bill_records = BillRecord.objects.filter(bill=bill) - self.assertEqual(len(bill_records), 3) - self.assertEqual(int(bill.sum), 35) + self.assertEqual(len(bill_records), 2) + + self.assertEqual(bill_records[0].starting_date, starting_date) + + self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at) + + # self.assertEqual(bill_records[0].ending_date, first_order_should_end_at) + + # self.assertEqual(bill_records[0].quantity, 1) + + # self.assertEqual(bill_records[1].quantity, 1) + # self.assertEqual(int(bill.sum), 15) + + def test_upgrade_product(self): + """ + Test upgrading behaviour + """ + + user = self.user + + # Create product + starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) + starting_price = 10 + product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) + product.create_order(starting_date) + + change1_date = start_after(starting_date + datetime.timedelta(days=15)) + product.rc_price = 20 + product.save() + product.create_or_update_recurring_order(when_to_start=change1_date) + + bill_ending_date = change1_date + datetime.timedelta(days=1) + bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) + + bill = bills[0] + bill_records = BillRecord.objects.filter(bill=bill) + + self.assertEqual(len(bill_records), 2) + self.assertEqual(bill_records[0].quantity, .5) + + self.assertEqual(bill_records[0].ending_date, end_before(change1_date)) + + self.assertEqual(bill_records[1].quantity, 1) + self.assertEqual(bill_records[1].starting_date, change1_date) + + self.assertEqual(int(bill.sum), 25) - # Expected bill sum & records: - # 2019-03-03 - 2019-04-02 +30d: 10 - # 2019-04-02 - 2019-04-17: +15d: 5 - # 2019-04-17 - 2019-05-17: +30d: 20 - # total: 35 # def test_bill_for_increasing_product(self): diff --git a/uncloud_service/migrations/0005_auto_20200928_1844.py b/uncloud_service/migrations/0005_auto_20200928_1844.py new file mode 100644 index 0000000..7cc4b92 --- /dev/null +++ b/uncloud_service/migrations/0005_auto_20200928_1844.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1 on 2020-09-28 18:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_service', '0004_auto_20200809_1237'), + ] + + operations = [ + migrations.RemoveField( + model_name='genericserviceproduct', + name='orders', + ), + migrations.RemoveField( + model_name='matrixserviceproduct', + name='orders', + ), + ] diff --git a/uncloud_vm/migrations/0005_auto_20200928_1844.py b/uncloud_vm/migrations/0005_auto_20200928_1844.py new file mode 100644 index 0000000..0a28188 --- /dev/null +++ b/uncloud_vm/migrations/0005_auto_20200928_1844.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1 on 2020-09-28 18:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0004_auto_20200809_1237'), + ] + + operations = [ + migrations.RemoveField( + model_name='vmdiskproduct', + name='orders', + ), + migrations.RemoveField( + model_name='vmproduct', + name='orders', + ), + migrations.RemoveField( + model_name='vmsnapshotproduct', + name='orders', + ), + ] From c6bacab35ac1a19638870ee46bef6c0169b71a84 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 28 Sep 2020 20:59:08 +0200 Subject: [PATCH 25/93] Phasing out Product model Signed-off-by: Nico Schottelius --- .../migrations/0006_auto_20200928_1858.py | 25 +++++++ opennebula/models.py | 2 +- opennebula/views.py | 22 +++---- uncloud/urls.py | 4 +- .../migrations/0006_auto_20200928_1858.py | 25 +++++++ uncloud_net/models.py | 2 +- .../migrations/0016_auto_20200928_1858.py | 65 +++++++++++++++++++ uncloud_pay/models.py | 12 ++-- .../migrations/0006_auto_20200928_1858.py | 37 +++++++++++ uncloud_service/models.py | 4 +- .../migrations/0006_auto_20200928_1858.py | 49 ++++++++++++++ uncloud_vm/models.py | 6 +- 12 files changed, 227 insertions(+), 26 deletions(-) create mode 100644 opennebula/migrations/0006_auto_20200928_1858.py create mode 100644 uncloud_net/migrations/0006_auto_20200928_1858.py create mode 100644 uncloud_pay/migrations/0016_auto_20200928_1858.py create mode 100644 uncloud_service/migrations/0006_auto_20200928_1858.py create mode 100644 uncloud_vm/migrations/0006_auto_20200928_1858.py diff --git a/opennebula/migrations/0006_auto_20200928_1858.py b/opennebula/migrations/0006_auto_20200928_1858.py new file mode 100644 index 0000000..49da56f --- /dev/null +++ b/opennebula/migrations/0006_auto_20200928_1858.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1 on 2020-09-28 18:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('opennebula', '0005_remove_vm_orders'), + ] + + operations = [ + migrations.RemoveField( + model_name='vm', + name='extra_data', + ), + migrations.RemoveField( + model_name='vm', + name='owner', + ), + migrations.RemoveField( + model_name='vm', + name='status', + ), + ] diff --git a/opennebula/models.py b/opennebula/models.py index 6c7dc52..f15b845 100644 --- a/opennebula/models.py +++ b/opennebula/models.py @@ -10,7 +10,7 @@ storage_class_mapping = { 'hdd': 'hdd' } -class VM(Product): +class VM(models.Model): vmid = models.IntegerField(primary_key=True) data = models.JSONField() diff --git a/opennebula/views.py b/opennebula/views.py index 89b1a52..688f0b4 100644 --- a/opennebula/views.py +++ b/opennebula/views.py @@ -1,16 +1,16 @@ from rest_framework import viewsets, permissions -from .models import VM -from .serializers import OpenNebulaVMSerializer +#from .models import VM +# from .serializers import OpenNebulaVMSerializer -class VMViewSet(viewsets.ModelViewSet): - permission_classes = [permissions.IsAuthenticated] - serializer_class = OpenNebulaVMSerializer +# class VMViewSet(viewsets.ModelViewSet): +# permission_classes = [permissions.IsAuthenticated] +# serializer_class = OpenNebulaVMSerializer - def get_queryset(self): - if self.request.user.is_superuser: - obj = VM.objects.all() - else: - obj = VM.objects.filter(owner=self.request.user) +# def get_queryset(self): +# if self.request.user.is_superuser: +# obj = VM.objects.all() +# else: +# obj = VM.objects.filter(owner=self.request.user) - return obj +# return obj diff --git a/uncloud/urls.py b/uncloud/urls.py index 8b4862e..ef950a0 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -12,7 +12,7 @@ from django.conf.urls.static import static from rest_framework import routers from rest_framework.schemas import get_schema_view -from opennebula import views as oneviews +#from opennebula import views as oneviews from uncloud_auth import views as authviews from uncloud_net import views as netviews from uncloud_pay import views as payviews @@ -60,7 +60,7 @@ router.register(r'v1/admin/order', payviews.AdminOrderViewSet, basename='admin/o router.register(r'v1/admin/vmhost', vmviews.VMHostViewSet) router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet) router.register(r'v1/admin/vpnpool', netviews.VPNPoolViewSet) -router.register(r'v1/admin/opennebula', oneviews.VMViewSet, basename='opennebula') +#router.register(r'v1/admin/opennebula', oneviews.VMViewSet, basename='opennebula') # User/Account router.register(r'v1/my/user', authviews.UserViewSet, basename='user') diff --git a/uncloud_net/migrations/0006_auto_20200928_1858.py b/uncloud_net/migrations/0006_auto_20200928_1858.py new file mode 100644 index 0000000..b1a04a6 --- /dev/null +++ b/uncloud_net/migrations/0006_auto_20200928_1858.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1 on 2020-09-28 18:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0005_remove_vpnnetwork_orders'), + ] + + operations = [ + migrations.RemoveField( + model_name='vpnnetwork', + name='extra_data', + ), + migrations.RemoveField( + model_name='vpnnetwork', + name='owner', + ), + migrations.RemoveField( + model_name='vpnnetwork', + name='status', + ), + ] diff --git a/uncloud_net/models.py b/uncloud_net/models.py index 4f80246..153b456 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -163,7 +163,7 @@ class VPNNetworkReservation(UncloudModel): ) -class VPNNetwork(Product): +class VPNNetwork(models.Model): """ A selected network. Used for tracking reservations / used networks """ diff --git a/uncloud_pay/migrations/0016_auto_20200928_1858.py b/uncloud_pay/migrations/0016_auto_20200928_1858.py new file mode 100644 index 0000000..0c5ebfa --- /dev/null +++ b/uncloud_pay/migrations/0016_auto_20200928_1858.py @@ -0,0 +1,65 @@ +# Generated by Django 3.1 on 2020-09-28 18:58 + +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), + ('uncloud_pay', '0015_auto_20200928_1844'), + ] + + operations = [ + migrations.RemoveField( + model_name='sampleonetimeproduct', + name='extra_data', + ), + migrations.RemoveField( + model_name='sampleonetimeproduct', + name='owner', + ), + migrations.RemoveField( + model_name='sampleonetimeproduct', + name='status', + ), + migrations.RemoveField( + model_name='samplerecurringproduct', + name='extra_data', + ), + migrations.RemoveField( + model_name='samplerecurringproduct', + name='owner', + ), + migrations.RemoveField( + model_name='samplerecurringproduct', + name='status', + ), + migrations.RemoveField( + model_name='samplerecurringproductonetimefee', + name='extra_data', + ), + migrations.RemoveField( + model_name='samplerecurringproductonetimefee', + name='owner', + ), + migrations.RemoveField( + model_name='samplerecurringproductonetimefee', + name='status', + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extra_data', models.JSONField(blank=True, editable=False, null=True)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), + ('config', models.JSONField()), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 5b6299c..b6f8f39 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -876,7 +876,7 @@ class Product(UncloudModel): choices=UncloudStatus.choices, default=UncloudStatus.AWAITING_PAYMENT) -# config = models.JSONField() + config = models.JSONField() # Default period for all products default_recurring_period = RecurringPeriod.PER_30D @@ -996,8 +996,8 @@ class Product(UncloudModel): def allowed_recurring_periods(): return RecurringPeriod.choices - class Meta: - abstract = True + # class Meta: + # abstract = True def discounted_price_by_period(self, requested_period): """ @@ -1082,7 +1082,7 @@ class Product(UncloudModel): # Sample products included into uncloud -class SampleOneTimeProduct(Product): +class SampleOneTimeProduct(models.Model): """ Products are usually more complex, but this product shows how easy it can be to create your own one time product. @@ -1096,7 +1096,7 @@ class SampleOneTimeProduct(Product): def one_time_price(self): return self.ot_price -class SampleRecurringProduct(Product): +class SampleRecurringProduct(models.Model): """ Products are usually more complex, but this product shows how easy it can be to create your own recurring fee product. @@ -1110,7 +1110,7 @@ class SampleRecurringProduct(Product): def recurring_price(self): return self.rc_price -class SampleRecurringProductOneTimeFee(Product): +class SampleRecurringProductOneTimeFee(models.Model): """ Products are usually more complex, but this product shows how easy it can be to create your own one time + recurring fee product. diff --git a/uncloud_service/migrations/0006_auto_20200928_1858.py b/uncloud_service/migrations/0006_auto_20200928_1858.py new file mode 100644 index 0000000..154ddb1 --- /dev/null +++ b/uncloud_service/migrations/0006_auto_20200928_1858.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1 on 2020-09-28 18:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_service', '0005_auto_20200928_1844'), + ] + + operations = [ + migrations.RemoveField( + model_name='genericserviceproduct', + name='extra_data', + ), + migrations.RemoveField( + model_name='genericserviceproduct', + name='owner', + ), + migrations.RemoveField( + model_name='genericserviceproduct', + name='status', + ), + migrations.RemoveField( + model_name='matrixserviceproduct', + name='extra_data', + ), + migrations.RemoveField( + model_name='matrixserviceproduct', + name='owner', + ), + migrations.RemoveField( + model_name='matrixserviceproduct', + name='status', + ), + ] diff --git a/uncloud_service/models.py b/uncloud_service/models.py index d067a23..8afb2e7 100644 --- a/uncloud_service/models.py +++ b/uncloud_service/models.py @@ -3,7 +3,7 @@ from uncloud_pay.models import Product, RecurringPeriod, AMOUNT_MAX_DIGITS, AMOU from uncloud_vm.models import VMProduct, VMDiskImageProduct from django.core.validators import MinValueValidator -class MatrixServiceProduct(Product): +class MatrixServiceProduct(models.Model): monthly_managment_fee = 20 description = "Managed Matrix HomeServer" @@ -34,7 +34,7 @@ class MatrixServiceProduct(Product): def one_time_price(self): return 30 -class GenericServiceProduct(Product): +class GenericServiceProduct(models.Model): custom_description = models.TextField() custom_recurring_price = models.DecimalField(default=0.0, max_digits=AMOUNT_MAX_DIGITS, diff --git a/uncloud_vm/migrations/0006_auto_20200928_1858.py b/uncloud_vm/migrations/0006_auto_20200928_1858.py new file mode 100644 index 0000000..96725d4 --- /dev/null +++ b/uncloud_vm/migrations/0006_auto_20200928_1858.py @@ -0,0 +1,49 @@ +# Generated by Django 3.1 on 2020-09-28 18:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0005_auto_20200928_1844'), + ] + + operations = [ + migrations.RemoveField( + model_name='vmdiskproduct', + name='extra_data', + ), + migrations.RemoveField( + model_name='vmdiskproduct', + name='owner', + ), + migrations.RemoveField( + model_name='vmdiskproduct', + name='status', + ), + migrations.RemoveField( + model_name='vmproduct', + name='extra_data', + ), + migrations.RemoveField( + model_name='vmproduct', + name='owner', + ), + migrations.RemoveField( + model_name='vmproduct', + name='status', + ), + migrations.RemoveField( + model_name='vmsnapshotproduct', + name='extra_data', + ), + migrations.RemoveField( + model_name='vmsnapshotproduct', + name='owner', + ), + migrations.RemoveField( + model_name='vmsnapshotproduct', + name='status', + ), + ] diff --git a/uncloud_vm/models.py b/uncloud_vm/models.py index a625555..72a9555 100644 --- a/uncloud_vm/models.py +++ b/uncloud_vm/models.py @@ -49,7 +49,7 @@ class VMHost(UncloudModel): -class VMProduct(Product): +class VMProduct(models.Model): vmhost = models.ForeignKey( VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True ) @@ -133,7 +133,7 @@ class VMDiskType(models.TextChoices): LOCAL_HDD = 'local/hdd' -class VMDiskProduct(Product): +class VMDiskProduct(models.Model): """ The VMDiskProduct is attached to a VM. @@ -180,7 +180,7 @@ class VMNetworkCard(models.Model): null=True) -class VMSnapshotProduct(Product): +class VMSnapshotProduct(models.Model): gb_ssd = models.FloatField(editable=False) gb_hdd = models.FloatField(editable=False) From c32499199acb67e16a54c5af13b0f966c11c19fc Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 28 Sep 2020 21:34:24 +0200 Subject: [PATCH 26/93] Add JSON support for product description --- uncloud_pay/admin.py | 7 +- uncloud_pay/migrations/0017_order_config.py | 19 + uncloud_pay/migrations/0018_order_product.py | 20 + .../migrations/0019_remove_product_owner.py | 17 + .../migrations/0020_auto_20200928_1915.py | 29 + .../migrations/0021_auto_20200928_1932.py | 23 + .../migrations/0022_auto_20200928_1932.py | 18 + uncloud_pay/models.py | 474 ++++----- uncloud_pay/tests-before-refactor.py | 721 ++++++++++++++ uncloud_pay/tests.py | 914 ++++++++++-------- 10 files changed, 1589 insertions(+), 653 deletions(-) create mode 100644 uncloud_pay/migrations/0017_order_config.py create mode 100644 uncloud_pay/migrations/0018_order_product.py create mode 100644 uncloud_pay/migrations/0019_remove_product_owner.py create mode 100644 uncloud_pay/migrations/0020_auto_20200928_1915.py create mode 100644 uncloud_pay/migrations/0021_auto_20200928_1932.py create mode 100644 uncloud_pay/migrations/0022_auto_20200928_1932.py create mode 100644 uncloud_pay/tests-before-refactor.py diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index 45714d6..14fc00f 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -11,7 +11,7 @@ from django.http import FileResponse from django.template.loader import render_to_string -from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress, SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee +from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress, Product class BillRecordInline(admin.TabularInline): @@ -91,7 +91,8 @@ admin.site.register(Order) admin.site.register(BillRecord) admin.site.register(BillingAddress) -for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]: - admin.site.register(m) +#for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]: + +admin.site.register(Product) #admin.site.register(Order, OrderAdmin) diff --git a/uncloud_pay/migrations/0017_order_config.py b/uncloud_pay/migrations/0017_order_config.py new file mode 100644 index 0000000..3afecee --- /dev/null +++ b/uncloud_pay/migrations/0017_order_config.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2020-09-28 19:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0016_auto_20200928_1858'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='config', + field=models.JSONField(default={}), + preserve_default=False, + ), + ] diff --git a/uncloud_pay/migrations/0018_order_product.py b/uncloud_pay/migrations/0018_order_product.py new file mode 100644 index 0000000..e4e6eb1 --- /dev/null +++ b/uncloud_pay/migrations/0018_order_product.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1 on 2020-09-28 19:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0017_order_config'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='product', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product'), + preserve_default=False, + ), + ] diff --git a/uncloud_pay/migrations/0019_remove_product_owner.py b/uncloud_pay/migrations/0019_remove_product_owner.py new file mode 100644 index 0000000..05ea2a8 --- /dev/null +++ b/uncloud_pay/migrations/0019_remove_product_owner.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-09-28 19:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0018_order_product'), + ] + + operations = [ + migrations.RemoveField( + model_name='product', + name='owner', + ), + ] diff --git a/uncloud_pay/migrations/0020_auto_20200928_1915.py b/uncloud_pay/migrations/0020_auto_20200928_1915.py new file mode 100644 index 0000000..2190397 --- /dev/null +++ b/uncloud_pay/migrations/0020_auto_20200928_1915.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1 on 2020-09-28 19:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0019_remove_product_owner'), + ] + + operations = [ + migrations.RemoveField( + model_name='product', + name='status', + ), + migrations.AddField( + model_name='product', + name='description', + field=models.CharField(default='', max_length=1024), + preserve_default=False, + ), + migrations.AddField( + model_name='product', + name='name', + field=models.CharField(default='', max_length=256), + preserve_default=False, + ), + ] diff --git a/uncloud_pay/migrations/0021_auto_20200928_1932.py b/uncloud_pay/migrations/0021_auto_20200928_1932.py new file mode 100644 index 0000000..85b8afe --- /dev/null +++ b/uncloud_pay/migrations/0021_auto_20200928_1932.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1 on 2020-09-28 19:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0020_auto_20200928_1915'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='default_currency', + field=models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32), + ), + migrations.AddField( + model_name='product', + name='default_recurring_period', + field=models.IntegerField(choices=[(31536000, 'Per 365 days'), (2592000, 'Per 30 days'), (604800, 'Per Week'), (86400, 'Per Day'), (3600, 'Per Hour'), (60, 'Per Minute'), (1, 'Per Second'), (0, 'Onetime')], default=2592000), + ), + ] diff --git a/uncloud_pay/migrations/0022_auto_20200928_1932.py b/uncloud_pay/migrations/0022_auto_20200928_1932.py new file mode 100644 index 0000000..0969b79 --- /dev/null +++ b/uncloud_pay/migrations/0022_auto_20200928_1932.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-09-28 19:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0021_auto_20200928_1932'), + ] + + operations = [ + migrations.RenameField( + model_name='product', + old_name='default_currency', + new_name='currency', + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index b6f8f39..adc044f 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -80,6 +80,16 @@ class RecurringPeriod(models.IntegerChoices): PER_SECOND = 1, _('Per Second') ONE_TIME = 0, _('Onetime') +class Currency(models.TextChoices): + """ + We don't support months are years, because they vary in length. + This is not only complicated, but also unfair to the user, as the user pays the same + amount for different durations. + """ + CHF = 'CHF', _('Swiss Franc') + EUR = 'EUR', _('Euro') + USD = 'USD', _('US Dollar') + class CountryField(models.CharField): def __init__(self, *args, **kwargs): kwargs.setdefault('choices', COUNTRIES) @@ -283,6 +293,235 @@ class VATRate(models.Model): logger.debug("Did not find VAT rate for %s, returning 0" % country_code) return 0 +### +# Products + +class Product(UncloudModel): + """ + A product is something a user can order. To record the pricing, we + create order that define a state in time. + + A product can have *one* one_time_order and/or *one* + recurring_order. + + If either of them needs to be updated, a new order of the same + type will be created and links to the previous order. + + """ + + name = models.CharField(max_length=256) + description = models.CharField(max_length=1024) + + config = models.JSONField() + default_recurring_period = models.IntegerField(choices=RecurringPeriod.choices, default=RecurringPeriod.PER_30D) + currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF) + + @property + def recurring_orders(self): + return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME) + + @property + def last_recurring_order(self): + return self.recurring_orders.last() + + @property + def one_time_orders(self): + return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.ONE_TIME) + + @property + def last_one_time_order(self): + return self.one_time_orders.last() + + def create_order(self, when_to_start=None, recurring_period=None): + billing_address = BillingAddress.get_address_for(self.owner) + + if not billing_address: + raise ValidationError("Cannot order without a billing address") + + if not when_to_start: + when_to_start = timezone.now() + + if not recurring_period: + recurring_period = self.default_recurring_period + + + # Create one time order if we did not create one already + if self.one_time_price > 0 and not self.last_one_time_order: + one_time_order = Order.objects.create(owner=self.owner, + billing_address=billing_address, + starting_date=when_to_start, + price=self.one_time_price, + recurring_period=RecurringPeriod.ONE_TIME, + description=str(self)) + self.orders.add(one_time_order) + else: + one_time_order = None + + if recurring_period != RecurringPeriod.ONE_TIME: + if one_time_order: + recurring_order = Order.objects.create(owner=self.owner, + billing_address=billing_address, + starting_date=when_to_start, + price=self.recurring_price, + recurring_period=recurring_period, + depends_on=one_time_order, + description=str(self)) + else: + recurring_order = Order.objects.create(owner=self.owner, + billing_address=billing_address, + starting_date=when_to_start, + price=self.recurring_price, + recurring_period=recurring_period, + description=str(self)) + self.orders.add(recurring_order) + + + # FIXME: this could/should be part of Order (?) + def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None): + if not self.recurring_price: + return + + if not recurring_period: + recurring_period = self.default_recurring_period + + if not when_to_start: + when_to_start = timezone.now() + + if self.last_recurring_order: + if self.recurring_price < self.last_recurring_order.price: + + if when_to_start < self.last_recurring_order.next_cancel_or_downgrade_date: + when_to_start = start_after(self.last_recurring_order.next_cancel_or_downgrade_date) + + when_to_end = end_before(when_to_start) + + new_order = Order.objects.create(owner=self.owner, + billing_address=self.last_recurring_order.billing_address, + starting_date=when_to_start, + price=self.recurring_price, + recurring_period=recurring_period, + description=str(self), + replaces=self.last_recurring_order) + + self.last_recurring_order.replace_with(new_order) + self.orders.add(new_order) + else: + self.create_order(when_to_start, recurring_period) + + + + @property + def recurring_price(self): + """ implement correct values in the child class """ + return 0 + + @property + def one_time_price(self): + """ implement correct values in the child class """ + return 0 + + + @property + def is_recurring(self): + return self.recurring_price > 0 + + @property + def billing_address(self): + return self.order.billing_address + + @staticmethod + def allowed_recurring_periods(): + return RecurringPeriod.choices + + # class Meta: + # abstract = True + + def discounted_price_by_period(self, requested_period): + """ + Each product has a standard recurring period for which + we define a pricing. I.e. VPN is usually year, VM is usually monthly. + + The user can opt-in to use a different period, which influences the price: + The longer a user commits, the higher the discount. + + Products can also be limited in the available periods. For instance + a VPN only makes sense to be bought for at least one day. + + Rules are as follows: + + given a standard recurring period of ..., changing to ... modifies price ... + + + # One month for free if buying / year, compared to a month: about 8.33% discount + per_year -> per_month -> /11 + per_month -> per_year -> *11 + + # Month has 30.42 days on average. About 7.9% discount to go monthly + per_month -> per_day -> /28 + per_day -> per_month -> *28 + + # Day has 24h, give one for free + per_day -> per_hour -> /23 + per_hour -> per_day -> /23 + + + Examples + + VPN @ 120CHF/y becomes + - 10.91 CHF/month (130.91 CHF/year) + - 0.39 CHF/day (142.21 CHF/year) + + VM @ 15 CHF/month becomes + - 165 CHF/month (13.75 CHF/month) + - 0.54 CHF/day (16.30 CHF/month) + + """ + + + if self.default_recurring_period == RecurringPeriod.PER_365D: + if requested_period == RecurringPeriod.PER_365D: + return self.recurring_price + if requested_period == RecurringPeriod.PER_30D: + return self.recurring_price/11. + if requested_period == RecurringPeriod.PER_DAY: + return self.recurring_price/11./28. + + elif self.default_recurring_period == RecurringPeriod.PER_30D: + if requested_period == RecurringPeriod.PER_365D: + return self.recurring_price*11 + if requested_period == RecurringPeriod.PER_30D: + return self.recurring_price + if requested_period == RecurringPeriod.PER_DAY: + return self.recurring_price/28. + + elif self.default_recurring_period == RecurringPeriod.PER_DAY: + if requested_period == RecurringPeriod.PER_365D: + return self.recurring_price*11*28 + if requested_period == RecurringPeriod.PER_30D: + return self.recurring_price*28 + if requested_period == RecurringPeriod.PER_DAY: + return self.recurring_price + else: + # FIXME: use the right type of exception here! + raise Exception("Did not implement the discounter for this case") + + + def save(self, *args, **kwargs): + # try: + # ba = BillingAddress.get_address_for(self.owner) + # except BillingAddress.DoesNotExist: + # raise ValidationError("User does not have a billing address") + + # if not ba.active: + # raise ValidationError("User does not have an active billing address") + + + # Verify the required JSON fields + + super().save(*args, **kwargs) + + + ### # Orders. @@ -317,7 +556,9 @@ class Order(models.Model): description = models.TextField() - # TODO: enforce ending_date - starting_date to be larger than recurring_period. + product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE) + config = models.JSONField() + creation_date = models.DateTimeField(auto_now_add=True) starting_date = models.DateTimeField(default=timezone.now) ending_date = models.DateTimeField(blank=True, null=True) @@ -850,237 +1091,6 @@ class BillRecord(models.Model): super().save(*args, **kwargs) -### -# Products - -class Product(UncloudModel): - """ - A product is something a user can order. To record the pricing, we - create order that define a state in time. - - A product can have *one* one_time_order and/or *one* - recurring_order. - - If either of them needs to be updated, a new order of the same - type will be created and links to the previous order. - - """ - - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) - - description = "Generic Product" - - status = models.CharField(max_length=32, - choices=UncloudStatus.choices, - default=UncloudStatus.AWAITING_PAYMENT) - - config = models.JSONField() - - # Default period for all products - default_recurring_period = RecurringPeriod.PER_30D - - @property - def recurring_orders(self): - return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME) - - @property - def last_recurring_order(self): - return self.recurring_orders.last() - - @property - def one_time_orders(self): - return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.ONE_TIME) - - @property - def last_one_time_order(self): - return self.one_time_orders.last() - - def create_order(self, when_to_start=None, recurring_period=None): - billing_address = BillingAddress.get_address_for(self.owner) - - if not billing_address: - raise ValidationError("Cannot order without a billing address") - - if not when_to_start: - when_to_start = timezone.now() - - if not recurring_period: - recurring_period = self.default_recurring_period - - - # Create one time order if we did not create one already - if self.one_time_price > 0 and not self.last_one_time_order: - one_time_order = Order.objects.create(owner=self.owner, - billing_address=billing_address, - starting_date=when_to_start, - price=self.one_time_price, - recurring_period=RecurringPeriod.ONE_TIME, - description=str(self)) - self.orders.add(one_time_order) - else: - one_time_order = None - - if recurring_period != RecurringPeriod.ONE_TIME: - if one_time_order: - recurring_order = Order.objects.create(owner=self.owner, - billing_address=billing_address, - starting_date=when_to_start, - price=self.recurring_price, - recurring_period=recurring_period, - depends_on=one_time_order, - description=str(self)) - else: - recurring_order = Order.objects.create(owner=self.owner, - billing_address=billing_address, - starting_date=when_to_start, - price=self.recurring_price, - recurring_period=recurring_period, - description=str(self)) - self.orders.add(recurring_order) - - - # FIXME: this could/should be part of Order (?) - def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None): - if not self.recurring_price: - return - - if not recurring_period: - recurring_period = self.default_recurring_period - - if not when_to_start: - when_to_start = timezone.now() - - if self.last_recurring_order: - if self.recurring_price < self.last_recurring_order.price: - - if when_to_start < self.last_recurring_order.next_cancel_or_downgrade_date: - when_to_start = start_after(self.last_recurring_order.next_cancel_or_downgrade_date) - - when_to_end = end_before(when_to_start) - - new_order = Order.objects.create(owner=self.owner, - billing_address=self.last_recurring_order.billing_address, - starting_date=when_to_start, - price=self.recurring_price, - recurring_period=recurring_period, - description=str(self), - replaces=self.last_recurring_order) - - self.last_recurring_order.replace_with(new_order) - self.orders.add(new_order) - else: - self.create_order(when_to_start, recurring_period) - - @property - def recurring_price(self): - """ implement correct values in the child class """ - return 0 - - @property - def one_time_price(self): - """ implement correct values in the child class """ - return 0 - - - @property - def is_recurring(self): - return self.recurring_price > 0 - - @property - def billing_address(self): - return self.order.billing_address - - @staticmethod - def allowed_recurring_periods(): - return RecurringPeriod.choices - - # class Meta: - # abstract = True - - def discounted_price_by_period(self, requested_period): - """ - Each product has a standard recurring period for which - we define a pricing. I.e. VPN is usually year, VM is usually monthly. - - The user can opt-in to use a different period, which influences the price: - The longer a user commits, the higher the discount. - - Products can also be limited in the available periods. For instance - a VPN only makes sense to be bought for at least one day. - - Rules are as follows: - - given a standard recurring period of ..., changing to ... modifies price ... - - - # One month for free if buying / year, compared to a month: about 8.33% discount - per_year -> per_month -> /11 - per_month -> per_year -> *11 - - # Month has 30.42 days on average. About 7.9% discount to go monthly - per_month -> per_day -> /28 - per_day -> per_month -> *28 - - # Day has 24h, give one for free - per_day -> per_hour -> /23 - per_hour -> per_day -> /23 - - - Examples - - VPN @ 120CHF/y becomes - - 10.91 CHF/month (130.91 CHF/year) - - 0.39 CHF/day (142.21 CHF/year) - - VM @ 15 CHF/month becomes - - 165 CHF/month (13.75 CHF/month) - - 0.54 CHF/day (16.30 CHF/month) - - """ - - - if self.default_recurring_period == RecurringPeriod.PER_365D: - if requested_period == RecurringPeriod.PER_365D: - return self.recurring_price - if requested_period == RecurringPeriod.PER_30D: - return self.recurring_price/11. - if requested_period == RecurringPeriod.PER_DAY: - return self.recurring_price/11./28. - - elif self.default_recurring_period == RecurringPeriod.PER_30D: - if requested_period == RecurringPeriod.PER_365D: - return self.recurring_price*11 - if requested_period == RecurringPeriod.PER_30D: - return self.recurring_price - if requested_period == RecurringPeriod.PER_DAY: - return self.recurring_price/28. - - elif self.default_recurring_period == RecurringPeriod.PER_DAY: - if requested_period == RecurringPeriod.PER_365D: - return self.recurring_price*11*28 - if requested_period == RecurringPeriod.PER_30D: - return self.recurring_price*28 - if requested_period == RecurringPeriod.PER_DAY: - return self.recurring_price - else: - # FIXME: use the right type of exception here! - raise Exception("Did not implement the discounter for this case") - - - def save(self, *args, **kwargs): - try: - ba = BillingAddress.get_address_for(self.owner) - except BillingAddress.DoesNotExist: - raise ValidationError("User does not have a billing address") - - if not ba.active: - raise ValidationError("User does not have an active billing address") - - super().save(*args, **kwargs) - - # Sample products included into uncloud class SampleOneTimeProduct(models.Model): """ diff --git a/uncloud_pay/tests-before-refactor.py b/uncloud_pay/tests-before-refactor.py new file mode 100644 index 0000000..49c51c6 --- /dev/null +++ b/uncloud_pay/tests-before-refactor.py @@ -0,0 +1,721 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from datetime import datetime, date, timedelta +from django.utils import timezone + +from .models import * +from uncloud_service.models import GenericServiceProduct + +class OrderTestCase(TestCase): + """ + The heart of ordering products + """ + + def setUp(self): + self.user = get_user_model().objects.create( + username='random_user', + email='jane.random@domain.tld') + + self.ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="somewhere else", + active=True) + + def test_create_one_time_product(self): + """ + One time payment products cannot be updated - can they? + """ + + p = SampleOneTimeProduct.objects.create(owner=self.user) + + self.assertEqual(p.one_time_price, 5) + self.assertEqual(p.recurring_price, 0) + + +# class ProductTestCase(TestCase): +# """ +# Test products and products <-> order interaction +# """ + +# def setUp(self): +# self.user = get_user_model().objects.create( +# username='random_user', +# email='jane.random@domain.tld') + +# self.ba = BillingAddress.objects.create( +# owner=self.user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="somewhere else", +# active=True) + +# def test_create_one_time_product(self): +# """ +# One time payment products cannot be updated - can they? +# """ + +# p = SampleOneTimeProduct.objects.create(owner=self.user) + +# self.assertEqual(p.one_time_price, 5) +# self.assertEqual(p.recurring_price, 0) + +# def test_create_product_without_active_billing_address(self): +# """ +# Fail to create a product without an active billing address +# """ + +# self.ba.active = False +# self.ba.save() + +# with self.assertRaises(ValidationError): +# p = SampleOneTimeProduct.objects.create(owner=self.user) + +# def test_create_product_without_billing_address(self): +# """ +# Fail to create a product without a billing address +# """ + +# user2 = get_user_model().objects.create( +# username='random_user2', +# email='jane.randomly@domain.tld') + +# with self.assertRaises(ValidationError): +# p = SampleOneTimeProduct.objects.create(owner=user2) + + +# def test_create_order_creates_correct_order_count(self): +# """ +# Ensure creating orders from product only creates 1 order +# """ + +# # One order +# p = SampleOneTimeProduct.objects.create(owner=self.user) +# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) + +# order_count = Order.objects.filter(owner=self.user).count() +# self.assertEqual(order_count, 1) + +# # One more order +# p = SampleRecurringProduct.objects.create(owner=self.user) +# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) + +# order_count = Order.objects.filter(owner=self.user).count() +# self.assertEqual(order_count, 2) + +# # Should create 2 orders +# p = SampleRecurringProductOneTimeFee.objects.create(owner=self.user) +# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) + +# order_count = Order.objects.filter(owner=self.user).count() +# self.assertEqual(order_count, 4) + + + # def test_update_recurring_order(self): + # """ + # Ensure creating orders from product only creates 1 order + # """ + + # p = SampleRecurringProduct.objects.create(owner=self.user) + # p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) + + # p.create_or_update_recurring_order(timezone.make_aware(datetime.datetime(2020,3,4))) + + # # FIXME: where is the assert? + + +class BillingAddressTestCase(TestCase): + def setUp(self): + self.user = get_user_model().objects.create( + username='random_user', + email='jane.random@domain.tld') + + + def test_user_no_address(self): + """ + Raise an error, when there is no address + """ + + self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, + BillingAddress.get_address_for, + self.user) + + def test_user_only_inactive_address(self): + """ + Raise an error, when there is no active address + """ + + ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="somewhere else", + active=False) + + self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, + BillingAddress.get_address_for, + self.user) + + def test_find_active_address(self): + """ + Find the active address + """ + + ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="unknown", + active=True) + + + self.assertEqual(BillingAddress.get_address_for(self.user), ba) + + def test_find_right_address_with_multiple_addresses(self): + """ + Find the active address only, skip inactive + """ + + ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="unknown", + active=True) + + ba2 = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="somewhere else", + active=False) + + + self.assertEqual(BillingAddress.get_address_for(self.user), ba) + + def test_change_addresses(self): + """ + Switch the active address + """ + + ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="unknown", + active=True) + + self.assertEqual(BillingAddress.get_address_for(self.user), ba) + + ba.active=False + ba.save() + + ba2 = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="somewhere else", + active=True) + + self.assertEqual(BillingAddress.get_address_for(self.user), ba2) + + + +class BillTestCase(TestCase): + def setUp(self): + self.user_without_address = get_user_model().objects.create( + username='no_home_person', + email='far.away@domain.tld') + + self.user = get_user_model().objects.create( + username='jdoe', + email='john.doe@domain.tld') + + self.recurring_user = get_user_model().objects.create( + username='recurrent_product_user', + email='jane.doe@domain.tld') + + self.user_addr = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="unknown", + active=True) + + self.recurring_user_addr = BillingAddress.objects.create( + owner=self.recurring_user, + organization = 'Test org', + street="Somewhere", + city="Else", + postal_code="unknown", + active=True) + + self.order_meta = {} + self.order_meta[1] = { + 'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)), + 'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)), + 'price': 15, + 'description': 'One chocolate bar' + } + + self.one_time_order = Order.objects.create( + owner=self.user, + starting_date=self.order_meta[1]['starting_date'], + ending_date=self.order_meta[1]['ending_date'], + recurring_period=RecurringPeriod.ONE_TIME, + price=self.order_meta[1]['price'], + description=self.order_meta[1]['description'], + billing_address=BillingAddress.get_address_for(self.user)) + + self.recurring_order = Order.objects.create( + owner=self.recurring_user, + starting_date=timezone.make_aware(datetime.datetime(2020,3,3)), + recurring_period=RecurringPeriod.PER_30D, + price=15, + description="A pretty VM", + billing_address=BillingAddress.get_address_for(self.recurring_user) + ) + + # used for generating multiple bills + self.bill_dates = [ + timezone.make_aware(datetime.datetime(2020,3,31)), + timezone.make_aware(datetime.datetime(2020,4,30)), + timezone.make_aware(datetime.datetime(2020,5,31)), + ] + + + + def test_bill_one_time_one_bill_record(self): + """ + Ensure there is only 1 bill record per order + """ + + bill = Bill.create_next_bill_for_user_address(self.user_addr) + + self.assertEqual(self.one_time_order.billrecord_set.count(), 1) + + def test_bill_sum_onetime(self): + """ + Check the bill sum for a single one time order + """ + + bill = Bill.create_next_bill_for_user_address(self.user_addr) + self.assertEqual(bill.sum, self.order_meta[1]['price']) + + + def test_bill_creates_record_for_recurring_order(self): + """ + Ensure there is only 1 bill record per order + """ + + bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr) + + self.assertEqual(self.recurring_order.billrecord_set.count(), 1) + self.assertEqual(bill.billrecord_set.count(), 1) + + + def test_new_bill_after_closing(self): + """ + After closing a bill and the user has a recurring product, + the next bill run should create e new bill + """ + + for ending_date in self.bill_dates: + b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date) + b.close() + + bill_count = Bill.objects.filter(owner=self.recurring_user).count() + + self.assertEqual(len(self.bill_dates), bill_count) + + def test_multi_addr_multi_bill(self): + """ + Ensure multiple bills are created if orders exist with different billing addresses + """ + + username="lotsofplaces" + multi_addr_user = get_user_model().objects.create( + username=username, + email=f"{username}@example.org") + + user_addr1 = BillingAddress.objects.create( + owner=multi_addr_user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="unknown", + active=True) + + order1 = Order.objects.create( + owner=multi_addr_user, + starting_date=self.order_meta[1]['starting_date'], + ending_date=self.order_meta[1]['ending_date'], + recurring_period=RecurringPeriod.ONE_TIME, + price=self.order_meta[1]['price'], + description=self.order_meta[1]['description'], + billing_address=BillingAddress.get_address_for(self.user)) + + # Make this address inactive + user_addr1.active = False + user_addr1.save() + + user_addr2 = BillingAddress.objects.create( + owner=multi_addr_user, + organization = 'Test2 org', + street="unknown2", + city="unknown2", + postal_code="unknown2", + active=True) + + order2 = Order.objects.create( + owner=multi_addr_user, + starting_date=self.order_meta[1]['starting_date'], + ending_date=self.order_meta[1]['ending_date'], + recurring_period=RecurringPeriod.ONE_TIME, + price=self.order_meta[1]['price'], + description=self.order_meta[1]['description'], + billing_address=BillingAddress.get_address_for(self.user)) + + + bills = Bill.create_next_bills_for_user(multi_addr_user) + + self.assertEqual(len(bills), 2) + + + # TO BE IMPLEMENTED -- once orders can be marked as "done" / "inactive" / "not for billing" + # def test_skip_disabled_orders(self): + # """ + # Ensure that a bill only considers "active" orders + # """ + + # self.assertEqual(1, 2) + + +class ModifyProductTestCase(TestCase): + def setUp(self): + self.user = get_user_model().objects.create( + username='random_user', + email='jane.random@domain.tld') + + self.ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="somewhere else", + active=True) + + def test_no_pro_rata_first_bill(self): + """ + The bill should NOT contain a partial amount -- this is a BILL TEST :-) + """ + + price = 5 + + # Standard 30d recurring product + product = SampleRecurringProduct.objects.create(owner=self.user, + rc_price=price) + + starting_date = timezone.make_aware(datetime.datetime(2020,3,3)) + ending_date = timezone.make_aware(datetime.datetime(2020,3,31)) + time_diff = (ending_date - starting_date).total_seconds() + + product.create_order(starting_date) + + bills = Bill.create_next_bills_for_user(self.user, + ending_date=ending_date) + + + # We expect 1 bill for 1 billing address and 1 time frame + self.assertEqual(len(bills), 1) + + pro_rata_amount = time_diff / product.default_recurring_period.value + + self.assertNotEqual(bills[0].sum, pro_rata_amount * price) + self.assertEqual(bills[0].sum, price) + + + def test_downgrade_product(self): + """ + Test downgrading behaviour: + + We create a recurring product (recurring time: 30 days) and downgrade after 15 days. + + We create the bill right AFTER the end of the first order. + + Expected result: + + - First bill record for 30 days + - Second bill record starting after 30 days + - Bill contains two bill records + + """ + + user = self.user + + + + starting_price = 10 + downgrade_price = 5 + + + starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) + first_order_should_end_at = starting_date + datetime.timedelta(days=30) + change1_date = start_after(starting_date + datetime.timedelta(days=15)) + bill_ending_date = change1_date + datetime.timedelta(days=1) + + + product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) + product.create_order(starting_date) + + product.rc_price = downgrade_price + product.save() + product.create_or_update_recurring_order(when_to_start=change1_date) + + bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) + + bill = bills[0] + bill_records = BillRecord.objects.filter(bill=bill) + + self.assertEqual(len(bill_records), 2) + + self.assertEqual(bill_records[0].starting_date, starting_date) + + self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at) + + # self.assertEqual(bill_records[0].ending_date, first_order_should_end_at) + + # self.assertEqual(bill_records[0].quantity, 1) + + # self.assertEqual(bill_records[1].quantity, 1) + # self.assertEqual(int(bill.sum), 15) + + def test_upgrade_product(self): + """ + Test upgrading behaviour + """ + + user = self.user + + # Create product + starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) + starting_price = 10 + product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) + product.create_order(starting_date) + + change1_date = start_after(starting_date + datetime.timedelta(days=15)) + product.rc_price = 20 + product.save() + product.create_or_update_recurring_order(when_to_start=change1_date) + + bill_ending_date = change1_date + datetime.timedelta(days=1) + bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) + + bill = bills[0] + bill_records = BillRecord.objects.filter(bill=bill) + + self.assertEqual(len(bill_records), 2) + self.assertEqual(bill_records[0].quantity, .5) + + self.assertEqual(bill_records[0].ending_date, end_before(change1_date)) + + self.assertEqual(bill_records[1].quantity, 1) + self.assertEqual(bill_records[1].starting_date, change1_date) + + self.assertEqual(int(bill.sum), 25) + + + + # def test_bill_for_increasing_product(self): + # """ + # Modify a product, see one pro rata entry + # """ + + # # Create product + # starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) + # starting_price = 30.5 + # product = SampleRecurringProduct.objects.create(owner=self.user, + # rc_price=starting_price) + # product.create_order(starting_date) + + # recurring_period = product.default_recurring_period.value + + # # First change + # change1_date = timezone.make_aware(datetime.datetime(2019,4,17)) + # product.rc_price = 49.5 + # product.save() + # product.create_or_update_recurring_order(when_to_start=change1_date) + + # # Second change + # change2_date = timezone.make_aware(datetime.datetime(2019,5,8)) + # product.rc_price = 56.5 + # product.save() + # product.create_or_update_recurring_order(when_to_start=change2_date) + + # # Create bill one month after 2nd change + # bill_ending_date = timezone.make_aware(datetime.datetime(2019,6,30)) + # bills = Bill.create_next_bills_for_user(self.user, + # ending_date=bill_ending_date) + + # # only one bill in this test case + # bill = bills[0] + + # expected_amount = starting_price + + # d2 = starting_date + recurring_period + # duration2 = change1_date - d2 + + # expected_amount = 0 + + # # Expected bill sum & records: + # # 2019-03-03 - 2019-04-02 +30d: 30.5 + # # 2019-04-02 - 2019-04-17: +15d: 15.25 + # # 2019-04-17 - 2019-05-08: +21d: (21/30) * 49.5 + # # 2019-05-08 - 2019-06-07: +30d: 56.5 + # # 2019-06-07 - 2019-07-07: +30d: 56.5 + + + # self.assertEqual(bills[0].sum, price) + + # # expeted result: + # # 1x 5 chf bill record + # # 1x 5 chf bill record + # # 1x 10 partial bill record + + + +# class NotABillingTC(TestCase): +# #class BillingTestCase(TestCase): +# def setUp(self): +# self.user = get_user_model().objects.create( +# username='jdoe', +# email='john.doe@domain.tld') +# self.billing_address = BillingAddress.objects.create( +# owner=self.user, +# street="unknown", +# city="unknown", +# postal_code="unknown") + +# def test_basic_monthly_billing(self): +# one_time_price = 10 +# recurring_price = 20 +# description = "Test Product 1" + +# # Three months: full, full, partial. +# # starting_date = datetime.fromisoformat('2020-03-01') +# starting_date = datetime(2020,3,1) +# ending_date = datetime(2020,5,8) + +# # Create order to be billed. +# order = Order.objects.create( +# owner=self.user, +# starting_date=starting_date, +# ending_date=ending_date, +# recurring_period=RecurringPeriod.PER_30D, +# recurring_price=recurring_price, +# one_time_price=one_time_price, +# description=description, +# billing_address=self.billing_address) + +# # Generate & check bill for first month: full recurring_price + setup. +# first_month_bills = order.generate_initial_bill() +# self.assertEqual(len(first_month_bills), 1) +# self.assertEqual(first_month_bills[0].amount, one_time_price + recurring_price) + +# # Generate & check bill for second month: full recurring_price. +# second_month_bills = Bill.generate_for(2020, 4, self.user) +# self.assertEqual(len(second_month_bills), 1) +# self.assertEqual(second_month_bills[0].amount, recurring_price) + +# # Generate & check bill for third and last month: partial recurring_price. +# third_month_bills = Bill.generate_for(2020, 5, self.user) +# self.assertEqual(len(third_month_bills), 1) +# # 31 days in May. +# self.assertEqual(float(third_month_bills[0].amount), +# round(round((7/31), AMOUNT_DECIMALS) * recurring_price, AMOUNT_DECIMALS)) + +# # Check that running Bill.generate_for() twice does not create duplicates. +# self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0) + +# def test_basic_yearly_billing(self): +# one_time_price = 10 +# recurring_price = 150 +# description = "Test Product 1" + +# starting_date = datetime.fromisoformat('2020-03-31T08:05:23') + +# # Create order to be billed. +# order = Order.objects.create( +# owner=self.user, +# starting_date=starting_date, +# recurring_period=RecurringPeriod.PER_365D, +# recurring_price=recurring_price, +# one_time_price=one_time_price, +# description=description, +# billing_address=self.billing_address) + +# # Generate & check bill for first year: recurring_price + setup. +# first_year_bills = order.generate_initial_bill() +# self.assertEqual(len(first_year_bills), 1) +# self.assertEqual(first_year_bills[0].starting_date.date(), +# date.fromisoformat('2020-03-31')) +# self.assertEqual(first_year_bills[0].ending_date.date(), +# date.fromisoformat('2021-03-30')) +# self.assertEqual(first_year_bills[0].amount, +# recurring_price + one_time_price) + +# # Generate & check bill for second year: recurring_price. +# second_year_bills = Bill.generate_for(2021, 3, self.user) +# self.assertEqual(len(second_year_bills), 1) +# self.assertEqual(second_year_bills[0].starting_date.date(), +# date.fromisoformat('2021-03-31')) +# self.assertEqual(second_year_bills[0].ending_date.date(), +# date.fromisoformat('2022-03-30')) +# self.assertEqual(second_year_bills[0].amount, recurring_price) + +# # Check that running Bill.generate_for() twice does not create duplicates. +# self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0) +# self.assertEqual(len(Bill.generate_for(2020, 4, self.user)), 0) +# self.assertEqual(len(Bill.generate_for(2020, 2, self.user)), 0) +# self.assertEqual(len(Bill.generate_for(2021, 3, self.user)), 0) + +# def test_basic_hourly_billing(self): +# one_time_price = 10 +# recurring_price = 1.4 +# description = "Test Product 1" + +# starting_date = datetime.fromisoformat('2020-03-31T08:05:23') +# ending_date = datetime.fromisoformat('2020-04-01T11:13:32') + +# # Create order to be billed. +# order = Order.objects.create( +# owner=self.user, +# starting_date=starting_date, +# ending_date=ending_date, +# recurring_period=RecurringPeriod.PER_HOUR, +# recurring_price=recurring_price, +# one_time_price=one_time_price, +# description=description, +# billing_address=self.billing_address) + +# # Generate & check bill for first month: recurring_price + setup. +# first_month_bills = order.generate_initial_bill() +# self.assertEqual(len(first_month_bills), 1) +# self.assertEqual(float(first_month_bills[0].amount), +# round(16 * recurring_price, AMOUNT_DECIMALS) + one_time_price) + +# # Generate & check bill for first month: recurring_price. +# second_month_bills = Bill.generate_for(2020, 4, self.user) +# self.assertEqual(len(second_month_bills), 1) +# self.assertEqual(float(second_month_bills[0].amount), +# round(12 * recurring_price, AMOUNT_DECIMALS)) diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index bee3545..a6ec43e 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -6,6 +6,36 @@ from django.utils import timezone from .models import * from uncloud_service.models import GenericServiceProduct +import json + +# class OrderTestCase(TestCase): +# """ +# The heart of ordering products +# """ + +# def setUp(self): +# self.user = get_user_model().objects.create( +# username='random_user', +# email='jane.random@domain.tld') + +# self.ba = BillingAddress.objects.create( +# owner=self.user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="somewhere else", +# active=True) + +# def test_create_one_time_product(self): +# """ +# One time payment products cannot be updated - can they? +# """ + +# p = SampleOneTimeProduct.objects.create(owner=self.user) + +# self.assertEqual(p.one_time_price, 5) +# self.assertEqual(p.recurring_price, 0) + class ProductTestCase(TestCase): """ @@ -25,486 +55,534 @@ class ProductTestCase(TestCase): postal_code="somewhere else", active=True) - def test_create_one_time_product(self): + def test_create_product(self): """ - One time payment products cannot be updated - can they? + Create a sample product """ - p = SampleOneTimeProduct.objects.create(owner=self.user) - - self.assertEqual(p.one_time_price, 5) - self.assertEqual(p.recurring_price, 0) - - def test_create_product_without_active_billing_address(self): - """ - Fail to create a product without an active billing address - """ - - self.ba.active = False - self.ba.save() - - with self.assertRaises(ValidationError): - p = SampleOneTimeProduct.objects.create(owner=self.user) - - def test_create_product_without_billing_address(self): - """ - Fail to create a product without a billing address - """ - - user2 = get_user_model().objects.create( - username='random_user2', - email='jane.randomly@domain.tld') - - with self.assertRaises(ValidationError): - p = SampleOneTimeProduct.objects.create(owner=user2) - - - def test_create_order_creates_correct_order_count(self): - """ - Ensure creating orders from product only creates 1 order - """ - - # One order - p = SampleOneTimeProduct.objects.create(owner=self.user) - p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - - order_count = Order.objects.filter(owner=self.user).count() - self.assertEqual(order_count, 1) - - # One more order - p = SampleRecurringProduct.objects.create(owner=self.user) - p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - - order_count = Order.objects.filter(owner=self.user).count() - self.assertEqual(order_count, 2) - - # Should create 2 orders - p = SampleRecurringProductOneTimeFee.objects.create(owner=self.user) - p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - - order_count = Order.objects.filter(owner=self.user).count() - self.assertEqual(order_count, 4) - - - def test_update_recurring_order(self): - """ - Ensure creating orders from product only creates 1 order - """ - - p = SampleRecurringProduct.objects.create(owner=self.user) - p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - - p.create_or_update_recurring_order(timezone.make_aware(datetime.datetime(2020,3,4))) - - # FIXME: where is the assert? - - -class BillingAddressTestCase(TestCase): - def setUp(self): - self.user = get_user_model().objects.create( - username='random_user', - email='jane.random@domain.tld') - - - def test_user_no_address(self): - """ - Raise an error, when there is no address - """ - - self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, - BillingAddress.get_address_for, - self.user) - - def test_user_only_inactive_address(self): - """ - Raise an error, when there is no active address - """ - - ba = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="somewhere else", - active=False) - - self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, - BillingAddress.get_address_for, - self.user) - - def test_find_active_address(self): - """ - Find the active address - """ - - ba = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="unknown", - active=True) - - - self.assertEqual(BillingAddress.get_address_for(self.user), ba) - - def test_find_right_address_with_multiple_addresses(self): - """ - Find the active address only, skip inactive - """ - - ba = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="unknown", - active=True) - - ba2 = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="somewhere else", - active=False) - - - self.assertEqual(BillingAddress.get_address_for(self.user), ba) - - def test_change_addresses(self): - """ - Switch the active address - """ - - ba = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="unknown", - active=True) - - self.assertEqual(BillingAddress.get_address_for(self.user), ba) - - ba.active=False - ba.save() - - ba2 = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="somewhere else", - active=True) - - self.assertEqual(BillingAddress.get_address_for(self.user), ba2) - - - -class BillTestCase(TestCase): - def setUp(self): - self.user_without_address = get_user_model().objects.create( - username='no_home_person', - email='far.away@domain.tld') - - self.user = get_user_model().objects.create( - username='jdoe', - email='john.doe@domain.tld') - - self.recurring_user = get_user_model().objects.create( - username='recurrent_product_user', - email='jane.doe@domain.tld') - - self.user_addr = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="unknown", - active=True) - - self.recurring_user_addr = BillingAddress.objects.create( - owner=self.recurring_user, - organization = 'Test org', - street="Somewhere", - city="Else", - postal_code="unknown", - active=True) - - self.order_meta = {} - self.order_meta[1] = { - 'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)), - 'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)), - 'price': 15, - 'description': 'One chocolate bar' + config = { + 'features': { + 'cores': + { 'min': 1, + 'max': 48, + 'one_time_price': 0, + 'recurring_price': 4 + }, + 'ram_gb': + { 'min': 1, + 'max': 256, + 'one_time_price': 0, + 'recurring_price': 3 + }, + }, } - self.one_time_order = Order.objects.create( - owner=self.user, - starting_date=self.order_meta[1]['starting_date'], - ending_date=self.order_meta[1]['ending_date'], - recurring_period=RecurringPeriod.ONE_TIME, - price=self.order_meta[1]['price'], - description=self.order_meta[1]['description'], - billing_address=BillingAddress.get_address_for(self.user)) + p = Product.objects.create(name="Testproduct", + description="Only for testing", + config=config) - self.recurring_order = Order.objects.create( - owner=self.recurring_user, - starting_date=timezone.make_aware(datetime.datetime(2020,3,3)), - recurring_period=RecurringPeriod.PER_30D, - price=15, - description="A pretty VM", - billing_address=BillingAddress.get_address_for(self.recurring_user) - ) - - # used for generating multiple bills - self.bill_dates = [ - timezone.make_aware(datetime.datetime(2020,3,31)), - timezone.make_aware(datetime.datetime(2020,4,30)), - timezone.make_aware(datetime.datetime(2020,5,31)), - ] + # self.assertEqual(p.one_time_price, 5) + # self.assertEqual(p.recurring_price, 0) +# class ProductTestCase(TestCase): +# """ +# Test products and products <-> order interaction +# """ - def test_bill_one_time_one_bill_record(self): - """ - Ensure there is only 1 bill record per order - """ +# def setUp(self): +# self.user = get_user_model().objects.create( +# username='random_user', +# email='jane.random@domain.tld') - bill = Bill.create_next_bill_for_user_address(self.user_addr) +# self.ba = BillingAddress.objects.create( +# owner=self.user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="somewhere else", +# active=True) - self.assertEqual(self.one_time_order.billrecord_set.count(), 1) +# def test_create_one_time_product(self): +# """ +# One time payment products cannot be updated - can they? +# """ - def test_bill_sum_onetime(self): - """ - Check the bill sum for a single one time order - """ +# p = SampleOneTimeProduct.objects.create(owner=self.user) - bill = Bill.create_next_bill_for_user_address(self.user_addr) - self.assertEqual(bill.sum, self.order_meta[1]['price']) +# self.assertEqual(p.one_time_price, 5) +# self.assertEqual(p.recurring_price, 0) + +# def test_create_product_without_active_billing_address(self): +# """ +# Fail to create a product without an active billing address +# """ + +# self.ba.active = False +# self.ba.save() + +# with self.assertRaises(ValidationError): +# p = SampleOneTimeProduct.objects.create(owner=self.user) + +# def test_create_product_without_billing_address(self): +# """ +# Fail to create a product without a billing address +# """ + +# user2 = get_user_model().objects.create( +# username='random_user2', +# email='jane.randomly@domain.tld') + +# with self.assertRaises(ValidationError): +# p = SampleOneTimeProduct.objects.create(owner=user2) - def test_bill_creates_record_for_recurring_order(self): - """ - Ensure there is only 1 bill record per order - """ +# def test_create_order_creates_correct_order_count(self): +# """ +# Ensure creating orders from product only creates 1 order +# """ - bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr) +# # One order +# p = SampleOneTimeProduct.objects.create(owner=self.user) +# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - self.assertEqual(self.recurring_order.billrecord_set.count(), 1) - self.assertEqual(bill.billrecord_set.count(), 1) +# order_count = Order.objects.filter(owner=self.user).count() +# self.assertEqual(order_count, 1) + +# # One more order +# p = SampleRecurringProduct.objects.create(owner=self.user) +# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) + +# order_count = Order.objects.filter(owner=self.user).count() +# self.assertEqual(order_count, 2) + +# # Should create 2 orders +# p = SampleRecurringProductOneTimeFee.objects.create(owner=self.user) +# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) + +# order_count = Order.objects.filter(owner=self.user).count() +# self.assertEqual(order_count, 4) - def test_new_bill_after_closing(self): - """ - After closing a bill and the user has a recurring product, - the next bill run should create e new bill - """ - - for ending_date in self.bill_dates: - b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date) - b.close() - - bill_count = Bill.objects.filter(owner=self.recurring_user).count() - - self.assertEqual(len(self.bill_dates), bill_count) - - def test_multi_addr_multi_bill(self): - """ - Ensure multiple bills are created if orders exist with different billing addresses - """ - - username="lotsofplaces" - multi_addr_user = get_user_model().objects.create( - username=username, - email=f"{username}@example.org") - - user_addr1 = BillingAddress.objects.create( - owner=multi_addr_user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="unknown", - active=True) - - order1 = Order.objects.create( - owner=multi_addr_user, - starting_date=self.order_meta[1]['starting_date'], - ending_date=self.order_meta[1]['ending_date'], - recurring_period=RecurringPeriod.ONE_TIME, - price=self.order_meta[1]['price'], - description=self.order_meta[1]['description'], - billing_address=BillingAddress.get_address_for(self.user)) - - # Make this address inactive - user_addr1.active = False - user_addr1.save() - - user_addr2 = BillingAddress.objects.create( - owner=multi_addr_user, - organization = 'Test2 org', - street="unknown2", - city="unknown2", - postal_code="unknown2", - active=True) - - order2 = Order.objects.create( - owner=multi_addr_user, - starting_date=self.order_meta[1]['starting_date'], - ending_date=self.order_meta[1]['ending_date'], - recurring_period=RecurringPeriod.ONE_TIME, - price=self.order_meta[1]['price'], - description=self.order_meta[1]['description'], - billing_address=BillingAddress.get_address_for(self.user)) - - - bills = Bill.create_next_bills_for_user(multi_addr_user) - - self.assertEqual(len(bills), 2) - - - # TO BE IMPLEMENTED -- once orders can be marked as "done" / "inactive" / "not for billing" - # def test_skip_disabled_orders(self): + # def test_update_recurring_order(self): # """ - # Ensure that a bill only considers "active" orders + # Ensure creating orders from product only creates 1 order # """ - # self.assertEqual(1, 2) + # p = SampleRecurringProduct.objects.create(owner=self.user) + # p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) + + # p.create_or_update_recurring_order(timezone.make_aware(datetime.datetime(2020,3,4))) + + # # FIXME: where is the assert? -class ModifyProductTestCase(TestCase): - def setUp(self): - self.user = get_user_model().objects.create( - username='random_user', - email='jane.random@domain.tld') - - self.ba = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="somewhere else", - active=True) - - def test_no_pro_rata_first_bill(self): - """ - The bill should NOT contain a partial amount -- this is a BILL TEST :-) - """ - - price = 5 - - # Standard 30d recurring product - product = SampleRecurringProduct.objects.create(owner=self.user, - rc_price=price) - - starting_date = timezone.make_aware(datetime.datetime(2020,3,3)) - ending_date = timezone.make_aware(datetime.datetime(2020,3,31)) - time_diff = (ending_date - starting_date).total_seconds() - - product.create_order(starting_date) - - bills = Bill.create_next_bills_for_user(self.user, - ending_date=ending_date) +# class BillingAddressTestCase(TestCase): +# def setUp(self): +# self.user = get_user_model().objects.create( +# username='random_user', +# email='jane.random@domain.tld') - # We expect 1 bill for 1 billing address and 1 time frame - self.assertEqual(len(bills), 1) +# def test_user_no_address(self): +# """ +# Raise an error, when there is no address +# """ - pro_rata_amount = time_diff / product.default_recurring_period.value +# self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, +# BillingAddress.get_address_for, +# self.user) - self.assertNotEqual(bills[0].sum, pro_rata_amount * price) - self.assertEqual(bills[0].sum, price) +# def test_user_only_inactive_address(self): +# """ +# Raise an error, when there is no active address +# """ + +# ba = BillingAddress.objects.create( +# owner=self.user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="somewhere else", +# active=False) + +# self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, +# BillingAddress.get_address_for, +# self.user) + +# def test_find_active_address(self): +# """ +# Find the active address +# """ + +# ba = BillingAddress.objects.create( +# owner=self.user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="unknown", +# active=True) - def test_downgrade_product(self): - """ - Test downgrading behaviour: +# self.assertEqual(BillingAddress.get_address_for(self.user), ba) - We create a recurring product (recurring time: 30 days) and downgrade after 15 days. +# def test_find_right_address_with_multiple_addresses(self): +# """ +# Find the active address only, skip inactive +# """ - We create the bill right AFTER the end of the first order. +# ba = BillingAddress.objects.create( +# owner=self.user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="unknown", +# active=True) - Expected result: +# ba2 = BillingAddress.objects.create( +# owner=self.user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="somewhere else", +# active=False) - - First bill record for 30 days - - Second bill record starting after 30 days - - Bill contains two bill records - """ +# self.assertEqual(BillingAddress.get_address_for(self.user), ba) - user = self.user +# def test_change_addresses(self): +# """ +# Switch the active address +# """ + +# ba = BillingAddress.objects.create( +# owner=self.user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="unknown", +# active=True) + +# self.assertEqual(BillingAddress.get_address_for(self.user), ba) + +# ba.active=False +# ba.save() + +# ba2 = BillingAddress.objects.create( +# owner=self.user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="somewhere else", +# active=True) + +# self.assertEqual(BillingAddress.get_address_for(self.user), ba2) - starting_price = 10 - downgrade_price = 5 +# class BillTestCase(TestCase): +# def setUp(self): +# self.user_without_address = get_user_model().objects.create( +# username='no_home_person', +# email='far.away@domain.tld') + +# self.user = get_user_model().objects.create( +# username='jdoe', +# email='john.doe@domain.tld') + +# self.recurring_user = get_user_model().objects.create( +# username='recurrent_product_user', +# email='jane.doe@domain.tld') + +# self.user_addr = BillingAddress.objects.create( +# owner=self.user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="unknown", +# active=True) + +# self.recurring_user_addr = BillingAddress.objects.create( +# owner=self.recurring_user, +# organization = 'Test org', +# street="Somewhere", +# city="Else", +# postal_code="unknown", +# active=True) + +# self.order_meta = {} +# self.order_meta[1] = { +# 'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)), +# 'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)), +# 'price': 15, +# 'description': 'One chocolate bar' +# } + +# self.one_time_order = Order.objects.create( +# owner=self.user, +# starting_date=self.order_meta[1]['starting_date'], +# ending_date=self.order_meta[1]['ending_date'], +# recurring_period=RecurringPeriod.ONE_TIME, +# price=self.order_meta[1]['price'], +# description=self.order_meta[1]['description'], +# billing_address=BillingAddress.get_address_for(self.user)) + +# self.recurring_order = Order.objects.create( +# owner=self.recurring_user, +# starting_date=timezone.make_aware(datetime.datetime(2020,3,3)), +# recurring_period=RecurringPeriod.PER_30D, +# price=15, +# description="A pretty VM", +# billing_address=BillingAddress.get_address_for(self.recurring_user) +# ) + +# # used for generating multiple bills +# self.bill_dates = [ +# timezone.make_aware(datetime.datetime(2020,3,31)), +# timezone.make_aware(datetime.datetime(2020,4,30)), +# timezone.make_aware(datetime.datetime(2020,5,31)), +# ] - starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) - first_order_should_end_at = starting_date + datetime.timedelta(days=30) - change1_date = start_after(starting_date + datetime.timedelta(days=15)) - bill_ending_date = change1_date + datetime.timedelta(days=1) + +# def test_bill_one_time_one_bill_record(self): +# """ +# Ensure there is only 1 bill record per order +# """ + +# bill = Bill.create_next_bill_for_user_address(self.user_addr) + +# self.assertEqual(self.one_time_order.billrecord_set.count(), 1) + +# def test_bill_sum_onetime(self): +# """ +# Check the bill sum for a single one time order +# """ + +# bill = Bill.create_next_bill_for_user_address(self.user_addr) +# self.assertEqual(bill.sum, self.order_meta[1]['price']) - product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) - product.create_order(starting_date) +# def test_bill_creates_record_for_recurring_order(self): +# """ +# Ensure there is only 1 bill record per order +# """ - product.rc_price = downgrade_price - product.save() - product.create_or_update_recurring_order(when_to_start=change1_date) +# bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr) - bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) +# self.assertEqual(self.recurring_order.billrecord_set.count(), 1) +# self.assertEqual(bill.billrecord_set.count(), 1) - bill = bills[0] - bill_records = BillRecord.objects.filter(bill=bill) - self.assertEqual(len(bill_records), 2) +# def test_new_bill_after_closing(self): +# """ +# After closing a bill and the user has a recurring product, +# the next bill run should create e new bill +# """ - self.assertEqual(bill_records[0].starting_date, starting_date) +# for ending_date in self.bill_dates: +# b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date) +# b.close() - self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at) +# bill_count = Bill.objects.filter(owner=self.recurring_user).count() - # self.assertEqual(bill_records[0].ending_date, first_order_should_end_at) +# self.assertEqual(len(self.bill_dates), bill_count) - # self.assertEqual(bill_records[0].quantity, 1) +# def test_multi_addr_multi_bill(self): +# """ +# Ensure multiple bills are created if orders exist with different billing addresses +# """ - # self.assertEqual(bill_records[1].quantity, 1) - # self.assertEqual(int(bill.sum), 15) +# username="lotsofplaces" +# multi_addr_user = get_user_model().objects.create( +# username=username, +# email=f"{username}@example.org") - def test_upgrade_product(self): - """ - Test upgrading behaviour - """ +# user_addr1 = BillingAddress.objects.create( +# owner=multi_addr_user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="unknown", +# active=True) - user = self.user +# order1 = Order.objects.create( +# owner=multi_addr_user, +# starting_date=self.order_meta[1]['starting_date'], +# ending_date=self.order_meta[1]['ending_date'], +# recurring_period=RecurringPeriod.ONE_TIME, +# price=self.order_meta[1]['price'], +# description=self.order_meta[1]['description'], +# billing_address=BillingAddress.get_address_for(self.user)) - # Create product - starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) - starting_price = 10 - product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) - product.create_order(starting_date) +# # Make this address inactive +# user_addr1.active = False +# user_addr1.save() - change1_date = start_after(starting_date + datetime.timedelta(days=15)) - product.rc_price = 20 - product.save() - product.create_or_update_recurring_order(when_to_start=change1_date) +# user_addr2 = BillingAddress.objects.create( +# owner=multi_addr_user, +# organization = 'Test2 org', +# street="unknown2", +# city="unknown2", +# postal_code="unknown2", +# active=True) - bill_ending_date = change1_date + datetime.timedelta(days=1) - bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) +# order2 = Order.objects.create( +# owner=multi_addr_user, +# starting_date=self.order_meta[1]['starting_date'], +# ending_date=self.order_meta[1]['ending_date'], +# recurring_period=RecurringPeriod.ONE_TIME, +# price=self.order_meta[1]['price'], +# description=self.order_meta[1]['description'], +# billing_address=BillingAddress.get_address_for(self.user)) - bill = bills[0] - bill_records = BillRecord.objects.filter(bill=bill) - self.assertEqual(len(bill_records), 2) - self.assertEqual(bill_records[0].quantity, .5) +# bills = Bill.create_next_bills_for_user(multi_addr_user) - self.assertEqual(bill_records[0].ending_date, end_before(change1_date)) +# self.assertEqual(len(bills), 2) - self.assertEqual(bill_records[1].quantity, 1) - self.assertEqual(bill_records[1].starting_date, change1_date) - self.assertEqual(int(bill.sum), 25) +# # TO BE IMPLEMENTED -- once orders can be marked as "done" / "inactive" / "not for billing" +# # def test_skip_disabled_orders(self): +# # """ +# # Ensure that a bill only considers "active" orders +# # """ + +# # self.assertEqual(1, 2) + + +# class ModifyProductTestCase(TestCase): +# def setUp(self): +# self.user = get_user_model().objects.create( +# username='random_user', +# email='jane.random@domain.tld') + +# self.ba = BillingAddress.objects.create( +# owner=self.user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="somewhere else", +# active=True) + +# def test_no_pro_rata_first_bill(self): +# """ +# The bill should NOT contain a partial amount -- this is a BILL TEST :-) +# """ + +# price = 5 + +# # Standard 30d recurring product +# product = SampleRecurringProduct.objects.create(owner=self.user, +# rc_price=price) + +# starting_date = timezone.make_aware(datetime.datetime(2020,3,3)) +# ending_date = timezone.make_aware(datetime.datetime(2020,3,31)) +# time_diff = (ending_date - starting_date).total_seconds() + +# product.create_order(starting_date) + +# bills = Bill.create_next_bills_for_user(self.user, +# ending_date=ending_date) + + +# # We expect 1 bill for 1 billing address and 1 time frame +# self.assertEqual(len(bills), 1) + +# pro_rata_amount = time_diff / product.default_recurring_period.value + +# self.assertNotEqual(bills[0].sum, pro_rata_amount * price) +# self.assertEqual(bills[0].sum, price) + + +# def test_downgrade_product(self): +# """ +# Test downgrading behaviour: + +# We create a recurring product (recurring time: 30 days) and downgrade after 15 days. + +# We create the bill right AFTER the end of the first order. + +# Expected result: + +# - First bill record for 30 days +# - Second bill record starting after 30 days +# - Bill contains two bill records + +# """ + +# user = self.user + + + +# starting_price = 10 +# downgrade_price = 5 + + +# starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) +# first_order_should_end_at = starting_date + datetime.timedelta(days=30) +# change1_date = start_after(starting_date + datetime.timedelta(days=15)) +# bill_ending_date = change1_date + datetime.timedelta(days=1) + + +# product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) +# product.create_order(starting_date) + +# product.rc_price = downgrade_price +# product.save() +# product.create_or_update_recurring_order(when_to_start=change1_date) + +# bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) + +# bill = bills[0] +# bill_records = BillRecord.objects.filter(bill=bill) + +# self.assertEqual(len(bill_records), 2) + +# self.assertEqual(bill_records[0].starting_date, starting_date) + +# self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at) + +# # self.assertEqual(bill_records[0].ending_date, first_order_should_end_at) + +# # self.assertEqual(bill_records[0].quantity, 1) + +# # self.assertEqual(bill_records[1].quantity, 1) +# # self.assertEqual(int(bill.sum), 15) + +# def test_upgrade_product(self): +# """ +# Test upgrading behaviour +# """ + +# user = self.user + +# # Create product +# starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) +# starting_price = 10 +# product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) +# product.create_order(starting_date) + +# change1_date = start_after(starting_date + datetime.timedelta(days=15)) +# product.rc_price = 20 +# product.save() +# product.create_or_update_recurring_order(when_to_start=change1_date) + +# bill_ending_date = change1_date + datetime.timedelta(days=1) +# bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) + +# bill = bills[0] +# bill_records = BillRecord.objects.filter(bill=bill) + +# self.assertEqual(len(bill_records), 2) +# self.assertEqual(bill_records[0].quantity, .5) + +# self.assertEqual(bill_records[0].ending_date, end_before(change1_date)) + +# self.assertEqual(bill_records[1].quantity, 1) +# self.assertEqual(bill_records[1].starting_date, change1_date) + +# self.assertEqual(int(bill.sum), 25) From 8d8c4d660c6d8f6c3c17e64f3ceef23584e4b083 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 28 Sep 2020 21:59:35 +0200 Subject: [PATCH 27/93] Can order a generic product now --- .../migrations/0023_auto_20200928_1944.py | 18 ++++ .../migrations/0024_auto_20200928_1945.py | 24 +++++ uncloud_pay/models.py | 37 ++++++- uncloud_pay/tests.py | 102 ++++++++++-------- 4 files changed, 135 insertions(+), 46 deletions(-) create mode 100644 uncloud_pay/migrations/0023_auto_20200928_1944.py create mode 100644 uncloud_pay/migrations/0024_auto_20200928_1945.py diff --git a/uncloud_pay/migrations/0023_auto_20200928_1944.py b/uncloud_pay/migrations/0023_auto_20200928_1944.py new file mode 100644 index 0000000..3eb0010 --- /dev/null +++ b/uncloud_pay/migrations/0023_auto_20200928_1944.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-09-28 19:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0022_auto_20200928_1932'), + ] + + operations = [ + migrations.RenameField( + model_name='order', + old_name='price', + new_name='one_time_price', + ), + ] diff --git a/uncloud_pay/migrations/0024_auto_20200928_1945.py b/uncloud_pay/migrations/0024_auto_20200928_1945.py new file mode 100644 index 0000000..6792049 --- /dev/null +++ b/uncloud_pay/migrations/0024_auto_20200928_1945.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1 on 2020-09-28 19:45 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0023_auto_20200928_1944'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='currency', + field=models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32), + ), + migrations.AddField( + model_name='order', + name='recurring_price', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index adc044f..988bd2c 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -563,14 +563,22 @@ class Order(models.Model): starting_date = models.DateTimeField(default=timezone.now) ending_date = models.DateTimeField(blank=True, null=True) + # FIXME: ensure the period is defined in the product recurring_period = models.IntegerField(choices = RecurringPeriod.choices, default = RecurringPeriod.PER_30D) - price = models.DecimalField(default=0.0, + one_time_price = models.DecimalField(default=0.0, max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS, validators=[MinValueValidator(0)]) + recurring_price = models.DecimalField(default=0.0, + max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS, + validators=[MinValueValidator(0)]) + + currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF) + replaces = models.ForeignKey('self', related_name='replaced_by', on_delete=models.CASCADE, @@ -768,6 +776,33 @@ class Order(models.Model): starting_date=starting_date, ending_date=ending_date) + def save(self, *args, **kwargs): + one_time_price = 0 + recurring_price = 0 + + # FIXME: support amount independent one time prices + # FIXME: support a base price + + if 'features' in self.product.config: + for feature in self.product.config['features']: + # FIXME: support optional features (?) + if not feature in self.config['features']: + raise ValidationError(f"Configuration is missing feature {feature}") + + one_time_price += self.product.config['features'][feature]['one_time_price'] * self.config['features'][feature] + recurring_price += self.product.config['features'][feature]['recurring_price'] * self.config['features'][feature] + + + # IMMUTABLE fields -- need to create new order to modify them + # However this is not enforced here... + if self._state.adding: + self.one_time_price = one_time_price + self.recurring_price = recurring_price + + + super().save(*args, **kwargs) + + def __str__(self): return f"{self.description} (order={self.id})" diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index a6ec43e..c341706 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -8,33 +8,29 @@ from uncloud_service.models import GenericServiceProduct import json -# class OrderTestCase(TestCase): -# """ -# The heart of ordering products -# """ +vm_sample_product_config = { + 'features': { + 'cores': + { 'min': 1, + 'max': 48, + 'one_time_price': 0, + 'recurring_price': 4 + }, + 'ram_gb': + { 'min': 1, + 'max': 256, + 'one_time_price': 0, + 'recurring_price': 4 + }, + }, +} -# def setUp(self): -# self.user = get_user_model().objects.create( -# username='random_user', -# email='jane.random@domain.tld') - -# self.ba = BillingAddress.objects.create( -# owner=self.user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="somewhere else", -# active=True) - -# def test_create_one_time_product(self): -# """ -# One time payment products cannot be updated - can they? -# """ - -# p = SampleOneTimeProduct.objects.create(owner=self.user) - -# self.assertEqual(p.one_time_price, 5) -# self.assertEqual(p.recurring_price, 0) +vm_sample_order_config = { + 'features': { + 'cores': 2, + 'ram_gb': 2 + } +} class ProductTestCase(TestCase): @@ -60,29 +56,45 @@ class ProductTestCase(TestCase): Create a sample product """ - config = { - 'features': { - 'cores': - { 'min': 1, - 'max': 48, - 'one_time_price': 0, - 'recurring_price': 4 - }, - 'ram_gb': - { 'min': 1, - 'max': 256, - 'one_time_price': 0, - 'recurring_price': 3 - }, - }, - } + p = Product.objects.create(name="Testproduct", + description="Only for testing", + config=vm_sample_product_config) + + +class OrderTestCase(TestCase): + """ + The heart of ordering products + """ + + def setUp(self): + self.user = get_user_model().objects.create( + username='random_user', + email='jane.random@domain.tld') + + self.ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="somewhere else", + active=True) + + def test_order_product(self): + """ + Order a product, ensure the order has correct price setup + """ p = Product.objects.create(name="Testproduct", description="Only for testing", - config=config) + config=vm_sample_product_config) - # self.assertEqual(p.one_time_price, 5) - # self.assertEqual(p.recurring_price, 0) + o = Order.objects.create(owner=self.user, + billing_address=self.ba, + product=p, + config=vm_sample_order_config) + + self.assertEqual(o.one_time_price, 0) + self.assertEqual(o.recurring_price, 16) # class ProductTestCase(TestCase): From 58883765d7217e336cd17f77dd28e93fed6ebd0e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 28 Sep 2020 23:16:17 +0200 Subject: [PATCH 28/93] [tests] back to 5 working tests! --- .../0025_billrecord_is_recurring_record.py | 19 + .../migrations/0026_order_should_be_billed.py | 18 + uncloud_pay/models.py | 51 ++- uncloud_pay/tests.py | 372 ++++++++++-------- 4 files changed, 271 insertions(+), 189 deletions(-) create mode 100644 uncloud_pay/migrations/0025_billrecord_is_recurring_record.py create mode 100644 uncloud_pay/migrations/0026_order_should_be_billed.py diff --git a/uncloud_pay/migrations/0025_billrecord_is_recurring_record.py b/uncloud_pay/migrations/0025_billrecord_is_recurring_record.py new file mode 100644 index 0000000..5e3e141 --- /dev/null +++ b/uncloud_pay/migrations/0025_billrecord_is_recurring_record.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2020-09-28 20:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0024_auto_20200928_1945'), + ] + + operations = [ + migrations.AddField( + model_name='billrecord', + name='is_recurring_record', + field=models.BooleanField(default=False), + preserve_default=False, + ), + ] diff --git a/uncloud_pay/migrations/0026_order_should_be_billed.py b/uncloud_pay/migrations/0026_order_should_be_billed.py new file mode 100644 index 0000000..c32c688 --- /dev/null +++ b/uncloud_pay/migrations/0026_order_should_be_billed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-09-28 20:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0025_billrecord_is_recurring_record'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='should_be_billed', + field=models.BooleanField(default=True), + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 988bd2c..5f90b02 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -592,6 +592,8 @@ class Order(models.Model): null=True) + should_be_billed = models.BooleanField(default=True) + @property def earliest_ending_date(self): """ @@ -702,24 +704,22 @@ class Order(models.Model): if self.ending_date and self.ending_date < self.starting_date: raise ValidationError("End date cannot be before starting date") - # do not check this if we upgrade - # if self.ending_date and self.ending_date < self.earliest_ending_date: - # raise ValidationError("Ending date is before minimum duration (starting_date + recurring period)") - super().save(*args, **kwargs) def create_bill_record(self, bill): br = None - if self.is_one_time: - if self.billrecord_set.count() == 0: - br = BillRecord.objects.create(bill=bill, - order=self, - starting_date=self.starting_date, - ending_date=self.ending_date) - else: - br = BillRecord.objects.filter(bill=bill, order=self).first() + if self.one_time_price != 0 and self.billrecord_set.count() == 0: + br = BillRecord.objects.create(bill=bill, + order=self, + starting_date=self.starting_date, + ending_date=self.starting_date, + is_recurring_record=False) + + if self.recurring_price != 0: + br = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first() + if br: self.update_bill_record_for_recurring_order(br, bill) @@ -752,7 +752,7 @@ class Order(models.Model): Create a new bill record """ - last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last() + last_bill_record = BillRecord.objects.filter(order=self, is_recurring_record=True).order_by('id').last() starting_date=self.starting_date @@ -772,9 +772,11 @@ class Order(models.Model): ending_date = self.get_ending_date_for_bill(bill) return BillRecord.objects.create(bill=bill, - order=self, - starting_date=starting_date, - ending_date=ending_date) + order=self, + starting_date=starting_date, + ending_date=ending_date, + is_recurring_record=True) + def save(self, *args, **kwargs): one_time_price = 0 @@ -799,7 +801,6 @@ class Order(models.Model): self.one_time_price = one_time_price self.recurring_price = recurring_price - super().save(*args, **kwargs) @@ -1102,10 +1103,12 @@ class BillRecord(models.Model): starting_date = models.DateTimeField() ending_date = models.DateTimeField() + is_recurring_record = models.BooleanField(blank=False, null=False) + @property def quantity(self): """ Determine the quantity by the duration""" - if self.order.is_one_time: + if not self.is_recurring_record: return 1 record_delta = self.ending_date - self.starting_date @@ -1114,10 +1117,18 @@ class BillRecord(models.Model): @property def sum(self): - return self.order.price * Decimal(self.quantity) + if self.is_recurring_record: + return self.order.recurring_price * Decimal(self.quantity) + else: + return self.order.one_time_price def __str__(self): - return f"{self.bill}: {self.quantity} x {self.order}" + if self.is_recurring_record: + bill_line = f"{self.starting_date} - {self.ending_date}: {self.quantity} x {self.order}" + else: + bill_line = f"{self.starting_date}: {self.order}" + + return bill_line def save(self, *args, **kwargs): if self.ending_date < self.starting_date: diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index c341706..dc02232 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -8,6 +8,25 @@ from uncloud_service.models import GenericServiceProduct import json +chocolate_product_config = { + 'features': { + 'gramm': + { 'min': 100, + 'max': 5000, + 'one_time_price': 0.2, + 'recurring_price': 0 + }, + }, +} + +chocolate_order_config = { + 'features': { + 'gramm': 500, + } +} + +chocolate_one_time_price = chocolate_order_config['features']['gramm'] * chocolate_product_config['features']['gramm']['one_time_price'] + vm_sample_product_config = { 'features': { 'cores': @@ -97,6 +116,190 @@ class OrderTestCase(TestCase): self.assertEqual(o.recurring_price, 16) + +class BillTestCase(TestCase): + """ + Test aspects of billing / creating a bill + """ + + def setUp(self): + self.user_without_address = get_user_model().objects.create( + username='no_home_person', + email='far.away@domain.tld') + + self.user = get_user_model().objects.create( + username='jdoe', + email='john.doe@domain.tld') + + self.recurring_user = get_user_model().objects.create( + username='recurrent_product_user', + email='jane.doe@domain.tld') + + self.user_addr = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="unknown", + active=True) + + self.recurring_user_addr = BillingAddress.objects.create( + owner=self.recurring_user, + organization = 'Test org', + street="Somewhere", + city="Else", + postal_code="unknown", + active=True) + + self.order_meta = {} + self.order_meta[1] = { + 'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)), + 'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)), + 'price': 15, + 'description': 'One chocolate bar' + } + + self.chocolate = Product.objects.create(name="Swiss Chocolate", + description="Not only for testing, but for joy", + config=chocolate_product_config) + + # self.recurring_order = Order.objects.create( + # owner=self.recurring_user, + # starting_date=timezone.make_aware(datetime.datetime(2020,3,3)), + # recurring_period=RecurringPeriod.PER_30D, + # price=15, + # description="A pretty VM", + # billing_address=BillingAddress.get_address_for(self.recurring_user) + # ) + + # used for generating multiple bills + self.bill_dates = [ + timezone.make_aware(datetime.datetime(2020,3,31)), + timezone.make_aware(datetime.datetime(2020,4,30)), + timezone.make_aware(datetime.datetime(2020,5,31)), + ] + + + def order_chocolate(self): + return Order.objects.create( + owner=self.user, + recurring_period=RecurringPeriod.ONE_TIME, + product=self.chocolate, + billing_address=BillingAddress.get_address_for(self.user), + starting_date=self.order_meta[1]['starting_date'], + ending_date=self.order_meta[1]['ending_date'], + config=chocolate_order_config) + + + + def test_bill_one_time_one_bill_record(self): + """ + Ensure there is only 1 bill record per order + """ + + order = self.order_chocolate() + + bill = Bill.create_next_bill_for_user_address(self.user_addr) + + self.assertEqual(order.billrecord_set.count(), 1) + + def test_bill_sum_onetime(self): + """ + Check the bill sum for a single one time order + """ + + order = self.order_chocolate() + bill = Bill.create_next_bill_for_user_address(self.user_addr) + self.assertEqual(bill.sum, chocolate_one_time_price) + + +# def test_bill_creates_record_for_recurring_order(self): +# """ +# Ensure there is only 1 bill record per order +# """ + +# bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr) + +# self.assertEqual(self.recurring_order.billrecord_set.count(), 1) +# self.assertEqual(bill.billrecord_set.count(), 1) + + +# def test_new_bill_after_closing(self): +# """ +# After closing a bill and the user has a recurring product, +# the next bill run should create e new bill +# """ + +# for ending_date in self.bill_dates: +# b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date) +# b.close() + +# bill_count = Bill.objects.filter(owner=self.recurring_user).count() + +# self.assertEqual(len(self.bill_dates), bill_count) + +# def test_multi_addr_multi_bill(self): +# """ +# Ensure multiple bills are created if orders exist with different billing addresses +# """ + +# username="lotsofplaces" +# multi_addr_user = get_user_model().objects.create( +# username=username, +# email=f"{username}@example.org") + +# user_addr1 = BillingAddress.objects.create( +# owner=multi_addr_user, +# organization = 'Test org', +# street="unknown", +# city="unknown", +# postal_code="unknown", +# active=True) + +# order1 = Order.objects.create( +# owner=multi_addr_user, +# starting_date=self.order_meta[1]['starting_date'], +# ending_date=self.order_meta[1]['ending_date'], +# recurring_period=RecurringPeriod.ONE_TIME, +# price=self.order_meta[1]['price'], +# description=self.order_meta[1]['description'], +# billing_address=BillingAddress.get_address_for(self.user)) + +# # Make this address inactive +# user_addr1.active = False +# user_addr1.save() + +# user_addr2 = BillingAddress.objects.create( +# owner=multi_addr_user, +# organization = 'Test2 org', +# street="unknown2", +# city="unknown2", +# postal_code="unknown2", +# active=True) + +# order2 = Order.objects.create( +# owner=multi_addr_user, +# starting_date=self.order_meta[1]['starting_date'], +# ending_date=self.order_meta[1]['ending_date'], +# recurring_period=RecurringPeriod.ONE_TIME, +# price=self.order_meta[1]['price'], +# description=self.order_meta[1]['description'], +# billing_address=BillingAddress.get_address_for(self.user)) + + +# bills = Bill.create_next_bills_for_user(multi_addr_user) + +# self.assertEqual(len(bills), 2) + + +# # TO BE IMPLEMENTED -- once orders can be marked as "done" / "inactive" / "not for billing" +# # def test_skip_disabled_orders(self): +# # """ +# # Ensure that a bill only considers "active" orders +# # """ + +# # self.assertEqual(1, 2) + # class ProductTestCase(TestCase): # """ # Test products and products <-> order interaction @@ -292,175 +495,6 @@ class OrderTestCase(TestCase): -# class BillTestCase(TestCase): -# def setUp(self): -# self.user_without_address = get_user_model().objects.create( -# username='no_home_person', -# email='far.away@domain.tld') - -# self.user = get_user_model().objects.create( -# username='jdoe', -# email='john.doe@domain.tld') - -# self.recurring_user = get_user_model().objects.create( -# username='recurrent_product_user', -# email='jane.doe@domain.tld') - -# self.user_addr = BillingAddress.objects.create( -# owner=self.user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="unknown", -# active=True) - -# self.recurring_user_addr = BillingAddress.objects.create( -# owner=self.recurring_user, -# organization = 'Test org', -# street="Somewhere", -# city="Else", -# postal_code="unknown", -# active=True) - -# self.order_meta = {} -# self.order_meta[1] = { -# 'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)), -# 'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)), -# 'price': 15, -# 'description': 'One chocolate bar' -# } - -# self.one_time_order = Order.objects.create( -# owner=self.user, -# starting_date=self.order_meta[1]['starting_date'], -# ending_date=self.order_meta[1]['ending_date'], -# recurring_period=RecurringPeriod.ONE_TIME, -# price=self.order_meta[1]['price'], -# description=self.order_meta[1]['description'], -# billing_address=BillingAddress.get_address_for(self.user)) - -# self.recurring_order = Order.objects.create( -# owner=self.recurring_user, -# starting_date=timezone.make_aware(datetime.datetime(2020,3,3)), -# recurring_period=RecurringPeriod.PER_30D, -# price=15, -# description="A pretty VM", -# billing_address=BillingAddress.get_address_for(self.recurring_user) -# ) - -# # used for generating multiple bills -# self.bill_dates = [ -# timezone.make_aware(datetime.datetime(2020,3,31)), -# timezone.make_aware(datetime.datetime(2020,4,30)), -# timezone.make_aware(datetime.datetime(2020,5,31)), -# ] - - - -# def test_bill_one_time_one_bill_record(self): -# """ -# Ensure there is only 1 bill record per order -# """ - -# bill = Bill.create_next_bill_for_user_address(self.user_addr) - -# self.assertEqual(self.one_time_order.billrecord_set.count(), 1) - -# def test_bill_sum_onetime(self): -# """ -# Check the bill sum for a single one time order -# """ - -# bill = Bill.create_next_bill_for_user_address(self.user_addr) -# self.assertEqual(bill.sum, self.order_meta[1]['price']) - - -# def test_bill_creates_record_for_recurring_order(self): -# """ -# Ensure there is only 1 bill record per order -# """ - -# bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr) - -# self.assertEqual(self.recurring_order.billrecord_set.count(), 1) -# self.assertEqual(bill.billrecord_set.count(), 1) - - -# def test_new_bill_after_closing(self): -# """ -# After closing a bill and the user has a recurring product, -# the next bill run should create e new bill -# """ - -# for ending_date in self.bill_dates: -# b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date) -# b.close() - -# bill_count = Bill.objects.filter(owner=self.recurring_user).count() - -# self.assertEqual(len(self.bill_dates), bill_count) - -# def test_multi_addr_multi_bill(self): -# """ -# Ensure multiple bills are created if orders exist with different billing addresses -# """ - -# username="lotsofplaces" -# multi_addr_user = get_user_model().objects.create( -# username=username, -# email=f"{username}@example.org") - -# user_addr1 = BillingAddress.objects.create( -# owner=multi_addr_user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="unknown", -# active=True) - -# order1 = Order.objects.create( -# owner=multi_addr_user, -# starting_date=self.order_meta[1]['starting_date'], -# ending_date=self.order_meta[1]['ending_date'], -# recurring_period=RecurringPeriod.ONE_TIME, -# price=self.order_meta[1]['price'], -# description=self.order_meta[1]['description'], -# billing_address=BillingAddress.get_address_for(self.user)) - -# # Make this address inactive -# user_addr1.active = False -# user_addr1.save() - -# user_addr2 = BillingAddress.objects.create( -# owner=multi_addr_user, -# organization = 'Test2 org', -# street="unknown2", -# city="unknown2", -# postal_code="unknown2", -# active=True) - -# order2 = Order.objects.create( -# owner=multi_addr_user, -# starting_date=self.order_meta[1]['starting_date'], -# ending_date=self.order_meta[1]['ending_date'], -# recurring_period=RecurringPeriod.ONE_TIME, -# price=self.order_meta[1]['price'], -# description=self.order_meta[1]['description'], -# billing_address=BillingAddress.get_address_for(self.user)) - - -# bills = Bill.create_next_bills_for_user(multi_addr_user) - -# self.assertEqual(len(bills), 2) - - -# # TO BE IMPLEMENTED -- once orders can be marked as "done" / "inactive" / "not for billing" -# # def test_skip_disabled_orders(self): -# # """ -# # Ensure that a bill only considers "active" orders -# # """ - -# # self.assertEqual(1, 2) # class ModifyProductTestCase(TestCase): From 992c7c551eea2932d8bfe456adace2abb43675d6 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 6 Oct 2020 15:46:22 +0200 Subject: [PATCH 29/93] Make recurring period a database model - For easier handling (foreignkeys, many2many) - For higher flexibility (users can define their own periods) --- .../management/commands/db-add-defaults.py | 11 + uncloud_net/models.py | 2 +- uncloud_pay/admin.py | 7 +- .../migrations/0027_auto_20201006_1319.py | 41 ++ uncloud_pay/models.py | 166 +++-- uncloud_pay/serializers.py | 2 +- uncloud_pay/tests.py | 679 +++++++++++------- uncloud_service/models.py | 14 +- uncloud_service/serializers.py | 8 +- uncloud_vm/models.py | 12 +- uncloud_vm/serializers.py | 8 +- 11 files changed, 588 insertions(+), 362 deletions(-) create mode 100644 uncloud/management/commands/db-add-defaults.py create mode 100644 uncloud_pay/migrations/0027_auto_20201006_1319.py diff --git a/uncloud/management/commands/db-add-defaults.py b/uncloud/management/commands/db-add-defaults.py new file mode 100644 index 0000000..28e29c1 --- /dev/null +++ b/uncloud/management/commands/db-add-defaults.py @@ -0,0 +1,11 @@ +from django.core.management.base import BaseCommand +from uncloud_pay.models import RecurringPeriod + +class Command(BaseCommand): + help = 'Add standard uncloud values' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + RecurringPeriod.populate_db_defaults() diff --git a/uncloud_net/models.py b/uncloud_net/models.py index 153b456..47990d8 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -173,7 +173,7 @@ class VPNNetwork(models.Model): wireguard_public_key = models.CharField(max_length=48) - default_recurring_period = RecurringPeriod.PER_365D +# default_recurring_period = RecurringPeriod.PER_365D @property def recurring_price(self): diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index 14fc00f..3154ae5 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -11,7 +11,7 @@ from django.http import FileResponse from django.template.loader import render_to_string -from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress, Product +from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress, Product, RecurringPeriod class BillRecordInline(admin.TabularInline): @@ -90,9 +90,8 @@ admin.site.register(Bill, BillAdmin) admin.site.register(Order) admin.site.register(BillRecord) admin.site.register(BillingAddress) - -#for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]: - +admin.site.register(RecurringPeriod) admin.site.register(Product) #admin.site.register(Order, OrderAdmin) +#for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]: diff --git a/uncloud_pay/migrations/0027_auto_20201006_1319.py b/uncloud_pay/migrations/0027_auto_20201006_1319.py new file mode 100644 index 0000000..a82955a --- /dev/null +++ b/uncloud_pay/migrations/0027_auto_20201006_1319.py @@ -0,0 +1,41 @@ +# Generated by Django 3.1 on 2020-10-06 13:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0026_order_should_be_billed'), + ] + + operations = [ + migrations.CreateModel( + name='RecurringPeriod', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('duration_seconds', models.IntegerField(unique=True)), + ], + ), + migrations.DeleteModel( + name='SampleOneTimeProduct', + ), + migrations.DeleteModel( + name='SampleRecurringProduct', + ), + migrations.DeleteModel( + name='SampleRecurringProductOneTimeFee', + ), + migrations.AlterField( + model_name='order', + name='recurring_period', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod'), + ), + migrations.AlterField( + model_name='product', + name='default_recurring_period', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod'), + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 5f90b02..261313a 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -64,21 +64,6 @@ def start_after(a_date): def default_payment_delay(): return timezone.now() + BILL_PAYMENT_DELAY -# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types -class RecurringPeriod(models.IntegerChoices): - """ - We don't support months are years, because they vary in length. - This is not only complicated, but also unfair to the user, as the user pays the same - amount for different durations. - """ - PER_365D = 365*24*3600, _('Per 365 days') - PER_30D = 30*24*3600, _('Per 30 days') - PER_WEEK = 7*24*3600, _('Per Week') - PER_DAY = 24*3600, _('Per Day') - PER_HOUR = 3600, _('Per Hour') - PER_MINUTE = 60, _('Per Minute') - PER_SECOND = 1, _('Per Second') - ONE_TIME = 0, _('Onetime') class Currency(models.TextChoices): """ @@ -236,6 +221,41 @@ class PaymentMethod(models.Model): # non-primary method. pass +# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types +class RecurringPeriodChoices(models.IntegerChoices): + """ + This is an old class and being superseeded by the database model below + """ + PER_365D = 365*24*3600, _('Per 365 days') + PER_30D = 30*24*3600, _('Per 30 days') + PER_WEEK = 7*24*3600, _('Per Week') + PER_DAY = 24*3600, _('Per Day') + PER_HOUR = 3600, _('Per Hour') + PER_MINUTE = 60, _('Per Minute') + PER_SECOND = 1, _('Per Second') + ONE_TIME = 0, _('Onetime') + +# RecurringPeriods +class RecurringPeriod(models.Model): + """ + Available recurring periods. + By default seeded from RecurringPeriodChoices + """ + + name = models.CharField(max_length=100, unique=True) + duration_seconds = models.IntegerField(unique=True) + + @classmethod + def populate_db_defaults(cls): + for (seconds, name) in RecurringPeriodChoices.choices: + obj, created = cls.objects.get_or_create(name=name, + defaults={ 'duration_seconds': seconds }) + + + def __str__(self): + return f"{self.name} ({self.duration_seconds})" + + ### # Bills. @@ -313,7 +333,11 @@ class Product(UncloudModel): description = models.CharField(max_length=1024) config = models.JSONField() - default_recurring_period = models.IntegerField(choices=RecurringPeriod.choices, default=RecurringPeriod.PER_30D) + +# default_recurring_period = models.IntegerField(choices=RecurringPeriod.choices, default=RecurringPeriod.PER_30D) + default_recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE, editable=True) + + currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF) @property @@ -409,7 +433,6 @@ class Product(UncloudModel): self.create_order(when_to_start, recurring_period) - @property def recurring_price(self): """ implement correct values in the child class """ @@ -564,8 +587,9 @@ class Order(models.Model): ending_date = models.DateTimeField(blank=True, null=True) # FIXME: ensure the period is defined in the product - recurring_period = models.IntegerField(choices = RecurringPeriod.choices, - default = RecurringPeriod.PER_30D) +# recurring_period = models.IntegerField(choices = RecurringPeriod.choices, +# default = RecurringPeriod.PER_30D) + recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE, editable=True) one_time_price = models.DecimalField(default=0.0, max_digits=AMOUNT_MAX_DIGITS, @@ -591,7 +615,6 @@ class Order(models.Model): blank=True, null=True) - should_be_billed = models.BooleanField(default=True) @property @@ -700,6 +723,29 @@ class Order(models.Model): self.ending_date = end_before(new_order.starting_date) self.save() + def update_order(self, config, starting_date=None): + """ + Updating an order means creating a new order and reference the previous order + """ + + if not starting_date: + starting_date = timezone.now() + + new_order = self.__class__(owner=self.owner, + billing_address=self.billing_address, + product=self.product, + starting_date=starting_date, + config=config) + + (new_order_one_time_price, new_order_recurring_price) = new_order.prices + + new_order.replaces = self + new_order.save() + + self.ending_date = end_before(new_order.starting_date) + self.save() + + def save(self, *args, **kwargs): if self.ending_date and self.ending_date < self.starting_date: raise ValidationError("End date cannot be before starting date") @@ -778,12 +824,14 @@ class Order(models.Model): is_recurring_record=True) - def save(self, *args, **kwargs): + @property + def prices(self): one_time_price = 0 recurring_price = 0 # FIXME: support amount independent one time prices # FIXME: support a base price + # FIXME: adjust to the selected recurring_period if 'features' in self.product.config: for feature in self.product.config['features']: @@ -794,12 +842,14 @@ class Order(models.Model): one_time_price += self.product.config['features'][feature]['one_time_price'] * self.config['features'][feature] recurring_price += self.product.config['features'][feature]['recurring_price'] * self.config['features'][feature] + return (one_time_price, recurring_price) + def save(self, *args, **kwargs): + # Calculate the price of the order when we create it # IMMUTABLE fields -- need to create new order to modify them # However this is not enforced here... if self._state.adding: - self.one_time_price = one_time_price - self.recurring_price = recurring_price + (self.one_time_price, self.recurring_price) = self.prices super().save(*args, **kwargs) @@ -1137,50 +1187,50 @@ class BillRecord(models.Model): super().save(*args, **kwargs) -# Sample products included into uncloud -class SampleOneTimeProduct(models.Model): - """ - Products are usually more complex, but this product shows how easy - it can be to create your own one time product. - """ +# # Sample products included into uncloud +# class SampleOneTimeProduct(models.Model): +# """ +# Products are usually more complex, but this product shows how easy +# it can be to create your own one time product. +# """ - default_recurring_period = RecurringPeriod.ONE_TIME +# default_recurring_period = RecurringPeriod.ONE_TIME - ot_price = models.IntegerField(default=5) +# ot_price = models.IntegerField(default=5) - @property - def one_time_price(self): - return self.ot_price +# @property +# def one_time_price(self): +# return self.ot_price -class SampleRecurringProduct(models.Model): - """ - Products are usually more complex, but this product shows how easy - it can be to create your own recurring fee product. - """ +# class SampleRecurringProduct(models.Model): +# """ +# Products are usually more complex, but this product shows how easy +# it can be to create your own recurring fee product. +# """ - default_recurring_period = RecurringPeriod.PER_30D +# default_recurring_period = RecurringPeriod.PER_30D - rc_price = models.IntegerField(default=10) +# rc_price = models.IntegerField(default=10) - @property - def recurring_price(self): - return self.rc_price +# @property +# def recurring_price(self): +# return self.rc_price -class SampleRecurringProductOneTimeFee(models.Model): - """ - Products are usually more complex, but this product shows how easy - it can be to create your own one time + recurring fee product. - """ +# class SampleRecurringProductOneTimeFee(models.Model): +# """ +# Products are usually more complex, but this product shows how easy +# it can be to create your own one time + recurring fee product. +# """ - default_recurring_period = RecurringPeriod.PER_30D +# default_recurring_period = RecurringPeriod.PER_30D - ot_price = models.IntegerField(default=5) - rc_price = models.IntegerField(default=10) +# ot_price = models.IntegerField(default=5) +# rc_price = models.IntegerField(default=10) - @property - def one_time_price(self): - return self.ot_price +# @property +# def one_time_price(self): +# return self.ot_price - @property - def recurring_price(self): - return self.rc_price +# @property +# def recurring_price(self): +# return self.rc_price diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py index e00541c..9214105 100644 --- a/uncloud_pay/serializers.py +++ b/uncloud_pay/serializers.py @@ -82,7 +82,7 @@ class BillRecordSerializer(serializers.Serializer): description = serializers.CharField() one_time_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) recurring_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) - recurring_period = serializers.ChoiceField(choices=RecurringPeriod.choices) +# recurring_period = serializers.ChoiceField() recurring_count = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) vat_rate = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) vat_amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index dc02232..c78fa8e 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -27,7 +27,7 @@ chocolate_order_config = { chocolate_one_time_price = chocolate_order_config['features']['gramm'] * chocolate_product_config['features']['gramm']['one_time_price'] -vm_sample_product_config = { +vm_product_config = { 'features': { 'cores': { 'min': 1, @@ -44,13 +44,28 @@ vm_sample_product_config = { }, } -vm_sample_order_config = { +vm_order_config = { 'features': { 'cores': 2, 'ram_gb': 2 } } +vm_order_downgrade_config = { + 'features': { + 'cores': 1, + 'ram_gb': 1 + } +} + +vm_order_upgrade_config = { + 'features': { + 'cores': 4, + 'ram_gb': 4 + } +} + + class ProductTestCase(TestCase): """ @@ -77,7 +92,7 @@ class ProductTestCase(TestCase): p = Product.objects.create(name="Testproduct", description="Only for testing", - config=vm_sample_product_config) + config=vm_product_config) class OrderTestCase(TestCase): @@ -105,16 +120,287 @@ class OrderTestCase(TestCase): p = Product.objects.create(name="Testproduct", description="Only for testing", - config=vm_sample_product_config) + config=vm_product_config) o = Order.objects.create(owner=self.user, billing_address=self.ba, product=p, - config=vm_sample_order_config) + config=vm_order_config) self.assertEqual(o.one_time_price, 0) self.assertEqual(o.recurring_price, 16) + def test_change_order(self): + """ + Change an order and ensure that + - a new order is created + - the price is correct in the new order + """ + + p = Product.objects.create(name="Testproduct", + description="Only for testing", + config=vm_product_config) + + order1 = Order.objects.create(owner=self.user, + billing_address=self.ba, + product=p, + config=vm_order_config) + + + + self.assertEqual(order1.one_time_price, 0) + self.assertEqual(order1.recurring_price, 16) + + +class ModifyOrderTestCase(TestCase): + """ + Test typical order flows like + - cancelling + - downgrading + - upgrading + """ + + def setUp(self): + self.user = get_user_model().objects.create( + username='random_user', + email='jane.random@domain.tld') + + self.ba = BillingAddress.objects.create( + owner=self.user, + organization = 'Test org', + street="unknown", + city="unknown", + postal_code="somewhere else", + active=True) + + self.product = Product.objects.create(name="Testproduct", + description="Only for testing", + config=vm_product_config) + + + def test_change_order(self): + """ + Test changing an order + + Expected result: + + - Old order should be closed before new order starts + - New order should start at starting data + """ + + user = self.user + + starting_price = 16 + downgrade_price = 8 + + starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) + ending1_date = starting_date + datetime.timedelta(days=15) + change1_date = start_after(ending1_date) + + bill_ending_date = change1_date + datetime.timedelta(days=1) + + + order1 = Order.objects.create(owner=self.user, + billing_address=BillingAddress.get_address_for(self.user), + product=self.product, + config=vm_order_config, + starting_date=starting_date) + + order1.update_order(vm_order_downgrade_config, starting_date=change1_date) + + bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) + + bill = bills[0] + bill_records = BillRecord.objects.filter(bill=bill) + + self.assertEqual(len(bill_records), 2) + + self.assertEqual(bill_records[0].starting_date, starting_date) + self.assertEqual(bill_records[0].ending_date, ending1_date) + + self.assertEqual(bill_records[1].starting_date, change1_date) + + + +# def test_no_pro_rata_first_bill(self): +# """ +# The bill should NOT contain a partial amount -- this is a BILL TEST :-) +# """ + +# price = 5 + +# # Standard 30d recurring product +# product = SampleRecurringProduct.objects.create(owner=self.user, +# rc_price=price) + +# starting_date = timezone.make_aware(datetime.datetime(2020,3,3)) +# ending_date = timezone.make_aware(datetime.datetime(2020,3,31)) +# time_diff = (ending_date - starting_date).total_seconds() + +# product.create_order(starting_date) + +# bills = Bill.create_next_bills_for_user(self.user, +# ending_date=ending_date) + + +# # We expect 1 bill for 1 billing address and 1 time frame +# self.assertEqual(len(bills), 1) + +# pro_rata_amount = time_diff / product.default_recurring_period.value + +# self.assertNotEqual(bills[0].sum, pro_rata_amount * price) +# self.assertEqual(bills[0].sum, price) + + + def test_downgrade_product(self): + """ + Test downgrading behaviour: + + We create a recurring product (recurring time: 30 days) and downgrade after 15 days. + + We create the bill right AFTER the end of the first order. + + Expected result: + + - First bill record for 30 days + - Second bill record starting after 30 days + - Bill contains two bill records + + """ + + user = self.user + + starting_price = 16 + downgrade_price = 8 + + starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) + first_order_should_end_at = starting_date + datetime.timedelta(days=30) + change1_date = start_after(starting_date + datetime.timedelta(days=15)) + bill_ending_date = change1_date + datetime.timedelta(days=1) + + + order1 = Order.objects.create(owner=self.user, + billing_address=BillingAddress.get_address_for(self.user), + product=self.product, + config=vm_order_config, + starting_date=starting_date) + + order1.update_order(vm_order_downgrade_config, starting_date=change1_date) + + # product = Product.objects.create(owner=user, rc_price=starting_price) + # product.create_order(starting_date) + # product.rc_price = downgrade_price + # product.save() + # product.create_or_update_recurring_order(when_to_start=change1_date) + + bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) + + bill = bills[0] + bill_records = BillRecord.objects.filter(bill=bill) + + self.assertEqual(len(bill_records), 2) + + self.assertEqual(bill_records[0].starting_date, starting_date) + self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at) + + # self.assertEqual(bill_records[0].ending_date, first_order_should_end_at) + + # self.assertEqual(bill_records[0].quantity, 1) + + # self.assertEqual(bill_records[1].quantity, 1) + # self.assertEqual(int(bill.sum), 15) + +# def test_upgrade_product(self): +# """ +# Test upgrading behaviour +# """ + +# user = self.user + +# # Create product +# starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) +# starting_price = 10 +# product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) +# product.create_order(starting_date) + +# change1_date = start_after(starting_date + datetime.timedelta(days=15)) +# product.rc_price = 20 +# product.save() +# product.create_or_update_recurring_order(when_to_start=change1_date) + +# bill_ending_date = change1_date + datetime.timedelta(days=1) +# bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) + +# bill = bills[0] +# bill_records = BillRecord.objects.filter(bill=bill) + +# self.assertEqual(len(bill_records), 2) +# self.assertEqual(bill_records[0].quantity, .5) + +# self.assertEqual(bill_records[0].ending_date, end_before(change1_date)) + +# self.assertEqual(bill_records[1].quantity, 1) +# self.assertEqual(bill_records[1].starting_date, change1_date) + +# self.assertEqual(int(bill.sum), 25) + + + + # def test_bill_for_increasing_product(self): + # """ + # Modify a product, see one pro rata entry + # """ + + # # Create product + # starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) + # starting_price = 30.5 + # product = SampleRecurringProduct.objects.create(owner=self.user, + # rc_price=starting_price) + # product.create_order(starting_date) + + # recurring_period = product.default_recurring_period.value + + # # First change + # change1_date = timezone.make_aware(datetime.datetime(2019,4,17)) + # product.rc_price = 49.5 + # product.save() + # product.create_or_update_recurring_order(when_to_start=change1_date) + + # # Second change + # change2_date = timezone.make_aware(datetime.datetime(2019,5,8)) + # product.rc_price = 56.5 + # product.save() + # product.create_or_update_recurring_order(when_to_start=change2_date) + + # # Create bill one month after 2nd change + # bill_ending_date = timezone.make_aware(datetime.datetime(2019,6,30)) + # bills = Bill.create_next_bills_for_user(self.user, + # ending_date=bill_ending_date) + + # # only one bill in this test case + # bill = bills[0] + + # expected_amount = starting_price + + # d2 = starting_date + recurring_period + # duration2 = change1_date - d2 + + # expected_amount = 0 + + # # Expected bill sum & records: + # # 2019-03-03 - 2019-04-02 +30d: 30.5 + # # 2019-04-02 - 2019-04-17: +15d: 15.25 + # # 2019-04-17 - 2019-05-08: +21d: (21/30) * 49.5 + # # 2019-05-08 - 2019-06-07: +30d: 56.5 + # # 2019-06-07 - 2019-07-07: +30d: 56.5 + + + # self.assertEqual(bills[0].sum, price) + + # # expeted result: + # # 1x 5 chf bill record + # # 1x 5 chf bill record + # # 1x 10 partial bill record class BillTestCase(TestCase): @@ -163,14 +449,11 @@ class BillTestCase(TestCase): description="Not only for testing, but for joy", config=chocolate_product_config) - # self.recurring_order = Order.objects.create( - # owner=self.recurring_user, - # starting_date=timezone.make_aware(datetime.datetime(2020,3,3)), - # recurring_period=RecurringPeriod.PER_30D, - # price=15, - # description="A pretty VM", - # billing_address=BillingAddress.get_address_for(self.recurring_user) - # ) + + self.vm = Product.objects.create(name="Super Fast VM", + description="Zooooom", + config=vm_product_config) + # used for generating multiple bills self.bill_dates = [ @@ -190,6 +473,28 @@ class BillTestCase(TestCase): ending_date=self.order_meta[1]['ending_date'], config=chocolate_order_config) + def order_vm(self, owner=None): + + if not owner: + owner = self.recurring_user + + return Order.objects.create( + owner=owner, + product=self.vm, + config=vm_order_config, + billing_address=BillingAddress.get_address_for(self.recurring_user), + starting_date=timezone.make_aware(datetime.datetime(2020,3,3)), + ) + + return Order.objects.create( + owner=self.user, + recurring_period=RecurringPeriod.ONE_TIME, + product=self.chocolate, + billing_address=BillingAddress.get_address_for(self.user), + starting_date=self.order_meta[1]['starting_date'], + ending_date=self.order_meta[1]['ending_date'], + config=chocolate_order_config) + def test_bill_one_time_one_bill_record(self): @@ -213,83 +518,95 @@ class BillTestCase(TestCase): self.assertEqual(bill.sum, chocolate_one_time_price) -# def test_bill_creates_record_for_recurring_order(self): -# """ -# Ensure there is only 1 bill record per order -# """ + def test_bill_creates_record_for_recurring_order(self): + """ + Ensure there is only 1 bill record per order + """ -# bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr) + order = self.order_vm() + bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr) -# self.assertEqual(self.recurring_order.billrecord_set.count(), 1) -# self.assertEqual(bill.billrecord_set.count(), 1) + self.assertEqual(order.billrecord_set.count(), 1) + self.assertEqual(bill.billrecord_set.count(), 1) -# def test_new_bill_after_closing(self): -# """ -# After closing a bill and the user has a recurring product, -# the next bill run should create e new bill -# """ + def test_new_bill_after_closing(self): + """ + After closing a bill and the user has a recurring product, + the next bill run should create e new bill + """ -# for ending_date in self.bill_dates: -# b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date) -# b.close() + order = self.order_vm() -# bill_count = Bill.objects.filter(owner=self.recurring_user).count() + for ending_date in self.bill_dates: + b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date) + b.close() -# self.assertEqual(len(self.bill_dates), bill_count) + bill_count = Bill.objects.filter(owner=self.recurring_user).count() -# def test_multi_addr_multi_bill(self): -# """ -# Ensure multiple bills are created if orders exist with different billing addresses -# """ + self.assertEqual(len(self.bill_dates), bill_count) -# username="lotsofplaces" -# multi_addr_user = get_user_model().objects.create( -# username=username, -# email=f"{username}@example.org") + # def test_multi_addr_multi_bill(self): + # """ + # Ensure multiple bills are created if orders exist with different billing addresses + # """ -# user_addr1 = BillingAddress.objects.create( -# owner=multi_addr_user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="unknown", -# active=True) + # username="lotsofplaces" + # multi_addr_user = get_user_model().objects.create( + # username=username, + # email=f"{username}@example.org") -# order1 = Order.objects.create( -# owner=multi_addr_user, -# starting_date=self.order_meta[1]['starting_date'], -# ending_date=self.order_meta[1]['ending_date'], -# recurring_period=RecurringPeriod.ONE_TIME, -# price=self.order_meta[1]['price'], -# description=self.order_meta[1]['description'], -# billing_address=BillingAddress.get_address_for(self.user)) + # user_addr1 = BillingAddress.objects.create( + # owner=multi_addr_user, + # organization = 'Test org', + # street="unknown", + # city="unknown", + # postal_code="unknown", + # active=True) -# # Make this address inactive -# user_addr1.active = False -# user_addr1.save() + # order1 = Order.objects.create( + # owner=multi_addr_user, + # recurring_period=RecurringPeriod.ONE_TIME, + # product=self.chocolate, + # billing_address=BillingAddress.get_address_for(self.user), + # starting_date=self.order_meta[1]['starting_date'], + # ending_date=self.order_meta[1]['ending_date'], + # config=chocolate_order_config) -# user_addr2 = BillingAddress.objects.create( -# owner=multi_addr_user, -# organization = 'Test2 org', -# street="unknown2", -# city="unknown2", -# postal_code="unknown2", -# active=True) + # order1 = Order.objects.create( + # owner=multi_addr_user, + # starting_date=self.order_meta[1]['starting_date'], + # ending_date=self.order_meta[1]['ending_date'], + # recurring_period=RecurringPeriod.ONE_TIME, + # price=self.order_meta[1]['price'], + # description=self.order_meta[1]['description'], + # billing_address=BillingAddress.get_address_for(self.user)) -# order2 = Order.objects.create( -# owner=multi_addr_user, -# starting_date=self.order_meta[1]['starting_date'], -# ending_date=self.order_meta[1]['ending_date'], -# recurring_period=RecurringPeriod.ONE_TIME, -# price=self.order_meta[1]['price'], -# description=self.order_meta[1]['description'], -# billing_address=BillingAddress.get_address_for(self.user)) + # # Make this address inactive + # user_addr1.active = False + # user_addr1.save() + + # user_addr2 = BillingAddress.objects.create( + # owner=multi_addr_user, + # organization = 'Test2 org', + # street="unknown2", + # city="unknown2", + # postal_code="unknown2", + # active=True) + + # order2 = Order.objects.create( + # owner=multi_addr_user, + # starting_date=self.order_meta[1]['starting_date'], + # ending_date=self.order_meta[1]['ending_date'], + # recurring_period=RecurringPeriod.ONE_TIME, + # price=self.order_meta[1]['price'], + # description=self.order_meta[1]['description'], + # billing_address=BillingAddress.get_address_for(self.user)) -# bills = Bill.create_next_bills_for_user(multi_addr_user) + # bills = Bill.create_next_bills_for_user(multi_addr_user) -# self.assertEqual(len(bills), 2) + # self.assertEqual(len(bills), 2) # # TO BE IMPLEMENTED -- once orders can be marked as "done" / "inactive" / "not for billing" @@ -392,21 +709,21 @@ class BillTestCase(TestCase): # # FIXME: where is the assert? -# class BillingAddressTestCase(TestCase): -# def setUp(self): -# self.user = get_user_model().objects.create( -# username='random_user', -# email='jane.random@domain.tld') +class BillingAddressTestCase(TestCase): + def setUp(self): + self.user = get_user_model().objects.create( + username='random_user', + email='jane.random@domain.tld') -# def test_user_no_address(self): -# """ -# Raise an error, when there is no address -# """ + def test_user_no_address(self): + """ + Raise an error, when there is no address + """ -# self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, -# BillingAddress.get_address_for, -# self.user) + self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, + BillingAddress.get_address_for, + self.user) # def test_user_only_inactive_address(self): # """ @@ -497,198 +814,6 @@ class BillTestCase(TestCase): -# class ModifyProductTestCase(TestCase): -# def setUp(self): -# self.user = get_user_model().objects.create( -# username='random_user', -# email='jane.random@domain.tld') - -# self.ba = BillingAddress.objects.create( -# owner=self.user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="somewhere else", -# active=True) - -# def test_no_pro_rata_first_bill(self): -# """ -# The bill should NOT contain a partial amount -- this is a BILL TEST :-) -# """ - -# price = 5 - -# # Standard 30d recurring product -# product = SampleRecurringProduct.objects.create(owner=self.user, -# rc_price=price) - -# starting_date = timezone.make_aware(datetime.datetime(2020,3,3)) -# ending_date = timezone.make_aware(datetime.datetime(2020,3,31)) -# time_diff = (ending_date - starting_date).total_seconds() - -# product.create_order(starting_date) - -# bills = Bill.create_next_bills_for_user(self.user, -# ending_date=ending_date) - - -# # We expect 1 bill for 1 billing address and 1 time frame -# self.assertEqual(len(bills), 1) - -# pro_rata_amount = time_diff / product.default_recurring_period.value - -# self.assertNotEqual(bills[0].sum, pro_rata_amount * price) -# self.assertEqual(bills[0].sum, price) - - -# def test_downgrade_product(self): -# """ -# Test downgrading behaviour: - -# We create a recurring product (recurring time: 30 days) and downgrade after 15 days. - -# We create the bill right AFTER the end of the first order. - -# Expected result: - -# - First bill record for 30 days -# - Second bill record starting after 30 days -# - Bill contains two bill records - -# """ - -# user = self.user - - - -# starting_price = 10 -# downgrade_price = 5 - - -# starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) -# first_order_should_end_at = starting_date + datetime.timedelta(days=30) -# change1_date = start_after(starting_date + datetime.timedelta(days=15)) -# bill_ending_date = change1_date + datetime.timedelta(days=1) - - -# product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) -# product.create_order(starting_date) - -# product.rc_price = downgrade_price -# product.save() -# product.create_or_update_recurring_order(when_to_start=change1_date) - -# bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) - -# bill = bills[0] -# bill_records = BillRecord.objects.filter(bill=bill) - -# self.assertEqual(len(bill_records), 2) - -# self.assertEqual(bill_records[0].starting_date, starting_date) - -# self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at) - -# # self.assertEqual(bill_records[0].ending_date, first_order_should_end_at) - -# # self.assertEqual(bill_records[0].quantity, 1) - -# # self.assertEqual(bill_records[1].quantity, 1) -# # self.assertEqual(int(bill.sum), 15) - -# def test_upgrade_product(self): -# """ -# Test upgrading behaviour -# """ - -# user = self.user - -# # Create product -# starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) -# starting_price = 10 -# product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) -# product.create_order(starting_date) - -# change1_date = start_after(starting_date + datetime.timedelta(days=15)) -# product.rc_price = 20 -# product.save() -# product.create_or_update_recurring_order(when_to_start=change1_date) - -# bill_ending_date = change1_date + datetime.timedelta(days=1) -# bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) - -# bill = bills[0] -# bill_records = BillRecord.objects.filter(bill=bill) - -# self.assertEqual(len(bill_records), 2) -# self.assertEqual(bill_records[0].quantity, .5) - -# self.assertEqual(bill_records[0].ending_date, end_before(change1_date)) - -# self.assertEqual(bill_records[1].quantity, 1) -# self.assertEqual(bill_records[1].starting_date, change1_date) - -# self.assertEqual(int(bill.sum), 25) - - - - # def test_bill_for_increasing_product(self): - # """ - # Modify a product, see one pro rata entry - # """ - - # # Create product - # starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) - # starting_price = 30.5 - # product = SampleRecurringProduct.objects.create(owner=self.user, - # rc_price=starting_price) - # product.create_order(starting_date) - - # recurring_period = product.default_recurring_period.value - - # # First change - # change1_date = timezone.make_aware(datetime.datetime(2019,4,17)) - # product.rc_price = 49.5 - # product.save() - # product.create_or_update_recurring_order(when_to_start=change1_date) - - # # Second change - # change2_date = timezone.make_aware(datetime.datetime(2019,5,8)) - # product.rc_price = 56.5 - # product.save() - # product.create_or_update_recurring_order(when_to_start=change2_date) - - # # Create bill one month after 2nd change - # bill_ending_date = timezone.make_aware(datetime.datetime(2019,6,30)) - # bills = Bill.create_next_bills_for_user(self.user, - # ending_date=bill_ending_date) - - # # only one bill in this test case - # bill = bills[0] - - # expected_amount = starting_price - - # d2 = starting_date + recurring_period - # duration2 = change1_date - d2 - - # expected_amount = 0 - - # # Expected bill sum & records: - # # 2019-03-03 - 2019-04-02 +30d: 30.5 - # # 2019-04-02 - 2019-04-17: +15d: 15.25 - # # 2019-04-17 - 2019-05-08: +21d: (21/30) * 49.5 - # # 2019-05-08 - 2019-06-07: +30d: 56.5 - # # 2019-06-07 - 2019-07-07: +30d: 56.5 - - - # self.assertEqual(bills[0].sum, price) - - # # expeted result: - # # 1x 5 chf bill record - # # 1x 5 chf bill record - # # 1x 10 partial bill record - - # class NotABillingTC(TestCase): # #class BillingTestCase(TestCase): diff --git a/uncloud_service/models.py b/uncloud_service/models.py index 8afb2e7..a37e42b 100644 --- a/uncloud_service/models.py +++ b/uncloud_service/models.py @@ -15,8 +15,8 @@ class MatrixServiceProduct(models.Model): domain = models.CharField(max_length=255, default='domain.tld') # Default recurring price is PER_MONT, see Product class. - def recurring_price(self, recurring_period=RecurringPeriod.PER_30D): - return self.monthly_managment_fee + # def recurring_price(self, recurring_period=RecurringPeriod.PER_30D): + # return self.monthly_managment_fee @staticmethod def base_image(): @@ -24,11 +24,11 @@ class MatrixServiceProduct(models.Model): #e return VMDiskImageProduct.objects.get(uuid="93e564c5-adb3-4741-941f-718f76075f02") return False - @staticmethod - def allowed_recurring_periods(): - return list(filter( - lambda pair: pair[0] in [RecurringPeriod.PER_30D], - RecurringPeriod.choices)) + # @staticmethod + # def allowed_recurring_periods(): + # return list(filter( + # lambda pair: pair[0] in [RecurringPeriod.PER_30D], + # RecurringPeriod.choices)) @property def one_time_price(self): diff --git a/uncloud_service/serializers.py b/uncloud_service/serializers.py index 8dbd547..bc6d753 100644 --- a/uncloud_service/serializers.py +++ b/uncloud_service/serializers.py @@ -17,8 +17,8 @@ class MatrixServiceProductSerializer(serializers.ModelSerializer): read_only_fields = ['order', 'owner', 'status'] class OrderMatrixServiceProductSerializer(MatrixServiceProductSerializer): - recurring_period = serializers.ChoiceField( - choices=MatrixServiceProduct.allowed_recurring_periods()) + # recurring_period = serializers.ChoiceField( + # choices=MatrixServiceProduct.allowed_recurring_periods()) def __init__(self, *args, **kwargs): super(OrderMatrixServiceProductSerializer, self).__init__(*args, **kwargs) @@ -42,8 +42,8 @@ class GenericServiceProductSerializer(serializers.ModelSerializer): read_only_fields = [ 'owner', 'status'] class OrderGenericServiceProductSerializer(GenericServiceProductSerializer): - recurring_period = serializers.ChoiceField( - choices=GenericServiceProduct.allowed_recurring_periods()) + # recurring_period = serializers.ChoiceField( + # choices=GenericServiceProduct.allowed_recurring_periods()) def __init__(self, *args, **kwargs): super(OrderGenericServiceProductSerializer, self).__init__(*args, **kwargs) diff --git a/uncloud_vm/models.py b/uncloud_vm/models.py index 72a9555..c605779 100644 --- a/uncloud_vm/models.py +++ b/uncloud_vm/models.py @@ -71,12 +71,12 @@ class VMProduct(models.Model): return "Virtual machine '{}': {} core(s), {}GB memory".format( self.name, self.cores, self.ram_in_gb) - @staticmethod - def allowed_recurring_periods(): - return list(filter( - lambda pair: pair[0] in [RecurringPeriod.PER_365D, - RecurringPeriod.PER_30D, RecurringPeriod.PER_HOUR], - RecurringPeriod.choices)) + # @staticmethod + # def allowed_recurring_periods(): + # return list(filter( + # lambda pair: pair[0] in [RecurringPeriod.PER_365D, + # RecurringPeriod.PER_30D, RecurringPeriod.PER_HOUR], + # RecurringPeriod.choices)) def __str__(self): diff --git a/uncloud_vm/serializers.py b/uncloud_vm/serializers.py index 5032ad4..a60d10b 100644 --- a/uncloud_vm/serializers.py +++ b/uncloud_vm/serializers.py @@ -101,8 +101,8 @@ class VMProductSerializer(serializers.ModelSerializer): read_only_fields = ['order', 'owner', 'status'] class OrderVMProductSerializer(VMProductSerializer): - recurring_period = serializers.ChoiceField( - choices=VMWithOSProduct.allowed_recurring_periods()) + # recurring_period = serializers.ChoiceField( + # choices=VMWithOSProduct.allowed_recurring_periods()) def __init__(self, *args, **kwargs): super(VMProductSerializer, self).__init__(*args, **kwargs) @@ -133,8 +133,8 @@ class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer): """ # Custom field used at creation (= ordering) only. - recurring_period = serializers.ChoiceField( - choices=VMProduct.allowed_recurring_periods()) + # recurring_period = serializers.ChoiceField( + # choices=VMProduct.allowed_recurring_periods()) os_disk_uuid = serializers.UUIDField() # os_disk_size = From c435639241040cc0762f1d7d09aea6f4e9446d9e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 6 Oct 2020 16:13:03 +0200 Subject: [PATCH 30/93] gitignore some tests --- uncloud_pay/management/commands/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 uncloud_pay/management/commands/.gitignore diff --git a/uncloud_pay/management/commands/.gitignore b/uncloud_pay/management/commands/.gitignore new file mode 100644 index 0000000..cf5c7fa --- /dev/null +++ b/uncloud_pay/management/commands/.gitignore @@ -0,0 +1,2 @@ +# Customer tests +customer-*.py From 9623a7790769720df8d5b91bf4823e177f7571b3 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 6 Oct 2020 18:53:13 +0200 Subject: [PATCH 31/93] Updating for products/recurring periods --- .../management/commands/db-add-defaults.py | 5 +- uncloud_pay/admin.py | 24 +-- .../migrations/0028_auto_20201006_1529.py | 36 ++++ .../migrations/0029_auto_20201006_1540.py | 18 ++ .../migrations/0030_auto_20201006_1640.py | 21 +++ uncloud_pay/models.py | 163 +++++++++++++----- uncloud_pay/tests.py | 23 ++- 7 files changed, 232 insertions(+), 58 deletions(-) create mode 100644 uncloud_pay/migrations/0028_auto_20201006_1529.py create mode 100644 uncloud_pay/migrations/0029_auto_20201006_1540.py create mode 100644 uncloud_pay/migrations/0030_auto_20201006_1640.py diff --git a/uncloud/management/commands/db-add-defaults.py b/uncloud/management/commands/db-add-defaults.py index 28e29c1..b513d2f 100644 --- a/uncloud/management/commands/db-add-defaults.py +++ b/uncloud/management/commands/db-add-defaults.py @@ -1,5 +1,6 @@ from django.core.management.base import BaseCommand -from uncloud_pay.models import RecurringPeriod + +from uncloud_pay.models import RecurringPeriod, Product class Command(BaseCommand): help = 'Add standard uncloud values' @@ -8,4 +9,6 @@ class Command(BaseCommand): pass def handle(self, *args, **options): + # Order matters, objects are somewhat dependent on each other RecurringPeriod.populate_db_defaults() + Product.populate_db_defaults() diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index 3154ae5..aa648d6 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -11,19 +11,17 @@ from django.http import FileResponse from django.template.loader import render_to_string -from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress, Product, RecurringPeriod +from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress, Product, RecurringPeriod, ProductToRecurringPeriod class BillRecordInline(admin.TabularInline): -# model = Bill.bill_records.through model = BillRecord -# AT some point in the future: expose REPLACED and orders that depend on us -# class OrderInline(admin.TabularInline): -# model = Order -# fk_name = "replaces" -# class OrderAdmin(admin.ModelAdmin): -# inlines = [ OrderInline ] +class RecurringPeriodInline(admin.TabularInline): + model = ProductToRecurringPeriod + +class ProductAdmin(admin.ModelAdmin): + inlines = [ RecurringPeriodInline ] class BillAdmin(admin.ModelAdmin): inlines = [ BillRecordInline ] @@ -87,11 +85,13 @@ class BillAdmin(admin.ModelAdmin): admin.site.register(Bill, BillAdmin) +admin.site.register(ProductToRecurringPeriod) +admin.site.register(Product, ProductAdmin) + +#admin.site.register(Order, OrderAdmin) +#for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]: + admin.site.register(Order) admin.site.register(BillRecord) admin.site.register(BillingAddress) admin.site.register(RecurringPeriod) -admin.site.register(Product) - -#admin.site.register(Order, OrderAdmin) -#for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]: diff --git a/uncloud_pay/migrations/0028_auto_20201006_1529.py b/uncloud_pay/migrations/0028_auto_20201006_1529.py new file mode 100644 index 0000000..1ca4ee1 --- /dev/null +++ b/uncloud_pay/migrations/0028_auto_20201006_1529.py @@ -0,0 +1,36 @@ +# Generated by Django 3.1 on 2020-10-06 15:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0027_auto_20201006_1319'), + ] + + operations = [ + migrations.RemoveField( + model_name='product', + name='default_recurring_period', + ), + migrations.CreateModel( + name='ProductToRecurringPeriod', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_default', models.BooleanField(default=False)), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product')), + ('recurring_period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod')), + ], + ), + migrations.AddField( + model_name='product', + name='recurring_periods', + field=models.ManyToManyField(through='uncloud_pay.ProductToRecurringPeriod', to='uncloud_pay.RecurringPeriod'), + ), + migrations.AddConstraint( + model_name='producttorecurringperiod', + constraint=models.UniqueConstraint(condition=models.Q(is_default=True), fields=('recurring_period', 'product'), name='one_default_recurring_period_per_product'), + ), + ] diff --git a/uncloud_pay/migrations/0029_auto_20201006_1540.py b/uncloud_pay/migrations/0029_auto_20201006_1540.py new file mode 100644 index 0000000..e439d54 --- /dev/null +++ b/uncloud_pay/migrations/0029_auto_20201006_1540.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-10-06 15:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0028_auto_20201006_1529'), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='name', + field=models.CharField(max_length=256, unique=True), + ), + ] diff --git a/uncloud_pay/migrations/0030_auto_20201006_1640.py b/uncloud_pay/migrations/0030_auto_20201006_1640.py new file mode 100644 index 0000000..51bc1f2 --- /dev/null +++ b/uncloud_pay/migrations/0030_auto_20201006_1640.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1 on 2020-10-06 16:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0029_auto_20201006_1540'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='producttorecurringperiod', + name='one_default_recurring_period_per_product', + ), + migrations.AddConstraint( + model_name='producttorecurringperiod', + constraint=models.UniqueConstraint(condition=models.Q(is_default=True), fields=('product',), name='one_default_recurring_period_per_product'), + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 261313a..47f1c22 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -252,8 +252,29 @@ class RecurringPeriod(models.Model): defaults={ 'duration_seconds': seconds }) + + @staticmethod + def secs_to_name(secs): + name = "" + days = 0 + hours = 0 + + if secs > 24*3600: + days = secs // (24*3600) + secs -= (days*24*3600) + + if secs > 3600: + hours = secs // 3600 + secs -= hours*3600 + + return f"{days} days {hours} hours {secs} seconds" + def __str__(self): - return f"{self.name} ({self.duration_seconds})" + duration = self.secs_to_name(self.duration_seconds) + + return f"{self.name} ({duration})" + + ### @@ -329,17 +350,64 @@ class Product(UncloudModel): """ - name = models.CharField(max_length=256) + name = models.CharField(max_length=256, unique=True) description = models.CharField(max_length=1024) - config = models.JSONField() - -# default_recurring_period = models.IntegerField(choices=RecurringPeriod.choices, default=RecurringPeriod.PER_30D) - default_recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE, editable=True) - - + recurring_periods = models.ManyToManyField(RecurringPeriod, through='ProductToRecurringPeriod') currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF) + @property + def default_recurring_period(self): + return RecurringPeriod.objects.get(product=self, + is_default=True) + + @classmethod + def populate_db_defaults(cls): + recurring_period = RecurringPeriod.objects.get(name="Per 30 days") + + obj, created = cls.objects.get_or_create(name="Dual Stack Virtual Machine v1", + description="A standard virtual machine", + currency=Currency.CHF, + config={ + 'features': { + 'base': + { 'min': 1, + 'max': 1, + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 8 + }, + 'cores': + { 'min': 1, + 'max': 48, + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 3 + }, + 'ram_gb': + { 'min': 1, + 'max': 256, + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 4 + }, + 'ssd_gb': + { 'min': 1, + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 3.5 + }, + 'hdd_gb': + { 'min': 0, + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 15/1000 + }, + } + } + ) + + obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True }) + + + def __str__(self): + return f"{self.name} - {self.description}" + @property def recurring_orders(self): return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME) @@ -432,18 +500,6 @@ class Product(UncloudModel): else: self.create_order(when_to_start, recurring_period) - - @property - def recurring_price(self): - """ implement correct values in the child class """ - return 0 - - @property - def one_time_price(self): - """ implement correct values in the child class """ - return 0 - - @property def is_recurring(self): return self.recurring_price > 0 @@ -452,13 +508,6 @@ class Product(UncloudModel): def billing_address(self): return self.order.billing_address - @staticmethod - def allowed_recurring_periods(): - return RecurringPeriod.choices - - # class Meta: - # abstract = True - def discounted_price_by_period(self, requested_period): """ Each product has a standard recurring period for which @@ -553,7 +602,6 @@ class Order(models.Model): Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating bills. Do **NOT** mutate then! - An one time order is "closed" (does not need to be billed anymore) if it has one bill record. Having more than one is a programming error. @@ -586,10 +634,9 @@ class Order(models.Model): starting_date = models.DateTimeField(default=timezone.now) ending_date = models.DateTimeField(blank=True, null=True) - # FIXME: ensure the period is defined in the product -# recurring_period = models.IntegerField(choices = RecurringPeriod.choices, -# default = RecurringPeriod.PER_30D) - recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE, editable=True) + recurring_period = models.ForeignKey(RecurringPeriod, + on_delete=models.CASCADE, + editable=True) one_time_price = models.DecimalField(default=0.0, max_digits=AMOUNT_MAX_DIGITS, @@ -823,24 +870,33 @@ class Order(models.Model): ending_date=ending_date, is_recurring_record=True) - @property def prices(self): one_time_price = 0 recurring_price = 0 - # FIXME: support amount independent one time prices - # FIXME: support a base price # FIXME: adjust to the selected recurring_period if 'features' in self.product.config: for feature in self.product.config['features']: - # FIXME: support optional features (?) - if not feature in self.config['features']: - raise ValidationError(f"Configuration is missing feature {feature}") - one_time_price += self.product.config['features'][feature]['one_time_price'] * self.config['features'][feature] - recurring_price += self.product.config['features'][feature]['recurring_price'] * self.config['features'][feature] + # Set min to 0 if not specified + min_val = self.product.config['features'][feature].get('min', 0) + + # We might not even have 'features' cannot use .get() on it + try: + value = self.config['features'][feature] + except KeyError: + value = self.product.config['features'][feature]['min'] + + # Set max to current value if not specified + max_val = self.product.config['features'][feature].get('max', value) + + if value < min_val or value > max_val: + raise ValidationError(f"Feature '{feature}' must be at least {min_val} and at maximum {max_val}. Value is: {value}") + + one_time_price += self.product.config['features'][feature]['one_time_price_per_unit'] * value + recurring_price += self.product.config['features'][feature]['recurring_price_per_unit'] * value return (one_time_price, recurring_price) @@ -851,6 +907,11 @@ class Order(models.Model): if self._state.adding: (self.one_time_price, self.recurring_price) = self.prices + if not self.recurring_period: + self.recurring_period = self.product.default_recurring_period + + # FIXME: ensure the recurring period is defined in the product + super().save(*args, **kwargs) @@ -1187,6 +1248,28 @@ class BillRecord(models.Model): super().save(*args, **kwargs) + +class ProductToRecurringPeriod(models.Model): + """ + Intermediate manytomany mapping class + """ + + recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE) + product = models.ForeignKey(Product, on_delete=models.CASCADE) + + is_default = models.BooleanField(default=False) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['product'], + condition=Q(is_default=True), + name='one_default_recurring_period_per_product') + ] + + def __str__(self): + return f"{self.product} - {self.recurring_period} (default: {self.is_default})" + + # # Sample products included into uncloud # class SampleOneTimeProduct(models.Model): # """ diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index c78fa8e..5bec86f 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -66,7 +66,6 @@ vm_order_upgrade_config = { } - class ProductTestCase(TestCase): """ Test products and products <-> order interaction @@ -85,6 +84,9 @@ class ProductTestCase(TestCase): postal_code="somewhere else", active=True) + RecurringPeriod.populate_db_defaults() + self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days") + def test_create_product(self): """ Create a sample product @@ -92,7 +94,8 @@ class ProductTestCase(TestCase): p = Product.objects.create(name="Testproduct", description="Only for testing", - config=vm_product_config) + config=vm_product_config, + default_recurring_period=self.default_recurring_period) class OrderTestCase(TestCase): @@ -113,6 +116,10 @@ class OrderTestCase(TestCase): postal_code="somewhere else", active=True) + RecurringPeriod.populate_db_defaults() + self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days") + + def test_order_product(self): """ Order a product, ensure the order has correct price setup @@ -120,7 +127,8 @@ class OrderTestCase(TestCase): p = Product.objects.create(name="Testproduct", description="Only for testing", - config=vm_product_config) + config=vm_product_config, + default_recurring_period=self.default_recurring_period) o = Order.objects.create(owner=self.user, billing_address=self.ba, @@ -139,7 +147,8 @@ class OrderTestCase(TestCase): p = Product.objects.create(name="Testproduct", description="Only for testing", - config=vm_product_config) + config=vm_product_config, + default_recurring_period=self.default_recurring_period) order1 = Order.objects.create(owner=self.user, billing_address=self.ba, @@ -173,9 +182,13 @@ class ModifyOrderTestCase(TestCase): postal_code="somewhere else", active=True) + RecurringPeriod.populate_db_defaults() + self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days") + self.product = Product.objects.create(name="Testproduct", description="Only for testing", - config=vm_product_config) + config=vm_product_config, + default_recurring_period=self.default_recurring_period) def test_change_order(self): From c26ff253de5d5b99156f173f180ce74544010999 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 6 Oct 2020 19:21:37 +0200 Subject: [PATCH 32/93] One step furter to allow saving of orders w/o explicit recurringperiod --- .../migrations/0031_auto_20201006_1655.py | 17 +++++++++++++++++ uncloud_pay/models.py | 15 +++++++++------ 2 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 uncloud_pay/migrations/0031_auto_20201006_1655.py diff --git a/uncloud_pay/migrations/0031_auto_20201006_1655.py b/uncloud_pay/migrations/0031_auto_20201006_1655.py new file mode 100644 index 0000000..e56a4cc --- /dev/null +++ b/uncloud_pay/migrations/0031_auto_20201006_1655.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-10-06 16:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0030_auto_20201006_1640'), + ] + + operations = [ + migrations.AddConstraint( + model_name='producttorecurringperiod', + constraint=models.UniqueConstraint(fields=('product', 'recurring_period'), name='recurring_period_once_per_product'), + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 47f1c22..6962601 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -252,7 +252,6 @@ class RecurringPeriod(models.Model): defaults={ 'duration_seconds': seconds }) - @staticmethod def secs_to_name(secs): name = "" @@ -358,8 +357,10 @@ class Product(UncloudModel): @property def default_recurring_period(self): - return RecurringPeriod.objects.get(product=self, - is_default=True) + """ + Return the default recurring Period + """ + return self.recurring_periods.get(producttorecurringperiod__is_default=True) @classmethod def populate_db_defaults(cls): @@ -907,7 +908,7 @@ class Order(models.Model): if self._state.adding: (self.one_time_price, self.recurring_price) = self.prices - if not self.recurring_period: + if self.recurring_period_id is None: self.recurring_period = self.product.default_recurring_period # FIXME: ensure the recurring period is defined in the product @@ -916,7 +917,7 @@ class Order(models.Model): def __str__(self): - return f"{self.description} (order={self.id})" + return f"Order {self.id} from {self.owner}: {self.product}" class Bill(models.Model): """ @@ -1263,7 +1264,9 @@ class ProductToRecurringPeriod(models.Model): constraints = [ models.UniqueConstraint(fields=['product'], condition=Q(is_default=True), - name='one_default_recurring_period_per_product') + name='one_default_recurring_period_per_product'), + models.UniqueConstraint(fields=['product', 'recurring_period'], + name='recurring_period_once_per_product') ] def __str__(self): From 2e746617021e0ce607f17babf6b980fbec90ddb7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 6 Oct 2020 23:14:32 +0200 Subject: [PATCH 33/93] Fix first test case / billing --- uncloud_pay/models.py | 112 ++++++++++++++++------------- uncloud_pay/templates/bill.html.j2 | 1 - uncloud_pay/tests.py | 77 ++++++++++++-------- 3 files changed, 111 insertions(+), 79 deletions(-) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 6962601..15613a7 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -371,12 +371,6 @@ class Product(UncloudModel): currency=Currency.CHF, config={ 'features': { - 'base': - { 'min': 1, - 'max': 1, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 8 - }, 'cores': { 'min': 1, 'max': 48, @@ -390,9 +384,9 @@ class Product(UncloudModel): 'recurring_price_per_unit': 4 }, 'ssd_gb': - { 'min': 1, + { 'min': 10, 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 3.5 + 'recurring_price_per_unit': 0.35 }, 'hdd_gb': { 'min': 0, @@ -673,7 +667,7 @@ class Order(models.Model): One time orders have a recurring period of 0, so this work universally """ - return self.starting_date + datetime.timedelta(seconds=self.recurring_period) + return self.starting_date + datetime.timedelta(seconds=self.recurring_period.duration_seconds) @property def next_cancel_or_downgrade_date(self): @@ -683,7 +677,7 @@ class Order(models.Model): or cancelling. """ - if self.recurring_period > 0: + if self.recurring_period.seconds > 0: now = timezone.now() delta = now - self.starting_date @@ -781,11 +775,14 @@ class Order(models.Model): new_order = self.__class__(owner=self.owner, billing_address=self.billing_address, + description=self.description, product=self.product, + config=config, starting_date=starting_date, - config=config) + currency=self.currency + ) - (new_order_one_time_price, new_order_recurring_price) = new_order.prices + (new_order.one_time_price, new_order.recurring_price, new_order.config) = new_order.calculate_prices_and_config() new_order.replaces = self new_order.save() @@ -793,12 +790,7 @@ class Order(models.Model): self.ending_date = end_before(new_order.starting_date) self.save() - - def save(self, *args, **kwargs): - if self.ending_date and self.ending_date < self.starting_date: - raise ValidationError("End date cannot be before starting date") - - super().save(*args, **kwargs) + return new_order def create_bill_record(self, bill): @@ -871,12 +863,22 @@ class Order(models.Model): ending_date=ending_date, is_recurring_record=True) - @property - def prices(self): + def calculate_prices_and_config(self): one_time_price = 0 recurring_price = 0 - # FIXME: adjust to the selected recurring_period + if self.config: + config = self.config + + if 'features' not in self.config: + self.config['features'] = {} + + else: + config = { + 'features': {} + } + + # FIXME: adjust prices to the selected recurring_period to the if 'features' in self.product.config: for feature in self.product.config['features']: @@ -887,37 +889,47 @@ class Order(models.Model): # We might not even have 'features' cannot use .get() on it try: value = self.config['features'][feature] - except KeyError: + except (KeyError, TypeError): value = self.product.config['features'][feature]['min'] # Set max to current value if not specified max_val = self.product.config['features'][feature].get('max', value) + if value < min_val or value > max_val: raise ValidationError(f"Feature '{feature}' must be at least {min_val} and at maximum {max_val}. Value is: {value}") one_time_price += self.product.config['features'][feature]['one_time_price_per_unit'] * value recurring_price += self.product.config['features'][feature]['recurring_price_per_unit'] * value + config['features'][feature] = value - return (one_time_price, recurring_price) + return (one_time_price, recurring_price, config) def save(self, *args, **kwargs): # Calculate the price of the order when we create it # IMMUTABLE fields -- need to create new order to modify them # However this is not enforced here... if self._state.adding: - (self.one_time_price, self.recurring_price) = self.prices + (self.one_time_price, self.recurring_price, self.config) = self.calculate_prices_and_config() if self.recurring_period_id is None: self.recurring_period = self.product.default_recurring_period - # FIXME: ensure the recurring period is defined in the product + try: + prod_period = self.product.recurring_periods.get(producttorecurringperiod__recurring_period=self.recurring_period) + except ObjectDoesNotExist: + raise ValidationError(f"Recurring Period {self.recurring_period} not allowed for product {self.product}") + + if self.ending_date and self.ending_date < self.starting_date: + raise ValidationError("End date cannot be before starting date") + + super().save(*args, **kwargs) def __str__(self): - return f"Order {self.id} from {self.owner}: {self.product}" + return f"Order {self.id}: {self.description} {self.config}" class Bill(models.Model): """ @@ -988,14 +1000,33 @@ class Bill(models.Model): return bills @classmethod - def get_or_create_bill(cls, billing_address): + def create_next_bill_for_user_address(cls, billing_address, ending_date=None): + + """ + Create the next bill for a specific billing address of a user + """ + + owner = billing_address.owner + + all_orders = Order.objects.filter(owner=owner, + billing_address=billing_address).order_by('id') + + bill = cls.get_or_create_bill(billing_address, ending_date=ending_date) + + for order in all_orders: + order.create_bill_record(bill) + + return bill + + + @classmethod + def get_or_create_bill(cls, billing_address, ending_date=None): last_bill = cls.objects.filter(billing_address=billing_address).order_by('id').last() all_orders = Order.objects.filter(billing_address=billing_address).order_by('id') first_order = all_orders.first() bill = None - ending_date = None # Get date & bill from previous bill, if it exists if last_bill: @@ -1025,27 +1056,6 @@ class Bill(models.Model): return bill - @classmethod - def create_next_bill_for_user_address(cls, - billing_address, - ending_date=None): - - """ - Create the next bill for a specific billing address of a user - """ - - owner = billing_address.owner - - all_orders = Order.objects.filter(owner=owner, - billing_address=billing_address).order_by('id') - - bill = cls.get_or_create_bill(billing_address) - - for order in all_orders: - order.create_bill_record(bill) - - return bill - # @classmethod # def create_bill_records_for_recurring_orders(cls, bill): # """ @@ -1100,6 +1110,8 @@ class Bill(models.Model): if not last_bill.is_final: bill = last_bill starting_date = last_bill.starting_date + + # FIXME: take given (parameter) or existing ending_date? ending_date = bill.ending_date else: starting_date = last_bill.ending_date + datetime.timedelta(seconds=1) @@ -1225,7 +1237,7 @@ class BillRecord(models.Model): record_delta = self.ending_date - self.starting_date - return record_delta.total_seconds()/self.order.recurring_period + return record_delta.total_seconds()/self.order.recurring_period.duration_seconds @property def sum(self): diff --git a/uncloud_pay/templates/bill.html.j2 b/uncloud_pay/templates/bill.html.j2 index 6fdfca8..e3238d3 100644 --- a/uncloud_pay/templates/bill.html.j2 +++ b/uncloud_pay/templates/bill.html.j2 @@ -36,7 +36,6 @@ font-weight: 500; line-height: 1.1; font-size: 14px; - width: 600px; margin: auto; padding-top: 40px; padding-bottom: 15px; diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 5bec86f..0ebd11c 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -13,8 +13,8 @@ chocolate_product_config = { 'gramm': { 'min': 100, 'max': 5000, - 'one_time_price': 0.2, - 'recurring_price': 0 + 'one_time_price_per_unit': 0.2, + 'recurring_price_per_unit': 0 }, }, } @@ -25,21 +25,21 @@ chocolate_order_config = { } } -chocolate_one_time_price = chocolate_order_config['features']['gramm'] * chocolate_product_config['features']['gramm']['one_time_price'] +chocolate_one_time_price = chocolate_order_config['features']['gramm'] * chocolate_product_config['features']['gramm']['one_time_price_per_unit'] vm_product_config = { 'features': { 'cores': { 'min': 1, 'max': 48, - 'one_time_price': 0, - 'recurring_price': 4 + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 4 }, 'ram_gb': { 'min': 1, 'max': 256, - 'one_time_price': 0, - 'recurring_price': 4 + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 4 }, }, } @@ -94,8 +94,10 @@ class ProductTestCase(TestCase): p = Product.objects.create(name="Testproduct", description="Only for testing", - config=vm_product_config, - default_recurring_period=self.default_recurring_period) + config=vm_product_config) + + p.recurring_periods.add(self.default_recurring_period, + through_defaults= { 'is_default': True }) class OrderTestCase(TestCase): @@ -116,24 +118,36 @@ class OrderTestCase(TestCase): postal_code="somewhere else", active=True) + self.product = Product.objects.create(name="Testproduct", + description="Only for testing", + config=vm_product_config) + RecurringPeriod.populate_db_defaults() self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days") + self.product.recurring_periods.add(self.default_recurring_period, + through_defaults= { 'is_default': True }) + + + def test_order_invalid_recurring_period(self): + """ + Order a products with a recurringperiod that is not added to the product + """ + + o = Order.objects.create(owner=self.user, + billing_address=self.ba, + product=self.product, + config=vm_order_config) + def test_order_product(self): """ Order a product, ensure the order has correct price setup """ - p = Product.objects.create(name="Testproduct", - description="Only for testing", - config=vm_product_config, - default_recurring_period=self.default_recurring_period) - o = Order.objects.create(owner=self.user, billing_address=self.ba, - product=p, - config=vm_order_config) + product=self.product) self.assertEqual(o.one_time_price, 0) self.assertEqual(o.recurring_price, 16) @@ -144,19 +158,12 @@ class OrderTestCase(TestCase): - a new order is created - the price is correct in the new order """ - - p = Product.objects.create(name="Testproduct", - description="Only for testing", - config=vm_product_config, - default_recurring_period=self.default_recurring_period) - order1 = Order.objects.create(owner=self.user, billing_address=self.ba, - product=p, + product=self.product, config=vm_order_config) - self.assertEqual(order1.one_time_price, 0) self.assertEqual(order1.recurring_price, 16) @@ -182,13 +189,15 @@ class ModifyOrderTestCase(TestCase): postal_code="somewhere else", active=True) + self.product = Product.objects.create(name="Testproduct", + description="Only for testing", + config=vm_product_config) + RecurringPeriod.populate_db_defaults() self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days") - self.product = Product.objects.create(name="Testproduct", - description="Only for testing", - config=vm_product_config, - default_recurring_period=self.default_recurring_period) + self.product.recurring_periods.add(self.default_recurring_period, + through_defaults= { 'is_default': True }) def test_change_order(self): @@ -468,6 +477,18 @@ class BillTestCase(TestCase): config=vm_product_config) + RecurringPeriod.populate_db_defaults() + self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days") + + self.onetime_recurring_period = RecurringPeriod.objects.get(name="Onetime") + + self.chocolate.recurring_periods.add(self.onetime_recurring_period, + through_defaults= { 'is_default': True }) + + self.vm.recurring_periods.add(self.default_recurring_period, + through_defaults= { 'is_default': True }) + + # used for generating multiple bills self.bill_dates = [ timezone.make_aware(datetime.datetime(2020,3,31)), From 50fd9e1f37867b5df03e2d823d21f6f216a2df97 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 7 Oct 2020 00:54:56 +0200 Subject: [PATCH 34/93] ++work --- .../management/commands/import-vat-rates.py | 4 +- uncloud_pay/models.py | 329 ++++++------------ 2 files changed, 103 insertions(+), 230 deletions(-) diff --git a/uncloud_pay/management/commands/import-vat-rates.py b/uncloud_pay/management/commands/import-vat-rates.py index 32938e4..2eaf80b 100644 --- a/uncloud_pay/management/commands/import-vat-rates.py +++ b/uncloud_pay/management/commands/import-vat-rates.py @@ -20,8 +20,8 @@ class Command(BaseCommand): if line_count == 0: line_count += 1 obj, created = VATRate.objects.get_or_create( - start_date=row["start_date"], - stop_date=row["stop_date"] if row["stop_date"] is not "" else None, + starting_date=row["start_date"], + ending_date=row["stop_date"] if row["stop_date"] is not "" else None, territory_codes=row["territory_codes"], currency_code=row["currency_code"], rate=row["rate"], diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 15613a7..36057d3 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -18,7 +18,6 @@ from calendar import monthrange from decimal import Decimal import uncloud_pay.stripe -from uncloud_pay.helpers import beginning_of_month, end_of_month from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS, COUNTRIES from uncloud.models import UncloudModel, UncloudStatus @@ -64,12 +63,9 @@ def start_after(a_date): def default_payment_delay(): return timezone.now() + BILL_PAYMENT_DELAY - class Currency(models.TextChoices): """ - We don't support months are years, because they vary in length. - This is not only complicated, but also unfair to the user, as the user pays the same - amount for different durations. + Possible currencies to be billed """ CHF = 'CHF', _('Swiss Franc') EUR = 'EUR', _('Euro') @@ -97,12 +93,32 @@ def get_balance_for_user(user): 0) return payments - bills +### +# Stripe + class StripeCustomer(models.Model): owner = models.OneToOneField( get_user_model(), primary_key=True, on_delete=models.CASCADE) stripe_id = models.CharField(max_length=32) +### +# Hosting company configuration + +class HostingProvider(models.Model): + """ + A class resembling who is running this uncloud instance. + This might change over time so we allow starting/ending dates + + This also defines the taxation rules + + WIP. + """ + starting_date = models.DateField() + ending_date = models.DateField() + + + ### # Payments and Payment Methods. @@ -393,6 +409,55 @@ class Product(UncloudModel): 'one_time_price_per_unit': 0, 'recurring_price_per_unit': 15/1000 }, + 'additional_ipv4_address': + { 'min': 0, + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 8 + }, + } + } + ) + + obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True }) + + obj, created = cls.objects.get_or_create(name="Dual Stack Virtual Machine v2", + description="A standard virtual machine", + currency=Currency.CHF, + config={ + 'features': { + 'base': + { 'min': 1, + 'max': 1, + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 1 + }, + 'cores': + { 'min': 1, + 'max': 48, + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 3 + }, + 'ram_gb': + { 'min': 1, + 'max': 256, + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 4 + }, + 'ssd_gb': + { 'min': 10, + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 0.35 + }, + 'hdd_gb': + { 'min': 0, + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 15/1000 + }, + 'additional_ipv4_address': + { 'min': 0, + 'one_time_price_per_unit': 0, + 'recurring_price_per_unit': 9 + }, } } ) @@ -544,6 +609,8 @@ class Product(UncloudModel): """ + # FIXME: This logic needs to be phased out / replaced by product specific (?) + # proportions. Maybe using the RecurringPeriod table to link the possible discounts/add ups if self.default_recurring_period == RecurringPeriod.PER_365D: if requested_period == RecurringPeriod.PER_365D: @@ -669,21 +736,26 @@ class Order(models.Model): return self.starting_date + datetime.timedelta(seconds=self.recurring_period.duration_seconds) - @property - def next_cancel_or_downgrade_date(self): + + def next_cancel_or_downgrade_date(self, until_when=None): """ Return the next proper ending date after n times the recurring_period, where n is an integer that applies for downgrading or cancelling. """ - if self.recurring_period.seconds > 0: - now = timezone.now() - delta = now - self.starting_date + if not until_when: + until_when = timezone.now() - num_times = ceil(delta.total_seconds() / self.recurring_period) + if until_when < self.starting_date: + raise ValidationError("Cannot end before start of start of order") - next_date = self.starting_date + datetime.timedelta(seconds= num_times * self.recurring_period) + if self.recurring_period.duration_seconds > 0: + delta = until_when - self.starting_date + + num_times = ceil(delta.total_seconds() / self.recurring_period.duration_seconds) + + next_date = self.starting_date + datetime.timedelta(seconds=num_times * self.recurring_period.duration_seconds) else: next_date = self.starting_date @@ -694,12 +766,13 @@ class Order(models.Model): Determine the ending date given a specific bill """ - # If the order is quit, charge the final amount (?) + # If the order is quit, charge the final amount / finish (????) + # Probably not a good idea -- FIXME :continue until usual if self.ending_date: this_ending_date = self.ending_date else: - if self.earliest_ending_date > bill.ending_date: - this_ending_date = self.earliest_ending_date + if self.next_cancel_or_downgrade_date(bill.ending_date) > bill.ending_date: + this_ending_date = self.next_cancel_or_downgrade_date(bill.ending_date) else: this_ending_date = bill.ending_date @@ -796,6 +869,7 @@ class Order(models.Model): def create_bill_record(self, bill): br = None + # Note: check for != 0 not > 0, as we allow discounts to be expressed with < 0 if self.one_time_price != 0 and self.billrecord_set.count() == 0: br = BillRecord.objects.create(bill=bill, order=self, @@ -808,7 +882,6 @@ class Order(models.Model): if br: self.update_bill_record_for_recurring_order(br, bill) - else: br = self.create_new_bill_record_for_recurring_order(bill) @@ -948,7 +1021,9 @@ class Bill(models.Model): on_delete=models.CASCADE, editable=True, null=False) + # FIXME: editable=True -> is in the admin, but also editable in DRF + # Maybe filter fields in the serializer? is_final = models.BooleanField(default=False) @@ -960,9 +1035,6 @@ class Bill(models.Model): name='one_bill_per_month_per_user') ] - def __str__(self): - return f"Bill {self.owner}-{self.id}" - def close(self): """ Close/finish a bill @@ -1001,7 +1073,6 @@ class Bill(models.Model): @classmethod def create_next_bill_for_user_address(cls, billing_address, ending_date=None): - """ Create the next bill for a specific billing address of a user """ @@ -1021,6 +1092,12 @@ class Bill(models.Model): @classmethod def get_or_create_bill(cls, billing_address, ending_date=None): + """ + Get / reuse last bill if it is not yet closed + + Create bill, if there is no bill or if bill is closed. + """ + last_bill = cls.objects.filter(billing_address=billing_address).order_by('id').last() all_orders = Order.objects.filter(billing_address=billing_address).order_by('id') @@ -1056,163 +1133,8 @@ class Bill(models.Model): return bill - # @classmethod - # def create_bill_records_for_recurring_orders(cls, bill): - # """ - # Create or update bill records for recurring orders for the - # given bill - # """ - - # owner = bill.owner - # billing_address = bill.billing_address - - # all_orders = Order.objects.filter(owner=owner, - # billing_address=billing_address).order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME) - - # for order in all_orders: - # bill_record_for_this_bill = BillRecord.objects.filter(bill=bill, - # order=order).first() - # if bill_record_for_this_bill: - # cls.update_bill_record_for_this_bill(bill_record_for_this_bill, - # order, - # bill) - - # else: - # cls.create_new_bill_record_for_this_bill(order, bill) - - - @classmethod - def create_next_bill_for_user_address_old(cls, - owner, - billing_address, - ending_date=None): - """ - Filtering ideas (TBD): - If order is replaced, it should not be added anymore if it has been billed "last time" - If order has ended and finally charged, do not charge anymore - - Find out the last billrecord for the order, if there is none, bill from the starting date - """ - - last_bill = cls.objects.filter(owner=owner, billing_address=billing_address).order_by('id').last() - - # it is important to sort orders here, as bill records will be - # created (and listed) in this order - all_orders = Order.objects.filter(owner=owner, - billing_address=billing_address).order_by('id') - first_order = all_orders.first() - - bill = None - ending_date = None - - # Get date & bill from previous bill, if it exists - if last_bill: - if not last_bill.is_final: - bill = last_bill - starting_date = last_bill.starting_date - - # FIXME: take given (parameter) or existing ending_date? - ending_date = bill.ending_date - else: - starting_date = last_bill.ending_date + datetime.timedelta(seconds=1) - else: - if first_order: - starting_date = first_order.starting_date - else: - starting_date = timezone.now() - - if not ending_date: - ending_date = end_of_month(starting_date) - - if not bill: - bill = cls.objects.create( - owner=owner, - starting_date=starting_date, - ending_date=ending_date, - billing_address=billing_address) - - for order in all_orders: - if order.is_one_time: - # this code should be ok, but needs to be double checked - if order.billrecord_set.count() == 0: - br = BillRecord.objects.create(bill=bill, - order=order, - starting_date=order.starting_date, - ending_date=order.ending_date) - - else: # recurring orders - bill_record_for_this_bill = BillRecord.objects.filter(bill=bill, - order=order).first() - - # This bill already has a bill record for the order - # We potentially need to update the ending_date if the ending_date - # of the bill changed. - if bill_record_for_this_bill: - # we may need to adjust it, but let's do this logic another time - - # If the order has an ending date set, we might need to adjust the bill_record - if order.ending_date: - if bill_record_for_this_bill.ending_date != order.ending_date: - bill_record_for_this_bill.ending_date = order.ending_date - - else: - # recurring, not terminated, should go until at least end of bill - if bill_record_for_this_bill.ending_date < bill.ending_date: - bill_record_for_this_bill.ending_date = bill.ending_date - - - bill_record_for_this_bill.save() - - else: # No bill record in this bill for this order yet - # Find out when billed last time - last_bill_record = BillRecord.objects.filter(order=order).order_by('id').last() - - # Default starting date - this_starting_date=order.starting_date - - # Skip billing again, if we have been processed for this bill duration - if last_bill_record: - if last_bill_record.ending_date >= bill.ending_date: - continue - - # If the order ended and has been fully billed - do not process it - # anymore - if order.ending_date: - if last_bill_record.ending_date == order.ending_date: - # FIXME: maybe mark order for not processing anymore? - # I imagina a boolean column, once this code is stable and - # verified - continue - - # Catch programming bugs if the last bill_record was - # created incorrectly - should never be entered! - if order.ending_date < last_bill_record.ending_date: - raise ValidationError(f"Order {order.id} ends before last bill record {last_bill_record.id}") - - # Start right after last billing run - this_starting_date = last_bill_record.ending_date + datetime.timedelta(seconds=1) - - - # If the order is already terminated, use that date instead of bill date - if order.ending_date: - this_ending_date = order.ending_date - else: - if order.earliest_ending_date > bill.ending_date: - this_ending_date = order.earliest_ending_date - else: - # bill at maximum for this billing period - this_ending_date = bill.ending_date - - # And finally create a new billrecord! - br = BillRecord.objects.create(bill=bill, - order=order, - starting_date=this_starting_date, - ending_date=this_ending_date) - - - - - return bill + def __str__(self): + return f"Bill {self.owner}-{self.id}" class BillRecord(models.Model): @@ -1261,10 +1183,10 @@ class BillRecord(models.Model): super().save(*args, **kwargs) - class ProductToRecurringPeriod(models.Model): """ - Intermediate manytomany mapping class + Intermediate manytomany mapping class that allows storing the default recurring period + for a product """ recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE) @@ -1283,52 +1205,3 @@ class ProductToRecurringPeriod(models.Model): def __str__(self): return f"{self.product} - {self.recurring_period} (default: {self.is_default})" - - -# # Sample products included into uncloud -# class SampleOneTimeProduct(models.Model): -# """ -# Products are usually more complex, but this product shows how easy -# it can be to create your own one time product. -# """ - -# default_recurring_period = RecurringPeriod.ONE_TIME - -# ot_price = models.IntegerField(default=5) - -# @property -# def one_time_price(self): -# return self.ot_price - -# class SampleRecurringProduct(models.Model): -# """ -# Products are usually more complex, but this product shows how easy -# it can be to create your own recurring fee product. -# """ - -# default_recurring_period = RecurringPeriod.PER_30D - -# rc_price = models.IntegerField(default=10) - -# @property -# def recurring_price(self): -# return self.rc_price - -# class SampleRecurringProductOneTimeFee(models.Model): -# """ -# Products are usually more complex, but this product shows how easy -# it can be to create your own one time + recurring fee product. -# """ - -# default_recurring_period = RecurringPeriod.PER_30D - -# ot_price = models.IntegerField(default=5) -# rc_price = models.IntegerField(default=10) - -# @property -# def one_time_price(self): -# return self.ot_price - -# @property -# def recurring_price(self): -# return self.rc_price From e03cdf214acc318fb1ae29155040fa0357f83bfb Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 8 Oct 2020 19:54:04 +0200 Subject: [PATCH 35/93] update VAT importer --- doc/uncloud-manual-2020-08-01.org | 16 +++ .../management/commands/db-add-defaults.py | 31 +++++- uncloud/settings.py | 4 +- uncloud_pay/admin.py | 11 +- .../management/commands/import-vat-rates.py | 57 +++++----- uncloud_pay/models.py | 101 ++++++++++++------ 6 files changed, 141 insertions(+), 79 deletions(-) diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index d316d18..658aa0d 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -60,6 +60,22 @@ python manage.py migrate python manage.py bootstrap-user --username nicocustomer #+END_SRC +** Initialise the database + While it is not strictly required to add default values to the + database, it might significantly reduce the starting time with + uncloud. + + To add the default database values run: + + #+BEGIN_SRC shell + # Add local objects + python manage.py db-add-defaults + + # Import VAT rates + python manage.py import-vat-rates + + #+END_SRC + * Testing / CLI Access Access via the commandline (CLI) can be done using curl or httpie. In our examples we will use httpie. diff --git a/uncloud/management/commands/db-add-defaults.py b/uncloud/management/commands/db-add-defaults.py index b513d2f..49cc991 100644 --- a/uncloud/management/commands/db-add-defaults.py +++ b/uncloud/management/commands/db-add-defaults.py @@ -1,6 +1,13 @@ -from django.core.management.base import BaseCommand +import random +import string + +from django.core.management.base import BaseCommand +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth import get_user_model +from django.conf import settings + +from uncloud_pay.models import BillingAddress, RecurringPeriod, Product -from uncloud_pay.models import RecurringPeriod, Product class Command(BaseCommand): help = 'Add standard uncloud values' @@ -9,6 +16,24 @@ class Command(BaseCommand): pass def handle(self, *args, **options): - # Order matters, objects are somewhat dependent on each other + # Order matters, objects can be dependent on each other + + admin_username="uncloud-admin" + pw_length = 32 + + # Only set password if the user did not exist before + try: + admin_user = get_user_model().objects.get(username=settings.UNCLOUD_ADMIN_NAME) + except ObjectDoesNotExist: + random_password = ''.join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(pw_length)) + + admin_user = get_user_model().objects.create_user(username=settings.UNCLOUD_ADMIN_NAME, password=random_password) + admin_user.is_superuser=True + admin_user.is_staff=True + admin_user.save() + + print(f"Created admin user '{admin_username}' with password '{random_password}'") + + BillingAddress.populate_db_defaults() RecurringPeriod.populate_db_defaults() Product.populate_db_defaults() diff --git a/uncloud/settings.py b/uncloud/settings.py index df3ba17..17f5200 100644 --- a/uncloud/settings.py +++ b/uncloud/settings.py @@ -19,8 +19,6 @@ from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion LOGGING = {} - - # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -185,6 +183,8 @@ ALLOWED_HOSTS = [] # required for hardcopy / pdf rendering: https://github.com/loftylabs/django-hardcopy CHROME_PATH = '/usr/bin/chromium-browser' +# Username that is created by default and owns the configuration objects +UNCLOUD_ADMIN_NAME = "uncloud-admin" # Overwrite settings with local settings, if existing try: diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index aa648d6..2123397 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -11,7 +11,7 @@ from django.http import FileResponse from django.template.loader import render_to_string -from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress, Product, RecurringPeriod, ProductToRecurringPeriod +from uncloud_pay.models import * class BillRecordInline(admin.TabularInline): @@ -88,10 +88,5 @@ admin.site.register(Bill, BillAdmin) admin.site.register(ProductToRecurringPeriod) admin.site.register(Product, ProductAdmin) -#admin.site.register(Order, OrderAdmin) -#for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]: - -admin.site.register(Order) -admin.site.register(BillRecord) -admin.site.register(BillingAddress) -admin.site.register(RecurringPeriod) +for m in [ Order, BillRecord, BillingAddress, RecurringPeriod, VATRate ]: + admin.site.register(m) diff --git a/uncloud_pay/management/commands/import-vat-rates.py b/uncloud_pay/management/commands/import-vat-rates.py index 2eaf80b..89381ba 100644 --- a/uncloud_pay/management/commands/import-vat-rates.py +++ b/uncloud_pay/management/commands/import-vat-rates.py @@ -1,44 +1,35 @@ from django.core.management.base import BaseCommand from uncloud_pay.models import VATRate -import csv +import urllib +import csv +import sys +import io class Command(BaseCommand): help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv''' + vat_url = "https://raw.githubusercontent.com/kdeldycke/vat-rates/main/vat_rates.csv" + def add_arguments(self, parser): - parser.add_argument('csv_file', nargs='+', type=str) + parser.add_argument('--vat-url', default=self.vat_url) def handle(self, *args, **options): - try: - for c_file in options['csv_file']: - print("c_file = %s" % c_file) - with open(c_file, mode='r') as csv_file: - csv_reader = csv.DictReader(csv_file) - line_count = 0 - for row in csv_reader: - if line_count == 0: - line_count += 1 - obj, created = VATRate.objects.get_or_create( - starting_date=row["start_date"], - ending_date=row["stop_date"] if row["stop_date"] is not "" else None, - territory_codes=row["territory_codes"], - currency_code=row["currency_code"], - rate=row["rate"], - rate_type=row["rate_type"], - description=row["description"] - ) - if created: - self.stdout.write(self.style.SUCCESS( - '%s. %s - %s - %s - %s' % ( - line_count, - obj.start_date, - obj.stop_date, - obj.territory_codes, - obj.rate - ) - )) - line_count+=1 + vat_url = options['vat_url'] + url_open = urllib.request.urlopen(vat_url) - except Exception as e: - print(" *** Error occurred. Details {}".format(str(e))) + # map to fileio using stringIO + csv_file = io.StringIO(url_open.read().decode('utf-8')) + reader = csv.DictReader(csv_file) + + for row in reader: +# print(row) + obj, created = VATRate.objects.get_or_create( + starting_date=row["start_date"], + ending_date=row["stop_date"] if row["stop_date"] != "" else None, + territory_codes=row["territory_codes"], + currency_code=row["currency_code"], + rate=row["rate"], + rate_type=row["rate_type"], + description=row["description"] + ) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 36057d3..dbe0aca 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -1,28 +1,26 @@ +import logging +import itertools +import datetime +from math import ceil +from calendar import monthrange +from decimal import Decimal +from functools import reduce + from django.db import models from django.db.models import Q from django.contrib.auth import get_user_model +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from django.core.validators import MinValueValidator from django.utils import timezone from django.core.exceptions import ObjectDoesNotExist, ValidationError - -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType - -import logging -from functools import reduce -import itertools -from math import ceil -import datetime -from calendar import monthrange -from decimal import Decimal +from django.conf import settings import uncloud_pay.stripe from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS, COUNTRIES from uncloud.models import UncloudModel, UncloudStatus -from decimal import Decimal -import decimal # Used to generate bill due dates. BILL_PAYMENT_DELAY=datetime.timedelta(days=10) @@ -102,22 +100,6 @@ class StripeCustomer(models.Model): on_delete=models.CASCADE) stripe_id = models.CharField(max_length=32) -### -# Hosting company configuration - -class HostingProvider(models.Model): - """ - A class resembling who is running this uncloud instance. - This might change over time so we allow starting/ending dates - - This also defines the taxation rules - - WIP. - """ - starting_date = models.DateField() - ending_date = models.DateField() - - ### # Payments and Payment Methods. @@ -267,7 +249,6 @@ class RecurringPeriod(models.Model): obj, created = cls.objects.get_or_create(name=name, defaults={ 'duration_seconds': seconds }) - @staticmethod def secs_to_name(secs): name = "" @@ -290,14 +271,11 @@ class RecurringPeriod(models.Model): return f"{self.name} ({duration})" - - ### # Bills. class BillingAddress(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) - organization = models.CharField(max_length=100, blank=True, null=True) name = models.CharField(max_length=100) street = models.CharField(max_length=100) @@ -314,6 +292,32 @@ class BillingAddress(models.Model): name='one_active_billing_address_per_user') ] + @classmethod + def populate_db_defaults(cls): + """ + Ensure we have at least one billing address that is associated with the uncloud-admin. + + This way we are sure that an UncloudProvider can be created. + + Cannot use get_or_create as that looks for exactly one. + + """ + + owner = get_user_model().objects.get(username=settings.UNCLOUD_ADMIN_NAME) + billing_address = cls.objects.filter(owner=owner).first() + + if not billing_address: + billing_address = cls.objects.create(owner=owner, + organization="uncloud admins", + name="Uncloud Admin", + street="Uncloudstreet. 42", + city="Luchsingen", + postal_code="8775", + country="CH", + active=True) + + + @staticmethod def get_address_for(user): return BillingAddress.objects.get(owner=user, active=True) @@ -349,6 +353,10 @@ class VATRate(models.Model): logger.debug("Did not find VAT rate for %s, returning 0" % country_code) return 0 + + def __str__(self): + return f"{self.territory_codes}: {self.starting_date} - {self.ending_date}: {self.rate_type}" + ### # Products @@ -1205,3 +1213,30 @@ class ProductToRecurringPeriod(models.Model): def __str__(self): return f"{self.product} - {self.recurring_period} (default: {self.is_default})" + + +### +# Who is running / providing this instance of uncloud? + +class UncloudProvider(models.Model): + """ + A class resembling who is running this uncloud instance. + This might change over time so we allow starting/ending dates + + This also defines the taxation rules. + + starting/ending date define from when to when this is valid. This way + we can model address changes and have it correct in the bills. + """ + + valid_from = models.DateField() + valid_to = models.DateField(blank=True) + + billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE) + + @classmethod + def populate_db_defaults(cls): + ba = BillingAddress.objects.get_or_create() + # obj, created = cls.objects.get_or_create( + # valid_from=timezone.now() + # defaults={ 'duration_seconds': seconds }) From fe4e200dc0c5262550349a373ed5c42fa49deee2 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 11 Oct 2020 17:45:25 +0200 Subject: [PATCH 36/93] Begin phasing in the uncloudprovider --- doc/uncloud-manual-2020-08-01.org | 1 - .../migrations/0032_uncloudprovider.py | 23 +++++++++++++++++++ uncloud_pay/models.py | 7 +++++- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 uncloud_pay/migrations/0032_uncloudprovider.py diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index 658aa0d..78dd900 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -73,7 +73,6 @@ python manage.py migrate # Import VAT rates python manage.py import-vat-rates - #+END_SRC * Testing / CLI Access diff --git a/uncloud_pay/migrations/0032_uncloudprovider.py b/uncloud_pay/migrations/0032_uncloudprovider.py new file mode 100644 index 0000000..0eef76c --- /dev/null +++ b/uncloud_pay/migrations/0032_uncloudprovider.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1 on 2020-10-11 15:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0031_auto_20201006_1655'), + ] + + operations = [ + migrations.CreateModel( + name='UncloudProvider', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('valid_from', models.DateField()), + ('valid_to', models.DateField(blank=True)), + ('billing_address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress')), + ], + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index dbe0aca..0360661 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -1010,7 +1010,12 @@ class Order(models.Model): def __str__(self): - return f"Order {self.id}: {self.description} {self.config}" + try: + conf = " ".join([ f"{key}:{val}" for key,val in self.config['features'].items() if val != 0 ]) + except KeyError: + conf = "" + + return f"Order {self.id}: {self.description} {conf}" class Bill(models.Model): """ From bbc7625550561d14edf1bfa2a34ac0e5875ab30d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 11 Oct 2020 22:32:08 +0200 Subject: [PATCH 37/93] phase in configuration - move address to base --- uncloud/__init__.py | 243 +++++++++++++++++ uncloud/admin.py | 6 + .../management/commands/db-add-defaults.py | 5 + uncloud/migrations/0001_initial.py | 24 ++ uncloud/migrations/0002_auto_20201011_2001.py | 18 ++ uncloud/migrations/0003_auto_20201011_2009.py | 27 ++ uncloud/migrations/0004_auto_20201011_2031.py | 51 ++++ uncloud/migrations/__init__.py | 0 uncloud/models.py | 85 +++++- uncloud_net/admin.py | 5 +- uncloud_net/migrations/0007_uncloudnetwork.py | 22 ++ .../migrations/0008_auto_20201011_1924.py | 18 ++ .../0009_uncloudnetwork_description.py | 19 ++ .../migrations/0010_auto_20201011_2009.py | 21 ++ uncloud_net/models.py | 41 ++- uncloud_net/tests.py | 9 +- uncloud_pay/__init__.py | 244 +----------------- .../management/commands/import-vat-rates.py | 2 +- uncloud_pay/migrations/0001_initial.py | 3 +- .../migrations/0033_auto_20201011_2003.py | 20 ++ .../migrations/0034_auto_20201011_2031.py | 37 +++ uncloud_pay/models.py | 68 ++--- 22 files changed, 668 insertions(+), 300 deletions(-) create mode 100644 uncloud/admin.py create mode 100644 uncloud/migrations/0001_initial.py create mode 100644 uncloud/migrations/0002_auto_20201011_2001.py create mode 100644 uncloud/migrations/0003_auto_20201011_2009.py create mode 100644 uncloud/migrations/0004_auto_20201011_2031.py create mode 100644 uncloud/migrations/__init__.py create mode 100644 uncloud_net/migrations/0007_uncloudnetwork.py create mode 100644 uncloud_net/migrations/0008_auto_20201011_1924.py create mode 100644 uncloud_net/migrations/0009_uncloudnetwork_description.py create mode 100644 uncloud_net/migrations/0010_auto_20201011_2009.py create mode 100644 uncloud_pay/migrations/0033_auto_20201011_2003.py create mode 100644 uncloud_pay/migrations/0034_auto_20201011_2031.py diff --git a/uncloud/__init__.py b/uncloud/__init__.py index e69de29..2676f97 100644 --- a/uncloud/__init__.py +++ b/uncloud/__init__.py @@ -0,0 +1,243 @@ +from django.utils.translation import gettext_lazy as _ + +# http://xml.coverpages.org/country3166.html +COUNTRIES = ( + ('AD', _('Andorra')), + ('AE', _('United Arab Emirates')), + ('AF', _('Afghanistan')), + ('AG', _('Antigua & Barbuda')), + ('AI', _('Anguilla')), + ('AL', _('Albania')), + ('AM', _('Armenia')), + ('AN', _('Netherlands Antilles')), + ('AO', _('Angola')), + ('AQ', _('Antarctica')), + ('AR', _('Argentina')), + ('AS', _('American Samoa')), + ('AT', _('Austria')), + ('AU', _('Australia')), + ('AW', _('Aruba')), + ('AZ', _('Azerbaijan')), + ('BA', _('Bosnia and Herzegovina')), + ('BB', _('Barbados')), + ('BD', _('Bangladesh')), + ('BE', _('Belgium')), + ('BF', _('Burkina Faso')), + ('BG', _('Bulgaria')), + ('BH', _('Bahrain')), + ('BI', _('Burundi')), + ('BJ', _('Benin')), + ('BM', _('Bermuda')), + ('BN', _('Brunei Darussalam')), + ('BO', _('Bolivia')), + ('BR', _('Brazil')), + ('BS', _('Bahama')), + ('BT', _('Bhutan')), + ('BV', _('Bouvet Island')), + ('BW', _('Botswana')), + ('BY', _('Belarus')), + ('BZ', _('Belize')), + ('CA', _('Canada')), + ('CC', _('Cocos (Keeling) Islands')), + ('CF', _('Central African Republic')), + ('CG', _('Congo')), + ('CH', _('Switzerland')), + ('CI', _('Ivory Coast')), + ('CK', _('Cook Iislands')), + ('CL', _('Chile')), + ('CM', _('Cameroon')), + ('CN', _('China')), + ('CO', _('Colombia')), + ('CR', _('Costa Rica')), + ('CU', _('Cuba')), + ('CV', _('Cape Verde')), + ('CX', _('Christmas Island')), + ('CY', _('Cyprus')), + ('CZ', _('Czech Republic')), + ('DE', _('Germany')), + ('DJ', _('Djibouti')), + ('DK', _('Denmark')), + ('DM', _('Dominica')), + ('DO', _('Dominican Republic')), + ('DZ', _('Algeria')), + ('EC', _('Ecuador')), + ('EE', _('Estonia')), + ('EG', _('Egypt')), + ('EH', _('Western Sahara')), + ('ER', _('Eritrea')), + ('ES', _('Spain')), + ('ET', _('Ethiopia')), + ('FI', _('Finland')), + ('FJ', _('Fiji')), + ('FK', _('Falkland Islands (Malvinas)')), + ('FM', _('Micronesia')), + ('FO', _('Faroe Islands')), + ('FR', _('France')), + ('FX', _('France, Metropolitan')), + ('GA', _('Gabon')), + ('GB', _('United Kingdom (Great Britain)')), + ('GD', _('Grenada')), + ('GE', _('Georgia')), + ('GF', _('French Guiana')), + ('GH', _('Ghana')), + ('GI', _('Gibraltar')), + ('GL', _('Greenland')), + ('GM', _('Gambia')), + ('GN', _('Guinea')), + ('GP', _('Guadeloupe')), + ('GQ', _('Equatorial Guinea')), + ('GR', _('Greece')), + ('GS', _('South Georgia and the South Sandwich Islands')), + ('GT', _('Guatemala')), + ('GU', _('Guam')), + ('GW', _('Guinea-Bissau')), + ('GY', _('Guyana')), + ('HK', _('Hong Kong')), + ('HM', _('Heard & McDonald Islands')), + ('HN', _('Honduras')), + ('HR', _('Croatia')), + ('HT', _('Haiti')), + ('HU', _('Hungary')), + ('ID', _('Indonesia')), + ('IE', _('Ireland')), + ('IL', _('Israel')), + ('IN', _('India')), + ('IO', _('British Indian Ocean Territory')), + ('IQ', _('Iraq')), + ('IR', _('Islamic Republic of Iran')), + ('IS', _('Iceland')), + ('IT', _('Italy')), + ('JM', _('Jamaica')), + ('JO', _('Jordan')), + ('JP', _('Japan')), + ('KE', _('Kenya')), + ('KG', _('Kyrgyzstan')), + ('KH', _('Cambodia')), + ('KI', _('Kiribati')), + ('KM', _('Comoros')), + ('KN', _('St. Kitts and Nevis')), + ('KP', _('Korea, Democratic People\'s Republic of')), + ('KR', _('Korea, Republic of')), + ('KW', _('Kuwait')), + ('KY', _('Cayman Islands')), + ('KZ', _('Kazakhstan')), + ('LA', _('Lao People\'s Democratic Republic')), + ('LB', _('Lebanon')), + ('LC', _('Saint Lucia')), + ('LI', _('Liechtenstein')), + ('LK', _('Sri Lanka')), + ('LR', _('Liberia')), + ('LS', _('Lesotho')), + ('LT', _('Lithuania')), + ('LU', _('Luxembourg')), + ('LV', _('Latvia')), + ('LY', _('Libyan Arab Jamahiriya')), + ('MA', _('Morocco')), + ('MC', _('Monaco')), + ('MD', _('Moldova, Republic of')), + ('MG', _('Madagascar')), + ('MH', _('Marshall Islands')), + ('ML', _('Mali')), + ('MN', _('Mongolia')), + ('MM', _('Myanmar')), + ('MO', _('Macau')), + ('MP', _('Northern Mariana Islands')), + ('MQ', _('Martinique')), + ('MR', _('Mauritania')), + ('MS', _('Monserrat')), + ('MT', _('Malta')), + ('MU', _('Mauritius')), + ('MV', _('Maldives')), + ('MW', _('Malawi')), + ('MX', _('Mexico')), + ('MY', _('Malaysia')), + ('MZ', _('Mozambique')), + ('NA', _('Namibia')), + ('NC', _('New Caledonia')), + ('NE', _('Niger')), + ('NF', _('Norfolk Island')), + ('NG', _('Nigeria')), + ('NI', _('Nicaragua')), + ('NL', _('Netherlands')), + ('NO', _('Norway')), + ('NP', _('Nepal')), + ('NR', _('Nauru')), + ('NU', _('Niue')), + ('NZ', _('New Zealand')), + ('OM', _('Oman')), + ('PA', _('Panama')), + ('PE', _('Peru')), + ('PF', _('French Polynesia')), + ('PG', _('Papua New Guinea')), + ('PH', _('Philippines')), + ('PK', _('Pakistan')), + ('PL', _('Poland')), + ('PM', _('St. Pierre & Miquelon')), + ('PN', _('Pitcairn')), + ('PR', _('Puerto Rico')), + ('PT', _('Portugal')), + ('PW', _('Palau')), + ('PY', _('Paraguay')), + ('QA', _('Qatar')), + ('RE', _('Reunion')), + ('RO', _('Romania')), + ('RU', _('Russian Federation')), + ('RW', _('Rwanda')), + ('SA', _('Saudi Arabia')), + ('SB', _('Solomon Islands')), + ('SC', _('Seychelles')), + ('SD', _('Sudan')), + ('SE', _('Sweden')), + ('SG', _('Singapore')), + ('SH', _('St. Helena')), + ('SI', _('Slovenia')), + ('SJ', _('Svalbard & Jan Mayen Islands')), + ('SK', _('Slovakia')), + ('SL', _('Sierra Leone')), + ('SM', _('San Marino')), + ('SN', _('Senegal')), + ('SO', _('Somalia')), + ('SR', _('Suriname')), + ('ST', _('Sao Tome & Principe')), + ('SV', _('El Salvador')), + ('SY', _('Syrian Arab Republic')), + ('SZ', _('Swaziland')), + ('TC', _('Turks & Caicos Islands')), + ('TD', _('Chad')), + ('TF', _('French Southern Territories')), + ('TG', _('Togo')), + ('TH', _('Thailand')), + ('TJ', _('Tajikistan')), + ('TK', _('Tokelau')), + ('TM', _('Turkmenistan')), + ('TN', _('Tunisia')), + ('TO', _('Tonga')), + ('TP', _('East Timor')), + ('TR', _('Turkey')), + ('TT', _('Trinidad & Tobago')), + ('TV', _('Tuvalu')), + ('TW', _('Taiwan, Province of China')), + ('TZ', _('Tanzania, United Republic of')), + ('UA', _('Ukraine')), + ('UG', _('Uganda')), + ('UM', _('United States Minor Outlying Islands')), + ('US', _('United States of America')), + ('UY', _('Uruguay')), + ('UZ', _('Uzbekistan')), + ('VA', _('Vatican City State (Holy See)')), + ('VC', _('St. Vincent & the Grenadines')), + ('VE', _('Venezuela')), + ('VG', _('British Virgin Islands')), + ('VI', _('United States Virgin Islands')), + ('VN', _('Viet Nam')), + ('VU', _('Vanuatu')), + ('WF', _('Wallis & Futuna Islands')), + ('WS', _('Samoa')), + ('YE', _('Yemen')), + ('YT', _('Mayotte')), + ('YU', _('Yugoslavia')), + ('ZA', _('South Africa')), + ('ZM', _('Zambia')), + ('ZR', _('Zaire')), + ('ZW', _('Zimbabwe')), +) diff --git a/uncloud/admin.py b/uncloud/admin.py new file mode 100644 index 0000000..330ba79 --- /dev/null +++ b/uncloud/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from .models import UncloudProvider + +for m in [ UncloudProvider ]: + admin.site.register(m) diff --git a/uncloud/management/commands/db-add-defaults.py b/uncloud/management/commands/db-add-defaults.py index 49cc991..60bcdb2 100644 --- a/uncloud/management/commands/db-add-defaults.py +++ b/uncloud/management/commands/db-add-defaults.py @@ -7,6 +7,8 @@ from django.contrib.auth import get_user_model from django.conf import settings from uncloud_pay.models import BillingAddress, RecurringPeriod, Product +from uncloud_net.models import UncloudNetwork +from uncloud.models import UncloudProvider class Command(BaseCommand): @@ -37,3 +39,6 @@ class Command(BaseCommand): BillingAddress.populate_db_defaults() RecurringPeriod.populate_db_defaults() Product.populate_db_defaults() + + UncloudNetwork.populate_db_defaults() + UncloudProvider.populate_db_defaults() diff --git a/uncloud/migrations/0001_initial.py b/uncloud/migrations/0001_initial.py new file mode 100644 index 0000000..8753d29 --- /dev/null +++ b/uncloud/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1 on 2020-10-11 19:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='UncloudProvider', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('starting_date', models.DateField()), + ('ending_date', models.DateField(blank=True)), + ('name', models.CharField(max_length=256)), + ('address', models.TextField()), + ], + ), + ] diff --git a/uncloud/migrations/0002_auto_20201011_2001.py b/uncloud/migrations/0002_auto_20201011_2001.py new file mode 100644 index 0000000..16b3f60 --- /dev/null +++ b/uncloud/migrations/0002_auto_20201011_2001.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-10-11 20:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='uncloudprovider', + name='ending_date', + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/uncloud/migrations/0003_auto_20201011_2009.py b/uncloud/migrations/0003_auto_20201011_2009.py new file mode 100644 index 0000000..9aee763 --- /dev/null +++ b/uncloud/migrations/0003_auto_20201011_2009.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1 on 2020-10-11 20:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0010_auto_20201011_2009'), + ('uncloud', '0002_auto_20201011_2001'), + ] + + operations = [ + migrations.AddField( + model_name='uncloudprovider', + name='billing_network', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderbill', to='uncloud_net.uncloudnetwork'), + preserve_default=False, + ), + migrations.AddField( + model_name='uncloudprovider', + name='referral_network', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderreferral', to='uncloud_net.uncloudnetwork'), + preserve_default=False, + ), + ] diff --git a/uncloud/migrations/0004_auto_20201011_2031.py b/uncloud/migrations/0004_auto_20201011_2031.py new file mode 100644 index 0000000..3b53b7f --- /dev/null +++ b/uncloud/migrations/0004_auto_20201011_2031.py @@ -0,0 +1,51 @@ +# Generated by Django 3.1 on 2020-10-11 20:31 + +from django.db import migrations, models +import uncloud.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud', '0003_auto_20201011_2009'), + ] + + operations = [ + migrations.RenameField( + model_name='uncloudprovider', + old_name='name', + new_name='full_name', + ), + migrations.RemoveField( + model_name='uncloudprovider', + name='address', + ), + migrations.AddField( + model_name='uncloudprovider', + name='city', + field=models.CharField(default='', max_length=256), + preserve_default=False, + ), + migrations.AddField( + model_name='uncloudprovider', + name='country', + field=uncloud.models.CountryField(blank=True, choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2), + ), + migrations.AddField( + model_name='uncloudprovider', + name='organization', + field=models.CharField(blank=True, max_length=256, null=True), + ), + migrations.AddField( + model_name='uncloudprovider', + name='postal_code', + field=models.CharField(default='', max_length=64), + preserve_default=False, + ), + migrations.AddField( + model_name='uncloudprovider', + name='street', + field=models.CharField(default='', max_length=256), + preserve_default=False, + ), + ] diff --git a/uncloud/migrations/__init__.py b/uncloud/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/models.py b/uncloud/models.py index 212d555..24bc02b 100644 --- a/uncloud/models.py +++ b/uncloud/models.py @@ -1,8 +1,12 @@ from django.db import models -from django.db.models import JSONField - +from django.db.models import JSONField, Q +from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from uncloud import COUNTRIES +from uncloud_net.models import UncloudNetwork + + class UncloudModel(models.Model): """ This class extends the standard model with an @@ -34,3 +38,80 @@ class UncloudStatus(models.TextChoices): DELETED = 'DELETED', _('Deleted') # Resource has been deleted DISABLED = 'DISABLED', _('Disabled') # Is usable, but cannot be used for new things UNUSABLE = 'UNUSABLE', _('Unusable'), # Has some kind of error + + + +### +# General address handling +class CountryField(models.CharField): + def __init__(self, *args, **kwargs): + kwargs.setdefault('choices', COUNTRIES) + kwargs.setdefault('default', 'CH') + kwargs.setdefault('max_length', 2) + + super().__init__(*args, **kwargs) + + def get_internal_type(self): + return "CharField" + + +class UncloudAddress(models.Model): + full_name = models.CharField(max_length=256) + organization = models.CharField(max_length=256, blank=True, null=True) + street = models.CharField(max_length=256) + city = models.CharField(max_length=256) + postal_code = models.CharField(max_length=64) + country = CountryField(blank=True) + + class Meta: + abstract = True + +### +# Who is running / providing this instance of uncloud? + +class UncloudProvider(UncloudAddress): + """ + A class resembling who is running this uncloud instance. + This might change over time so we allow starting/ending dates + + This also defines the taxation rules. + + starting/ending date define from when to when this is valid. This way + we can model address changes and have it correct in the bills. + """ + + # Meta: + # FIXMe: only allow non overlapping time frames -- how to define this as a constraint? + starting_date = models.DateField() + ending_date = models.DateField(blank=True, null=True) + + billing_network = models.ForeignKey(UncloudNetwork, related_name="uncloudproviderbill", on_delete=models.CASCADE) + referral_network = models.ForeignKey(UncloudNetwork, related_name="uncloudproviderreferral", on_delete=models.CASCADE) + + + @classmethod + def get_provider(cls, when=None): + """ + Find active provide at a certain time - if there was any + """ + + if not when: + when = timezone.now() + + + return cls.objects.get(Q(starting_date__gte=when, ending_date__lte=when) | + Q(starting_date__gte=when, ending_date__isnull=True)) + + + @classmethod + def populate_db_defaults(cls): + obj, created = cls.objects.get_or_create(name="ungleich glarus ag", + address="Bahnhofstrasse 1\n8783 Linthal\nSwitzerland", + starting_date=timezone.now(), + billing_network=UncloudNetwork.objects.get(description="uncloud Billing"), + referral_network=UncloudNetwork.objects.get(description="uncloud Referral") + ) + + + def __str__(self): + return f"{self.name} {self.address}" diff --git a/uncloud_net/admin.py b/uncloud_net/admin.py index 8c38f3f..29782ef 100644 --- a/uncloud_net/admin.py +++ b/uncloud_net/admin.py @@ -1,3 +1,6 @@ from django.contrib import admin -# Register your models here. +from .models import UncloudNetwork + +for m in [ UncloudNetwork ]: + admin.site.register(m) diff --git a/uncloud_net/migrations/0007_uncloudnetwork.py b/uncloud_net/migrations/0007_uncloudnetwork.py new file mode 100644 index 0000000..aea05bb --- /dev/null +++ b/uncloud_net/migrations/0007_uncloudnetwork.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1 on 2020-10-11 19:20 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0006_auto_20200928_1858'), + ] + + operations = [ + migrations.CreateModel( + name='UncloudNetwork', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('network_address', models.GenericIPAddressField()), + ('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), + ], + ), + ] diff --git a/uncloud_net/migrations/0008_auto_20201011_1924.py b/uncloud_net/migrations/0008_auto_20201011_1924.py new file mode 100644 index 0000000..29ad3e6 --- /dev/null +++ b/uncloud_net/migrations/0008_auto_20201011_1924.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-10-11 19:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0007_uncloudnetwork'), + ] + + operations = [ + migrations.AlterField( + model_name='uncloudnetwork', + name='network_address', + field=models.GenericIPAddressField(unique=True), + ), + ] diff --git a/uncloud_net/migrations/0009_uncloudnetwork_description.py b/uncloud_net/migrations/0009_uncloudnetwork_description.py new file mode 100644 index 0000000..46292fa --- /dev/null +++ b/uncloud_net/migrations/0009_uncloudnetwork_description.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2020-10-11 19:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0008_auto_20201011_1924'), + ] + + operations = [ + migrations.AddField( + model_name='uncloudnetwork', + name='description', + field=models.CharField(default='', max_length=256), + preserve_default=False, + ), + ] diff --git a/uncloud_net/migrations/0010_auto_20201011_2009.py b/uncloud_net/migrations/0010_auto_20201011_2009.py new file mode 100644 index 0000000..b713a4b --- /dev/null +++ b/uncloud_net/migrations/0010_auto_20201011_2009.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1 on 2020-10-11 20:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0009_uncloudnetwork_description'), + ] + + operations = [ + migrations.RemoveField( + model_name='vpnnetworkreservation', + name='extra_data', + ), + migrations.RemoveField( + model_name='vpnpool', + name='extra_data', + ), + ] diff --git a/uncloud_net/models.py b/uncloud_net/models.py index 47990d8..a01dcc5 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -4,16 +4,49 @@ import ipaddress from django.db import models from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator, MaxValueValidator +from django.core.exceptions import FieldError + +class UncloudNetwork(models.Model): + """ + Storing IP networks + """ + + network_address = models.GenericIPAddressField(null=False, unique=True) + network_mask = models.IntegerField(null=False, + validators=[MinValueValidator(0), + MaxValueValidator(128)] + ) + + description = models.CharField(max_length=256) + + @classmethod + def populate_db_defaults(cls): + for net, desc in [ + ( "2a0a:e5c0:11::", "uncloud Billing" ), + ( "2a0a:e5c0:11:1::", "uncloud Referral" ) + ]: + obj, created = cls.objects.get_or_create(network_address=net, + defaults= { + 'network_mask': 64, + 'description': desc + } + ) -from uncloud_pay.models import Product, RecurringPeriod -from uncloud.models import UncloudModel, UncloudStatus + def save(self, *args, **kwargs): + if not ':' in self.network_address and self.network_mask > 32: + raise FieldError("Mask cannot exceed 32 for IPv4") + super().save(*args, **kwargs) + + + def __str__(self): + return f"{self.network_address}/{self.network_mask} {self.description}" class MACAdress(models.Model): default_prefix = 0x420000000000 -class VPNPool(UncloudModel): +class VPNPool(models.Model): """ Network address pools from which VPNs can be created """ @@ -145,7 +178,7 @@ AllowedIPs = {peer_network} pass -class VPNNetworkReservation(UncloudModel): +class VPNNetworkReservation(models.Model): """ This class tracks the used VPN networks. It will be deleted, when the product is cancelled. """ diff --git a/uncloud_net/tests.py b/uncloud_net/tests.py index 540bc6a..974f8dd 100644 --- a/uncloud_net/tests.py +++ b/uncloud_net/tests.py @@ -3,13 +3,20 @@ from rest_framework.test import APIRequestFactory, force_authenticate from rest_framework.reverse import reverse from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError +from django.core.exceptions import ValidationError, FieldError from .views import * from .models import * from uncloud_pay.models import BillingAddress, Order + +class UncloudNetworkTests(TestCase): + def test_invalid_IPv4_network(self): + with self.assertRaises(FieldError): + UncloudNetwork.objects.create(network_address="192.168.1.0", + network_mask=33) + class VPNTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user('django-test-user', 'noreply@ungleich.ch') diff --git a/uncloud_pay/__init__.py b/uncloud_pay/__init__.py index 4bda45f..810fd3e 100644 --- a/uncloud_pay/__init__.py +++ b/uncloud_pay/__init__.py @@ -1,4 +1,4 @@ -from django.utils.translation import gettext_lazy as _ + import decimal # Define DecimalField properties, used to represent amounts of money. @@ -6,245 +6,3 @@ AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 decimal.getcontext().prec = AMOUNT_DECIMALS - -# http://xml.coverpages.org/country3166.html -COUNTRIES = ( - ('AD', _('Andorra')), - ('AE', _('United Arab Emirates')), - ('AF', _('Afghanistan')), - ('AG', _('Antigua & Barbuda')), - ('AI', _('Anguilla')), - ('AL', _('Albania')), - ('AM', _('Armenia')), - ('AN', _('Netherlands Antilles')), - ('AO', _('Angola')), - ('AQ', _('Antarctica')), - ('AR', _('Argentina')), - ('AS', _('American Samoa')), - ('AT', _('Austria')), - ('AU', _('Australia')), - ('AW', _('Aruba')), - ('AZ', _('Azerbaijan')), - ('BA', _('Bosnia and Herzegovina')), - ('BB', _('Barbados')), - ('BD', _('Bangladesh')), - ('BE', _('Belgium')), - ('BF', _('Burkina Faso')), - ('BG', _('Bulgaria')), - ('BH', _('Bahrain')), - ('BI', _('Burundi')), - ('BJ', _('Benin')), - ('BM', _('Bermuda')), - ('BN', _('Brunei Darussalam')), - ('BO', _('Bolivia')), - ('BR', _('Brazil')), - ('BS', _('Bahama')), - ('BT', _('Bhutan')), - ('BV', _('Bouvet Island')), - ('BW', _('Botswana')), - ('BY', _('Belarus')), - ('BZ', _('Belize')), - ('CA', _('Canada')), - ('CC', _('Cocos (Keeling) Islands')), - ('CF', _('Central African Republic')), - ('CG', _('Congo')), - ('CH', _('Switzerland')), - ('CI', _('Ivory Coast')), - ('CK', _('Cook Iislands')), - ('CL', _('Chile')), - ('CM', _('Cameroon')), - ('CN', _('China')), - ('CO', _('Colombia')), - ('CR', _('Costa Rica')), - ('CU', _('Cuba')), - ('CV', _('Cape Verde')), - ('CX', _('Christmas Island')), - ('CY', _('Cyprus')), - ('CZ', _('Czech Republic')), - ('DE', _('Germany')), - ('DJ', _('Djibouti')), - ('DK', _('Denmark')), - ('DM', _('Dominica')), - ('DO', _('Dominican Republic')), - ('DZ', _('Algeria')), - ('EC', _('Ecuador')), - ('EE', _('Estonia')), - ('EG', _('Egypt')), - ('EH', _('Western Sahara')), - ('ER', _('Eritrea')), - ('ES', _('Spain')), - ('ET', _('Ethiopia')), - ('FI', _('Finland')), - ('FJ', _('Fiji')), - ('FK', _('Falkland Islands (Malvinas)')), - ('FM', _('Micronesia')), - ('FO', _('Faroe Islands')), - ('FR', _('France')), - ('FX', _('France, Metropolitan')), - ('GA', _('Gabon')), - ('GB', _('United Kingdom (Great Britain)')), - ('GD', _('Grenada')), - ('GE', _('Georgia')), - ('GF', _('French Guiana')), - ('GH', _('Ghana')), - ('GI', _('Gibraltar')), - ('GL', _('Greenland')), - ('GM', _('Gambia')), - ('GN', _('Guinea')), - ('GP', _('Guadeloupe')), - ('GQ', _('Equatorial Guinea')), - ('GR', _('Greece')), - ('GS', _('South Georgia and the South Sandwich Islands')), - ('GT', _('Guatemala')), - ('GU', _('Guam')), - ('GW', _('Guinea-Bissau')), - ('GY', _('Guyana')), - ('HK', _('Hong Kong')), - ('HM', _('Heard & McDonald Islands')), - ('HN', _('Honduras')), - ('HR', _('Croatia')), - ('HT', _('Haiti')), - ('HU', _('Hungary')), - ('ID', _('Indonesia')), - ('IE', _('Ireland')), - ('IL', _('Israel')), - ('IN', _('India')), - ('IO', _('British Indian Ocean Territory')), - ('IQ', _('Iraq')), - ('IR', _('Islamic Republic of Iran')), - ('IS', _('Iceland')), - ('IT', _('Italy')), - ('JM', _('Jamaica')), - ('JO', _('Jordan')), - ('JP', _('Japan')), - ('KE', _('Kenya')), - ('KG', _('Kyrgyzstan')), - ('KH', _('Cambodia')), - ('KI', _('Kiribati')), - ('KM', _('Comoros')), - ('KN', _('St. Kitts and Nevis')), - ('KP', _('Korea, Democratic People\'s Republic of')), - ('KR', _('Korea, Republic of')), - ('KW', _('Kuwait')), - ('KY', _('Cayman Islands')), - ('KZ', _('Kazakhstan')), - ('LA', _('Lao People\'s Democratic Republic')), - ('LB', _('Lebanon')), - ('LC', _('Saint Lucia')), - ('LI', _('Liechtenstein')), - ('LK', _('Sri Lanka')), - ('LR', _('Liberia')), - ('LS', _('Lesotho')), - ('LT', _('Lithuania')), - ('LU', _('Luxembourg')), - ('LV', _('Latvia')), - ('LY', _('Libyan Arab Jamahiriya')), - ('MA', _('Morocco')), - ('MC', _('Monaco')), - ('MD', _('Moldova, Republic of')), - ('MG', _('Madagascar')), - ('MH', _('Marshall Islands')), - ('ML', _('Mali')), - ('MN', _('Mongolia')), - ('MM', _('Myanmar')), - ('MO', _('Macau')), - ('MP', _('Northern Mariana Islands')), - ('MQ', _('Martinique')), - ('MR', _('Mauritania')), - ('MS', _('Monserrat')), - ('MT', _('Malta')), - ('MU', _('Mauritius')), - ('MV', _('Maldives')), - ('MW', _('Malawi')), - ('MX', _('Mexico')), - ('MY', _('Malaysia')), - ('MZ', _('Mozambique')), - ('NA', _('Namibia')), - ('NC', _('New Caledonia')), - ('NE', _('Niger')), - ('NF', _('Norfolk Island')), - ('NG', _('Nigeria')), - ('NI', _('Nicaragua')), - ('NL', _('Netherlands')), - ('NO', _('Norway')), - ('NP', _('Nepal')), - ('NR', _('Nauru')), - ('NU', _('Niue')), - ('NZ', _('New Zealand')), - ('OM', _('Oman')), - ('PA', _('Panama')), - ('PE', _('Peru')), - ('PF', _('French Polynesia')), - ('PG', _('Papua New Guinea')), - ('PH', _('Philippines')), - ('PK', _('Pakistan')), - ('PL', _('Poland')), - ('PM', _('St. Pierre & Miquelon')), - ('PN', _('Pitcairn')), - ('PR', _('Puerto Rico')), - ('PT', _('Portugal')), - ('PW', _('Palau')), - ('PY', _('Paraguay')), - ('QA', _('Qatar')), - ('RE', _('Reunion')), - ('RO', _('Romania')), - ('RU', _('Russian Federation')), - ('RW', _('Rwanda')), - ('SA', _('Saudi Arabia')), - ('SB', _('Solomon Islands')), - ('SC', _('Seychelles')), - ('SD', _('Sudan')), - ('SE', _('Sweden')), - ('SG', _('Singapore')), - ('SH', _('St. Helena')), - ('SI', _('Slovenia')), - ('SJ', _('Svalbard & Jan Mayen Islands')), - ('SK', _('Slovakia')), - ('SL', _('Sierra Leone')), - ('SM', _('San Marino')), - ('SN', _('Senegal')), - ('SO', _('Somalia')), - ('SR', _('Suriname')), - ('ST', _('Sao Tome & Principe')), - ('SV', _('El Salvador')), - ('SY', _('Syrian Arab Republic')), - ('SZ', _('Swaziland')), - ('TC', _('Turks & Caicos Islands')), - ('TD', _('Chad')), - ('TF', _('French Southern Territories')), - ('TG', _('Togo')), - ('TH', _('Thailand')), - ('TJ', _('Tajikistan')), - ('TK', _('Tokelau')), - ('TM', _('Turkmenistan')), - ('TN', _('Tunisia')), - ('TO', _('Tonga')), - ('TP', _('East Timor')), - ('TR', _('Turkey')), - ('TT', _('Trinidad & Tobago')), - ('TV', _('Tuvalu')), - ('TW', _('Taiwan, Province of China')), - ('TZ', _('Tanzania, United Republic of')), - ('UA', _('Ukraine')), - ('UG', _('Uganda')), - ('UM', _('United States Minor Outlying Islands')), - ('US', _('United States of America')), - ('UY', _('Uruguay')), - ('UZ', _('Uzbekistan')), - ('VA', _('Vatican City State (Holy See)')), - ('VC', _('St. Vincent & the Grenadines')), - ('VE', _('Venezuela')), - ('VG', _('British Virgin Islands')), - ('VI', _('United States Virgin Islands')), - ('VN', _('Viet Nam')), - ('VU', _('Vanuatu')), - ('WF', _('Wallis & Futuna Islands')), - ('WS', _('Samoa')), - ('YE', _('Yemen')), - ('YT', _('Mayotte')), - ('YU', _('Yugoslavia')), - ('ZA', _('South Africa')), - ('ZM', _('Zambia')), - ('ZR', _('Zaire')), - ('ZW', _('Zimbabwe')), -) diff --git a/uncloud_pay/management/commands/import-vat-rates.py b/uncloud_pay/management/commands/import-vat-rates.py index 89381ba..46848cd 100644 --- a/uncloud_pay/management/commands/import-vat-rates.py +++ b/uncloud_pay/management/commands/import-vat-rates.py @@ -8,7 +8,7 @@ import io class Command(BaseCommand): help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv''' - vat_url = "https://raw.githubusercontent.com/kdeldycke/vat-rates/main/vat_rates.csv" + vat_url = "https://raw.githubusercontent.com/ungleich/vat-rates/main/vat_rates.csv" def add_arguments(self, parser): diff --git a/uncloud_pay/migrations/0001_initial.py b/uncloud_pay/migrations/0001_initial.py index 439b3d0..9169f19 100644 --- a/uncloud_pay/migrations/0001_initial.py +++ b/uncloud_pay/migrations/0001_initial.py @@ -6,6 +6,7 @@ from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import uncloud_pay.models +import uncloud.models class Migration(migrations.Migration): @@ -38,7 +39,7 @@ class Migration(migrations.Migration): ('street', models.CharField(max_length=100)), ('city', models.CharField(max_length=50)), ('postal_code', models.CharField(max_length=50)), - ('country', uncloud_pay.models.CountryField(blank=True, choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2)), + ('country', uncloud.models.CountryField(blank=True, choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2)), ('vat_number', models.CharField(blank=True, default='', max_length=100)), ('active', models.BooleanField(default=False)), ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), diff --git a/uncloud_pay/migrations/0033_auto_20201011_2003.py b/uncloud_pay/migrations/0033_auto_20201011_2003.py new file mode 100644 index 0000000..186dd16 --- /dev/null +++ b/uncloud_pay/migrations/0033_auto_20201011_2003.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1 on 2020-10-11 20:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0032_uncloudprovider'), + ] + + operations = [ + migrations.RemoveField( + model_name='product', + name='extra_data', + ), + migrations.DeleteModel( + name='UncloudProvider', + ), + ] diff --git a/uncloud_pay/migrations/0034_auto_20201011_2031.py b/uncloud_pay/migrations/0034_auto_20201011_2031.py new file mode 100644 index 0000000..b976450 --- /dev/null +++ b/uncloud_pay/migrations/0034_auto_20201011_2031.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1 on 2020-10-11 20:31 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0033_auto_20201011_2003'), + ] + + operations = [ + migrations.RemoveField( + model_name='billingaddress', + name='city', + ), + migrations.RemoveField( + model_name='billingaddress', + name='country', + ), + migrations.RemoveField( + model_name='billingaddress', + name='name', + ), + migrations.RemoveField( + model_name='billingaddress', + name='organization', + ), + migrations.RemoveField( + model_name='billingaddress', + name='postal_code', + ), + migrations.RemoveField( + model_name='billingaddress', + name='street', + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 0360661..3648795 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -18,9 +18,8 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.conf import settings import uncloud_pay.stripe -from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS, COUNTRIES -from uncloud.models import UncloudModel, UncloudStatus - +from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS +from uncloud.models import UncloudAddress # Used to generate bill due dates. BILL_PAYMENT_DELAY=datetime.timedelta(days=10) @@ -69,16 +68,6 @@ class Currency(models.TextChoices): EUR = 'EUR', _('Euro') USD = 'USD', _('US Dollar') -class CountryField(models.CharField): - def __init__(self, *args, **kwargs): - kwargs.setdefault('choices', COUNTRIES) - kwargs.setdefault('default', 'CH') - kwargs.setdefault('max_length', 2) - - super().__init__(*args, **kwargs) - - def get_internal_type(self): - return "CharField" def get_balance_for_user(user): bills = reduce( @@ -276,12 +265,6 @@ class RecurringPeriod(models.Model): class BillingAddress(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) - organization = models.CharField(max_length=100, blank=True, null=True) - name = models.CharField(max_length=100) - street = models.CharField(max_length=100) - city = models.CharField(max_length=50) - postal_code = models.CharField(max_length=50) - country = CountryField(blank=True) vat_number = models.CharField(max_length=100, default="", blank=True) active = models.BooleanField(default=False) @@ -360,7 +343,7 @@ class VATRate(models.Model): ### # Products -class Product(UncloudModel): +class Product(models.Model): """ A product is something a user can order. To record the pricing, we create order that define a state in time. @@ -1061,6 +1044,24 @@ class Bill(models.Model): bill_records = BillRecord.objects.filter(bill=self) return sum([ br.sum for br in bill_records ]) + @property + def vat_rate(self): + """ + Handling VAT is a tricky business - thus we only implement the cases + that we clearly now and leave it open to fellow developers to implement + correct handling for other cases. + + Case CH: + + - If the customer is in .ch -> apply standard rate + - If the customer is in EU AND private -> apply country specific rate + - If the customer is in EU AND business -> do not apply VAT + - If the customer is outside EU and outside CH -> do not apply VAT + """ + + provider_country = UncloudProvider.objects.get() + + @classmethod def create_bills_for_all_users(cls): """ @@ -1218,30 +1219,3 @@ class ProductToRecurringPeriod(models.Model): def __str__(self): return f"{self.product} - {self.recurring_period} (default: {self.is_default})" - - -### -# Who is running / providing this instance of uncloud? - -class UncloudProvider(models.Model): - """ - A class resembling who is running this uncloud instance. - This might change over time so we allow starting/ending dates - - This also defines the taxation rules. - - starting/ending date define from when to when this is valid. This way - we can model address changes and have it correct in the bills. - """ - - valid_from = models.DateField() - valid_to = models.DateField(blank=True) - - billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE) - - @classmethod - def populate_db_defaults(cls): - ba = BillingAddress.objects.get_or_create() - # obj, created = cls.objects.get_or_create( - # valid_from=timezone.now() - # defaults={ 'duration_seconds': seconds }) From 0cd8a3a78773c5237026863af50ea2d79ea2f4bb Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 11 Oct 2020 22:36:01 +0200 Subject: [PATCH 38/93] ++update ungleich_provider --- uncloud/models.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/uncloud/models.py b/uncloud/models.py index 24bc02b..680e166 100644 --- a/uncloud/models.py +++ b/uncloud/models.py @@ -105,8 +105,11 @@ class UncloudProvider(UncloudAddress): @classmethod def populate_db_defaults(cls): - obj, created = cls.objects.get_or_create(name="ungleich glarus ag", - address="Bahnhofstrasse 1\n8783 Linthal\nSwitzerland", + obj, created = cls.objects.get_or_create(full_name="ungleich glarus ag", + street="Bahnhofstrasse 1", + postal_code="8783", + city="Linthal", + country="CH", starting_date=timezone.now(), billing_network=UncloudNetwork.objects.get(description="uncloud Billing"), referral_network=UncloudNetwork.objects.get(description="uncloud Referral") @@ -114,4 +117,4 @@ class UncloudProvider(UncloudAddress): def __str__(self): - return f"{self.name} {self.address}" + return f"{self.full_name} {self.country}" From 8959bc6ad5f7517923b1fb5ab4b110bd7dad5a65 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 25 Oct 2020 13:52:36 +0100 Subject: [PATCH 39/93] various updates --- .../0005_uncloudprovider_coupon_network.py | 21 ++++++++ uncloud/models.py | 4 +- uncloud_net/models.py | 3 +- .../migrations/0035_auto_20201012_1728.py | 48 +++++++++++++++++++ uncloud_pay/models.py | 28 +++++++++-- uncloud_pay/templates/bill.html.j2 | 8 ++-- uncloud_pay/views.py | 1 + 7 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 uncloud/migrations/0005_uncloudprovider_coupon_network.py create mode 100644 uncloud_pay/migrations/0035_auto_20201012_1728.py diff --git a/uncloud/migrations/0005_uncloudprovider_coupon_network.py b/uncloud/migrations/0005_uncloudprovider_coupon_network.py new file mode 100644 index 0000000..b74b878 --- /dev/null +++ b/uncloud/migrations/0005_uncloudprovider_coupon_network.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1 on 2020-10-12 17:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0010_auto_20201011_2009'), + ('uncloud', '0004_auto_20201011_2031'), + ] + + operations = [ + migrations.AddField( + model_name='uncloudprovider', + name='coupon_network', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='uncloudprovidercoupon', to='uncloud_net.uncloudnetwork'), + preserve_default=False, + ), + ] diff --git a/uncloud/models.py b/uncloud/models.py index 680e166..623d144 100644 --- a/uncloud/models.py +++ b/uncloud/models.py @@ -87,6 +87,7 @@ class UncloudProvider(UncloudAddress): billing_network = models.ForeignKey(UncloudNetwork, related_name="uncloudproviderbill", on_delete=models.CASCADE) referral_network = models.ForeignKey(UncloudNetwork, related_name="uncloudproviderreferral", on_delete=models.CASCADE) + coupon_network = models.ForeignKey(UncloudNetwork, related_name="uncloudprovidercoupon", on_delete=models.CASCADE) @classmethod @@ -112,7 +113,8 @@ class UncloudProvider(UncloudAddress): country="CH", starting_date=timezone.now(), billing_network=UncloudNetwork.objects.get(description="uncloud Billing"), - referral_network=UncloudNetwork.objects.get(description="uncloud Referral") + referral_network=UncloudNetwork.objects.get(description="uncloud Referral"), + coupon_network=UncloudNetwork.objects.get(description="uncloud Coupon") ) diff --git a/uncloud_net/models.py b/uncloud_net/models.py index a01dcc5..e4e1bb1 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -23,7 +23,8 @@ class UncloudNetwork(models.Model): def populate_db_defaults(cls): for net, desc in [ ( "2a0a:e5c0:11::", "uncloud Billing" ), - ( "2a0a:e5c0:11:1::", "uncloud Referral" ) + ( "2a0a:e5c0:11:1::", "uncloud Referral" ), + ( "2a0a:e5c0:11:2::", "uncloud Coupon" ) ]: obj, created = cls.objects.get_or_create(network_address=net, defaults= { diff --git a/uncloud_pay/migrations/0035_auto_20201012_1728.py b/uncloud_pay/migrations/0035_auto_20201012_1728.py new file mode 100644 index 0000000..af30d98 --- /dev/null +++ b/uncloud_pay/migrations/0035_auto_20201012_1728.py @@ -0,0 +1,48 @@ +# Generated by Django 3.1 on 2020-10-12 17:28 + +from django.db import migrations, models +import uncloud.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0034_auto_20201011_2031'), + ] + + operations = [ + migrations.AddField( + model_name='billingaddress', + name='city', + field=models.CharField(default='', max_length=256), + preserve_default=False, + ), + migrations.AddField( + model_name='billingaddress', + name='country', + field=uncloud.models.CountryField(blank=True, choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2), + ), + migrations.AddField( + model_name='billingaddress', + name='full_name', + field=models.CharField(default='', max_length=256), + preserve_default=False, + ), + migrations.AddField( + model_name='billingaddress', + name='organization', + field=models.CharField(blank=True, max_length=256, null=True), + ), + migrations.AddField( + model_name='billingaddress', + name='postal_code', + field=models.CharField(default='', max_length=64), + preserve_default=False, + ), + migrations.AddField( + model_name='billingaddress', + name='street', + field=models.CharField(default='', max_length=256), + preserve_default=False, + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 3648795..5bff5a3 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -263,7 +263,7 @@ class RecurringPeriod(models.Model): ### # Bills. -class BillingAddress(models.Model): +class BillingAddress(UncloudAddress): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) vat_number = models.CharField(max_length=100, default="", blank=True) active = models.BooleanField(default=False) @@ -300,7 +300,6 @@ class BillingAddress(models.Model): active=True) - @staticmethod def get_address_for(user): return BillingAddress.objects.get(owner=user, active=True) @@ -308,7 +307,7 @@ class BillingAddress(models.Model): def __str__(self): return "{} - {}, {}, {} {}, {}".format( self.owner, - self.name, self.street, self.postal_code, self.city, + self.full_name, self.street, self.postal_code, self.city, self.country) ### @@ -1059,7 +1058,21 @@ class Bill(models.Model): - If the customer is outside EU and outside CH -> do not apply VAT """ - provider_country = UncloudProvider.objects.get() + provider = UncloudProvider.objects.get() + + # Assume always VAT inside the country + if provider.country = self.billing_address.country: + vat_rate = VATRate.objects.get(country=provider.country, + when=self.ending_date) + elif self.billing_address.country in EU: + # FIXME: need to check for validated vat number + if self.billing_address.vat_number: + return 0 + else: + return VATRate.objects.get(country=self.biling_address.country, + when=self.ending_date) + else: # non-EU, non-national + return 0 @classmethod @@ -1182,6 +1195,13 @@ class BillRecord(models.Model): else: return self.order.one_time_price + @property + def price(self): + if self.is_recurring_record: + return self.order.recurring_price + else: + return self.order.one_time_price + def __str__(self): if self.is_recurring_record: bill_line = f"{self.starting_date} - {self.ending_date}: {self.quantity} x {self.order}" diff --git a/uncloud_pay/templates/bill.html.j2 b/uncloud_pay/templates/bill.html.j2 index e3238d3..c227f43 100644 --- a/uncloud_pay/templates/bill.html.j2 +++ b/uncloud_pay/templates/bill.html.j2 @@ -6,7 +6,7 @@ Icons, fonts, etc. are INLINED. This is rather ugly, but as the PDF generation is based on a local snapshot of the HTML file, URLs are - screwed if they are not absolute. + screwed if they are not absolute to the *local* filesystem. As this document is used ONLY for bills and ONLY for downloading, I decided that this is an acceptable uglyness. @@ -695,9 +695,9 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7 Detail - Units Price/Unit - Total price + Units + Total price @@ -707,8 +707,8 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7 - {{ record.ending_date|date:"c" }} {{ record.order }} + {{ record.price|floatformat:2 }} {{ record.quantity|floatformat:2 }} - {{ record.order.price|floatformat:2 }} {{ record.sum|floatformat:2 }} {% endfor %} diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py index 9ca3bcf..edfb189 100644 --- a/uncloud_pay/views.py +++ b/uncloud_pay/views.py @@ -201,6 +201,7 @@ class BillViewSet(viewsets.ReadOnlyModelViewSet): Allow to download """ bill = self.get_object() + provider = UncloudProvider.get_provider() output_file = NamedTemporaryFile() bill_html = render_to_string("bill.html.j2", {'bill': bill}) From 20c7c867036a96adbfdf1910e1c1a0ac049fc89a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 25 Oct 2020 21:00:30 +0100 Subject: [PATCH 40/93] restructure to move uncloudnetwork into core --- uncloud/admin.py | 4 +- .../management/commands/db-add-defaults.py | 3 +- uncloud/migrations/0006_auto_20201025_1931.py | 40 +++++++++++++++ uncloud/models.py | 45 ++++++++++++++++- uncloud_net/admin.py | 5 +- .../migrations/0011_auto_20201025_1931.py | 29 +++++++++++ uncloud_net/models.py | 49 +++++-------------- uncloud_pay/models.py | 2 +- 8 files changed, 130 insertions(+), 47 deletions(-) create mode 100644 uncloud/migrations/0006_auto_20201025_1931.py create mode 100644 uncloud_net/migrations/0011_auto_20201025_1931.py diff --git a/uncloud/admin.py b/uncloud/admin.py index 330ba79..4ecc53a 100644 --- a/uncloud/admin.py +++ b/uncloud/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import UncloudProvider +from .models import UncloudProvider, UncloudNetwork -for m in [ UncloudProvider ]: +for m in [ UncloudProvider, UncloudNetwork ]: admin.site.register(m) diff --git a/uncloud/management/commands/db-add-defaults.py b/uncloud/management/commands/db-add-defaults.py index 60bcdb2..605c8f5 100644 --- a/uncloud/management/commands/db-add-defaults.py +++ b/uncloud/management/commands/db-add-defaults.py @@ -7,8 +7,7 @@ from django.contrib.auth import get_user_model from django.conf import settings from uncloud_pay.models import BillingAddress, RecurringPeriod, Product -from uncloud_net.models import UncloudNetwork -from uncloud.models import UncloudProvider +from uncloud.models import UncloudProvider, UncloudNetwork class Command(BaseCommand): diff --git a/uncloud/migrations/0006_auto_20201025_1931.py b/uncloud/migrations/0006_auto_20201025_1931.py new file mode 100644 index 0000000..d1162ef --- /dev/null +++ b/uncloud/migrations/0006_auto_20201025_1931.py @@ -0,0 +1,40 @@ +# Generated by Django 3.1 on 2020-10-25 19:31 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud', '0005_uncloudprovider_coupon_network'), + ] + + operations = [ + migrations.CreateModel( + name='UncloudNetwork', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('network_address', models.GenericIPAddressField(unique=True)), + ('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), + ('description', models.CharField(max_length=256)), + ], + ), + migrations.AlterField( + model_name='uncloudprovider', + name='billing_network', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderbill', to='uncloud.uncloudnetwork'), + ), + migrations.AlterField( + model_name='uncloudprovider', + name='coupon_network', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudprovidercoupon', to='uncloud.uncloudnetwork'), + ), + migrations.AlterField( + model_name='uncloudprovider', + name='referral_network', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderreferral', to='uncloud.uncloudnetwork'), + ), + ] +2 diff --git a/uncloud/models.py b/uncloud/models.py index 623d144..5a65f1c 100644 --- a/uncloud/models.py +++ b/uncloud/models.py @@ -2,10 +2,9 @@ from django.db import models from django.db.models import JSONField, Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from django.core.validators import MinValueValidator, MaxValueValidator from uncloud import COUNTRIES -from uncloud_net.models import UncloudNetwork - class UncloudModel(models.Model): """ @@ -66,6 +65,48 @@ class UncloudAddress(models.Model): class Meta: abstract = True + +### +# UncloudNetworks are used as identifiers - such they are a base of uncloud + +class UncloudNetwork(models.Model): + """ + Storing IP networks + """ + + network_address = models.GenericIPAddressField(null=False, unique=True) + network_mask = models.IntegerField(null=False, + validators=[MinValueValidator(0), + MaxValueValidator(128)] + ) + + description = models.CharField(max_length=256) + + @classmethod + def populate_db_defaults(cls): + for net, desc in [ + ( "2a0a:e5c0:11::", "uncloud Billing" ), + ( "2a0a:e5c0:11:1::", "uncloud Referral" ), + ( "2a0a:e5c0:11:2::", "uncloud Coupon" ) + ]: + obj, created = cls.objects.get_or_create(network_address=net, + defaults= { + 'network_mask': 64, + 'description': desc + } + ) + + + def save(self, *args, **kwargs): + if not ':' in self.network_address and self.network_mask > 32: + raise FieldError("Mask cannot exceed 32 for IPv4") + + super().save(*args, **kwargs) + + + def __str__(self): + return f"{self.network_address}/{self.network_mask} {self.description}" + ### # Who is running / providing this instance of uncloud? diff --git a/uncloud_net/admin.py b/uncloud_net/admin.py index 29782ef..dec357a 100644 --- a/uncloud_net/admin.py +++ b/uncloud_net/admin.py @@ -1,6 +1,5 @@ from django.contrib import admin -from .models import UncloudNetwork -for m in [ UncloudNetwork ]: - admin.site.register(m) +# for m in [ UncloudNetwork ]: +# admin.site.register(m) diff --git a/uncloud_net/migrations/0011_auto_20201025_1931.py b/uncloud_net/migrations/0011_auto_20201025_1931.py new file mode 100644 index 0000000..c4135d9 --- /dev/null +++ b/uncloud_net/migrations/0011_auto_20201025_1931.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1 on 2020-10-25 19:31 + +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), + ('uncloud', '0006_auto_20201025_1931'), + ('uncloud_net', '0010_auto_20201011_2009'), + ] + + operations = [ + migrations.CreateModel( + name='ReverseDNSEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip_address', models.GenericIPAddressField(unique=True)), + ('name', models.CharField(max_length=253)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.DeleteModel( + name='UncloudNetwork', + ), + ] diff --git a/uncloud_net/models.py b/uncloud_net/models.py index e4e1bb1..afd5004 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -6,43 +6,6 @@ from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator, MaxValueValidator from django.core.exceptions import FieldError -class UncloudNetwork(models.Model): - """ - Storing IP networks - """ - - network_address = models.GenericIPAddressField(null=False, unique=True) - network_mask = models.IntegerField(null=False, - validators=[MinValueValidator(0), - MaxValueValidator(128)] - ) - - description = models.CharField(max_length=256) - - @classmethod - def populate_db_defaults(cls): - for net, desc in [ - ( "2a0a:e5c0:11::", "uncloud Billing" ), - ( "2a0a:e5c0:11:1::", "uncloud Referral" ), - ( "2a0a:e5c0:11:2::", "uncloud Coupon" ) - ]: - obj, created = cls.objects.get_or_create(network_address=net, - defaults= { - 'network_mask': 64, - 'description': desc - } - ) - - - def save(self, *args, **kwargs): - if not ':' in self.network_address and self.network_mask > 32: - raise FieldError("Mask cannot exceed 32 for IPv4") - - super().save(*args, **kwargs) - - - def __str__(self): - return f"{self.network_address}/{self.network_mask} {self.description}" class MACAdress(models.Model): default_prefix = 0x420000000000 @@ -219,3 +182,15 @@ class VPNNetwork(models.Model): self.network.save() super().save(*args, **kwargs) print("deleted {}".format(self)) + + +class ReverseDNSEntry(models.Model): + """ + A reverse DNS entry + """ + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE) + + ip_address = models.GenericIPAddressField(null=False, unique=True) + + name = models.CharField(max_length=253, null=False) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 5bff5a3..00163b6 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -1061,7 +1061,7 @@ class Bill(models.Model): provider = UncloudProvider.objects.get() # Assume always VAT inside the country - if provider.country = self.billing_address.country: + if provider.country == self.billing_address.country: vat_rate = VATRate.objects.get(country=provider.country, when=self.ending_date) elif self.billing_address.country in EU: From ecc9e6f73432efa6ca2309423f61571b9767808d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 25 Oct 2020 22:43:34 +0100 Subject: [PATCH 41/93] [reverseDNS] add basic logic --- doc/uncloud-manual-2020-08-01.org | 3 ++- uncloud_net/admin.py | 5 +++-- uncloud_net/models.py | 30 +++++++++++++++++++++++++++++- uncloud_pay/models.py | 20 +++++++++++++++++++- 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index 78dd900..6dd8fb2 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -236,7 +236,8 @@ VPNNetworks can be managed by all authenticated users. ** Milestones :uncloud: *** 1.1 (cleanup 1) -**** +**** TODO Unify ValidationError, FieldError - define proper Exception + - What do we use for model errors *** 1.0 (initial release) **** TODO Initial Generic product support - Product diff --git a/uncloud_net/admin.py b/uncloud_net/admin.py index dec357a..5dad27b 100644 --- a/uncloud_net/admin.py +++ b/uncloud_net/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin +from .models import ReverseDNSEntry -# for m in [ UncloudNetwork ]: -# admin.site.register(m) +for m in [ ReverseDNSEntry ]: + admin.site.register(m) diff --git a/uncloud_net/models.py b/uncloud_net/models.py index afd5004..c9c8bc3 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -4,8 +4,9 @@ import ipaddress from django.db import models from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator, MaxValueValidator -from django.core.exceptions import FieldError +from django.core.exceptions import FieldError, ValidationError +from uncloud_pay.models import Order class MACAdress(models.Model): default_prefix = 0x420000000000 @@ -194,3 +195,30 @@ class ReverseDNSEntry(models.Model): ip_address = models.GenericIPAddressField(null=False, unique=True) name = models.CharField(max_length=253, null=False) + + def save(self, *args, **kwargs): + # Product.objects.filter(config__parameters__contains='reverse_dns_network') + # FIXME: check if order is still active / not replaced + + allowed = False + + for order in Order.objects.filter(config__parameters__reverse_dns_network__isnull=False, + owner=self.owner): + network = order.config['parameters']['reverse_dns_network'] + + net = ipaddress.ip_network(network) + addr = ipaddress.ip_address(self.ip_address) + + if addr in net: + allowed = True + break + + + if not allowed: + raise ValidationError(f"User {self.owner} does not have the right to create reverse DNS entry for {self.ip_address}") + + super().save(*args, **kwargs) + + + def __str__(self): + return f"{self.ip_address} - {self.name}" diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 00163b6..353ab94 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -454,6 +454,16 @@ class Product(models.Model): obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True }) + obj, created = cls.objects.get_or_create(name="reverse DNS", + description="Reverse DNS network", + currency=Currency.CHF, + config={ + 'parameters': [ + 'network' + ] + }) + obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True }) + def __str__(self): return f"{self.name} - {self.description}" @@ -968,6 +978,13 @@ class Order(models.Model): return (one_time_price, recurring_price, config) + def check_parameters(self): + if 'parameters' in self.product.config: + for parameter in self.product.config['parameters']: + if not parameter in self.config['parameters']: + raise ValidationError(f"Required parameter '{parameter}' is missing.") + + def save(self, *args, **kwargs): # Calculate the price of the order when we create it # IMMUTABLE fields -- need to create new order to modify them @@ -983,11 +1000,12 @@ class Order(models.Model): except ObjectDoesNotExist: raise ValidationError(f"Recurring Period {self.recurring_period} not allowed for product {self.product}") + self.check_parameters() + if self.ending_date and self.ending_date < self.starting_date: raise ValidationError("End date cannot be before starting date") - super().save(*args, **kwargs) From 4845ab1e395428121d07c646b0f85b827c8fc075 Mon Sep 17 00:00:00 2001 From: Ahmad Bilal Khalid Date: Sat, 14 Nov 2020 14:50:43 +0500 Subject: [PATCH 42/93] Create account using api Registration and change_email is backed by ldap --- requirements.txt | 1 + uncloud/.gitignore | 1 + uncloud/urls.py | 1 + uncloud_auth/serializers.py | 57 ++++++- uncloud_auth/ungleich_ldap.py | 284 ++++++++++++++++++++++++++++++++++ uncloud_auth/views.py | 34 ++-- 6 files changed, 362 insertions(+), 16 deletions(-) create mode 100644 uncloud_auth/ungleich_ldap.py diff --git a/requirements.txt b/requirements.txt index a7fc9f2..12e01f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ django-auth-ldap stripe xmltodict psycopg2 +ldap3 parsedatetime diff --git a/uncloud/.gitignore b/uncloud/.gitignore index 6a07bff..b03e0a5 100644 --- a/uncloud/.gitignore +++ b/uncloud/.gitignore @@ -1 +1,2 @@ local_settings.py +ldap_max_uid_file \ No newline at end of file diff --git a/uncloud/urls.py b/uncloud/urls.py index ef950a0..789ca06 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -65,6 +65,7 @@ router.register(r'v1/admin/vpnpool', netviews.VPNPoolViewSet) # User/Account router.register(r'v1/my/user', authviews.UserViewSet, basename='user') router.register(r'v1/admin/user', authviews.AdminUserViewSet, basename='useradmin') +router.register(r'v1/user/register', authviews.AccountManagementViewSet, basename='user/register') urlpatterns = [ path(r'api/', include(router.urls)), diff --git a/uncloud_auth/serializers.py b/uncloud_auth/serializers.py index 92bbf01..931b29d 100644 --- a/uncloud_auth/serializers.py +++ b/uncloud_auth/serializers.py @@ -1,25 +1,72 @@ from django.contrib.auth import get_user_model +from django.db import transaction +from ldap3.core.exceptions import LDAPEntryAlreadyExistsResult from rest_framework import serializers from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS from uncloud_pay.models import BillingAddress +from .ungleich_ldap import LdapManager + + class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() read_only_fields = [ 'username', 'balance', 'maximum_credit' ] - fields = read_only_fields + [ 'email', 'primary_billing_address' ] + fields = read_only_fields + [ 'email' ] # , 'primary_billing_address' ] def validate(self, data): """ Ensure that the primary billing address belongs to the user """ - - if 'primary_billing_address' in data: - if not data['primary_billing_address'].owner == self.instance: - raise serializers.ValidationError("Invalid data") + # The following is raising exceptions probably, it is WIP somewhere + # if 'primary_billing_address' in data: + # if not data['primary_billing_address'].owner == self.instance: + # raise serializers.ValidationError('Invalid data') return data + def update(self, instance, validated_data): + ldap_manager = LdapManager() + return_val, _ = ldap_manager.change_user_details( + instance.username, {'mail': validated_data.get('email')} + ) + if not return_val: + raise serializers.ValidationError('Couldn\'t update email') + instance.email = validated_data.get('email') + instance.save() + return instance + + +class UserRegistrationSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ['username', 'first_name', 'last_name', 'email', 'password'] + extra_kwargs = { + 'password': {'style': {'input_type': 'password'}}, + 'first_name': {'allow_blank': False, 'required': True}, + 'last_name': {'allow_blank': False, 'required': True}, + 'email': {'allow_blank': False, 'required': True}, + } + + def create(self, validated_data): + ldap_manager = LdapManager() + try: + data = { + 'user': validated_data['username'], + 'password': validated_data['password'], + 'email': validated_data['email'], + 'firstname': validated_data['first_name'], + 'lastname': validated_data['last_name'], + } + ldap_manager.create_user(**data) + except LDAPEntryAlreadyExistsResult: + raise serializers.ValidationError( + {'username': ['A user with that username already exists.']} + ) + else: + return get_user_model().objects.create_user(**validated_data) + + class ImportUserSerializer(serializers.Serializer): username = serializers.CharField() diff --git a/uncloud_auth/ungleich_ldap.py b/uncloud_auth/ungleich_ldap.py new file mode 100644 index 0000000..f22b423 --- /dev/null +++ b/uncloud_auth/ungleich_ldap.py @@ -0,0 +1,284 @@ +import base64 +import hashlib +import logging +import random + +import ldap3 +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class LdapManager: + __instance = None + def __new__(cls): + if LdapManager.__instance is None: + LdapManager.__instance = object.__new__(cls) + return LdapManager.__instance + + def __init__(self): + """ + Initialize the LDAP subsystem. + """ + self.rng = random.SystemRandom() + self.server = ldap3.Server(settings.AUTH_LDAP_SERVER) + + + def get_admin_conn(self): + """ + Return a bound :class:`ldap3.Connection` instance which has write + permissions on the dn in which the user accounts reside. + """ + conn = self.get_conn(user=settings.LDAP_ADMIN_DN, + password=settings.LDAP_ADMIN_PASSWORD, + raise_exceptions=True) + conn.bind() + return conn + + + def get_conn(self, **kwargs): + """ + Return an unbound :class:`ldap3.Connection` which talks to the configured + LDAP server. + + The *kwargs* are passed to the constructor of :class:`ldap3.Connection` and + can be used to set *user*, *password* and other useful arguments. + """ + return ldap3.Connection(self.server, **kwargs) + + + def _ssha_password(self, password): + """ + Apply the SSHA password hashing scheme to the given *password*. + *password* must be a :class:`bytes` object, containing the utf-8 + encoded password. + + Return a :class:`bytes` object containing ``ascii``-compatible data + which can be used as LDAP value, e.g. after armoring it once more using + base64 or decoding it to unicode from ``ascii``. + """ + SALT_BYTES = 15 + + sha1 = hashlib.sha1() + salt = self.rng.getrandbits(SALT_BYTES * 8).to_bytes(SALT_BYTES, + "little") + sha1.update(password) + sha1.update(salt) + + digest = sha1.digest() + passwd = b"{SSHA}" + base64.b64encode(digest + salt) + return passwd + + + def create_user(self, user, password, firstname, lastname, email): + conn = self.get_admin_conn() + uidNumber = self._get_max_uid() + 1 + logger.debug("uidNumber={uidNumber}".format(uidNumber=uidNumber)) + user_exists = True + while user_exists: + user_exists, _ = self.check_user_exists( + "", + '(&(objectClass=inetOrgPerson)(objectClass=posixAccount)' + '(objectClass=top)(uidNumber={uidNumber}))'.format( + uidNumber=uidNumber + ) + ) + if user_exists: + logger.debug( + "{uid} exists. Trying next.".format(uid=uidNumber) + ) + uidNumber += 1 + logger.debug("{uid} does not exist. Using it".format(uid=uidNumber)) + self._set_max_uid(uidNumber) + try: + uid = user # user.encode("utf-8") + conn.add("uid={uid},{customer_dn}".format( + uid=uid, customer_dn=settings.LDAP_CUSTOMER_DN + ), + ["inetOrgPerson", "posixAccount", "ldapPublickey"], + { + "uid": [uid], + "sn": [lastname.encode("utf-8")], + "givenName": [firstname.encode("utf-8")], + "cn": [uid], + "displayName": ["{} {}".format(firstname, lastname).encode("utf-8")], + "uidNumber": [str(uidNumber)], + "gidNumber": [str(settings.LDAP_CUSTOMER_GROUP_ID)], + "loginShell": ["/bin/bash"], + "homeDirectory": ["/home/{}".format(user).encode("utf-8")], + "mail": email.encode("utf-8"), + "userPassword": [self._ssha_password( + password.encode("utf-8") + )] + } + ) + logger.debug('Created user %s %s' % (user.encode('utf-8'), + uidNumber)) + except Exception as ex: + logger.debug('Could not create user %s' % user.encode('utf-8')) + logger.error("Exception: " + str(ex)) + raise + finally: + conn.unbind() + + + def change_password(self, uid, new_password): + """ + Changes the password of the user identified by user_dn + + :param uid: str The uid that identifies the user + :param new_password: str The new password string + :return: True if password was changed successfully False otherwise + """ + conn = self.get_admin_conn() + + # Make sure the user exists first to change his/her details + user_exists, entries = self.check_user_exists( + uid=uid, + search_base=settings.ENTIRE_SEARCH_BASE + ) + return_val = False + if user_exists: + try: + return_val = conn.modify( + entries[0].entry_dn, + { + "userpassword": ( + ldap3.MODIFY_REPLACE, + [self._ssha_password(new_password.encode("utf-8"))] + ) + } + ) + except Exception as ex: + logger.error("Exception: " + str(ex)) + else: + logger.error("User {} not found".format(uid)) + + conn.unbind() + return return_val + + def change_user_details(self, uid, details): + """ + Updates the user details as per given values in kwargs of the user + identified by user_dn. + + Assumes that all attributes passed in kwargs are valid. + + :param uid: str The uid that identifies the user + :param details: dict A dictionary containing the new values + :return: True if user details were updated successfully False otherwise + """ + conn = self.get_admin_conn() + + # Make sure the user exists first to change his/her details + user_exists, entries = self.check_user_exists( + uid=uid, + search_base=settings.ENTIRE_SEARCH_BASE + ) + + return_val = False + if user_exists: + details_dict = {k: (ldap3.MODIFY_REPLACE, [v.encode("utf-8")]) for + k, v in details.items()} + try: + return_val = conn.modify(entries[0].entry_dn, details_dict) + msg = "success" + except Exception as ex: + msg = str(ex) + logger.error("Exception: " + msg) + finally: + conn.unbind() + else: + msg = "User {} not found".format(uid) + logger.error(msg) + conn.unbind() + return return_val, msg + + def check_user_exists(self, uid, search_filter="", attributes=None, + search_base=settings.LDAP_CUSTOMER_DN): + """ + Check if the user with the given uid exists in the customer group. + + :param uid: str representing the user + :param search_filter: str representing the filter condition to find + users. If its empty, the search finds the user with + the given uid. + :param attributes: list A list of str representing all the attributes + to be obtained in the result entries + :param search_base: str + :return: tuple (bool, [ldap3.abstract.entry.Entry ..]) + A bool indicating if the user exists + A list of all entries obtained in the search + """ + conn = self.get_admin_conn() + entries = [] + try: + result = conn.search( + search_base=search_base, + search_filter=search_filter if len(search_filter)> 0 else + '(uid={uid})'.format(uid=uid), + attributes=attributes + ) + entries = conn.entries + finally: + conn.unbind() + return result, entries + + def delete_user(self, uid): + """ + Deletes the user with the given uid from ldap + + :param uid: str representing the user + :return: True if the delete was successful False otherwise + """ + conn = self.get_admin_conn() + try: + return_val = conn.delete( + ("uid={uid}," + settings.LDAP_CUSTOMER_DN).format(uid=uid), + ) + msg = "success" + except Exception as ex: + msg = str(ex) + logger.error("Exception: " + msg) + return_val = False + finally: + conn.unbind() + return return_val, msg + + def _set_max_uid(self, max_uid): + """ + a utility function to save max_uid value to a file + + :param max_uid: an integer representing the max uid + :return: + """ + with open(settings.LDAP_MAX_UID_FILE_PATH, 'w+') as handler: + handler.write(str(max_uid)) + + def _get_max_uid(self): + """ + A utility function to read the max uid value that was previously set + + :return: An integer representing the max uid value that was previously + set + """ + try: + with open(settings.LDAP_MAX_UID_FILE_PATH, 'r+') as handler: + try: + return_value = int(handler.read()) + except ValueError as ve: + logger.error( + "Error reading int value from {}. {}" + "Returning default value {} instead".format( + settings.LDAP_MAX_UID_PATH, + str(ve), + settings.LDAP_DEFAULT_START_UID + ) + ) + return_value = settings.LDAP_DEFAULT_START_UID + return return_value + except FileNotFoundError as fnfe: + logger.error("File not found : " + str(fnfe)) + return_value = settings.LDAP_DEFAULT_START_UID + logger.error("So, returning UID={}".format(return_value)) + return return_value diff --git a/uncloud_auth/views.py b/uncloud_auth/views.py index 77f0a0f..520e1b0 100644 --- a/uncloud_auth/views.py +++ b/uncloud_auth/views.py @@ -1,9 +1,11 @@ -from rest_framework import viewsets, permissions, status -from .serializers import * from django_auth_ldap.backend import LDAPBackend +from rest_framework import mixins, permissions, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response +from .serializers import * + + class UserViewSet(viewsets.GenericViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = UserSerializer @@ -19,19 +21,29 @@ class UserViewSet(viewsets.GenericViewSet): serializer = self.get_serializer(user, context = {'request': request}) return Response(serializer.data) - def create(self, request): - """ - Modify existing user data - """ - - user = request.user - serializer = self.get_serializer(user, - context = {'request': request}, - data=request.data) + @action(detail=False, methods=['post']) + def change_email(self, request): + serializer = self.get_serializer( + request.user, data=request.data, context={'request': request} + ) serializer.is_valid(raise_exception=True) serializer.save() return Response(serializer.data) + +class AccountManagementViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): + serializer_class = UserRegistrationSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + class AdminUserViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = [permissions.IsAdminUser] From 0b1c2cc1684acf37f53457863009741660857b31 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 15 Nov 2020 15:43:11 +0100 Subject: [PATCH 43/93] Cleanup code so that *most* test work again Still need to solve the downgrade test --- doc/uncloud-manual-2020-08-01.org | 15 +- uncloud/models.py | 1 + uncloud_net/models.py | 17 + uncloud_net/tests.py | 2 +- uncloud_pay/models.py | 16 +- uncloud_pay/models_prior_to_cleanup.py | 1003 ------------------------ uncloud_pay/tests-before-refactor.py | 721 ----------------- uncloud_pay/tests.py | 519 +----------- 8 files changed, 40 insertions(+), 2254 deletions(-) delete mode 100644 uncloud_pay/models_prior_to_cleanup.py delete mode 100644 uncloud_pay/tests-before-refactor.py diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index 6dd8fb2..8c8d7e5 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -236,16 +236,16 @@ VPNNetworks can be managed by all authenticated users. ** Milestones :uncloud: *** 1.1 (cleanup 1) -**** TODO Unify ValidationError, FieldError - define proper Exception +**** TODO [#C] Unify ValidationError, FieldError - define proper Exception - What do we use for model errors *** 1.0 (initial release) -**** TODO Initial Generic product support +**** TODO [#C] Initial Generic product support - Product -***** TODO Recurring product support -****** TODO Support replacing orders for updates +***** TODO [#C] Recurring product support +****** TODO [#C] Support replacing orders for updates ****** DONE [#A] Finish split of bill creation CLOSED: [2020-09-11 Fri 23:19] -****** TODO Test the new functions in the Order class +****** TODO [#C] Test the new functions in the Order class ****** Define the correct order replacement logic Assumption: - recurringperiods are 30days @@ -302,13 +302,14 @@ VPNNetworks can be managed by all authenticated users. - Total on bill: 30 CHF -****** TODO Note: ending date not set if replaced by default (implicit!) +****** TODO [#C] Note: ending date not set if replaced by default (implicit!) - Should the new order modify the old order on save()? ****** DONE Fix totally wrong bill dates in our test case CLOSED: [2020-09-09 Wed 01:00] - 2020 used instead of 2019 - Was due to existing test data ... -***** TODO Bill logic is still wrong +***** DONE Bill logic is still wrong + CLOSED: [2020-11-05 Thu 18:58] - Bill starting_date is the date of the first order - However first encountered order does not have to be the earliest in the bill! diff --git a/uncloud/models.py b/uncloud/models.py index 5a65f1c..9f977e8 100644 --- a/uncloud/models.py +++ b/uncloud/models.py @@ -3,6 +3,7 @@ from django.db.models import JSONField, Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.core.validators import MinValueValidator, MaxValueValidator +from django.core.exceptions import FieldError from uncloud import COUNTRIES diff --git a/uncloud_net/models.py b/uncloud_net/models.py index c9c8bc3..509bb61 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -196,11 +196,27 @@ class ReverseDNSEntry(models.Model): name = models.CharField(max_length=253, null=False) + @property + def reverse_pointer(self): + return ipaddress.ip_address(self.ip_address).reverse_pointer + + def implement(self): + """ + The implement function implements the change + """ + + # Get all DNS entries (?) / update this DNS entry + # convert to DNS name + # + pass + + def save(self, *args, **kwargs): # Product.objects.filter(config__parameters__contains='reverse_dns_network') # FIXME: check if order is still active / not replaced allowed = False + product = None for order in Order.objects.filter(config__parameters__reverse_dns_network__isnull=False, owner=self.owner): @@ -211,6 +227,7 @@ class ReverseDNSEntry(models.Model): if addr in net: allowed = True + product = order.product break diff --git a/uncloud_net/tests.py b/uncloud_net/tests.py index 974f8dd..4491551 100644 --- a/uncloud_net/tests.py +++ b/uncloud_net/tests.py @@ -9,7 +9,7 @@ from .views import * from .models import * from uncloud_pay.models import BillingAddress, Order - +from uncloud.models import UncloudNetwork class UncloudNetworkTests(TestCase): def test_invalid_IPv4_network(self): diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 353ab94..96fc8ec 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -209,7 +209,7 @@ class PaymentMethod(models.Model): pass # See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types -class RecurringPeriodChoices(models.IntegerChoices): +class RecurringPeriodDefaultChoices(models.IntegerChoices): """ This is an old class and being superseeded by the database model below """ @@ -234,7 +234,7 @@ class RecurringPeriod(models.Model): @classmethod def populate_db_defaults(cls): - for (seconds, name) in RecurringPeriodChoices.choices: + for (seconds, name) in RecurringPeriodDefaultChoices.choices: obj, created = cls.objects.get_or_create(name=name, defaults={ 'duration_seconds': seconds }) @@ -470,7 +470,7 @@ class Product(models.Model): @property def recurring_orders(self): - return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME) + return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.objects.get(name="ONE_TIME")) @property def last_recurring_order(self): @@ -478,7 +478,7 @@ class Product(models.Model): @property def one_time_orders(self): - return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.ONE_TIME) + return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.objects.get(name="ONE_TIME")) @property def last_one_time_order(self): @@ -503,13 +503,13 @@ class Product(models.Model): billing_address=billing_address, starting_date=when_to_start, price=self.one_time_price, - recurring_period=RecurringPeriod.ONE_TIME, + recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"), description=str(self)) self.orders.add(one_time_order) else: one_time_order = None - if recurring_period != RecurringPeriod.ONE_TIME: + if recurring_period != RecurringPeriod.objects.get(name="ONE_TIME"): if one_time_order: recurring_order = Order.objects.create(owner=self.owner, billing_address=billing_address, @@ -827,7 +827,7 @@ class Order(models.Model): @property def is_recurring(self): - return not self.recurring_period == RecurringPeriod.ONE_TIME + return not self.recurring_period == RecurringPeriod.objects.get(name="ONE_TIME") @property def is_one_time(self): @@ -857,6 +857,8 @@ class Order(models.Model): (new_order.one_time_price, new_order.recurring_price, new_order.config) = new_order.calculate_prices_and_config() + + new_order.replaces = self new_order.save() diff --git a/uncloud_pay/models_prior_to_cleanup.py b/uncloud_pay/models_prior_to_cleanup.py deleted file mode 100644 index 55ccffb..0000000 --- a/uncloud_pay/models_prior_to_cleanup.py +++ /dev/null @@ -1,1003 +0,0 @@ -from django.db import models -from django.db.models import Q -from django.contrib.auth import get_user_model -from django.utils.translation import gettext_lazy as _ -from django.core.validators import MinValueValidator -from django.utils import timezone -from django.core.exceptions import ObjectDoesNotExist, ValidationError - -import uuid -import logging -from functools import reduce -import itertools -from math import ceil -from datetime import timedelta -from calendar import monthrange -from decimal import Decimal - -import uncloud_pay.stripe -from uncloud_pay.helpers import beginning_of_month, end_of_month -from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS, COUNTRIES -from uncloud.models import UncloudModel, UncloudStatus - -from decimal import Decimal -import decimal - -# Used to generate bill due dates. -BILL_PAYMENT_DELAY=timedelta(days=10) - -# Initialize logger. -logger = logging.getLogger(__name__) - -# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types -class RecurringPeriod(models.IntegerChoices): - PER_365D = 365*24*3600, _('Per 365 days') - PER_30D = 30*24*3600, _('Per 30 days') - PER_WEEK = 7*24*3600, _('Per Week') - PER_DAY = 24*3600, _('Per Day') - PER_HOUR = 3600, _('Per Hour') - PER_MINUTE = 60, _('Per Minute') - PER_SECOND = 1, _('Per Second') - ONE_TIME = 0, _('Onetime') - - -class CountryField(models.CharField): - def __init__(self, *args, **kwargs): - kwargs.setdefault('choices', COUNTRIES) - kwargs.setdefault('default', 'CH') - kwargs.setdefault('max_length', 2) - - super(CountryField, self).__init__(*args, **kwargs) - - def get_internal_type(self): - return "CharField" - -def get_balance_for_user(user): - bills = reduce( - lambda acc, entry: acc + entry.total, - Bill.objects.filter(owner=user), - 0) - payments = reduce( - lambda acc, entry: acc + entry.amount, - Payment.objects.filter(owner=user), - 0) - return payments - bills - -class StripeCustomer(models.Model): - owner = models.OneToOneField( get_user_model(), - primary_key=True, - on_delete=models.CASCADE) - stripe_id = models.CharField(max_length=32) - -### -# Payments and Payment Methods. - -class Payment(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE) - - amount = models.DecimalField( - default=0.0, - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)]) - - source = models.CharField(max_length=256, - choices = ( - ('wire', 'Wire Transfer'), - ('stripe', 'Stripe'), - ('voucher', 'Voucher'), - ('referral', 'Referral'), - ('unknown', 'Unknown') - ), - default='unknown') - timestamp = models.DateTimeField(editable=False, auto_now_add=True) - - # We override save() in order to active products awaiting payment. - def save(self, *args, **kwargs): - # _state.adding is switched to false after super(...) call. - being_created = self._state.adding - - unpaid_bills_before_payment = Bill.get_unpaid_for(self.owner) - super(Payment, self).save(*args, **kwargs) # Save payment in DB. - unpaid_bills_after_payment = Bill.get_unpaid_for(self.owner) - - newly_paid_bills = list( - set(unpaid_bills_before_payment) - set(unpaid_bills_after_payment)) - for bill in newly_paid_bills: - bill.activate_products() - - -class PaymentMethod(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) - source = models.CharField(max_length=256, - choices = ( - ('stripe', 'Stripe'), - ('unknown', 'Unknown'), - ), - default='stripe') - description = models.TextField() - primary = models.BooleanField(default=False, editable=False) - - # Only used for "Stripe" source - stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True) - stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True) - - @property - def stripe_card_last4(self): - if self.source == 'stripe' and self.active: - payment_method = uncloud_pay.stripe.get_payment_method( - self.stripe_payment_method_id) - return payment_method.card.last4 - else: - return None - - @property - def active(self): - if self.source == 'stripe' and self.stripe_payment_method_id != None: - return True - else: - return False - - def charge(self, amount): - if not self.active: - raise Exception('This payment method is inactive.') - - if amount < 0: # Make sure we don't charge negative amount by errors... - raise Exception('Cannot charge negative amount.') - - if self.source == 'stripe': - stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id - stripe_payment = uncloud_pay.stripe.charge_customer( - amount, stripe_customer, self.stripe_payment_method_id) - if 'paid' in stripe_payment and stripe_payment['paid'] == False: - raise Exception(stripe_payment['error']) - else: - payment = Payment.objects.create( - owner=self.owner, source=self.source, amount=amount) - - return payment - else: - raise Exception('This payment method is unsupported/cannot be charged.') - - def set_as_primary_for(self, user): - methods = PaymentMethod.objects.filter(owner=user, primary=True) - for method in methods: - print(method) - method.primary = False - method.save() - - self.primary = True - self.save() - - def get_primary_for(user): - methods = PaymentMethod.objects.filter(owner=user) - for method in methods: - # Do we want to do something with non-primary method? - if method.active and method.primary: - return method - - return None - - class Meta: - # TODO: limit to one primary method per user. - # unique_together is no good since it won't allow more than one - # non-primary method. - pass - -### -# Bills. - -class BillingAddress(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) - - organization = models.CharField(max_length=100) - name = models.CharField(max_length=100) - street = models.CharField(max_length=100) - city = models.CharField(max_length=50) - postal_code = models.CharField(max_length=50) - country = CountryField(blank=True) - vat_number = models.CharField(max_length=100, default="", blank=True) - - @staticmethod - def get_addresses_for(user): - return BillingAddress.objects.filter(owner=user) - - @classmethod - def get_preferred_address_for(cls, user): - addresses = cls.get_addresses_for(user) - if len(addresses) == 0: - return None - else: - # TODO: allow user to set primary/preferred address - return addresses[0] - - def __str__(self): - return "{}, {}, {} {}, {}".format( - self.name, self.street, self.postal_code, self.city, - self.country) - -# Populated with the import-vat-numbers django command. -class VATRate(models.Model): - start_date = models.DateField(blank=True, null=True) - stop_date = models.DateField(blank=True, null=True) - territory_codes = models.TextField(blank=True, default='') - currency_code = models.CharField(max_length=10) - rate = models.FloatField() - rate_type = models.TextField(blank=True, default='') - description = models.TextField(blank=True, default='') - - @staticmethod - def get_for_country(country_code): - vat_rate = None - try: - vat_rate = VATRate.objects.get( - territory_codes=country_code, start_date__isnull=False, stop_date=None - ) - return vat_rate.rate - except VATRate.DoesNotExist as dne: - logger.debug(str(dne)) - logger.debug("Did not find VAT rate for %s, returning 0" % country_code) - return 0 - -class BillNico(models.Model): - """ FIXME: - Bill needs to be unique in the triple (owner, year, month) - """ - - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE) - - creation_date = models.DateTimeField(auto_now_add=True) - starting_date = models.DateTimeField() - ending_date = models.DateTimeField() - due_date = models.DateField() - - valid = models.BooleanField(default=True) - - @staticmethod - def create_all_bills(): - for owner in get_user_model().objects.all(): - # mintime = time of first order - # maxtime = time of last order - # iterate month based through it - pass - - def assign_orders_to_bill(self, owner, year, month): - """ - Generate a bill for the specific month of a user. - - First handle all one time orders - - FIXME: - - - limit this to active users in the future! (2020-05-23) - """ - - """ - Find all one time orders that have a starting date that falls into this month - recurring_period=RecurringPeriod.ONE_TIME, - - Can we do this even for recurring / all of them - - """ - - # FIXME: add something to check whether the order should be billed at all - i.e. a marker that - # disables searching -> optimization for later - for order in Order.objects.filter(Q(starting_date__gte=self.starting_date), - Q(starting_date__lte=self.ending_date), - owner=owner): - - order.bill.add(self) - - - """ - Find all recurring orders that did not start in this time frame, but need - to be billed in this time frame. - - This is: - - order starting time before our starting time - - order start time + (x * (the_period)) is inside our time frame, x must be integer - test cases: - + 365days: - time_since_last_billed = self.starting_or_ending_date - order.last_bill_date - periods = - [ we could in theory add this as a property to the order: next - """ - for order in Order.objects.filter(~Q(recurring_period=RecurringPeriod.ONE_TIME), - Q(starting_date__lt=self.starting_date), - owner=owner): - - if order.recurring_period > 0: # avoid div/0 - these are one time payments - - # How much time will have passed by the end of the billing cycle - td = self.ending_date - order.starting_date - - # How MANY times it will have been used by then - used_times = ceil(td / timedelta(seconds=order.recurring_period)) - - billed_times = len(order.bills) - - # How many times it WAS billed -- can also be inferred from the bills that link to it! - if used_times > billed_times: - billing_times = used_times - billed_times - - # ALSO REGISTER THE TIME PERIOD! -# order. - pass - - -class Bill(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE) - - creation_date = models.DateTimeField(auto_now_add=True) - starting_date = models.DateTimeField() - ending_date = models.DateTimeField() - due_date = models.DateField() - - valid = models.BooleanField(default=True) - - # Trigger product activation if bill paid at creation (from balance). - def save(self, *args, **kwargs): - super(Bill, self).save(*args, **kwargs) - if not self in Bill.get_unpaid_for(self.owner): - self.activate_products() - - @property - def reference(self): - return "{}-{}".format( - self.owner.username, - self.creation_date.strftime("%Y-%m-%d-%H%M")) - - @property - def records(self): - bill_records = [] - orders = Order.objects.filter(bill=self) - for order in orders: - bill_record = BillRecord(self, order) - bill_records.append(bill_record) - - return bill_records - - @property - def amount(self): - return reduce(lambda acc, record: acc + record.amount, self.records, 0) - - @property - def vat_amount(self): - return reduce(lambda acc, record: acc + record.vat_amount, self.records, 0) - - @property - def total(self): - return self.amount + self.vat_amount - - @property - def final(self): - # A bill is final when its ending date is passed, or when all of its - # orders have been terminated. - every_order_terminated = True - billing_period_is_over = self.ending_date < timezone.now() - for order in self.order_set.all(): - every_order_terminated = every_order_terminated and order.is_terminated - - return billing_period_is_over or every_order_terminated - - def activate_products(self): - for order in self.order_set.all(): - # FIXME: using __something might not be a good idea. - for product_class in Product.__subclasses__(): - for product in product_class.objects.filter(order=order): - if product.status == UncloudStatus.AWAITING_PAYMENT: - product.status = UncloudStatus.PENDING - product.save() - - @property - def billing_address(self): - orders = Order.objects.filter(bill=self) - # The genrate_for method makes sure all the orders of a bill share the - # same billing address. TODO: It would be nice to enforce that somehow... - if orders: - return orders[0].billing_address - else: - return None - - # TODO: split this huuuge method! - @staticmethod - def generate_for(year, month, user): - # /!\ We exclusively work on the specified year and month. - generated_bills = [] - - # Default values for next bill (if any). - starting_date=beginning_of_month(year, month) - ending_date=end_of_month(year, month) - creation_date=timezone.now() - - # Select all orders active on the request period (i.e. starting on or after starting_date). - orders = Order.objects.filter( - Q(ending_date__gte=starting_date) | Q(ending_date__isnull=True), - owner=user) - - # Check if there is already a bill covering the order and period pair: - # * Get latest bill by ending_date: previous_bill.ending_date - # * For monthly bills: if previous_bill.ending_date is before - # (next_bill) ending_date, a new bill has to be generated. - # * For yearly bill: if previous_bill.ending_date is on working - # month, generate new bill. - unpaid_orders = { 'monthly_or_less': [], 'yearly': {} } - for order in orders: - try: - previous_bill = order.bill.latest('ending_date') - except ObjectDoesNotExist: - previous_bill = None - - # FIXME: control flow is confusing in this block. - # if order.recurring_period == RecurringPeriod.PER_YEAR: - # # We ignore anything smaller than a day in here. - # next_yearly_bill_start_on = None - # if previous_bill == None: - # next_yearly_bill_start_on = order.starting_date - # elif previous_bill.ending_date <= ending_date: - # next_yearly_bill_start_on = (previous_bill.ending_date + timedelta(days=1)) - - # # Store for bill generation. One bucket per day of month with a starting bill. - # # bucket is a reference here, no need to reassign. - # if next_yearly_bill_start_on: - # # We want to group orders by date but keep using datetimes. - # next_yearly_bill_start_on = next_yearly_bill_start_on.replace( - # minute=0, hour=0, second=0, microsecond=0) - # bucket = unpaid_orders['yearly'].get(next_yearly_bill_start_on) - # if bucket == None: - # unpaid_orders['yearly'][next_yearly_bill_start_on] = [order] - # else: - # unpaid_orders['yearly'][next_yearly_bill_start_on] = bucket + [order] - # else: - # if previous_bill == None or previous_bill.ending_date < ending_date: - # unpaid_orders['monthly_or_less'].append(order) - - # Handle working month's billing. - if len(unpaid_orders['monthly_or_less']) > 0: - # TODO: PREPAID billing is not supported yet. - prepaid_due_date = min(creation_date, starting_date) + BILL_PAYMENT_DELAY - postpaid_due_date = max(creation_date, ending_date) + BILL_PAYMENT_DELAY - - # There should not be any bill linked to orders with different - # billing addresses. - per_address_orders = itertools.groupby( - unpaid_orders['monthly_or_less'], - lambda o: o.billing_address) - - for addr, bill_orders in per_address_orders: - next_monthly_bill = Bill.objects.create(owner=user, - creation_date=creation_date, - starting_date=starting_date, # FIXME: this is a hack! - ending_date=ending_date, - due_date=postpaid_due_date) - - # It is not possible to register many-to-many relationship before - # the two end-objects are saved in database. - for order in bill_orders: - order.bill.add(next_monthly_bill) - - logger.info("Generated monthly bill {} (amount: {}) for user {}." - .format(next_monthly_bill.uuid, next_monthly_bill.total, user)) - - # Add to output. - generated_bills.append(next_monthly_bill) - - # Handle yearly bills starting on working month. - if len(unpaid_orders['yearly']) > 0: - # For every starting date, generate new bill. - for next_yearly_bill_start_on in unpaid_orders['yearly']: - # No postpaid for yearly payments. - prepaid_due_date = min(creation_date, next_yearly_bill_start_on) + BILL_PAYMENT_DELAY - # Bump by one year, remove one day. - ending_date = next_yearly_bill_start_on.replace( - year=next_yearly_bill_start_on.year+1) - timedelta(days=1) - - # There should not be any bill linked to orders with different - # billing addresses. - per_address_orders = itertools.groupby( - unpaid_orders['yearly'][next_yearly_bill_start_on], - lambda o: o.billing_address) - - for addr, bill_orders in per_address_orders: - next_yearly_bill = Bill.objects.create(owner=user, - creation_date=creation_date, - starting_date=next_yearly_bill_start_on, - ending_date=ending_date, - due_date=prepaid_due_date) - - # It is not possible to register many-to-many relationship before - # the two end-objects are saved in database. - for order in bill_orders: - order.bill.add(next_yearly_bill) - - logger.info("Generated yearly bill {} (amount: {}) for user {}." - .format(next_yearly_bill.uuid, next_yearly_bill.total, user)) - - # Add to output. - generated_bills.append(next_yearly_bill) - - # Return generated (monthly + yearly) bills. - return generated_bills - - @staticmethod - def get_unpaid_for(user): - balance = get_balance_for_user(user) - unpaid_bills = [] - # No unpaid bill if balance is positive. - if balance >= 0: - return unpaid_bills - else: - bills = Bill.objects.filter( - owner=user, - ).order_by('-creation_date') - - # Amount to be paid by the customer. - unpaid_balance = abs(balance) - for bill in bills: - if unpaid_balance <= 0: - break - - unpaid_balance -= bill.total - unpaid_bills.append(bill) - - return unpaid_bills - - @staticmethod - def get_overdue_for(user): - unpaid_bills = Bill.get_unpaid_for(user) - return list(filter(lambda bill: bill.due_date > timezone.now(), unpaid_bills)) - -class BillRecord(): - """ - Entry of a bill, dynamically generated from an order. - """ - - def __init__(self, bill, order): - self.bill = bill - self.order = order - self.recurring_price = order.recurring_price - self.recurring_period = order.recurring_period - self.description = order.description - - if self.order.starting_date >= self.bill.starting_date: - self.one_time_price = order.one_time_price - else: - self.one_time_price = 0 - - # Set decimal context for amount computations. - # XXX: understand why we need +1 here. - decimal.getcontext().prec = AMOUNT_DECIMALS + 1 - - @property - def recurring_count(self): - # Compute billing delta. - billed_until = self.bill.ending_date - if self.order.ending_date != None and self.order.ending_date <= self.bill.ending_date: - billed_until = self.order.ending_date - - billed_from = self.bill.starting_date - if self.order.starting_date > self.bill.starting_date: - billed_from = self.order.starting_date - - if billed_from > billed_until: - # TODO: think about and check edge cases. This should not be - # possible. - raise Exception('Impossible billing delta!') - - billed_delta = billed_until - billed_from - - # TODO: refactor this thing? - # TODO: weekly - # if self.recurring_period == RecurringPeriod.PER_YEAR: - # # XXX: Should always be one => we do not bill for more than one year. - # # TODO: check billed_delta is ~365 days. - # return 1 - # elif self.recurring_period == RecurringPeriod.PER_MONTH: - # days = ceil(billed_delta / timedelta(days=1)) - - # # Monthly bills always cover one single month. - # if (self.bill.starting_date.year != self.bill.starting_date.year or - # self.bill.starting_date.month != self.bill.ending_date.month): - # raise Exception('Bill {} covers more than one month. Cannot bill PER_MONTH.'. - # format(self.bill.uuid)) - - # # XXX: minumal length of monthly order is to be enforced somewhere else. - # (_, days_in_month) = monthrange( - # self.bill.starting_date.year, - # self.bill.starting_date.month) - # return round(days / days_in_month, AMOUNT_DECIMALS) - if self.recurring_period == RecurringPeriod.PER_WEEK: - weeks = ceil(billed_delta / timedelta(week=1)) - return weeks - elif self.recurring_period == RecurringPeriod.PER_DAY: - days = ceil(billed_delta / timedelta(days=1)) - return days - elif self.recurring_period == RecurringPeriod.PER_HOUR: - hours = ceil(billed_delta / timedelta(hours=1)) - return hours - elif self.recurring_period == RecurringPeriod.PER_SECOND: - seconds = ceil(billed_delta / timedelta(seconds=1)) - return seconds - elif self.recurring_period == RecurringPeriod.ONE_TIME: - return 0 - else: - raise Exception('Unsupported recurring period: {}.'. - format(self.order.recurring_period)) - - @property - def vat_rate(self): - return Decimal(VATRate.get_for_country(self.bill.billing_address.country)) - - @property - def vat_amount(self): - return self.amount * self.vat_rate - - @property - def amount(self): - return Decimal(float(self.recurring_price) * self.recurring_count) + self.one_time_price - - @property - def total(self): - return self.amount + self.vat_amount - -### -# Orders. - -# Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating -# bills. Do **NOT** mutate then! -class Order(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) - billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE) - description = models.TextField() - replaced_by = models.ForeignKey('self', on_delete=models.CASCADE, blank=True, null=True) - - # TODO: enforce ending_date - starting_date to be larger than recurring_period. - creation_date = models.DateTimeField(auto_now_add=True) - starting_date = models.DateTimeField(default=timezone.now) - ending_date = models.DateTimeField(blank=True, - null=True) - - bill = models.ManyToManyField(Bill, - editable=False, - blank=True) - - recurring_period = models.IntegerField(choices = RecurringPeriod.choices, default = RecurringPeriod.PER_30D) - - one_time_price = models.DecimalField(default=0.0, - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)]) - - recurring_price = models.DecimalField(default=0.0, - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)]) - - replaced_by = models.ForeignKey('self', - related_name='supersede', - on_delete=models.PROTECT, - blank=True, - null=True) - - depends_on = models.ForeignKey('self', - related_name='parent_of', - on_delete=models.PROTECT, - blank=True, - null=True) - - def active_before(self, ending_date): - # Was this order started before the specified ending date? - if self.starting_date <= ending_date: - if self.ending_date: - if self.ending_date > ending_date: - pass - - @property - def is_recurring(self): - return not self.recurring_period == RecurringPeriod.ONE_TIME - - @property - def is_terminated(self): - return self.ending_date != None and self.ending_date < timezone.now() - - def is_terminated_at(self, a_date): - return self.ending_date != None and self.ending_date < timezone.now() - - def terminate(self): - if not self.is_terminated: - self.ending_date = timezone.now() - self.save() - - def is_to_be_charged_in(year, month): - pass - - # Trigger initial bill generation at order creation. - def save(self, *args, **kwargs): - if self.ending_date and self.ending_date < self.starting_date: - raise ValidationError("End date cannot be before starting date") - - super().save(*args, **kwargs) - - def generate_initial_bill(self): - return Bill.generate_for(self.starting_date.year, self.starting_date.month, self.owner) - - # Used by uncloud_pay tests. - @property - def bills(self): - return Bill.objects.filter(order=self) - - @staticmethod - def from_product(product, **kwargs): - # FIXME: this is only a workaround. - billing_address = BillingAddress.get_preferred_address_for(product.owner) - if billing_address == None: - raise Exception("Owner does not have a billing address!") - - return Order(description=product.description, - one_time_price=product.one_time_price, - recurring_price=product.recurring_price, - billing_address=billing_address, - owner=product.owner, - **kwargs) - - def __str__(self): - return "Order {} created at {}, {}->{}, recurring period {}. One time price {}, recurring price {}".format( - self.uuid, self.creation_date, - self.starting_date, self.ending_date, - self.recurring_period, - self.one_time_price, - self.recurring_price) - -class OrderTimothee(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) - billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE) - - # TODO: enforce ending_date - starting_date to be larger than recurring_period. - creation_date = models.DateTimeField(auto_now_add=True) - starting_date = models.DateTimeField(default=timezone.now) - ending_date = models.DateTimeField(blank=True, - null=True) - - bill = models.ManyToManyField(Bill, - editable=False, - blank=True) - - recurring_period = models.IntegerField(choices = RecurringPeriod.choices, - default = RecurringPeriod.PER_30D) - - # Trigger initial bill generation at order creation. - def save(self, *args, **kwargs): - if self.ending_date and self.ending_date < self.starting_date: - raise ValidationError("End date cannot be before starting date") - - super().save(*args, **kwargs) - - Bill.generate_for(self.starting_date.year, self.starting_date.month, self.owner) - - @property - def records(self): - return OrderRecord.objects.filter(order=self) - - @property - def one_time_price(self): - return reduce(lambda acc, record: acc + record.one_time_price, self.records, 0) - - @property - def recurring_price(self): - return reduce(lambda acc, record: acc + record.recurring_price, self.records, 0) - - # Used by uncloud_pay tests. - @property - def bills(self): - return Bill.objects.filter(order=self) - - def add_record(self, one_time_price, recurring_price, description): - OrderRecord.objects.create(order=self, - one_time_price=one_time_price, - recurring_price=recurring_price, - description=description) - - def __str__(self): - return "Order {} created at {}, {}->{}, recurring period {}. Price one time {}, recurring {}".format( - self.uuid, self.creation_date, - self.starting_date, self.ending_date, - self.recurring_period, - self.one_time_price, - self.recurring_price) - - - -class OrderRecord(models.Model): - """ - Order records store billing informations for products: the actual product - might be mutated and/or moved to another order but we do not want to loose - the details of old orders. - - Used as source of trust to dynamically generate bill entries. - """ - - order = models.ForeignKey(Order, on_delete=models.CASCADE) - one_time_price = models.DecimalField(default=0.0, - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)]) - recurring_price = models.DecimalField(default=0.0, - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)]) - - description = models.TextField() - - - @property - def recurring_period(self): - return self.order.recurring_period - - @property - def starting_date(self): - return self.order.starting_date - - @property - def ending_date(self): - return self.order.ending_date - - -### -# Products - -# Abstract (= no database representation) class used as parent for products -# (e.g. uncloud_vm.models.VMProduct). -class Product(UncloudModel): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) - - description = "Generic Product" - - status = models.CharField(max_length=32, - choices=UncloudStatus.choices, - default=UncloudStatus.AWAITING_PAYMENT) - - order = models.ForeignKey(Order, - on_delete=models.CASCADE, - editable=False, - null=True) - - # Default period for all products - default_recurring_period = RecurringPeriod.PER_30D - - # Used to save records. - def save(self, *args, **kwargs): - # _state.adding is switched to false after super(...) call. - being_created = self._state.adding - - # First time saving - create an order - if not self.order: - billing_address = BillingAddress.get_preferred_address_for(self.owner) - print(billing_address) - - if not billing_address: - raise ValidationError("Cannot order without a billing address") - - # FIXME: allow user to choose recurring_period - self.order = Order.objects.create(owner=self.owner, - billing_address=billing_address, - one_time_price=self.one_time_price, - recurring_period=self.default_recurring_period, - recurring_price=self.recurring_price) - - super().save(*args, **kwargs) - - # # Make sure we only create records on creation. - # if being_created: - # record = OrderRecord( - # one_time_price=self.one_time_price, - # recurring_price=self.recurring_price, - # description=self.description) - # self.order.orderrecord_set.add(record, bulk=False) - - @property - def recurring_price(self): - pass # To be implemented in child. - - @property - def one_time_price(self): - return 0 - - @property - def billing_address(self): - return self.order.billing_address - - @staticmethod - def allowed_recurring_periods(): - return RecurringPeriod.choices - - class Meta: - abstract = True - - def discounted_price_by_period(self, requested_period): - """ - Each product has a standard recurring period for which - we define a pricing. I.e. VPN is usually year, VM is usually monthly. - - The user can opt-in to use a different period, which influences the price: - The longer a user commits, the higher the discount. - - Products can also be limited in the available periods. For instance - a VPN only makes sense to be bought for at least one day. - - Rules are as follows: - - given a standard recurring period of ..., changing to ... modifies price ... - - - # One month for free if buying / year, compared to a month: about 8.33% discount - per_year -> per_month -> /11 - per_month -> per_year -> *11 - - # Month has 30.42 days on average. About 7.9% discount to go monthly - per_month -> per_day -> /28 - per_day -> per_month -> *28 - - # Day has 24h, give one for free - per_day -> per_hour -> /23 - per_hour -> per_day -> /23 - - - Examples - - VPN @ 120CHF/y becomes - - 10.91 CHF/month (130.91 CHF/year) - - 0.39 CHF/day (142.21 CHF/year) - - VM @ 15 CHF/month becomes - - 165 CHF/month (13.75 CHF/month) - - 0.54 CHF/day (16.30 CHF/month) - - """ - - - if self.default_recurring_period == RecurringPeriod.PER_365D: - if requested_period == RecurringPeriod.PER_365D: - return self.recurring_price - if requested_period == RecurringPeriod.PER_30D: - return self.recurring_price/11. - if requested_period == RecurringPeriod.PER_DAY: - return self.recurring_price/11./28. - - elif self.default_recurring_period == RecurringPeriod.PER_30D: - if requested_period == RecurringPeriod.PER_365D: - return self.recurring_price*11 - if requested_period == RecurringPeriod.PER_30D: - return self.recurring_price - if requested_period == RecurringPeriod.PER_DAY: - return self.recurring_price/28. - - elif self.default_recurring_period == RecurringPeriod.PER_DAY: - if requested_period == RecurringPeriod.PER_365D: - return self.recurring_price*11*28 - if requested_period == RecurringPeriod.PER_30D: - return self.recurring_price*28 - if requested_period == RecurringPeriod.PER_DAY: - return self.recurring_price - else: - # FIXME: use the right type of exception here! - raise Exception("Did not implement the discounter for this case") diff --git a/uncloud_pay/tests-before-refactor.py b/uncloud_pay/tests-before-refactor.py deleted file mode 100644 index 49c51c6..0000000 --- a/uncloud_pay/tests-before-refactor.py +++ /dev/null @@ -1,721 +0,0 @@ -from django.test import TestCase -from django.contrib.auth import get_user_model -from datetime import datetime, date, timedelta -from django.utils import timezone - -from .models import * -from uncloud_service.models import GenericServiceProduct - -class OrderTestCase(TestCase): - """ - The heart of ordering products - """ - - def setUp(self): - self.user = get_user_model().objects.create( - username='random_user', - email='jane.random@domain.tld') - - self.ba = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="somewhere else", - active=True) - - def test_create_one_time_product(self): - """ - One time payment products cannot be updated - can they? - """ - - p = SampleOneTimeProduct.objects.create(owner=self.user) - - self.assertEqual(p.one_time_price, 5) - self.assertEqual(p.recurring_price, 0) - - -# class ProductTestCase(TestCase): -# """ -# Test products and products <-> order interaction -# """ - -# def setUp(self): -# self.user = get_user_model().objects.create( -# username='random_user', -# email='jane.random@domain.tld') - -# self.ba = BillingAddress.objects.create( -# owner=self.user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="somewhere else", -# active=True) - -# def test_create_one_time_product(self): -# """ -# One time payment products cannot be updated - can they? -# """ - -# p = SampleOneTimeProduct.objects.create(owner=self.user) - -# self.assertEqual(p.one_time_price, 5) -# self.assertEqual(p.recurring_price, 0) - -# def test_create_product_without_active_billing_address(self): -# """ -# Fail to create a product without an active billing address -# """ - -# self.ba.active = False -# self.ba.save() - -# with self.assertRaises(ValidationError): -# p = SampleOneTimeProduct.objects.create(owner=self.user) - -# def test_create_product_without_billing_address(self): -# """ -# Fail to create a product without a billing address -# """ - -# user2 = get_user_model().objects.create( -# username='random_user2', -# email='jane.randomly@domain.tld') - -# with self.assertRaises(ValidationError): -# p = SampleOneTimeProduct.objects.create(owner=user2) - - -# def test_create_order_creates_correct_order_count(self): -# """ -# Ensure creating orders from product only creates 1 order -# """ - -# # One order -# p = SampleOneTimeProduct.objects.create(owner=self.user) -# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - -# order_count = Order.objects.filter(owner=self.user).count() -# self.assertEqual(order_count, 1) - -# # One more order -# p = SampleRecurringProduct.objects.create(owner=self.user) -# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - -# order_count = Order.objects.filter(owner=self.user).count() -# self.assertEqual(order_count, 2) - -# # Should create 2 orders -# p = SampleRecurringProductOneTimeFee.objects.create(owner=self.user) -# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - -# order_count = Order.objects.filter(owner=self.user).count() -# self.assertEqual(order_count, 4) - - - # def test_update_recurring_order(self): - # """ - # Ensure creating orders from product only creates 1 order - # """ - - # p = SampleRecurringProduct.objects.create(owner=self.user) - # p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - - # p.create_or_update_recurring_order(timezone.make_aware(datetime.datetime(2020,3,4))) - - # # FIXME: where is the assert? - - -class BillingAddressTestCase(TestCase): - def setUp(self): - self.user = get_user_model().objects.create( - username='random_user', - email='jane.random@domain.tld') - - - def test_user_no_address(self): - """ - Raise an error, when there is no address - """ - - self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, - BillingAddress.get_address_for, - self.user) - - def test_user_only_inactive_address(self): - """ - Raise an error, when there is no active address - """ - - ba = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="somewhere else", - active=False) - - self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, - BillingAddress.get_address_for, - self.user) - - def test_find_active_address(self): - """ - Find the active address - """ - - ba = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="unknown", - active=True) - - - self.assertEqual(BillingAddress.get_address_for(self.user), ba) - - def test_find_right_address_with_multiple_addresses(self): - """ - Find the active address only, skip inactive - """ - - ba = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="unknown", - active=True) - - ba2 = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="somewhere else", - active=False) - - - self.assertEqual(BillingAddress.get_address_for(self.user), ba) - - def test_change_addresses(self): - """ - Switch the active address - """ - - ba = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="unknown", - active=True) - - self.assertEqual(BillingAddress.get_address_for(self.user), ba) - - ba.active=False - ba.save() - - ba2 = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="somewhere else", - active=True) - - self.assertEqual(BillingAddress.get_address_for(self.user), ba2) - - - -class BillTestCase(TestCase): - def setUp(self): - self.user_without_address = get_user_model().objects.create( - username='no_home_person', - email='far.away@domain.tld') - - self.user = get_user_model().objects.create( - username='jdoe', - email='john.doe@domain.tld') - - self.recurring_user = get_user_model().objects.create( - username='recurrent_product_user', - email='jane.doe@domain.tld') - - self.user_addr = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="unknown", - active=True) - - self.recurring_user_addr = BillingAddress.objects.create( - owner=self.recurring_user, - organization = 'Test org', - street="Somewhere", - city="Else", - postal_code="unknown", - active=True) - - self.order_meta = {} - self.order_meta[1] = { - 'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)), - 'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)), - 'price': 15, - 'description': 'One chocolate bar' - } - - self.one_time_order = Order.objects.create( - owner=self.user, - starting_date=self.order_meta[1]['starting_date'], - ending_date=self.order_meta[1]['ending_date'], - recurring_period=RecurringPeriod.ONE_TIME, - price=self.order_meta[1]['price'], - description=self.order_meta[1]['description'], - billing_address=BillingAddress.get_address_for(self.user)) - - self.recurring_order = Order.objects.create( - owner=self.recurring_user, - starting_date=timezone.make_aware(datetime.datetime(2020,3,3)), - recurring_period=RecurringPeriod.PER_30D, - price=15, - description="A pretty VM", - billing_address=BillingAddress.get_address_for(self.recurring_user) - ) - - # used for generating multiple bills - self.bill_dates = [ - timezone.make_aware(datetime.datetime(2020,3,31)), - timezone.make_aware(datetime.datetime(2020,4,30)), - timezone.make_aware(datetime.datetime(2020,5,31)), - ] - - - - def test_bill_one_time_one_bill_record(self): - """ - Ensure there is only 1 bill record per order - """ - - bill = Bill.create_next_bill_for_user_address(self.user_addr) - - self.assertEqual(self.one_time_order.billrecord_set.count(), 1) - - def test_bill_sum_onetime(self): - """ - Check the bill sum for a single one time order - """ - - bill = Bill.create_next_bill_for_user_address(self.user_addr) - self.assertEqual(bill.sum, self.order_meta[1]['price']) - - - def test_bill_creates_record_for_recurring_order(self): - """ - Ensure there is only 1 bill record per order - """ - - bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr) - - self.assertEqual(self.recurring_order.billrecord_set.count(), 1) - self.assertEqual(bill.billrecord_set.count(), 1) - - - def test_new_bill_after_closing(self): - """ - After closing a bill and the user has a recurring product, - the next bill run should create e new bill - """ - - for ending_date in self.bill_dates: - b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date) - b.close() - - bill_count = Bill.objects.filter(owner=self.recurring_user).count() - - self.assertEqual(len(self.bill_dates), bill_count) - - def test_multi_addr_multi_bill(self): - """ - Ensure multiple bills are created if orders exist with different billing addresses - """ - - username="lotsofplaces" - multi_addr_user = get_user_model().objects.create( - username=username, - email=f"{username}@example.org") - - user_addr1 = BillingAddress.objects.create( - owner=multi_addr_user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="unknown", - active=True) - - order1 = Order.objects.create( - owner=multi_addr_user, - starting_date=self.order_meta[1]['starting_date'], - ending_date=self.order_meta[1]['ending_date'], - recurring_period=RecurringPeriod.ONE_TIME, - price=self.order_meta[1]['price'], - description=self.order_meta[1]['description'], - billing_address=BillingAddress.get_address_for(self.user)) - - # Make this address inactive - user_addr1.active = False - user_addr1.save() - - user_addr2 = BillingAddress.objects.create( - owner=multi_addr_user, - organization = 'Test2 org', - street="unknown2", - city="unknown2", - postal_code="unknown2", - active=True) - - order2 = Order.objects.create( - owner=multi_addr_user, - starting_date=self.order_meta[1]['starting_date'], - ending_date=self.order_meta[1]['ending_date'], - recurring_period=RecurringPeriod.ONE_TIME, - price=self.order_meta[1]['price'], - description=self.order_meta[1]['description'], - billing_address=BillingAddress.get_address_for(self.user)) - - - bills = Bill.create_next_bills_for_user(multi_addr_user) - - self.assertEqual(len(bills), 2) - - - # TO BE IMPLEMENTED -- once orders can be marked as "done" / "inactive" / "not for billing" - # def test_skip_disabled_orders(self): - # """ - # Ensure that a bill only considers "active" orders - # """ - - # self.assertEqual(1, 2) - - -class ModifyProductTestCase(TestCase): - def setUp(self): - self.user = get_user_model().objects.create( - username='random_user', - email='jane.random@domain.tld') - - self.ba = BillingAddress.objects.create( - owner=self.user, - organization = 'Test org', - street="unknown", - city="unknown", - postal_code="somewhere else", - active=True) - - def test_no_pro_rata_first_bill(self): - """ - The bill should NOT contain a partial amount -- this is a BILL TEST :-) - """ - - price = 5 - - # Standard 30d recurring product - product = SampleRecurringProduct.objects.create(owner=self.user, - rc_price=price) - - starting_date = timezone.make_aware(datetime.datetime(2020,3,3)) - ending_date = timezone.make_aware(datetime.datetime(2020,3,31)) - time_diff = (ending_date - starting_date).total_seconds() - - product.create_order(starting_date) - - bills = Bill.create_next_bills_for_user(self.user, - ending_date=ending_date) - - - # We expect 1 bill for 1 billing address and 1 time frame - self.assertEqual(len(bills), 1) - - pro_rata_amount = time_diff / product.default_recurring_period.value - - self.assertNotEqual(bills[0].sum, pro_rata_amount * price) - self.assertEqual(bills[0].sum, price) - - - def test_downgrade_product(self): - """ - Test downgrading behaviour: - - We create a recurring product (recurring time: 30 days) and downgrade after 15 days. - - We create the bill right AFTER the end of the first order. - - Expected result: - - - First bill record for 30 days - - Second bill record starting after 30 days - - Bill contains two bill records - - """ - - user = self.user - - - - starting_price = 10 - downgrade_price = 5 - - - starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) - first_order_should_end_at = starting_date + datetime.timedelta(days=30) - change1_date = start_after(starting_date + datetime.timedelta(days=15)) - bill_ending_date = change1_date + datetime.timedelta(days=1) - - - product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) - product.create_order(starting_date) - - product.rc_price = downgrade_price - product.save() - product.create_or_update_recurring_order(when_to_start=change1_date) - - bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) - - bill = bills[0] - bill_records = BillRecord.objects.filter(bill=bill) - - self.assertEqual(len(bill_records), 2) - - self.assertEqual(bill_records[0].starting_date, starting_date) - - self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at) - - # self.assertEqual(bill_records[0].ending_date, first_order_should_end_at) - - # self.assertEqual(bill_records[0].quantity, 1) - - # self.assertEqual(bill_records[1].quantity, 1) - # self.assertEqual(int(bill.sum), 15) - - def test_upgrade_product(self): - """ - Test upgrading behaviour - """ - - user = self.user - - # Create product - starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) - starting_price = 10 - product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) - product.create_order(starting_date) - - change1_date = start_after(starting_date + datetime.timedelta(days=15)) - product.rc_price = 20 - product.save() - product.create_or_update_recurring_order(when_to_start=change1_date) - - bill_ending_date = change1_date + datetime.timedelta(days=1) - bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) - - bill = bills[0] - bill_records = BillRecord.objects.filter(bill=bill) - - self.assertEqual(len(bill_records), 2) - self.assertEqual(bill_records[0].quantity, .5) - - self.assertEqual(bill_records[0].ending_date, end_before(change1_date)) - - self.assertEqual(bill_records[1].quantity, 1) - self.assertEqual(bill_records[1].starting_date, change1_date) - - self.assertEqual(int(bill.sum), 25) - - - - # def test_bill_for_increasing_product(self): - # """ - # Modify a product, see one pro rata entry - # """ - - # # Create product - # starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) - # starting_price = 30.5 - # product = SampleRecurringProduct.objects.create(owner=self.user, - # rc_price=starting_price) - # product.create_order(starting_date) - - # recurring_period = product.default_recurring_period.value - - # # First change - # change1_date = timezone.make_aware(datetime.datetime(2019,4,17)) - # product.rc_price = 49.5 - # product.save() - # product.create_or_update_recurring_order(when_to_start=change1_date) - - # # Second change - # change2_date = timezone.make_aware(datetime.datetime(2019,5,8)) - # product.rc_price = 56.5 - # product.save() - # product.create_or_update_recurring_order(when_to_start=change2_date) - - # # Create bill one month after 2nd change - # bill_ending_date = timezone.make_aware(datetime.datetime(2019,6,30)) - # bills = Bill.create_next_bills_for_user(self.user, - # ending_date=bill_ending_date) - - # # only one bill in this test case - # bill = bills[0] - - # expected_amount = starting_price - - # d2 = starting_date + recurring_period - # duration2 = change1_date - d2 - - # expected_amount = 0 - - # # Expected bill sum & records: - # # 2019-03-03 - 2019-04-02 +30d: 30.5 - # # 2019-04-02 - 2019-04-17: +15d: 15.25 - # # 2019-04-17 - 2019-05-08: +21d: (21/30) * 49.5 - # # 2019-05-08 - 2019-06-07: +30d: 56.5 - # # 2019-06-07 - 2019-07-07: +30d: 56.5 - - - # self.assertEqual(bills[0].sum, price) - - # # expeted result: - # # 1x 5 chf bill record - # # 1x 5 chf bill record - # # 1x 10 partial bill record - - - -# class NotABillingTC(TestCase): -# #class BillingTestCase(TestCase): -# def setUp(self): -# self.user = get_user_model().objects.create( -# username='jdoe', -# email='john.doe@domain.tld') -# self.billing_address = BillingAddress.objects.create( -# owner=self.user, -# street="unknown", -# city="unknown", -# postal_code="unknown") - -# def test_basic_monthly_billing(self): -# one_time_price = 10 -# recurring_price = 20 -# description = "Test Product 1" - -# # Three months: full, full, partial. -# # starting_date = datetime.fromisoformat('2020-03-01') -# starting_date = datetime(2020,3,1) -# ending_date = datetime(2020,5,8) - -# # Create order to be billed. -# order = Order.objects.create( -# owner=self.user, -# starting_date=starting_date, -# ending_date=ending_date, -# recurring_period=RecurringPeriod.PER_30D, -# recurring_price=recurring_price, -# one_time_price=one_time_price, -# description=description, -# billing_address=self.billing_address) - -# # Generate & check bill for first month: full recurring_price + setup. -# first_month_bills = order.generate_initial_bill() -# self.assertEqual(len(first_month_bills), 1) -# self.assertEqual(first_month_bills[0].amount, one_time_price + recurring_price) - -# # Generate & check bill for second month: full recurring_price. -# second_month_bills = Bill.generate_for(2020, 4, self.user) -# self.assertEqual(len(second_month_bills), 1) -# self.assertEqual(second_month_bills[0].amount, recurring_price) - -# # Generate & check bill for third and last month: partial recurring_price. -# third_month_bills = Bill.generate_for(2020, 5, self.user) -# self.assertEqual(len(third_month_bills), 1) -# # 31 days in May. -# self.assertEqual(float(third_month_bills[0].amount), -# round(round((7/31), AMOUNT_DECIMALS) * recurring_price, AMOUNT_DECIMALS)) - -# # Check that running Bill.generate_for() twice does not create duplicates. -# self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0) - -# def test_basic_yearly_billing(self): -# one_time_price = 10 -# recurring_price = 150 -# description = "Test Product 1" - -# starting_date = datetime.fromisoformat('2020-03-31T08:05:23') - -# # Create order to be billed. -# order = Order.objects.create( -# owner=self.user, -# starting_date=starting_date, -# recurring_period=RecurringPeriod.PER_365D, -# recurring_price=recurring_price, -# one_time_price=one_time_price, -# description=description, -# billing_address=self.billing_address) - -# # Generate & check bill for first year: recurring_price + setup. -# first_year_bills = order.generate_initial_bill() -# self.assertEqual(len(first_year_bills), 1) -# self.assertEqual(first_year_bills[0].starting_date.date(), -# date.fromisoformat('2020-03-31')) -# self.assertEqual(first_year_bills[0].ending_date.date(), -# date.fromisoformat('2021-03-30')) -# self.assertEqual(first_year_bills[0].amount, -# recurring_price + one_time_price) - -# # Generate & check bill for second year: recurring_price. -# second_year_bills = Bill.generate_for(2021, 3, self.user) -# self.assertEqual(len(second_year_bills), 1) -# self.assertEqual(second_year_bills[0].starting_date.date(), -# date.fromisoformat('2021-03-31')) -# self.assertEqual(second_year_bills[0].ending_date.date(), -# date.fromisoformat('2022-03-30')) -# self.assertEqual(second_year_bills[0].amount, recurring_price) - -# # Check that running Bill.generate_for() twice does not create duplicates. -# self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0) -# self.assertEqual(len(Bill.generate_for(2020, 4, self.user)), 0) -# self.assertEqual(len(Bill.generate_for(2020, 2, self.user)), 0) -# self.assertEqual(len(Bill.generate_for(2021, 3, self.user)), 0) - -# def test_basic_hourly_billing(self): -# one_time_price = 10 -# recurring_price = 1.4 -# description = "Test Product 1" - -# starting_date = datetime.fromisoformat('2020-03-31T08:05:23') -# ending_date = datetime.fromisoformat('2020-04-01T11:13:32') - -# # Create order to be billed. -# order = Order.objects.create( -# owner=self.user, -# starting_date=starting_date, -# ending_date=ending_date, -# recurring_period=RecurringPeriod.PER_HOUR, -# recurring_price=recurring_price, -# one_time_price=one_time_price, -# description=description, -# billing_address=self.billing_address) - -# # Generate & check bill for first month: recurring_price + setup. -# first_month_bills = order.generate_initial_bill() -# self.assertEqual(len(first_month_bills), 1) -# self.assertEqual(float(first_month_bills[0].amount), -# round(16 * recurring_price, AMOUNT_DECIMALS) + one_time_price) - -# # Generate & check bill for first month: recurring_price. -# second_month_bills = Bill.generate_for(2020, 4, self.user) -# self.assertEqual(len(second_month_bills), 1) -# self.assertEqual(float(second_month_bills[0].amount), -# round(12 * recurring_price, AMOUNT_DECIMALS)) diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py index 0ebd11c..ca91cc9 100644 --- a/uncloud_pay/tests.py +++ b/uncloud_pay/tests.py @@ -244,36 +244,6 @@ class ModifyOrderTestCase(TestCase): -# def test_no_pro_rata_first_bill(self): -# """ -# The bill should NOT contain a partial amount -- this is a BILL TEST :-) -# """ - -# price = 5 - -# # Standard 30d recurring product -# product = SampleRecurringProduct.objects.create(owner=self.user, -# rc_price=price) - -# starting_date = timezone.make_aware(datetime.datetime(2020,3,3)) -# ending_date = timezone.make_aware(datetime.datetime(2020,3,31)) -# time_diff = (ending_date - starting_date).total_seconds() - -# product.create_order(starting_date) - -# bills = Bill.create_next_bills_for_user(self.user, -# ending_date=ending_date) - - -# # We expect 1 bill for 1 billing address and 1 time frame -# self.assertEqual(len(bills), 1) - -# pro_rata_amount = time_diff / product.default_recurring_period.value - -# self.assertNotEqual(bills[0].sum, pro_rata_amount * price) -# self.assertEqual(bills[0].sum, price) - - def test_downgrade_product(self): """ Test downgrading behaviour: @@ -300,7 +270,6 @@ class ModifyOrderTestCase(TestCase): change1_date = start_after(starting_date + datetime.timedelta(days=15)) bill_ending_date = change1_date + datetime.timedelta(days=1) - order1 = Order.objects.create(owner=self.user, billing_address=BillingAddress.get_address_for(self.user), product=self.product, @@ -309,12 +278,6 @@ class ModifyOrderTestCase(TestCase): order1.update_order(vm_order_downgrade_config, starting_date=change1_date) - # product = Product.objects.create(owner=user, rc_price=starting_price) - # product.create_order(starting_date) - # product.rc_price = downgrade_price - # product.save() - # product.create_or_update_recurring_order(when_to_start=change1_date) - bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) bill = bills[0] @@ -325,105 +288,6 @@ class ModifyOrderTestCase(TestCase): self.assertEqual(bill_records[0].starting_date, starting_date) self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at) - # self.assertEqual(bill_records[0].ending_date, first_order_should_end_at) - - # self.assertEqual(bill_records[0].quantity, 1) - - # self.assertEqual(bill_records[1].quantity, 1) - # self.assertEqual(int(bill.sum), 15) - -# def test_upgrade_product(self): -# """ -# Test upgrading behaviour -# """ - -# user = self.user - -# # Create product -# starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) -# starting_price = 10 -# product = SampleRecurringProduct.objects.create(owner=user, rc_price=starting_price) -# product.create_order(starting_date) - -# change1_date = start_after(starting_date + datetime.timedelta(days=15)) -# product.rc_price = 20 -# product.save() -# product.create_or_update_recurring_order(when_to_start=change1_date) - -# bill_ending_date = change1_date + datetime.timedelta(days=1) -# bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date) - -# bill = bills[0] -# bill_records = BillRecord.objects.filter(bill=bill) - -# self.assertEqual(len(bill_records), 2) -# self.assertEqual(bill_records[0].quantity, .5) - -# self.assertEqual(bill_records[0].ending_date, end_before(change1_date)) - -# self.assertEqual(bill_records[1].quantity, 1) -# self.assertEqual(bill_records[1].starting_date, change1_date) - -# self.assertEqual(int(bill.sum), 25) - - - - # def test_bill_for_increasing_product(self): - # """ - # Modify a product, see one pro rata entry - # """ - - # # Create product - # starting_date = timezone.make_aware(datetime.datetime(2019,3,3)) - # starting_price = 30.5 - # product = SampleRecurringProduct.objects.create(owner=self.user, - # rc_price=starting_price) - # product.create_order(starting_date) - - # recurring_period = product.default_recurring_period.value - - # # First change - # change1_date = timezone.make_aware(datetime.datetime(2019,4,17)) - # product.rc_price = 49.5 - # product.save() - # product.create_or_update_recurring_order(when_to_start=change1_date) - - # # Second change - # change2_date = timezone.make_aware(datetime.datetime(2019,5,8)) - # product.rc_price = 56.5 - # product.save() - # product.create_or_update_recurring_order(when_to_start=change2_date) - - # # Create bill one month after 2nd change - # bill_ending_date = timezone.make_aware(datetime.datetime(2019,6,30)) - # bills = Bill.create_next_bills_for_user(self.user, - # ending_date=bill_ending_date) - - # # only one bill in this test case - # bill = bills[0] - - # expected_amount = starting_price - - # d2 = starting_date + recurring_period - # duration2 = change1_date - d2 - - # expected_amount = 0 - - # # Expected bill sum & records: - # # 2019-03-03 - 2019-04-02 +30d: 30.5 - # # 2019-04-02 - 2019-04-17: +15d: 15.25 - # # 2019-04-17 - 2019-05-08: +21d: (21/30) * 49.5 - # # 2019-05-08 - 2019-06-07: +30d: 56.5 - # # 2019-06-07 - 2019-07-07: +30d: 56.5 - - - # self.assertEqual(bills[0].sum, price) - - # # expeted result: - # # 1x 5 chf bill record - # # 1x 5 chf bill record - # # 1x 10 partial bill record - class BillTestCase(TestCase): """ @@ -431,6 +295,8 @@ class BillTestCase(TestCase): """ def setUp(self): + RecurringPeriod.populate_db_defaults() + self.user_without_address = get_user_model().objects.create( username='no_home_person', email='far.away@domain.tld') @@ -500,7 +366,7 @@ class BillTestCase(TestCase): def order_chocolate(self): return Order.objects.create( owner=self.user, - recurring_period=RecurringPeriod.ONE_TIME, + recurring_period=RecurringPeriod.objects.get(name="Onetime"), product=self.chocolate, billing_address=BillingAddress.get_address_for(self.user), starting_date=self.order_meta[1]['starting_date'], @@ -522,7 +388,7 @@ class BillTestCase(TestCase): return Order.objects.create( owner=self.user, - recurring_period=RecurringPeriod.ONE_TIME, + recurring_period=RecurringPeriod.objects.get(name="Onetime"), product=self.chocolate, billing_address=BillingAddress.get_address_for(self.user), starting_date=self.order_meta[1]['starting_date'], @@ -580,167 +446,6 @@ class BillTestCase(TestCase): self.assertEqual(len(self.bill_dates), bill_count) - # def test_multi_addr_multi_bill(self): - # """ - # Ensure multiple bills are created if orders exist with different billing addresses - # """ - - # username="lotsofplaces" - # multi_addr_user = get_user_model().objects.create( - # username=username, - # email=f"{username}@example.org") - - # user_addr1 = BillingAddress.objects.create( - # owner=multi_addr_user, - # organization = 'Test org', - # street="unknown", - # city="unknown", - # postal_code="unknown", - # active=True) - - # order1 = Order.objects.create( - # owner=multi_addr_user, - # recurring_period=RecurringPeriod.ONE_TIME, - # product=self.chocolate, - # billing_address=BillingAddress.get_address_for(self.user), - # starting_date=self.order_meta[1]['starting_date'], - # ending_date=self.order_meta[1]['ending_date'], - # config=chocolate_order_config) - - # order1 = Order.objects.create( - # owner=multi_addr_user, - # starting_date=self.order_meta[1]['starting_date'], - # ending_date=self.order_meta[1]['ending_date'], - # recurring_period=RecurringPeriod.ONE_TIME, - # price=self.order_meta[1]['price'], - # description=self.order_meta[1]['description'], - # billing_address=BillingAddress.get_address_for(self.user)) - - # # Make this address inactive - # user_addr1.active = False - # user_addr1.save() - - # user_addr2 = BillingAddress.objects.create( - # owner=multi_addr_user, - # organization = 'Test2 org', - # street="unknown2", - # city="unknown2", - # postal_code="unknown2", - # active=True) - - # order2 = Order.objects.create( - # owner=multi_addr_user, - # starting_date=self.order_meta[1]['starting_date'], - # ending_date=self.order_meta[1]['ending_date'], - # recurring_period=RecurringPeriod.ONE_TIME, - # price=self.order_meta[1]['price'], - # description=self.order_meta[1]['description'], - # billing_address=BillingAddress.get_address_for(self.user)) - - - # bills = Bill.create_next_bills_for_user(multi_addr_user) - - # self.assertEqual(len(bills), 2) - - -# # TO BE IMPLEMENTED -- once orders can be marked as "done" / "inactive" / "not for billing" -# # def test_skip_disabled_orders(self): -# # """ -# # Ensure that a bill only considers "active" orders -# # """ - -# # self.assertEqual(1, 2) - -# class ProductTestCase(TestCase): -# """ -# Test products and products <-> order interaction -# """ - -# def setUp(self): -# self.user = get_user_model().objects.create( -# username='random_user', -# email='jane.random@domain.tld') - -# self.ba = BillingAddress.objects.create( -# owner=self.user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="somewhere else", -# active=True) - -# def test_create_one_time_product(self): -# """ -# One time payment products cannot be updated - can they? -# """ - -# p = SampleOneTimeProduct.objects.create(owner=self.user) - -# self.assertEqual(p.one_time_price, 5) -# self.assertEqual(p.recurring_price, 0) - -# def test_create_product_without_active_billing_address(self): -# """ -# Fail to create a product without an active billing address -# """ - -# self.ba.active = False -# self.ba.save() - -# with self.assertRaises(ValidationError): -# p = SampleOneTimeProduct.objects.create(owner=self.user) - -# def test_create_product_without_billing_address(self): -# """ -# Fail to create a product without a billing address -# """ - -# user2 = get_user_model().objects.create( -# username='random_user2', -# email='jane.randomly@domain.tld') - -# with self.assertRaises(ValidationError): -# p = SampleOneTimeProduct.objects.create(owner=user2) - - -# def test_create_order_creates_correct_order_count(self): -# """ -# Ensure creating orders from product only creates 1 order -# """ - -# # One order -# p = SampleOneTimeProduct.objects.create(owner=self.user) -# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - -# order_count = Order.objects.filter(owner=self.user).count() -# self.assertEqual(order_count, 1) - -# # One more order -# p = SampleRecurringProduct.objects.create(owner=self.user) -# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - -# order_count = Order.objects.filter(owner=self.user).count() -# self.assertEqual(order_count, 2) - -# # Should create 2 orders -# p = SampleRecurringProductOneTimeFee.objects.create(owner=self.user) -# p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - -# order_count = Order.objects.filter(owner=self.user).count() -# self.assertEqual(order_count, 4) - - - # def test_update_recurring_order(self): - # """ - # Ensure creating orders from product only creates 1 order - # """ - - # p = SampleRecurringProduct.objects.create(owner=self.user) - # p.create_order(timezone.make_aware(datetime.datetime(2020,3,3))) - - # p.create_or_update_recurring_order(timezone.make_aware(datetime.datetime(2020,3,4))) - - # # FIXME: where is the assert? class BillingAddressTestCase(TestCase): @@ -758,219 +463,3 @@ class BillingAddressTestCase(TestCase): self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, BillingAddress.get_address_for, self.user) - -# def test_user_only_inactive_address(self): -# """ -# Raise an error, when there is no active address -# """ - -# ba = BillingAddress.objects.create( -# owner=self.user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="somewhere else", -# active=False) - -# self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist, -# BillingAddress.get_address_for, -# self.user) - -# def test_find_active_address(self): -# """ -# Find the active address -# """ - -# ba = BillingAddress.objects.create( -# owner=self.user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="unknown", -# active=True) - - -# self.assertEqual(BillingAddress.get_address_for(self.user), ba) - -# def test_find_right_address_with_multiple_addresses(self): -# """ -# Find the active address only, skip inactive -# """ - -# ba = BillingAddress.objects.create( -# owner=self.user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="unknown", -# active=True) - -# ba2 = BillingAddress.objects.create( -# owner=self.user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="somewhere else", -# active=False) - - -# self.assertEqual(BillingAddress.get_address_for(self.user), ba) - -# def test_change_addresses(self): -# """ -# Switch the active address -# """ - -# ba = BillingAddress.objects.create( -# owner=self.user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="unknown", -# active=True) - -# self.assertEqual(BillingAddress.get_address_for(self.user), ba) - -# ba.active=False -# ba.save() - -# ba2 = BillingAddress.objects.create( -# owner=self.user, -# organization = 'Test org', -# street="unknown", -# city="unknown", -# postal_code="somewhere else", -# active=True) - -# self.assertEqual(BillingAddress.get_address_for(self.user), ba2) - - - - - - -# class NotABillingTC(TestCase): -# #class BillingTestCase(TestCase): -# def setUp(self): -# self.user = get_user_model().objects.create( -# username='jdoe', -# email='john.doe@domain.tld') -# self.billing_address = BillingAddress.objects.create( -# owner=self.user, -# street="unknown", -# city="unknown", -# postal_code="unknown") - -# def test_basic_monthly_billing(self): -# one_time_price = 10 -# recurring_price = 20 -# description = "Test Product 1" - -# # Three months: full, full, partial. -# # starting_date = datetime.fromisoformat('2020-03-01') -# starting_date = datetime(2020,3,1) -# ending_date = datetime(2020,5,8) - -# # Create order to be billed. -# order = Order.objects.create( -# owner=self.user, -# starting_date=starting_date, -# ending_date=ending_date, -# recurring_period=RecurringPeriod.PER_30D, -# recurring_price=recurring_price, -# one_time_price=one_time_price, -# description=description, -# billing_address=self.billing_address) - -# # Generate & check bill for first month: full recurring_price + setup. -# first_month_bills = order.generate_initial_bill() -# self.assertEqual(len(first_month_bills), 1) -# self.assertEqual(first_month_bills[0].amount, one_time_price + recurring_price) - -# # Generate & check bill for second month: full recurring_price. -# second_month_bills = Bill.generate_for(2020, 4, self.user) -# self.assertEqual(len(second_month_bills), 1) -# self.assertEqual(second_month_bills[0].amount, recurring_price) - -# # Generate & check bill for third and last month: partial recurring_price. -# third_month_bills = Bill.generate_for(2020, 5, self.user) -# self.assertEqual(len(third_month_bills), 1) -# # 31 days in May. -# self.assertEqual(float(third_month_bills[0].amount), -# round(round((7/31), AMOUNT_DECIMALS) * recurring_price, AMOUNT_DECIMALS)) - -# # Check that running Bill.generate_for() twice does not create duplicates. -# self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0) - -# def test_basic_yearly_billing(self): -# one_time_price = 10 -# recurring_price = 150 -# description = "Test Product 1" - -# starting_date = datetime.fromisoformat('2020-03-31T08:05:23') - -# # Create order to be billed. -# order = Order.objects.create( -# owner=self.user, -# starting_date=starting_date, -# recurring_period=RecurringPeriod.PER_365D, -# recurring_price=recurring_price, -# one_time_price=one_time_price, -# description=description, -# billing_address=self.billing_address) - -# # Generate & check bill for first year: recurring_price + setup. -# first_year_bills = order.generate_initial_bill() -# self.assertEqual(len(first_year_bills), 1) -# self.assertEqual(first_year_bills[0].starting_date.date(), -# date.fromisoformat('2020-03-31')) -# self.assertEqual(first_year_bills[0].ending_date.date(), -# date.fromisoformat('2021-03-30')) -# self.assertEqual(first_year_bills[0].amount, -# recurring_price + one_time_price) - -# # Generate & check bill for second year: recurring_price. -# second_year_bills = Bill.generate_for(2021, 3, self.user) -# self.assertEqual(len(second_year_bills), 1) -# self.assertEqual(second_year_bills[0].starting_date.date(), -# date.fromisoformat('2021-03-31')) -# self.assertEqual(second_year_bills[0].ending_date.date(), -# date.fromisoformat('2022-03-30')) -# self.assertEqual(second_year_bills[0].amount, recurring_price) - -# # Check that running Bill.generate_for() twice does not create duplicates. -# self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0) -# self.assertEqual(len(Bill.generate_for(2020, 4, self.user)), 0) -# self.assertEqual(len(Bill.generate_for(2020, 2, self.user)), 0) -# self.assertEqual(len(Bill.generate_for(2021, 3, self.user)), 0) - -# def test_basic_hourly_billing(self): -# one_time_price = 10 -# recurring_price = 1.4 -# description = "Test Product 1" - -# starting_date = datetime.fromisoformat('2020-03-31T08:05:23') -# ending_date = datetime.fromisoformat('2020-04-01T11:13:32') - -# # Create order to be billed. -# order = Order.objects.create( -# owner=self.user, -# starting_date=starting_date, -# ending_date=ending_date, -# recurring_period=RecurringPeriod.PER_HOUR, -# recurring_price=recurring_price, -# one_time_price=one_time_price, -# description=description, -# billing_address=self.billing_address) - -# # Generate & check bill for first month: recurring_price + setup. -# first_month_bills = order.generate_initial_bill() -# self.assertEqual(len(first_month_bills), 1) -# self.assertEqual(float(first_month_bills[0].amount), -# round(16 * recurring_price, AMOUNT_DECIMALS) + one_time_price) - -# # Generate & check bill for first month: recurring_price. -# second_month_bills = Bill.generate_for(2020, 4, self.user) -# self.assertEqual(len(second_month_bills), 1) -# self.assertEqual(float(second_month_bills[0].amount), -# round(12 * recurring_price, AMOUNT_DECIMALS)) From 0fd5ac18cdd3435898ba9bd58e2e65e448bb703f Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 6 Dec 2020 11:53:37 +0100 Subject: [PATCH 44/93] do not import pay->auth Try to keep common things in the "uncloud" module --- uncloud/__init__.py | 7 +++++++ uncloud_auth/models.py | 10 +--------- uncloud_pay/__init__.py | 7 ------- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/uncloud/__init__.py b/uncloud/__init__.py index 2676f97..4bda45f 100644 --- a/uncloud/__init__.py +++ b/uncloud/__init__.py @@ -1,4 +1,11 @@ from django.utils.translation import gettext_lazy as _ +import decimal + +# Define DecimalField properties, used to represent amounts of money. +AMOUNT_MAX_DIGITS=10 +AMOUNT_DECIMALS=2 + +decimal.getcontext().prec = AMOUNT_DECIMALS # http://xml.coverpages.org/country3166.html COUNTRIES = ( diff --git a/uncloud_auth/models.py b/uncloud_auth/models.py index 9132f96..90463e1 100644 --- a/uncloud_auth/models.py +++ b/uncloud_auth/models.py @@ -2,8 +2,7 @@ from django.contrib.auth.models import AbstractUser from django.db import models from django.core.validators import MinValueValidator -from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS -from uncloud_pay.models import get_balance_for_user +from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS class User(AbstractUser): """ @@ -16,10 +15,3 @@ class User(AbstractUser): max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS, validators=[MinValueValidator(0)]) - - # @property - # def primary_billing_address(self): - - @property - def balance(self): - return get_balance_for_user(self) diff --git a/uncloud_pay/__init__.py b/uncloud_pay/__init__.py index 810fd3e..8b13789 100644 --- a/uncloud_pay/__init__.py +++ b/uncloud_pay/__init__.py @@ -1,8 +1 @@ -import decimal - -# Define DecimalField properties, used to represent amounts of money. -AMOUNT_MAX_DIGITS=10 -AMOUNT_DECIMALS=2 - -decimal.getcontext().prec = AMOUNT_DECIMALS From 7f32d05cd42880a59e2e0cac5077e27abe5601b4 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 9 Dec 2020 20:22:33 +0100 Subject: [PATCH 45/93] begin phasing in vpn support [poc] --- uncloud/settings.py | 3 ++ uncloud/urls.py | 4 ++ uncloud_auth/serializers.py | 2 +- .../templates/uncloud_auth/login.html | 13 ++++++ uncloud_auth/uldap.py | 42 +++++++++++++++++++ uncloud_auth/views.py | 11 +++++ uncloud_net/models.py | 3 +- .../uncloud_net/vpnnetwork_form.html | 35 ++++++++++++++++ uncloud_net/views.py | 26 ++++++++++++ uncloud_pay/models.py | 2 +- 10 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 uncloud_auth/templates/uncloud_auth/login.html create mode 100644 uncloud_auth/uldap.py create mode 100644 uncloud_net/templates/uncloud_net/vpnnetwork_form.html diff --git a/uncloud/settings.py b/uncloud/settings.py index 17f5200..9a9f0a6 100644 --- a/uncloud/settings.py +++ b/uncloud/settings.py @@ -186,6 +186,9 @@ CHROME_PATH = '/usr/bin/chromium-browser' # Username that is created by default and owns the configuration objects UNCLOUD_ADMIN_NAME = "uncloud-admin" +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/' + # Overwrite settings with local settings, if existing try: from uncloud.local_settings import * diff --git a/uncloud/urls.py b/uncloud/urls.py index 789ca06..3845d43 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -77,5 +77,9 @@ urlpatterns = [ description="uncloud API", version="1.0.0" ), name='openapi-schema'), + path('vpn/create/', netviews.VPNCreateView.as_view(), name="vpncreate"), + path('login/', authviews.LoginView.as_view(), name="login"), + path('logout/', authviews.LogoutView.as_view(), name="logout"), + path('admin/', admin.site.urls), ] diff --git a/uncloud_auth/serializers.py b/uncloud_auth/serializers.py index 931b29d..c3f6694 100644 --- a/uncloud_auth/serializers.py +++ b/uncloud_auth/serializers.py @@ -3,7 +3,7 @@ from django.db import transaction from ldap3.core.exceptions import LDAPEntryAlreadyExistsResult from rest_framework import serializers -from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS +from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS from uncloud_pay.models import BillingAddress from .ungleich_ldap import LdapManager diff --git a/uncloud_auth/templates/uncloud_auth/login.html b/uncloud_auth/templates/uncloud_auth/login.html new file mode 100644 index 0000000..04f9a15 --- /dev/null +++ b/uncloud_auth/templates/uncloud_auth/login.html @@ -0,0 +1,13 @@ +{% extends 'uncloud/base.html' %} + +{% block body %} +
+ +
+ {% csrf_token %} + {{ form }} + +
+
+ +{% endblock %} diff --git a/uncloud_auth/uldap.py b/uncloud_auth/uldap.py new file mode 100644 index 0000000..aa90c77 --- /dev/null +++ b/uncloud_auth/uldap.py @@ -0,0 +1,42 @@ +import ldap +# from django.conf import settings + +AUTH_LDAP_SERVER_URI = "ldaps://ldap1.ungleich.ch,ldaps://ldap2.ungleich.ch" +AUTH_LDAP_BIND_DN="uid=django-create,ou=system,dc=ungleich,dc=ch" +AUTH_LDAP_BIND_PASSWORD="kS#e+v\zjKn]L!,RIu2}V+DUS" +# AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=ungleich,dc=ch", +# ldap.SCOPE_SUBTREE, +# "(uid=%(user)s)") + + + +ldap_object = ldap.initialize(AUTH_LDAP_SERVER_URI) +cancelid = ldap_object.bind(AUTH_LDAP_BIND_DN, AUTH_LDAP_BIND_PASSWORD) + +res = ldap_object.search_s("dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=nico)") +print(res) + +# class LDAP(object): +# """ +# Managing users in LDAP + +# Requires the following settings? + +# LDAP_USER_DN: where to create users in the tree + +# LDAP_ADMIN_DN: which DN to use for managing users +# LDAP_ADMIN_PASSWORD: which password to used + +# This module will reuse information from djagno_auth_ldap, including: + +# AUTH_LDAP_SERVER_URI + +# """ +# def __init__(self): +# pass + +# def create_user(self): +# pass + +# def change_password(self): +# pass diff --git a/uncloud_auth/views.py b/uncloud_auth/views.py index 520e1b0..9310a4c 100644 --- a/uncloud_auth/views.py +++ b/uncloud_auth/views.py @@ -1,3 +1,6 @@ +from django.contrib.auth import views as auth_views +from django.contrib.auth import logout + from django_auth_ldap.backend import LDAPBackend from rest_framework import mixins, permissions, status, viewsets from rest_framework.decorators import action @@ -6,6 +9,14 @@ from rest_framework.response import Response from .serializers import * +class LoginView(auth_views.LoginView): + template_name = 'uncloud_auth/login.html' + +class LogoutView(auth_views.LogoutView): + pass +# template_name = 'uncloud_auth/logo.html' + + class UserViewSet(viewsets.GenericViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = UserSerializer diff --git a/uncloud_net/models.py b/uncloud_net/models.py index 509bb61..3a7fc6a 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -171,8 +171,6 @@ class VPNNetwork(models.Model): wireguard_public_key = models.CharField(max_length=48) -# default_recurring_period = RecurringPeriod.PER_365D - @property def recurring_price(self): return 120 @@ -185,6 +183,7 @@ class VPNNetwork(models.Model): print("deleted {}".format(self)) + class ReverseDNSEntry(models.Model): """ A reverse DNS entry diff --git a/uncloud_net/templates/uncloud_net/vpnnetwork_form.html b/uncloud_net/templates/uncloud_net/vpnnetwork_form.html new file mode 100644 index 0000000..14f8656 --- /dev/null +++ b/uncloud_net/templates/uncloud_net/vpnnetwork_form.html @@ -0,0 +1,35 @@ +{% extends 'uncloud/base.html' %} + +{% block body %} +
+
+
+

+

Generate new prefix

+

+ A new random prefix will be generated for you. +

+ +

+ All ULA prefixes are /48 networks. Simply add the first IP address + (without any netmask, for instance fd23:2323:2323::). + + You can choose the name of your liking and an organization name. +

+

+ ULA prefixes are always subnets of the fd00::/8 network. +

+ +
+ +
+
+ {% csrf_token %} + {{ form }} + +
+
+
+
+ +{% endblock %} diff --git a/uncloud_net/views.py b/uncloud_net/views.py index dc86959..f554e36 100644 --- a/uncloud_net/views.py +++ b/uncloud_net/views.py @@ -1,3 +1,6 @@ +from django.views.generic.edit import CreateView +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.messages.views import SuccessMessageMixin from django.shortcuts import render @@ -31,3 +34,26 @@ class VPNNetworkViewSet(viewsets.ModelViewSet): obj = VPNNetwork.objects.filter(owner=self.request.user) return obj + + + + +class VPNCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = VPNNetwork + + login_url = '/login/' + success_url = '/' + success_message = "%(the_prefix)s/48 was created successfully" + + gen_method = "undef" + + fields = [ "wireguard_public_key" ] + + def get_success_message(self, cleaned_data): + return self.success_message % dict(cleaned_data, + the_prefix = self.object.prefix) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['method'] = self.gen_method + return context diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 96fc8ec..f0e469d 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -18,7 +18,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.conf import settings import uncloud_pay.stripe -from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS +from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS from uncloud.models import UncloudAddress # Used to generate bill due dates. From 074cffcbd742bc6dead0eb07c5385a49ace610ee Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 9 Dec 2020 21:20:33 +0100 Subject: [PATCH 46/93] Add selection for vpnnetworkreservations --- uncloud_net/selectors.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 uncloud_net/selectors.py diff --git a/uncloud_net/selectors.py b/uncloud_net/selectors.py new file mode 100644 index 0000000..024d44f --- /dev/null +++ b/uncloud_net/selectors.py @@ -0,0 +1,23 @@ +from django.db.models import Count +from .models import * + +@transaction.atomic +def get_suitable_pool(subnetwork_size): + """ + Find suitable pools for a certain network size. + + First, filter for all pools that offer the requested subnetwork_size. + + Then find those pools that are not fully exhausted: + + The number of available networks in a pool is 2^(subnetwork_size-network_size. + + The number of available networks in a pool is given by the number of VPNNetworkreservations. + + """ + + return VPNPool.objects.annotate( + num_reservations=Count('vpnnetworkreservation'), + max_reservations=2**(F('subnetwork_size')-F('network_size'))).filter( + num_reservations__lt=F('max_reservations'), + subnetwork_size=subnetwork_size) From 10d5a72c5a2daf9a1dc3b1cf92ee1d02c250006e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 13 Dec 2020 11:38:41 +0100 Subject: [PATCH 47/93] [refactor] cleaning up uncloud_net for Wireguardvpn --- opennebula/migrations/0001_initial.py | 16 +- .../migrations/0002_auto_20200801_2332.py | 20 --- .../migrations/0003_auto_20200808_1953.py | 23 --- .../migrations/0004_auto_20200809_1237.py | 23 --- .../migrations/0005_remove_vm_orders.py | 17 -- .../migrations/0006_auto_20200928_1858.py | 25 --- uncloud/migrations/0001_initial.py | 30 +++- uncloud/migrations/0002_auto_20201011_2001.py | 18 -- uncloud/migrations/0003_auto_20201011_2009.py | 27 --- uncloud/migrations/0004_auto_20201011_2031.py | 51 ------ .../0005_uncloudprovider_coupon_network.py | 21 --- uncloud/migrations/0006_auto_20201025_1931.py | 40 ----- uncloud/urls.py | 8 +- uncloud_auth/migrations/0001_initial.py | 6 +- .../migrations/0002_auto_20200808_1953.py | 18 -- uncloud_net/admin.py | 5 +- uncloud_net/forms.py | 12 ++ uncloud_net/migrations/0001_initial.py | 65 +++---- .../migrations/0002_auto_20200801_2332.py | 20 --- .../migrations/0003_auto_20200808_1953.py | 28 --- .../migrations/0004_auto_20200809_1237.py | 23 --- .../0005_remove_vpnnetwork_orders.py | 17 -- .../migrations/0006_auto_20200928_1858.py | 25 --- uncloud_net/migrations/0007_uncloudnetwork.py | 22 --- .../migrations/0008_auto_20201011_1924.py | 18 -- .../0009_uncloudnetwork_description.py | 19 -- .../migrations/0010_auto_20201011_2009.py | 21 --- .../migrations/0011_auto_20201025_1931.py | 29 --- uncloud_net/models.py | 169 ++---------------- uncloud_net/selectors.py | 23 ++- uncloud_net/serializers.py | 134 +++++++------- uncloud_net/services.py | 118 ++++++++++++ .../uncloud_net/vpnnetwork_form.html | 14 +- uncloud_net/views.py | 58 +++--- uncloud_pay/migrations/0001_initial.py | 100 +++++++---- .../migrations/0002_auto_20200801_2208.py | 18 -- .../migrations/0003_auto_20200801_2332.py | 33 ---- .../0004_remove_order_one_time_price.py | 17 -- .../migrations/0005_auto_20200808_1954.py | 18 -- .../0006_remove_billrecord_quantity.py | 17 -- .../0007_remove_bill_bill_records.py | 17 -- .../migrations/0008_delete_orderrecord.py | 16 -- .../migrations/0009_auto_20200808_2113.py | 22 --- .../migrations/0010_auto_20200809_0856.py | 65 ------- .../migrations/0011_bill_billing_address.py | 19 -- .../migrations/0012_auto_20200809_1026.py | 19 -- .../migrations/0013_auto_20200809_1237.py | 40 ----- .../migrations/0014_auto_20200825_1915.py | 33 ---- .../migrations/0015_auto_20200928_1844.py | 25 --- .../migrations/0016_auto_20200928_1858.py | 65 ------- uncloud_pay/migrations/0017_order_config.py | 19 -- uncloud_pay/migrations/0018_order_product.py | 20 --- .../migrations/0019_remove_product_owner.py | 17 -- .../migrations/0020_auto_20200928_1915.py | 29 --- .../migrations/0021_auto_20200928_1932.py | 23 --- .../migrations/0022_auto_20200928_1932.py | 18 -- .../migrations/0023_auto_20200928_1944.py | 18 -- .../migrations/0024_auto_20200928_1945.py | 24 --- .../0025_billrecord_is_recurring_record.py | 19 -- .../migrations/0026_order_should_be_billed.py | 18 -- .../migrations/0027_auto_20201006_1319.py | 41 ----- .../migrations/0028_auto_20201006_1529.py | 36 ---- .../migrations/0029_auto_20201006_1540.py | 18 -- .../migrations/0030_auto_20201006_1640.py | 21 --- .../migrations/0031_auto_20201006_1655.py | 17 -- .../migrations/0032_uncloudprovider.py | 23 --- .../migrations/0033_auto_20201011_2003.py | 20 --- .../migrations/0034_auto_20201011_2031.py | 37 ---- .../migrations/0035_auto_20201012_1728.py | 48 ----- uncloud_service/migrations/0001_initial.py | 52 ------ .../migrations/0002_auto_20200801_2332.py | 25 --- .../migrations/0003_auto_20200808_1953.py | 23 --- .../migrations/0004_auto_20200809_1237.py | 32 ---- .../migrations/0005_auto_20200928_1844.py | 21 --- .../migrations/0006_auto_20200928_1858.py | 37 ---- uncloud_service/migrations/__init__.py | 0 uncloud_vm/migrations/0001_initial.py | 52 ++---- .../migrations/0002_auto_20200801_2332.py | 30 ---- .../migrations/0003_auto_20200808_1953.py | 43 ----- .../migrations/0004_auto_20200809_1237.py | 41 ----- .../migrations/0005_auto_20200928_1844.py | 25 --- .../migrations/0006_auto_20200928_1858.py | 49 ----- 82 files changed, 403 insertions(+), 2180 deletions(-) delete mode 100644 opennebula/migrations/0002_auto_20200801_2332.py delete mode 100644 opennebula/migrations/0003_auto_20200808_1953.py delete mode 100644 opennebula/migrations/0004_auto_20200809_1237.py delete mode 100644 opennebula/migrations/0005_remove_vm_orders.py delete mode 100644 opennebula/migrations/0006_auto_20200928_1858.py delete mode 100644 uncloud/migrations/0002_auto_20201011_2001.py delete mode 100644 uncloud/migrations/0003_auto_20201011_2009.py delete mode 100644 uncloud/migrations/0004_auto_20201011_2031.py delete mode 100644 uncloud/migrations/0005_uncloudprovider_coupon_network.py delete mode 100644 uncloud/migrations/0006_auto_20201025_1931.py delete mode 100644 uncloud_auth/migrations/0002_auto_20200808_1953.py create mode 100644 uncloud_net/forms.py delete mode 100644 uncloud_net/migrations/0002_auto_20200801_2332.py delete mode 100644 uncloud_net/migrations/0003_auto_20200808_1953.py delete mode 100644 uncloud_net/migrations/0004_auto_20200809_1237.py delete mode 100644 uncloud_net/migrations/0005_remove_vpnnetwork_orders.py delete mode 100644 uncloud_net/migrations/0006_auto_20200928_1858.py delete mode 100644 uncloud_net/migrations/0007_uncloudnetwork.py delete mode 100644 uncloud_net/migrations/0008_auto_20201011_1924.py delete mode 100644 uncloud_net/migrations/0009_uncloudnetwork_description.py delete mode 100644 uncloud_net/migrations/0010_auto_20201011_2009.py delete mode 100644 uncloud_net/migrations/0011_auto_20201025_1931.py create mode 100644 uncloud_net/services.py delete mode 100644 uncloud_pay/migrations/0002_auto_20200801_2208.py delete mode 100644 uncloud_pay/migrations/0003_auto_20200801_2332.py delete mode 100644 uncloud_pay/migrations/0004_remove_order_one_time_price.py delete mode 100644 uncloud_pay/migrations/0005_auto_20200808_1954.py delete mode 100644 uncloud_pay/migrations/0006_remove_billrecord_quantity.py delete mode 100644 uncloud_pay/migrations/0007_remove_bill_bill_records.py delete mode 100644 uncloud_pay/migrations/0008_delete_orderrecord.py delete mode 100644 uncloud_pay/migrations/0009_auto_20200808_2113.py delete mode 100644 uncloud_pay/migrations/0010_auto_20200809_0856.py delete mode 100644 uncloud_pay/migrations/0011_bill_billing_address.py delete mode 100644 uncloud_pay/migrations/0012_auto_20200809_1026.py delete mode 100644 uncloud_pay/migrations/0013_auto_20200809_1237.py delete mode 100644 uncloud_pay/migrations/0014_auto_20200825_1915.py delete mode 100644 uncloud_pay/migrations/0015_auto_20200928_1844.py delete mode 100644 uncloud_pay/migrations/0016_auto_20200928_1858.py delete mode 100644 uncloud_pay/migrations/0017_order_config.py delete mode 100644 uncloud_pay/migrations/0018_order_product.py delete mode 100644 uncloud_pay/migrations/0019_remove_product_owner.py delete mode 100644 uncloud_pay/migrations/0020_auto_20200928_1915.py delete mode 100644 uncloud_pay/migrations/0021_auto_20200928_1932.py delete mode 100644 uncloud_pay/migrations/0022_auto_20200928_1932.py delete mode 100644 uncloud_pay/migrations/0023_auto_20200928_1944.py delete mode 100644 uncloud_pay/migrations/0024_auto_20200928_1945.py delete mode 100644 uncloud_pay/migrations/0025_billrecord_is_recurring_record.py delete mode 100644 uncloud_pay/migrations/0026_order_should_be_billed.py delete mode 100644 uncloud_pay/migrations/0027_auto_20201006_1319.py delete mode 100644 uncloud_pay/migrations/0028_auto_20201006_1529.py delete mode 100644 uncloud_pay/migrations/0029_auto_20201006_1540.py delete mode 100644 uncloud_pay/migrations/0030_auto_20201006_1640.py delete mode 100644 uncloud_pay/migrations/0031_auto_20201006_1655.py delete mode 100644 uncloud_pay/migrations/0032_uncloudprovider.py delete mode 100644 uncloud_pay/migrations/0033_auto_20201011_2003.py delete mode 100644 uncloud_pay/migrations/0034_auto_20201011_2031.py delete mode 100644 uncloud_pay/migrations/0035_auto_20201012_1728.py delete mode 100644 uncloud_service/migrations/0001_initial.py delete mode 100644 uncloud_service/migrations/0002_auto_20200801_2332.py delete mode 100644 uncloud_service/migrations/0003_auto_20200808_1953.py delete mode 100644 uncloud_service/migrations/0004_auto_20200809_1237.py delete mode 100644 uncloud_service/migrations/0005_auto_20200928_1844.py delete mode 100644 uncloud_service/migrations/0006_auto_20200928_1858.py delete mode 100644 uncloud_service/migrations/__init__.py delete mode 100644 uncloud_vm/migrations/0002_auto_20200801_2332.py delete mode 100644 uncloud_vm/migrations/0003_auto_20200808_1953.py delete mode 100644 uncloud_vm/migrations/0004_auto_20200809_1237.py delete mode 100644 uncloud_vm/migrations/0005_auto_20200928_1844.py delete mode 100644 uncloud_vm/migrations/0006_auto_20200928_1858.py diff --git a/opennebula/migrations/0001_initial.py b/opennebula/migrations/0001_initial.py index 0852436..9a135c6 100644 --- a/opennebula/migrations/0001_initial.py +++ b/opennebula/migrations/0001_initial.py @@ -1,9 +1,6 @@ -# Generated by Django 3.0.6 on 2020-08-01 16:38 +# Generated by Django 3.1 on 2020-12-13 10:38 -from django.conf import settings -import django.contrib.postgres.fields.jsonb from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): @@ -11,23 +8,14 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_pay', '0001_initial'), ] operations = [ migrations.CreateModel( name='VM', fields=[ - ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), ('vmid', models.IntegerField(primary_key=True, serialize=False)), - ('data', django.contrib.postgres.fields.jsonb.JSONField()), - ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('data', models.JSONField()), ], - options={ - 'abstract': False, - }, ), ] diff --git a/opennebula/migrations/0002_auto_20200801_2332.py b/opennebula/migrations/0002_auto_20200801_2332.py deleted file mode 100644 index 1aa6d34..0000000 --- a/opennebula/migrations/0002_auto_20200801_2332.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.0.8 on 2020-08-01 23:32 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0003_auto_20200801_2332'), - ('opennebula', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='vm', - name='order', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), - ), - ] diff --git a/opennebula/migrations/0003_auto_20200808_1953.py b/opennebula/migrations/0003_auto_20200808_1953.py deleted file mode 100644 index 218b9a7..0000000 --- a/opennebula/migrations/0003_auto_20200808_1953.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.1 on 2020-08-08 19:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('opennebula', '0002_auto_20200801_2332'), - ] - - operations = [ - migrations.AlterField( - model_name='vm', - name='data', - field=models.JSONField(), - ), - migrations.AlterField( - model_name='vm', - name='extra_data', - field=models.JSONField(blank=True, editable=False, null=True), - ), - ] diff --git a/opennebula/migrations/0004_auto_20200809_1237.py b/opennebula/migrations/0004_auto_20200809_1237.py deleted file mode 100644 index ac4ac86..0000000 --- a/opennebula/migrations/0004_auto_20200809_1237.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.1 on 2020-08-09 12:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0013_auto_20200809_1237'), - ('opennebula', '0003_auto_20200808_1953'), - ] - - operations = [ - migrations.RemoveField( - model_name='vm', - name='order', - ), - migrations.AddField( - model_name='vm', - name='orders', - field=models.ManyToManyField(to='uncloud_pay.Order'), - ), - ] diff --git a/opennebula/migrations/0005_remove_vm_orders.py b/opennebula/migrations/0005_remove_vm_orders.py deleted file mode 100644 index 8426aec..0000000 --- a/opennebula/migrations/0005_remove_vm_orders.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 18:44 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('opennebula', '0004_auto_20200809_1237'), - ] - - operations = [ - migrations.RemoveField( - model_name='vm', - name='orders', - ), - ] diff --git a/opennebula/migrations/0006_auto_20200928_1858.py b/opennebula/migrations/0006_auto_20200928_1858.py deleted file mode 100644 index 49da56f..0000000 --- a/opennebula/migrations/0006_auto_20200928_1858.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 18:58 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('opennebula', '0005_remove_vm_orders'), - ] - - operations = [ - migrations.RemoveField( - model_name='vm', - name='extra_data', - ), - migrations.RemoveField( - model_name='vm', - name='owner', - ), - migrations.RemoveField( - model_name='vm', - name='status', - ), - ] diff --git a/uncloud/migrations/0001_initial.py b/uncloud/migrations/0001_initial.py index 8753d29..10d1144 100644 --- a/uncloud/migrations/0001_initial.py +++ b/uncloud/migrations/0001_initial.py @@ -1,6 +1,9 @@ -# Generated by Django 3.1 on 2020-10-11 19:59 +# Generated by Django 3.1 on 2020-12-13 10:38 +import django.core.validators from django.db import migrations, models +import django.db.models.deletion +import uncloud.models class Migration(migrations.Migration): @@ -11,14 +14,33 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='UncloudNetwork', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('network_address', models.GenericIPAddressField(unique=True)), + ('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), + ('description', models.CharField(max_length=256)), + ], + ), migrations.CreateModel( name='UncloudProvider', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=256)), + ('organization', models.CharField(blank=True, max_length=256, null=True)), + ('street', models.CharField(max_length=256)), + ('city', models.CharField(max_length=256)), + ('postal_code', models.CharField(max_length=64)), + ('country', uncloud.models.CountryField(blank=True, choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2)), ('starting_date', models.DateField()), - ('ending_date', models.DateField(blank=True)), - ('name', models.CharField(max_length=256)), - ('address', models.TextField()), + ('ending_date', models.DateField(blank=True, null=True)), + ('billing_network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderbill', to='uncloud.uncloudnetwork')), + ('coupon_network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudprovidercoupon', to='uncloud.uncloudnetwork')), + ('referral_network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderreferral', to='uncloud.uncloudnetwork')), ], + options={ + 'abstract': False, + }, ), ] diff --git a/uncloud/migrations/0002_auto_20201011_2001.py b/uncloud/migrations/0002_auto_20201011_2001.py deleted file mode 100644 index 16b3f60..0000000 --- a/uncloud/migrations/0002_auto_20201011_2001.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.1 on 2020-10-11 20:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='uncloudprovider', - name='ending_date', - field=models.DateField(blank=True, null=True), - ), - ] diff --git a/uncloud/migrations/0003_auto_20201011_2009.py b/uncloud/migrations/0003_auto_20201011_2009.py deleted file mode 100644 index 9aee763..0000000 --- a/uncloud/migrations/0003_auto_20201011_2009.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 3.1 on 2020-10-11 20:09 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_net', '0010_auto_20201011_2009'), - ('uncloud', '0002_auto_20201011_2001'), - ] - - operations = [ - migrations.AddField( - model_name='uncloudprovider', - name='billing_network', - field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderbill', to='uncloud_net.uncloudnetwork'), - preserve_default=False, - ), - migrations.AddField( - model_name='uncloudprovider', - name='referral_network', - field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderreferral', to='uncloud_net.uncloudnetwork'), - preserve_default=False, - ), - ] diff --git a/uncloud/migrations/0004_auto_20201011_2031.py b/uncloud/migrations/0004_auto_20201011_2031.py deleted file mode 100644 index 3b53b7f..0000000 --- a/uncloud/migrations/0004_auto_20201011_2031.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 3.1 on 2020-10-11 20:31 - -from django.db import migrations, models -import uncloud.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud', '0003_auto_20201011_2009'), - ] - - operations = [ - migrations.RenameField( - model_name='uncloudprovider', - old_name='name', - new_name='full_name', - ), - migrations.RemoveField( - model_name='uncloudprovider', - name='address', - ), - migrations.AddField( - model_name='uncloudprovider', - name='city', - field=models.CharField(default='', max_length=256), - preserve_default=False, - ), - migrations.AddField( - model_name='uncloudprovider', - name='country', - field=uncloud.models.CountryField(blank=True, choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2), - ), - migrations.AddField( - model_name='uncloudprovider', - name='organization', - field=models.CharField(blank=True, max_length=256, null=True), - ), - migrations.AddField( - model_name='uncloudprovider', - name='postal_code', - field=models.CharField(default='', max_length=64), - preserve_default=False, - ), - migrations.AddField( - model_name='uncloudprovider', - name='street', - field=models.CharField(default='', max_length=256), - preserve_default=False, - ), - ] diff --git a/uncloud/migrations/0005_uncloudprovider_coupon_network.py b/uncloud/migrations/0005_uncloudprovider_coupon_network.py deleted file mode 100644 index b74b878..0000000 --- a/uncloud/migrations/0005_uncloudprovider_coupon_network.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.1 on 2020-10-12 17:32 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_net', '0010_auto_20201011_2009'), - ('uncloud', '0004_auto_20201011_2031'), - ] - - operations = [ - migrations.AddField( - model_name='uncloudprovider', - name='coupon_network', - field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='uncloudprovidercoupon', to='uncloud_net.uncloudnetwork'), - preserve_default=False, - ), - ] diff --git a/uncloud/migrations/0006_auto_20201025_1931.py b/uncloud/migrations/0006_auto_20201025_1931.py deleted file mode 100644 index d1162ef..0000000 --- a/uncloud/migrations/0006_auto_20201025_1931.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 3.1 on 2020-10-25 19:31 - -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud', '0005_uncloudprovider_coupon_network'), - ] - - operations = [ - migrations.CreateModel( - name='UncloudNetwork', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('network_address', models.GenericIPAddressField(unique=True)), - ('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), - ('description', models.CharField(max_length=256)), - ], - ), - migrations.AlterField( - model_name='uncloudprovider', - name='billing_network', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderbill', to='uncloud.uncloudnetwork'), - ), - migrations.AlterField( - model_name='uncloudprovider', - name='coupon_network', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudprovidercoupon', to='uncloud.uncloudnetwork'), - ), - migrations.AlterField( - model_name='uncloudprovider', - name='referral_network', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderreferral', to='uncloud.uncloudnetwork'), - ), - ] -2 diff --git a/uncloud/urls.py b/uncloud/urls.py index 3845d43..bc24d81 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -42,8 +42,8 @@ router.register(r'v1/service/generic', serviceviews.GenericServiceProductViewSet # Net -router.register(r'v1/net/vpn', netviews.VPNNetworkViewSet, basename='vpnnetwork') -router.register(r'v1/admin/vpnreservation', netviews.VPNNetworkReservationViewSet, basename='vpnnetreservation') +#router.register(r'v1/net/vpn', netviews.VPNNetworkViewSet, basename='vpnnetwork') +#router.register(r'v1/admin/vpnreservation', netviews.VPNNetworkReservationViewSet, basename='vpnnetreservation') # Pay @@ -59,7 +59,7 @@ router.register(r'v1/admin/payment', payviews.AdminPaymentViewSet, basename='adm router.register(r'v1/admin/order', payviews.AdminOrderViewSet, basename='admin/order') router.register(r'v1/admin/vmhost', vmviews.VMHostViewSet) router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet) -router.register(r'v1/admin/vpnpool', netviews.VPNPoolViewSet) +#router.register(r'v1/admin/vpnpool', netviews.VPNPoolViewSet) #router.register(r'v1/admin/opennebula', oneviews.VMViewSet, basename='opennebula') # User/Account @@ -77,7 +77,7 @@ urlpatterns = [ description="uncloud API", version="1.0.0" ), name='openapi-schema'), - path('vpn/create/', netviews.VPNCreateView.as_view(), name="vpncreate"), + path('vpn/create/', netviews.WireGuardVPNCreateView.as_view(), name="vpncreate"), path('login/', authviews.LoginView.as_view(), name="login"), path('logout/', authviews.LogoutView.as_view(), name="logout"), diff --git a/uncloud_auth/migrations/0001_initial.py b/uncloud_auth/migrations/0001_initial.py index ebb14ae..b263dc6 100644 --- a/uncloud_auth/migrations/0001_initial.py +++ b/uncloud_auth/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.6 on 2020-08-01 16:38 +# Generated by Django 3.1 on 2020-12-13 10:38 import django.contrib.auth.models import django.contrib.auth.validators @@ -12,7 +12,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('auth', '0011_update_proxy_permissions'), + ('auth', '0012_alter_user_first_name_max_length'), ] operations = [ @@ -24,7 +24,7 @@ class Migration(migrations.Migration): ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), diff --git a/uncloud_auth/migrations/0002_auto_20200808_1953.py b/uncloud_auth/migrations/0002_auto_20200808_1953.py deleted file mode 100644 index 234af95..0000000 --- a/uncloud_auth/migrations/0002_auto_20200808_1953.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.1 on 2020-08-08 19:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_auth', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='first_name', - field=models.CharField(blank=True, max_length=150, verbose_name='first name'), - ), - ] diff --git a/uncloud_net/admin.py b/uncloud_net/admin.py index 5dad27b..ca6aaa1 100644 --- a/uncloud_net/admin.py +++ b/uncloud_net/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin -from .models import ReverseDNSEntry + +from .models import * -for m in [ ReverseDNSEntry ]: +for m in [ ReverseDNSEntry, WireGuardVPNPool, WireGuardVPN ]: admin.site.register(m) diff --git a/uncloud_net/forms.py b/uncloud_net/forms.py new file mode 100644 index 0000000..6125143 --- /dev/null +++ b/uncloud_net/forms.py @@ -0,0 +1,12 @@ +from django import forms + +from .models import * +from .selectors import * + +class WireGuardVPNForm(forms.ModelForm): + class Meta: + model = WireGuardVPN + + network_size = forms.ChoiceField(choices=allowed_vpn_network_reservation_size) + + fields = [ "wireguard_public_key" ] diff --git a/uncloud_net/migrations/0001_initial.py b/uncloud_net/migrations/0001_initial.py index 7e018b2..36ba522 100644 --- a/uncloud_net/migrations/0001_initial.py +++ b/uncloud_net/migrations/0001_initial.py @@ -1,11 +1,9 @@ -# Generated by Django 3.0.6 on 2020-08-01 16:38 +# Generated by Django 3.1 on 2020-12-13 10:38 from django.conf import settings -import django.contrib.postgres.fields.jsonb import django.core.validators from django.db import migrations, models import django.db.models.deletion -import uuid class Migration(migrations.Migration): @@ -14,7 +12,6 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_pay', '__first__'), ] operations = [ @@ -25,45 +22,31 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='VPNPool', - fields=[ - ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('network', models.GenericIPAddressField(unique=True)), - ('network_size', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), - ('subnetwork_size', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), - ('vpn_hostname', models.CharField(max_length=256)), - ('wireguard_private_key', models.CharField(max_length=48)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='VPNNetworkReservation', - fields=[ - ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), - ('address', models.GenericIPAddressField(primary_key=True, serialize=False)), - ('status', models.CharField(choices=[('used', 'used'), ('free', 'free')], default='used', max_length=256)), - ('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNPool')), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='VPNNetwork', + name='WireGuardVPNPool', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), - ('wireguard_public_key', models.CharField(max_length=48)), - ('network', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNNetworkReservation')), - ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('network', models.GenericIPAddressField(unique=True)), + ('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), + ('subnetwork_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), + ('vpn_server_hostname', models.CharField(max_length=256)), + ('wireguard_private_key', models.CharField(max_length=48)), + ], + ), + migrations.CreateModel( + name='WireGuardVPN', + fields=[ + ('address', models.GenericIPAddressField(primary_key=True, serialize=False)), + ('wireguard_public_key', models.CharField(max_length=48)), + ('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.wireguardvpnpool')), + ], + ), + migrations.CreateModel( + name='ReverseDNSEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip_address', models.GenericIPAddressField(unique=True)), + ('name', models.CharField(max_length=253)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], - options={ - 'abstract': False, - }, ), ] diff --git a/uncloud_net/migrations/0002_auto_20200801_2332.py b/uncloud_net/migrations/0002_auto_20200801_2332.py deleted file mode 100644 index 81d12ab..0000000 --- a/uncloud_net/migrations/0002_auto_20200801_2332.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.0.8 on 2020-08-01 23:32 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0003_auto_20200801_2332'), - ('uncloud_net', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='vpnnetwork', - name='order', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), - ), - ] diff --git a/uncloud_net/migrations/0003_auto_20200808_1953.py b/uncloud_net/migrations/0003_auto_20200808_1953.py deleted file mode 100644 index 211a8a4..0000000 --- a/uncloud_net/migrations/0003_auto_20200808_1953.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.1 on 2020-08-08 19:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_net', '0002_auto_20200801_2332'), - ] - - operations = [ - migrations.AlterField( - model_name='vpnnetwork', - name='extra_data', - field=models.JSONField(blank=True, editable=False, null=True), - ), - migrations.AlterField( - model_name='vpnnetworkreservation', - name='extra_data', - field=models.JSONField(blank=True, editable=False, null=True), - ), - migrations.AlterField( - model_name='vpnpool', - name='extra_data', - field=models.JSONField(blank=True, editable=False, null=True), - ), - ] diff --git a/uncloud_net/migrations/0004_auto_20200809_1237.py b/uncloud_net/migrations/0004_auto_20200809_1237.py deleted file mode 100644 index 7d500c2..0000000 --- a/uncloud_net/migrations/0004_auto_20200809_1237.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.1 on 2020-08-09 12:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0013_auto_20200809_1237'), - ('uncloud_net', '0003_auto_20200808_1953'), - ] - - operations = [ - migrations.RemoveField( - model_name='vpnnetwork', - name='order', - ), - migrations.AddField( - model_name='vpnnetwork', - name='orders', - field=models.ManyToManyField(to='uncloud_pay.Order'), - ), - ] diff --git a/uncloud_net/migrations/0005_remove_vpnnetwork_orders.py b/uncloud_net/migrations/0005_remove_vpnnetwork_orders.py deleted file mode 100644 index f7b607a..0000000 --- a/uncloud_net/migrations/0005_remove_vpnnetwork_orders.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 18:44 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_net', '0004_auto_20200809_1237'), - ] - - operations = [ - migrations.RemoveField( - model_name='vpnnetwork', - name='orders', - ), - ] diff --git a/uncloud_net/migrations/0006_auto_20200928_1858.py b/uncloud_net/migrations/0006_auto_20200928_1858.py deleted file mode 100644 index b1a04a6..0000000 --- a/uncloud_net/migrations/0006_auto_20200928_1858.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 18:58 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_net', '0005_remove_vpnnetwork_orders'), - ] - - operations = [ - migrations.RemoveField( - model_name='vpnnetwork', - name='extra_data', - ), - migrations.RemoveField( - model_name='vpnnetwork', - name='owner', - ), - migrations.RemoveField( - model_name='vpnnetwork', - name='status', - ), - ] diff --git a/uncloud_net/migrations/0007_uncloudnetwork.py b/uncloud_net/migrations/0007_uncloudnetwork.py deleted file mode 100644 index aea05bb..0000000 --- a/uncloud_net/migrations/0007_uncloudnetwork.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.1 on 2020-10-11 19:20 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_net', '0006_auto_20200928_1858'), - ] - - operations = [ - migrations.CreateModel( - name='UncloudNetwork', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('network_address', models.GenericIPAddressField()), - ('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), - ], - ), - ] diff --git a/uncloud_net/migrations/0008_auto_20201011_1924.py b/uncloud_net/migrations/0008_auto_20201011_1924.py deleted file mode 100644 index 29ad3e6..0000000 --- a/uncloud_net/migrations/0008_auto_20201011_1924.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.1 on 2020-10-11 19:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_net', '0007_uncloudnetwork'), - ] - - operations = [ - migrations.AlterField( - model_name='uncloudnetwork', - name='network_address', - field=models.GenericIPAddressField(unique=True), - ), - ] diff --git a/uncloud_net/migrations/0009_uncloudnetwork_description.py b/uncloud_net/migrations/0009_uncloudnetwork_description.py deleted file mode 100644 index 46292fa..0000000 --- a/uncloud_net/migrations/0009_uncloudnetwork_description.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.1 on 2020-10-11 19:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_net', '0008_auto_20201011_1924'), - ] - - operations = [ - migrations.AddField( - model_name='uncloudnetwork', - name='description', - field=models.CharField(default='', max_length=256), - preserve_default=False, - ), - ] diff --git a/uncloud_net/migrations/0010_auto_20201011_2009.py b/uncloud_net/migrations/0010_auto_20201011_2009.py deleted file mode 100644 index b713a4b..0000000 --- a/uncloud_net/migrations/0010_auto_20201011_2009.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.1 on 2020-10-11 20:09 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_net', '0009_uncloudnetwork_description'), - ] - - operations = [ - migrations.RemoveField( - model_name='vpnnetworkreservation', - name='extra_data', - ), - migrations.RemoveField( - model_name='vpnpool', - name='extra_data', - ), - ] diff --git a/uncloud_net/migrations/0011_auto_20201025_1931.py b/uncloud_net/migrations/0011_auto_20201025_1931.py deleted file mode 100644 index c4135d9..0000000 --- a/uncloud_net/migrations/0011_auto_20201025_1931.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 3.1 on 2020-10-25 19:31 - -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), - ('uncloud', '0006_auto_20201025_1931'), - ('uncloud_net', '0010_auto_20201011_2009'), - ] - - operations = [ - migrations.CreateModel( - name='ReverseDNSEntry', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('ip_address', models.GenericIPAddressField(unique=True)), - ('name', models.CharField(max_length=253)), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.DeleteModel( - name='UncloudNetwork', - ), - ] diff --git a/uncloud_net/models.py b/uncloud_net/models.py index 3a7fc6a..819d3b8 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -8,180 +8,39 @@ from django.core.exceptions import FieldError, ValidationError from uncloud_pay.models import Order -class MACAdress(models.Model): - default_prefix = 0x420000000000 - -class VPNPool(models.Model): +class WireGuardVPNPool(models.Model): """ Network address pools from which VPNs can be created """ - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - network = models.GenericIPAddressField(unique=True) - network_size = models.IntegerField(validators=[MinValueValidator(0), + network_mask = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(128)]) - subnetwork_size = models.IntegerField(validators=[ - MinValueValidator(0), - MaxValueValidator(128) - ]) - - vpn_hostname = models.CharField(max_length=256) + subnetwork_mask = models.IntegerField(validators=[ + MinValueValidator(0), + MaxValueValidator(128) + ]) + vpn_server_hostname = models.CharField(max_length=256) wireguard_private_key = models.CharField(max_length=48) - @property - def num_maximum_networks(self): - """ - sample: - network_size = 40 - subnetwork_size = 48 - maximum_networks = 2^(48-40) - - 2nd sample: - network_size = 8 - subnetwork_size = 24 - maximum_networks = 2^(24-8) - """ - - return 2**(self.subnetwork_size - self.network_size) - - @property - def used_networks(self): - return self.vpnnetworkreservation_set.filter(vpnpool=self, status='used') - - @property - def free_networks(self): - return self.vpnnetworkreservation_set.filter(vpnpool=self, status='free') - - @property - def num_used_networks(self): - return len(self.used_networks) - - @property - def num_free_networks(self): - return self.num_maximum_networks - self.num_used_networks + len(self.free_networks) - - @property - def next_free_network(self): - if self.num_free_networks == 0: - # FIXME: use right exception - raise Exception("No free networks") - - if len(self.free_networks) > 0: - return self.free_networks[0].address - - if len(self.used_networks) > 0: - """ - sample: - - pool = 2a0a:e5c1:200::/40 - last_used = 2a0a:e5c1:204::/48 - - next: - """ - - last_net = ipaddress.ip_network(self.used_networks.last().address) - last_net_ip = last_net[0] - - if last_net_ip.version == 6: - offset_to_next = 2**(128 - self.subnetwork_size) - elif last_net_ip.version == 4: - offset_to_next = 2**(32 - self.subnetwork_size) - - next_net_ip = last_net_ip + offset_to_next - - return str(next_net_ip) - else: - # first network to be created - return self.network - - @property - def wireguard_config_filename(self): - return '/etc/wireguard/{}.conf'.format(self.network) - - @property - def wireguard_config(self): - wireguard_config = [ - """ -[Interface] -ListenPort = 51820 -PrivateKey = {privatekey} -""".format(privatekey=self.wireguard_private_key) ] - - peers = [] - - for reservation in self.vpnnetworkreservation_set.filter(status='used'): - public_key = reservation.vpnnetwork_set.first().wireguard_public_key - peer_network = "{}/{}".format(reservation.address, self.subnetwork_size) - owner = reservation.vpnnetwork_set.first().owner - - peers.append(""" -# Owner: {owner} -[Peer] -PublicKey = {public_key} -AllowedIPs = {peer_network} -""".format( - owner=owner, - public_key=public_key, - peer_network=peer_network)) - - wireguard_config.extend(peers) - - return "\n".join(wireguard_config) - - - def configure_wireguard_vpnserver(self): - """ - This method is designed to run as a celery task and should - not be called directly from the web - """ - - # subprocess, ssh - - pass - - -class VPNNetworkReservation(models.Model): +class WireGuardVPN(models.Model): """ - This class tracks the used VPN networks. It will be deleted, when the product is cancelled. - """ - vpnpool = models.ForeignKey(VPNPool, + Created VPNNetworks + """ + vpnpool = models.ForeignKey(WireGuardVPNPool, on_delete=models.CASCADE) address = models.GenericIPAddressField(primary_key=True) - status = models.CharField(max_length=256, - default='used', - choices = ( - ('used', 'used'), - ('free', 'free') - ) - ) - - -class VPNNetwork(models.Model): - """ - A selected network. Used for tracking reservations / used networks - """ - network = models.ForeignKey(VPNNetworkReservation, - on_delete=models.CASCADE, - editable=False) - wireguard_public_key = models.CharField(max_length=48) - @property - def recurring_price(self): - return 120 +################################################################################ - def delete(self, *args, **kwargs): - self.network.status = 'free' - self.network.save() - super().save(*args, **kwargs) - print("deleted {}".format(self)) - +class MACAdress(models.Model): + default_prefix = 0x420000000000 class ReverseDNSEntry(models.Model): diff --git a/uncloud_net/selectors.py b/uncloud_net/selectors.py index 024d44f..4888f60 100644 --- a/uncloud_net/selectors.py +++ b/uncloud_net/selectors.py @@ -1,7 +1,9 @@ -from django.db.models import Count +from django.db import transaction +from django.db.models import Count, F + + from .models import * -@transaction.atomic def get_suitable_pool(subnetwork_size): """ Find suitable pools for a certain network size. @@ -21,3 +23,20 @@ def get_suitable_pool(subnetwork_size): max_reservations=2**(F('subnetwork_size')-F('network_size'))).filter( num_reservations__lt=F('max_reservations'), subnetwork_size=subnetwork_size) + + +def allowed_vpn_network_reservation_size(): + """ + Find all possible sizes of subnetworks that are available. + + Select all pools with free networks. + + Get their subnetwork sizes, reduce to a set + + """ + + pools = VPNPool.objects.annotate(num_reservations=Count('vpnnetworkreservation'), + max_reservations=2**(F('subnetwork_size')-F('network_size'))).filter( + num_reservations__lt=F('max_reservations')) + + return set([ pool.subnetwork_size for pool in pools ]) diff --git a/uncloud_net/serializers.py b/uncloud_net/serializers.py index dc4866e..e2555d5 100644 --- a/uncloud_net/serializers.py +++ b/uncloud_net/serializers.py @@ -6,95 +6,95 @@ from rest_framework import serializers from .models import * -class VPNPoolSerializer(serializers.ModelSerializer): - class Meta: - model = VPNPool - fields = '__all__' +# class WireGuardVPNPoolSerializer(serializers.ModelSerializer): +# class Meta: +# model = WireGuardVPNPool +# fields = '__all__' -class VPNNetworkReservationSerializer(serializers.ModelSerializer): - class Meta: - model = VPNNetworkReservation - fields = '__all__' +# class WireGuardVPNSerializer(serializers.ModelSerializer): +# class Meta: +# model = VPNNetworkReservation +# fields = '__all__' -class VPNNetworkSerializer(serializers.ModelSerializer): - class Meta: - model = VPNNetwork - fields = '__all__' +# class VPNNetworkSerializer(serializers.ModelSerializer): +# class Meta: +# model = VPNNetwork +# fields = '__all__' - # This is required for finding the VPN pool, but does not - # exist in the model - network_size = serializers.IntegerField(min_value=0, - max_value=128, - write_only=True) +# # This is required for finding the VPN pool, but does not +# # exist in the model +# network_size = serializers.IntegerField(min_value=0, +# max_value=128, +# write_only=True) - def validate_wireguard_public_key(self, value): - msg = _("Supplied key is not a valid wireguard public key") +# def validate_wireguard_public_key(self, value): +# msg = _("Supplied key is not a valid wireguard public key") - """ FIXME: verify that this does not create broken wireguard config files, - i.e. contains \n or similar! - We might even need to be more strict to not break wireguard... - """ +# """ FIXME: verify that this does not create broken wireguard config files, +# i.e. contains \n or similar! +# We might even need to be more strict to not break wireguard... +# """ - try: - base64.standard_b64decode(value) - except Exception as e: - raise serializers.ValidationError(msg) +# try: +# base64.standard_b64decode(value) +# except Exception as e: +# raise serializers.ValidationError(msg) - if '\n' in value: - raise serializers.ValidationError(msg) +# if '\n' in value: +# raise serializers.ValidationError(msg) - return value +# return value - def validate(self, data): +# def validate(self, data): - # FIXME: filter for status = active or similar - all_pools = VPNPool.objects.all() - sizes = [ p.subnetwork_size for p in all_pools ] +# # FIXME: filter for status = active or similar +# all_pools = VPNPool.objects.all() +# sizes = [ p.subnetwork_size for p in all_pools ] - pools = VPNPool.objects.filter(subnetwork_size=data['network_size']) +# pools = VPNPool.objects.filter(subnetwork_size=data['network_size']) - if len(pools) == 0: - msg = _("No pool available for networks with size = {}. Available are: {}".format(data['network_size'], sizes)) - raise serializers.ValidationError(msg) +# if len(pools) == 0: +# msg = _("No pool available for networks with size = {}. Available are: {}".format(data['network_size'], sizes)) +# raise serializers.ValidationError(msg) - return data +# return data - def create(self, validated_data): - """ - Creating a new vpnnetwork - there are a couple of race conditions, - especially when run in parallel. +# def create(self, validated_data): +# """ +# Creating a new vpnnetwork - there are a couple of race conditions, +# especially when run in parallel. - What we should be doing: +# What we should be doing: - - create a reservation race free - - map the reservation to a network (?) - """ +# - create a reservation race free +# - map the reservation to a network (?) +# """ - pools = VPNPool.objects.filter(subnetwork_size=validated_data['network_size']) +# pools = VPNPool.objects.filter(subnetwork_size=validated_data['network_size']) - vpn_network = None +# vpn_network = None - for pool in pools: - if pool.num_free_networks > 0: - next_address = pool.next_free_network +# for pool in pools: +# if pool.num_free_networks > 0: +# next_address = pool.next_free_network - reservation, created = VPNNetworkReservation.objects.update_or_create( - vpnpool=pool, address=next_address, - defaults = { - 'status': 'used' - }) +# reservation, created = VPNNetworkReservation.objects.update_or_create( +# vpnpool=pool, address=next_address, +# defaults = { +# 'status': 'used' +# }) - vpn_network = VPNNetwork.objects.create( - owner=self.context['request'].user, - network=reservation, - wireguard_public_key=validated_data['wireguard_public_key'] - ) +# vpn_network = VPNNetwork.objects.create( +# owner=self.context['request'].user, +# network=reservation, +# wireguard_public_key=validated_data['wireguard_public_key'] +# ) - break - if not vpn_network: - # FIXME: use correct exception - raise Exception("Did not find any free pool") +# break +# if not vpn_network: +# # FIXME: use correct exception +# raise Exception("Did not find any free pool") - return vpn_network +# return vpn_network diff --git a/uncloud_net/services.py b/uncloud_net/services.py new file mode 100644 index 0000000..68c1e79 --- /dev/null +++ b/uncloud_net/services.py @@ -0,0 +1,118 @@ +from django.db import transaction +from .models import * + +@transaction.atomic +def create_vpn(*, + public_key: str, + network_size: int + ) -> VPNNetwork: + + # Select suitable pool + pools = VPNPool.objects.filter(subnetwork_size=network_size) + + # FIXME: exception - which? + if not pools: + return None + + + + # Find all pools with the correct size + + # For each pool see if it has still space: + # num network reversations < 2**(subnetwork_size-network_size) + + + def next_free_network(self): + if self.num_free_networks == 0: + # FIXME: use right exception + raise Exception("No free networks") + + if len(self.free_networks) > 0: + return self.free_networks[0].address + + if len(self.used_networks) > 0: + """ + sample: + + pool = 2a0a:e5c1:200::/40 + last_used = 2a0a:e5c1:204::/48 + + next: + """ + + last_net = ipaddress.ip_network(self.used_networks.last().address) + last_net_ip = last_net[0] + + if last_net_ip.version == 6: + offset_to_next = 2**(128 - self.subnetwork_size) + elif last_net_ip.version == 4: + offset_to_next = 2**(32 - self.subnetwork_size) + + next_net_ip = last_net_ip + offset_to_next + + return str(next_net_ip) + else: + # first network to be created + return self.network + + @property + def wireguard_config_filename(self): + return '/etc/wireguard/{}.conf'.format(self.network) + + @property + def wireguard_config(self): + wireguard_config = [ + """ +[Interface] +ListenPort = 51820 +PrivateKey = {privatekey} +""".format(privatekey=self.wireguard_private_key) ] + + peers = [] + + for reservation in self.vpnnetworkreservation_set.filter(status='used'): + public_key = reservation.vpnnetwork_set.first().wireguard_public_key + peer_network = "{}/{}".format(reservation.address, self.subnetwork_size) + owner = reservation.vpnnetwork_set.first().owner + + peers.append(""" +# Owner: {owner} +[Peer] +PublicKey = {public_key} +AllowedIPs = {peer_network} +""".format( + owner=owner, + public_key=public_key, + peer_network=peer_network)) + + wireguard_config.extend(peers) + + return "\n".join(wireguard_config) + + + def configure_wireguard_vpnserver(self): + """ + This method is designed to run as a celery task and should + not be called directly from the web + """ + + # subprocess, ssh + + pass + + + + def num_maximum_networks(self): + """ + sample: + network_size = 40 + subnetwork_size = 48 + maximum_networks = 2^(48-40) + + 2nd sample: + network_size = 8 + subnetwork_size = 24 + maximum_networks = 2^(24-8) + """ + + return 2**(self.subnetwork_mask - self.network_mask) diff --git a/uncloud_net/templates/uncloud_net/vpnnetwork_form.html b/uncloud_net/templates/uncloud_net/vpnnetwork_form.html index 14f8656..1463f41 100644 --- a/uncloud_net/templates/uncloud_net/vpnnetwork_form.html +++ b/uncloud_net/templates/uncloud_net/vpnnetwork_form.html @@ -5,19 +5,9 @@

-

Generate new prefix

+

Create a VPN Network

- A new random prefix will be generated for you. -

- -

- All ULA prefixes are /48 networks. Simply add the first IP address - (without any netmask, for instance fd23:2323:2323::). - - You can choose the name of your liking and an organization name. -

-

- ULA prefixes are always subnets of the fd00::/8 network. + Create a new wireguard based VPN network.

diff --git a/uncloud_net/views.py b/uncloud_net/views.py index f554e36..d52b076 100644 --- a/uncloud_net/views.py +++ b/uncloud_net/views.py @@ -9,45 +9,41 @@ from rest_framework import viewsets, permissions from .models import * from .serializers import * +from .selectors import * +from .forms import * + +# class VPNPoolViewSet(viewsets.ModelViewSet): +# serializer_class = VPNPoolSerializer +# permission_classes = [permissions.IsAdminUser] +# queryset = VPNPool.objects.all() + +# class VPNNetworkReservationViewSet(viewsets.ModelViewSet): +# serializer_class = VPNNetworkReservationSerializer +# permission_classes = [permissions.IsAdminUser] +# queryset = VPNNetworkReservation.objects.all() -class VPNPoolViewSet(viewsets.ModelViewSet): - serializer_class = VPNPoolSerializer - permission_classes = [permissions.IsAdminUser] - queryset = VPNPool.objects.all() +# class VPNNetworkViewSet(viewsets.ModelViewSet): +# serializer_class = VPNNetworkSerializer +# permission_classes = [permissions.IsAuthenticated] -class VPNNetworkReservationViewSet(viewsets.ModelViewSet): - serializer_class = VPNNetworkReservationSerializer - permission_classes = [permissions.IsAdminUser] - queryset = VPNNetworkReservation.objects.all() +# def get_queryset(self): +# if self.request.user.is_superuser: +# obj = VPNNetwork.objects.all() +# else: +# obj = VPNNetwork.objects.filter(owner=self.request.user) + +# return obj -class VPNNetworkViewSet(viewsets.ModelViewSet): - serializer_class = VPNNetworkSerializer -# permission_classes = [permissions.IsAdminUser] - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - if self.request.user.is_superuser: - obj = VPNNetwork.objects.all() - else: - obj = VPNNetwork.objects.filter(owner=self.request.user) - - return obj - - - - -class VPNCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): - model = VPNNetwork +class WireGuardVPNCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): + model = WireGuardVPN login_url = '/login/' success_url = '/' - success_message = "%(the_prefix)s/48 was created successfully" + success_message = "%(network) was created successfully" - gen_method = "undef" - - fields = [ "wireguard_public_key" ] + form_class = WireGuardVPNForm def get_success_message(self, cleaned_data): return self.success_message % dict(cleaned_data, @@ -55,5 +51,5 @@ class VPNCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['method'] = self.gen_method + context['available_sizes'] = 2 return context diff --git a/uncloud_pay/migrations/0001_initial.py b/uncloud_pay/migrations/0001_initial.py index 9169f19..b1b68c5 100644 --- a/uncloud_pay/migrations/0001_initial.py +++ b/uncloud_pay/migrations/0001_initial.py @@ -1,12 +1,12 @@ -# Generated by Django 3.0.6 on 2020-08-01 16:38 +# Generated by Django 3.1 on 2020-12-13 10:38 from django.conf import settings import django.core.validators from django.db import migrations, models import django.db.models.deletion import django.utils.timezone -import uncloud_pay.models import uncloud.models +import uncloud_pay.models class Migration(migrations.Migration): @@ -14,8 +14,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('uncloud_auth', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_auth', '0001_initial'), ] operations = [ @@ -25,20 +25,20 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('creation_date', models.DateTimeField(auto_now_add=True)), ('starting_date', models.DateTimeField(default=uncloud_pay.models.start_of_this_month)), - ('ending_date', models.DateTimeField(default=uncloud_pay.models.end_of_this_month)), + ('ending_date', models.DateTimeField()), ('due_date', models.DateField(default=uncloud_pay.models.default_payment_delay)), - ('valid', models.BooleanField(default=True)), + ('is_final', models.BooleanField(default=False)), ], ), migrations.CreateModel( name='BillingAddress', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('organization', models.CharField(max_length=100)), - ('name', models.CharField(max_length=100)), - ('street', models.CharField(max_length=100)), - ('city', models.CharField(max_length=50)), - ('postal_code', models.CharField(max_length=50)), + ('full_name', models.CharField(max_length=256)), + ('organization', models.CharField(blank=True, max_length=256, null=True)), + ('street', models.CharField(max_length=256)), + ('city', models.CharField(max_length=256)), + ('postal_code', models.CharField(max_length=64)), ('country', uncloud.models.CountryField(blank=True, choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2)), ('vat_number', models.CharField(blank=True, default='', max_length=100)), ('active', models.BooleanField(default=False)), @@ -46,26 +46,27 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='Order', + name='Product', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('description', models.TextField()), - ('creation_date', models.DateTimeField(auto_now_add=True)), - ('starting_date', models.DateTimeField(default=django.utils.timezone.now)), - ('ending_date', models.DateTimeField(blank=True, null=True)), - ('recurring_period', models.IntegerField(choices=[(31536000, 'Per 365 days'), (2592000, 'Per 30 days'), (604800, 'Per Week'), (86400, 'Per Day'), (3600, 'Per Hour'), (60, 'Per Minute'), (1, 'Per Second'), (0, 'Onetime')], default=2592000)), - ('one_time_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), - ('recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), - ('billing_address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.BillingAddress')), - ('depends_on', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parent_of', to='uncloud_pay.Order')), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('replaces', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='replaced_by', to='uncloud_pay.Order')), + ('name', models.CharField(max_length=256, unique=True)), + ('description', models.CharField(max_length=1024)), + ('config', models.JSONField()), + ('currency', models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32)), + ], + ), + migrations.CreateModel( + name='RecurringPeriod', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('duration_seconds', models.IntegerField(unique=True)), ], ), migrations.CreateModel( name='StripeCustomer', fields=[ - ('owner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('owner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='uncloud_auth.user')), ('stripe_id', models.CharField(max_length=32)), ], ), @@ -73,8 +74,8 @@ class Migration(migrations.Migration): name='VATRate', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('start_date', models.DateField(blank=True, null=True)), - ('stop_date', models.DateField(blank=True, null=True)), + ('starting_date', models.DateField(blank=True, null=True)), + ('ending_date', models.DateField(blank=True, null=True)), ('territory_codes', models.TextField(blank=True, default='')), ('currency_code', models.CharField(max_length=10)), ('rate', models.FloatField()), @@ -82,6 +83,20 @@ class Migration(migrations.Migration): ('description', models.TextField(blank=True, default='')), ], ), + migrations.CreateModel( + name='ProductToRecurringPeriod', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_default', models.BooleanField(default=False)), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product')), + ('recurring_period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod')), + ], + ), + migrations.AddField( + model_name='product', + name='recurring_periods', + field=models.ManyToManyField(through='uncloud_pay.ProductToRecurringPeriod', to='uncloud_pay.RecurringPeriod'), + ), migrations.CreateModel( name='PaymentMethod', fields=[ @@ -105,37 +120,56 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='OrderRecord', + name='Order', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField()), + ('config', models.JSONField()), + ('creation_date', models.DateTimeField(auto_now_add=True)), + ('starting_date', models.DateTimeField(default=django.utils.timezone.now)), + ('ending_date', models.DateTimeField(blank=True, null=True)), ('one_time_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), ('recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), - ('description', models.TextField()), - ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), + ('currency', models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32)), + ('should_be_billed', models.BooleanField(default=True)), + ('billing_address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress')), + ('depends_on', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_of', to='uncloud_pay.order')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product')), + ('recurring_period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod')), + ('replaces', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replaced_by', to='uncloud_pay.order')), ], ), migrations.CreateModel( name='BillRecord', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('quantity', models.IntegerField(default=1)), ('creation_date', models.DateTimeField(auto_now_add=True)), ('starting_date', models.DateTimeField()), ('ending_date', models.DateTimeField()), - ('bill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Bill')), - ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), + ('is_recurring_record', models.BooleanField()), + ('bill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.bill')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.order')), ], ), migrations.AddField( model_name='bill', - name='bill_records', - field=models.ManyToManyField(through='uncloud_pay.BillRecord', to='uncloud_pay.Order'), + name='billing_address', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress'), ), migrations.AddField( model_name='bill', name='owner', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), ), + migrations.AddConstraint( + model_name='producttorecurringperiod', + constraint=models.UniqueConstraint(condition=models.Q(is_default=True), fields=('product',), name='one_default_recurring_period_per_product'), + ), + migrations.AddConstraint( + model_name='producttorecurringperiod', + constraint=models.UniqueConstraint(fields=('product', 'recurring_period'), name='recurring_period_once_per_product'), + ), migrations.AddConstraint( model_name='billingaddress', constraint=models.UniqueConstraint(condition=models.Q(active=True), fields=('owner',), name='one_active_billing_address_per_user'), diff --git a/uncloud_pay/migrations/0002_auto_20200801_2208.py b/uncloud_pay/migrations/0002_auto_20200801_2208.py deleted file mode 100644 index 9bc76f1..0000000 --- a/uncloud_pay/migrations/0002_auto_20200801_2208.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.8 on 2020-08-01 22:08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='billingaddress', - name='organization', - field=models.CharField(blank=True, max_length=100, null=True), - ), - ] diff --git a/uncloud_pay/migrations/0003_auto_20200801_2332.py b/uncloud_pay/migrations/0003_auto_20200801_2332.py deleted file mode 100644 index f30ed59..0000000 --- a/uncloud_pay/migrations/0003_auto_20200801_2332.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.0.8 on 2020-08-01 23:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0002_auto_20200801_2208'), - ] - - operations = [ - migrations.RenameField( - model_name='vatrate', - old_name='stop_date', - new_name='ending_date', - ), - migrations.RenameField( - model_name='vatrate', - old_name='start_date', - new_name='starting_date', - ), - migrations.AlterField( - model_name='bill', - name='ending_date', - field=models.DateTimeField(), - ), - migrations.AlterField( - model_name='billrecord', - name='quantity', - field=models.DecimalField(decimal_places=10, max_digits=19), - ), - ] diff --git a/uncloud_pay/migrations/0004_remove_order_one_time_price.py b/uncloud_pay/migrations/0004_remove_order_one_time_price.py deleted file mode 100644 index a908c54..0000000 --- a/uncloud_pay/migrations/0004_remove_order_one_time_price.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.1 on 2020-08-08 19:53 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0003_auto_20200801_2332'), - ] - - operations = [ - migrations.RemoveField( - model_name='order', - name='one_time_price', - ), - ] diff --git a/uncloud_pay/migrations/0005_auto_20200808_1954.py b/uncloud_pay/migrations/0005_auto_20200808_1954.py deleted file mode 100644 index 92f69a0..0000000 --- a/uncloud_pay/migrations/0005_auto_20200808_1954.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.1 on 2020-08-08 19:54 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0004_remove_order_one_time_price'), - ] - - operations = [ - migrations.RenameField( - model_name='order', - old_name='recurring_price', - new_name='price', - ), - ] diff --git a/uncloud_pay/migrations/0006_remove_billrecord_quantity.py b/uncloud_pay/migrations/0006_remove_billrecord_quantity.py deleted file mode 100644 index e8b50da..0000000 --- a/uncloud_pay/migrations/0006_remove_billrecord_quantity.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.1 on 2020-08-08 19:57 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0005_auto_20200808_1954'), - ] - - operations = [ - migrations.RemoveField( - model_name='billrecord', - name='quantity', - ), - ] diff --git a/uncloud_pay/migrations/0007_remove_bill_bill_records.py b/uncloud_pay/migrations/0007_remove_bill_bill_records.py deleted file mode 100644 index 6ba9563..0000000 --- a/uncloud_pay/migrations/0007_remove_bill_bill_records.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.1 on 2020-08-08 20:20 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0006_remove_billrecord_quantity'), - ] - - operations = [ - migrations.RemoveField( - model_name='bill', - name='bill_records', - ), - ] diff --git a/uncloud_pay/migrations/0008_delete_orderrecord.py b/uncloud_pay/migrations/0008_delete_orderrecord.py deleted file mode 100644 index 074210a..0000000 --- a/uncloud_pay/migrations/0008_delete_orderrecord.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 3.1 on 2020-08-08 20:36 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0007_remove_bill_bill_records'), - ] - - operations = [ - migrations.DeleteModel( - name='OrderRecord', - ), - ] diff --git a/uncloud_pay/migrations/0009_auto_20200808_2113.py b/uncloud_pay/migrations/0009_auto_20200808_2113.py deleted file mode 100644 index e5090ef..0000000 --- a/uncloud_pay/migrations/0009_auto_20200808_2113.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.1 on 2020-08-08 21:13 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0008_delete_orderrecord'), - ] - - operations = [ - migrations.RemoveField( - model_name='bill', - name='valid', - ), - migrations.AddField( - model_name='bill', - name='is_final', - field=models.BooleanField(default=False), - ), - ] diff --git a/uncloud_pay/migrations/0010_auto_20200809_0856.py b/uncloud_pay/migrations/0010_auto_20200809_0856.py deleted file mode 100644 index db2f7d7..0000000 --- a/uncloud_pay/migrations/0010_auto_20200809_0856.py +++ /dev/null @@ -1,65 +0,0 @@ -# Generated by Django 3.1 on 2020-08-09 08:56 - -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), - ('uncloud_pay', '0009_auto_20200808_2113'), - ] - - operations = [ - migrations.AlterField( - model_name='order', - name='depends_on', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_of', to='uncloud_pay.order'), - ), - migrations.AlterField( - model_name='order', - name='replaces', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replaced_by', to='uncloud_pay.order'), - ), - migrations.CreateModel( - name='SampleRecurringProductOneTimeFee', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', models.JSONField(blank=True, editable=False, null=True)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), - ('order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.order')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='SampleRecurringProduct', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', models.JSONField(blank=True, editable=False, null=True)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), - ('order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.order')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='SampleOneTimeProduct', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', models.JSONField(blank=True, editable=False, null=True)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), - ('order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.order')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/uncloud_pay/migrations/0011_bill_billing_address.py b/uncloud_pay/migrations/0011_bill_billing_address.py deleted file mode 100644 index e13e7b3..0000000 --- a/uncloud_pay/migrations/0011_bill_billing_address.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.1 on 2020-08-09 10:24 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0010_auto_20200809_0856'), - ] - - operations = [ - migrations.AddField( - model_name='bill', - name='billing_address', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress'), - ), - ] diff --git a/uncloud_pay/migrations/0012_auto_20200809_1026.py b/uncloud_pay/migrations/0012_auto_20200809_1026.py deleted file mode 100644 index dff0e78..0000000 --- a/uncloud_pay/migrations/0012_auto_20200809_1026.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.1 on 2020-08-09 10:26 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0011_bill_billing_address'), - ] - - operations = [ - migrations.AlterField( - model_name='bill', - name='billing_address', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress'), - ), - ] diff --git a/uncloud_pay/migrations/0013_auto_20200809_1237.py b/uncloud_pay/migrations/0013_auto_20200809_1237.py deleted file mode 100644 index 7f1ed91..0000000 --- a/uncloud_pay/migrations/0013_auto_20200809_1237.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 3.1 on 2020-08-09 12:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0012_auto_20200809_1026'), - ] - - operations = [ - migrations.RemoveField( - model_name='sampleonetimeproduct', - name='order', - ), - migrations.RemoveField( - model_name='samplerecurringproduct', - name='order', - ), - migrations.RemoveField( - model_name='samplerecurringproductonetimefee', - name='order', - ), - migrations.AddField( - model_name='sampleonetimeproduct', - name='orders', - field=models.ManyToManyField(to='uncloud_pay.Order'), - ), - migrations.AddField( - model_name='samplerecurringproduct', - name='orders', - field=models.ManyToManyField(to='uncloud_pay.Order'), - ), - migrations.AddField( - model_name='samplerecurringproductonetimefee', - name='orders', - field=models.ManyToManyField(to='uncloud_pay.Order'), - ), - ] diff --git a/uncloud_pay/migrations/0014_auto_20200825_1915.py b/uncloud_pay/migrations/0014_auto_20200825_1915.py deleted file mode 100644 index 97c4b7a..0000000 --- a/uncloud_pay/migrations/0014_auto_20200825_1915.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.1 on 2020-08-25 19:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0013_auto_20200809_1237'), - ] - - operations = [ - migrations.AddField( - model_name='sampleonetimeproduct', - name='ot_price', - field=models.IntegerField(default=5), - ), - migrations.AddField( - model_name='samplerecurringproduct', - name='rc_price', - field=models.IntegerField(default=10), - ), - migrations.AddField( - model_name='samplerecurringproductonetimefee', - name='ot_price', - field=models.IntegerField(default=5), - ), - migrations.AddField( - model_name='samplerecurringproductonetimefee', - name='rc_price', - field=models.IntegerField(default=10), - ), - ] diff --git a/uncloud_pay/migrations/0015_auto_20200928_1844.py b/uncloud_pay/migrations/0015_auto_20200928_1844.py deleted file mode 100644 index 4aecb6e..0000000 --- a/uncloud_pay/migrations/0015_auto_20200928_1844.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 18:44 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0014_auto_20200825_1915'), - ] - - operations = [ - migrations.RemoveField( - model_name='sampleonetimeproduct', - name='orders', - ), - migrations.RemoveField( - model_name='samplerecurringproduct', - name='orders', - ), - migrations.RemoveField( - model_name='samplerecurringproductonetimefee', - name='orders', - ), - ] diff --git a/uncloud_pay/migrations/0016_auto_20200928_1858.py b/uncloud_pay/migrations/0016_auto_20200928_1858.py deleted file mode 100644 index 0c5ebfa..0000000 --- a/uncloud_pay/migrations/0016_auto_20200928_1858.py +++ /dev/null @@ -1,65 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 18:58 - -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), - ('uncloud_pay', '0015_auto_20200928_1844'), - ] - - operations = [ - migrations.RemoveField( - model_name='sampleonetimeproduct', - name='extra_data', - ), - migrations.RemoveField( - model_name='sampleonetimeproduct', - name='owner', - ), - migrations.RemoveField( - model_name='sampleonetimeproduct', - name='status', - ), - migrations.RemoveField( - model_name='samplerecurringproduct', - name='extra_data', - ), - migrations.RemoveField( - model_name='samplerecurringproduct', - name='owner', - ), - migrations.RemoveField( - model_name='samplerecurringproduct', - name='status', - ), - migrations.RemoveField( - model_name='samplerecurringproductonetimefee', - name='extra_data', - ), - migrations.RemoveField( - model_name='samplerecurringproductonetimefee', - name='owner', - ), - migrations.RemoveField( - model_name='samplerecurringproductonetimefee', - name='status', - ), - migrations.CreateModel( - name='Product', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', models.JSONField(blank=True, editable=False, null=True)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), - ('config', models.JSONField()), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/uncloud_pay/migrations/0017_order_config.py b/uncloud_pay/migrations/0017_order_config.py deleted file mode 100644 index 3afecee..0000000 --- a/uncloud_pay/migrations/0017_order_config.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 19:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0016_auto_20200928_1858'), - ] - - operations = [ - migrations.AddField( - model_name='order', - name='config', - field=models.JSONField(default={}), - preserve_default=False, - ), - ] diff --git a/uncloud_pay/migrations/0018_order_product.py b/uncloud_pay/migrations/0018_order_product.py deleted file mode 100644 index e4e6eb1..0000000 --- a/uncloud_pay/migrations/0018_order_product.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 19:08 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0017_order_config'), - ] - - operations = [ - migrations.AddField( - model_name='order', - name='product', - field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product'), - preserve_default=False, - ), - ] diff --git a/uncloud_pay/migrations/0019_remove_product_owner.py b/uncloud_pay/migrations/0019_remove_product_owner.py deleted file mode 100644 index 05ea2a8..0000000 --- a/uncloud_pay/migrations/0019_remove_product_owner.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 19:14 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0018_order_product'), - ] - - operations = [ - migrations.RemoveField( - model_name='product', - name='owner', - ), - ] diff --git a/uncloud_pay/migrations/0020_auto_20200928_1915.py b/uncloud_pay/migrations/0020_auto_20200928_1915.py deleted file mode 100644 index 2190397..0000000 --- a/uncloud_pay/migrations/0020_auto_20200928_1915.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 19:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0019_remove_product_owner'), - ] - - operations = [ - migrations.RemoveField( - model_name='product', - name='status', - ), - migrations.AddField( - model_name='product', - name='description', - field=models.CharField(default='', max_length=1024), - preserve_default=False, - ), - migrations.AddField( - model_name='product', - name='name', - field=models.CharField(default='', max_length=256), - preserve_default=False, - ), - ] diff --git a/uncloud_pay/migrations/0021_auto_20200928_1932.py b/uncloud_pay/migrations/0021_auto_20200928_1932.py deleted file mode 100644 index 85b8afe..0000000 --- a/uncloud_pay/migrations/0021_auto_20200928_1932.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 19:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0020_auto_20200928_1915'), - ] - - operations = [ - migrations.AddField( - model_name='product', - name='default_currency', - field=models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32), - ), - migrations.AddField( - model_name='product', - name='default_recurring_period', - field=models.IntegerField(choices=[(31536000, 'Per 365 days'), (2592000, 'Per 30 days'), (604800, 'Per Week'), (86400, 'Per Day'), (3600, 'Per Hour'), (60, 'Per Minute'), (1, 'Per Second'), (0, 'Onetime')], default=2592000), - ), - ] diff --git a/uncloud_pay/migrations/0022_auto_20200928_1932.py b/uncloud_pay/migrations/0022_auto_20200928_1932.py deleted file mode 100644 index 0969b79..0000000 --- a/uncloud_pay/migrations/0022_auto_20200928_1932.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 19:32 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0021_auto_20200928_1932'), - ] - - operations = [ - migrations.RenameField( - model_name='product', - old_name='default_currency', - new_name='currency', - ), - ] diff --git a/uncloud_pay/migrations/0023_auto_20200928_1944.py b/uncloud_pay/migrations/0023_auto_20200928_1944.py deleted file mode 100644 index 3eb0010..0000000 --- a/uncloud_pay/migrations/0023_auto_20200928_1944.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 19:44 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0022_auto_20200928_1932'), - ] - - operations = [ - migrations.RenameField( - model_name='order', - old_name='price', - new_name='one_time_price', - ), - ] diff --git a/uncloud_pay/migrations/0024_auto_20200928_1945.py b/uncloud_pay/migrations/0024_auto_20200928_1945.py deleted file mode 100644 index 6792049..0000000 --- a/uncloud_pay/migrations/0024_auto_20200928_1945.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 19:45 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0023_auto_20200928_1944'), - ] - - operations = [ - migrations.AddField( - model_name='order', - name='currency', - field=models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32), - ), - migrations.AddField( - model_name='order', - name='recurring_price', - field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), - ), - ] diff --git a/uncloud_pay/migrations/0025_billrecord_is_recurring_record.py b/uncloud_pay/migrations/0025_billrecord_is_recurring_record.py deleted file mode 100644 index 5e3e141..0000000 --- a/uncloud_pay/migrations/0025_billrecord_is_recurring_record.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 20:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0024_auto_20200928_1945'), - ] - - operations = [ - migrations.AddField( - model_name='billrecord', - name='is_recurring_record', - field=models.BooleanField(default=False), - preserve_default=False, - ), - ] diff --git a/uncloud_pay/migrations/0026_order_should_be_billed.py b/uncloud_pay/migrations/0026_order_should_be_billed.py deleted file mode 100644 index c32c688..0000000 --- a/uncloud_pay/migrations/0026_order_should_be_billed.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 20:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0025_billrecord_is_recurring_record'), - ] - - operations = [ - migrations.AddField( - model_name='order', - name='should_be_billed', - field=models.BooleanField(default=True), - ), - ] diff --git a/uncloud_pay/migrations/0027_auto_20201006_1319.py b/uncloud_pay/migrations/0027_auto_20201006_1319.py deleted file mode 100644 index a82955a..0000000 --- a/uncloud_pay/migrations/0027_auto_20201006_1319.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 3.1 on 2020-10-06 13:19 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0026_order_should_be_billed'), - ] - - operations = [ - migrations.CreateModel( - name='RecurringPeriod', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, unique=True)), - ('duration_seconds', models.IntegerField(unique=True)), - ], - ), - migrations.DeleteModel( - name='SampleOneTimeProduct', - ), - migrations.DeleteModel( - name='SampleRecurringProduct', - ), - migrations.DeleteModel( - name='SampleRecurringProductOneTimeFee', - ), - migrations.AlterField( - model_name='order', - name='recurring_period', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod'), - ), - migrations.AlterField( - model_name='product', - name='default_recurring_period', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod'), - ), - ] diff --git a/uncloud_pay/migrations/0028_auto_20201006_1529.py b/uncloud_pay/migrations/0028_auto_20201006_1529.py deleted file mode 100644 index 1ca4ee1..0000000 --- a/uncloud_pay/migrations/0028_auto_20201006_1529.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 3.1 on 2020-10-06 15:29 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0027_auto_20201006_1319'), - ] - - operations = [ - migrations.RemoveField( - model_name='product', - name='default_recurring_period', - ), - migrations.CreateModel( - name='ProductToRecurringPeriod', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_default', models.BooleanField(default=False)), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product')), - ('recurring_period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod')), - ], - ), - migrations.AddField( - model_name='product', - name='recurring_periods', - field=models.ManyToManyField(through='uncloud_pay.ProductToRecurringPeriod', to='uncloud_pay.RecurringPeriod'), - ), - migrations.AddConstraint( - model_name='producttorecurringperiod', - constraint=models.UniqueConstraint(condition=models.Q(is_default=True), fields=('recurring_period', 'product'), name='one_default_recurring_period_per_product'), - ), - ] diff --git a/uncloud_pay/migrations/0029_auto_20201006_1540.py b/uncloud_pay/migrations/0029_auto_20201006_1540.py deleted file mode 100644 index e439d54..0000000 --- a/uncloud_pay/migrations/0029_auto_20201006_1540.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.1 on 2020-10-06 15:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0028_auto_20201006_1529'), - ] - - operations = [ - migrations.AlterField( - model_name='product', - name='name', - field=models.CharField(max_length=256, unique=True), - ), - ] diff --git a/uncloud_pay/migrations/0030_auto_20201006_1640.py b/uncloud_pay/migrations/0030_auto_20201006_1640.py deleted file mode 100644 index 51bc1f2..0000000 --- a/uncloud_pay/migrations/0030_auto_20201006_1640.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.1 on 2020-10-06 16:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0029_auto_20201006_1540'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='producttorecurringperiod', - name='one_default_recurring_period_per_product', - ), - migrations.AddConstraint( - model_name='producttorecurringperiod', - constraint=models.UniqueConstraint(condition=models.Q(is_default=True), fields=('product',), name='one_default_recurring_period_per_product'), - ), - ] diff --git a/uncloud_pay/migrations/0031_auto_20201006_1655.py b/uncloud_pay/migrations/0031_auto_20201006_1655.py deleted file mode 100644 index e56a4cc..0000000 --- a/uncloud_pay/migrations/0031_auto_20201006_1655.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.1 on 2020-10-06 16:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0030_auto_20201006_1640'), - ] - - operations = [ - migrations.AddConstraint( - model_name='producttorecurringperiod', - constraint=models.UniqueConstraint(fields=('product', 'recurring_period'), name='recurring_period_once_per_product'), - ), - ] diff --git a/uncloud_pay/migrations/0032_uncloudprovider.py b/uncloud_pay/migrations/0032_uncloudprovider.py deleted file mode 100644 index 0eef76c..0000000 --- a/uncloud_pay/migrations/0032_uncloudprovider.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.1 on 2020-10-11 15:42 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0031_auto_20201006_1655'), - ] - - operations = [ - migrations.CreateModel( - name='UncloudProvider', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('valid_from', models.DateField()), - ('valid_to', models.DateField(blank=True)), - ('billing_address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress')), - ], - ), - ] diff --git a/uncloud_pay/migrations/0033_auto_20201011_2003.py b/uncloud_pay/migrations/0033_auto_20201011_2003.py deleted file mode 100644 index 186dd16..0000000 --- a/uncloud_pay/migrations/0033_auto_20201011_2003.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.1 on 2020-10-11 20:03 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0032_uncloudprovider'), - ] - - operations = [ - migrations.RemoveField( - model_name='product', - name='extra_data', - ), - migrations.DeleteModel( - name='UncloudProvider', - ), - ] diff --git a/uncloud_pay/migrations/0034_auto_20201011_2031.py b/uncloud_pay/migrations/0034_auto_20201011_2031.py deleted file mode 100644 index b976450..0000000 --- a/uncloud_pay/migrations/0034_auto_20201011_2031.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.1 on 2020-10-11 20:31 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0033_auto_20201011_2003'), - ] - - operations = [ - migrations.RemoveField( - model_name='billingaddress', - name='city', - ), - migrations.RemoveField( - model_name='billingaddress', - name='country', - ), - migrations.RemoveField( - model_name='billingaddress', - name='name', - ), - migrations.RemoveField( - model_name='billingaddress', - name='organization', - ), - migrations.RemoveField( - model_name='billingaddress', - name='postal_code', - ), - migrations.RemoveField( - model_name='billingaddress', - name='street', - ), - ] diff --git a/uncloud_pay/migrations/0035_auto_20201012_1728.py b/uncloud_pay/migrations/0035_auto_20201012_1728.py deleted file mode 100644 index af30d98..0000000 --- a/uncloud_pay/migrations/0035_auto_20201012_1728.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 3.1 on 2020-10-12 17:28 - -from django.db import migrations, models -import uncloud.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0034_auto_20201011_2031'), - ] - - operations = [ - migrations.AddField( - model_name='billingaddress', - name='city', - field=models.CharField(default='', max_length=256), - preserve_default=False, - ), - migrations.AddField( - model_name='billingaddress', - name='country', - field=uncloud.models.CountryField(blank=True, choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2), - ), - migrations.AddField( - model_name='billingaddress', - name='full_name', - field=models.CharField(default='', max_length=256), - preserve_default=False, - ), - migrations.AddField( - model_name='billingaddress', - name='organization', - field=models.CharField(blank=True, max_length=256, null=True), - ), - migrations.AddField( - model_name='billingaddress', - name='postal_code', - field=models.CharField(default='', max_length=64), - preserve_default=False, - ), - migrations.AddField( - model_name='billingaddress', - name='street', - field=models.CharField(default='', max_length=256), - preserve_default=False, - ), - ] diff --git a/uncloud_service/migrations/0001_initial.py b/uncloud_service/migrations/0001_initial.py deleted file mode 100644 index 96fb3c0..0000000 --- a/uncloud_service/migrations/0001_initial.py +++ /dev/null @@ -1,52 +0,0 @@ -# Generated by Django 3.0.6 on 2020-08-01 16:38 - -from django.conf import settings -import django.contrib.postgres.fields.jsonb -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('uncloud_vm', '__first__'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_pay', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='MatrixServiceProduct', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), - ('domain', models.CharField(default='domain.tld', max_length=255)), - ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='GenericServiceProduct', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), - ('custom_description', models.TextField()), - ('custom_recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), - ('custom_one_time_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), - ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/uncloud_service/migrations/0002_auto_20200801_2332.py b/uncloud_service/migrations/0002_auto_20200801_2332.py deleted file mode 100644 index 46acdf1..0000000 --- a/uncloud_service/migrations/0002_auto_20200801_2332.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.0.8 on 2020-08-01 23:32 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0003_auto_20200801_2332'), - ('uncloud_service', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='genericserviceproduct', - name='order', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), - ), - migrations.AlterField( - model_name='matrixserviceproduct', - name='order', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), - ), - ] diff --git a/uncloud_service/migrations/0003_auto_20200808_1953.py b/uncloud_service/migrations/0003_auto_20200808_1953.py deleted file mode 100644 index 244a4c4..0000000 --- a/uncloud_service/migrations/0003_auto_20200808_1953.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.1 on 2020-08-08 19:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_service', '0002_auto_20200801_2332'), - ] - - operations = [ - migrations.AlterField( - model_name='genericserviceproduct', - name='extra_data', - field=models.JSONField(blank=True, editable=False, null=True), - ), - migrations.AlterField( - model_name='matrixserviceproduct', - name='extra_data', - field=models.JSONField(blank=True, editable=False, null=True), - ), - ] diff --git a/uncloud_service/migrations/0004_auto_20200809_1237.py b/uncloud_service/migrations/0004_auto_20200809_1237.py deleted file mode 100644 index 743514f..0000000 --- a/uncloud_service/migrations/0004_auto_20200809_1237.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 3.1 on 2020-08-09 12:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0013_auto_20200809_1237'), - ('uncloud_service', '0003_auto_20200808_1953'), - ] - - operations = [ - migrations.RemoveField( - model_name='genericserviceproduct', - name='order', - ), - migrations.RemoveField( - model_name='matrixserviceproduct', - name='order', - ), - migrations.AddField( - model_name='genericserviceproduct', - name='orders', - field=models.ManyToManyField(to='uncloud_pay.Order'), - ), - migrations.AddField( - model_name='matrixserviceproduct', - name='orders', - field=models.ManyToManyField(to='uncloud_pay.Order'), - ), - ] diff --git a/uncloud_service/migrations/0005_auto_20200928_1844.py b/uncloud_service/migrations/0005_auto_20200928_1844.py deleted file mode 100644 index 7cc4b92..0000000 --- a/uncloud_service/migrations/0005_auto_20200928_1844.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 18:44 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_service', '0004_auto_20200809_1237'), - ] - - operations = [ - migrations.RemoveField( - model_name='genericserviceproduct', - name='orders', - ), - migrations.RemoveField( - model_name='matrixserviceproduct', - name='orders', - ), - ] diff --git a/uncloud_service/migrations/0006_auto_20200928_1858.py b/uncloud_service/migrations/0006_auto_20200928_1858.py deleted file mode 100644 index 154ddb1..0000000 --- a/uncloud_service/migrations/0006_auto_20200928_1858.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 18:58 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_service', '0005_auto_20200928_1844'), - ] - - operations = [ - migrations.RemoveField( - model_name='genericserviceproduct', - name='extra_data', - ), - migrations.RemoveField( - model_name='genericserviceproduct', - name='owner', - ), - migrations.RemoveField( - model_name='genericserviceproduct', - name='status', - ), - migrations.RemoveField( - model_name='matrixserviceproduct', - name='extra_data', - ), - migrations.RemoveField( - model_name='matrixserviceproduct', - name='owner', - ), - migrations.RemoveField( - model_name='matrixserviceproduct', - name='status', - ), - ] diff --git a/uncloud_service/migrations/__init__.py b/uncloud_service/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/uncloud_vm/migrations/0001_initial.py b/uncloud_vm/migrations/0001_initial.py index e104129..4ec089a 100644 --- a/uncloud_vm/migrations/0001_initial.py +++ b/uncloud_vm/migrations/0001_initial.py @@ -1,7 +1,6 @@ -# Generated by Django 3.0.6 on 2020-08-01 16:38 +# Generated by Django 3.1 on 2020-12-13 10:38 from django.conf import settings -import django.contrib.postgres.fields.jsonb from django.db import migrations, models import django.db.models.deletion @@ -12,7 +11,6 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_pay', '0001_initial'), ] operations = [ @@ -20,7 +18,7 @@ class Migration(migrations.Migration): name='VMCluster', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), + ('extra_data', models.JSONField(blank=True, editable=False, null=True)), ('name', models.CharField(max_length=128, unique=True)), ], options={ @@ -31,7 +29,7 @@ class Migration(migrations.Migration): name='VMDiskImageProduct', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), + ('extra_data', models.JSONField(blank=True, editable=False, null=True)), ('name', models.CharField(max_length=256)), ('is_os_image', models.BooleanField(default=False)), ('is_public', models.BooleanField(default=False, editable=False)), @@ -51,13 +49,13 @@ class Migration(migrations.Migration): name='VMHost', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), + ('extra_data', models.JSONField(blank=True, editable=False, null=True)), ('hostname', models.CharField(max_length=253, unique=True)), ('physical_cores', models.IntegerField(default=0)), ('usable_cores', models.IntegerField(default=0)), ('usable_ram_in_gb', models.FloatField(default=0)), ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32)), - ('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMCluster')), + ('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmcluster')), ], options={ 'abstract': False, @@ -67,35 +65,21 @@ class Migration(migrations.Migration): name='VMProduct', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), ('name', models.CharField(blank=True, max_length=32, null=True)), ('cores', models.IntegerField()), ('ram_in_gb', models.FloatField()), - ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMCluster')), - ('vmhost', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMHost')), + ('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmcluster')), + ('vmhost', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmhost')), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( name='VMSnapshotProduct', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), ('gb_ssd', models.FloatField(editable=False)), ('gb_hdd', models.FloatField(editable=False)), - ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='uncloud_vm.VMProduct')), + ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='uncloud_vm.vmproduct')), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( name='VMNetworkCard', @@ -103,35 +87,25 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('mac_address', models.BigIntegerField()), ('ip_address', models.GenericIPAddressField(blank=True, null=True)), - ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')), + ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmproduct')), ], ), migrations.CreateModel( name='VMDiskProduct', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)), ('size_in_gb', models.FloatField(blank=True)), ('disk_type', models.CharField(choices=[('ceph/ssd', 'Ceph Ssd'), ('ceph/hdd', 'Ceph Hdd'), ('local/ssd', 'Local Ssd'), ('local/hdd', 'Local Hdd')], default='ceph/ssd', max_length=20)), - ('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMDiskImageProduct')), - ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')), + ('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmdiskimageproduct')), + ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmproduct')), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( name='VMWithOSProduct', fields=[ - ('vmproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.VMProduct')), - ('primary_disk', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMDiskProduct')), + ('vmproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.vmproduct')), + ('primary_disk', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmdiskproduct')), ], - options={ - 'abstract': False, - }, bases=('uncloud_vm.vmproduct',), ), ] diff --git a/uncloud_vm/migrations/0002_auto_20200801_2332.py b/uncloud_vm/migrations/0002_auto_20200801_2332.py deleted file mode 100644 index 3d45f6e..0000000 --- a/uncloud_vm/migrations/0002_auto_20200801_2332.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 3.0.8 on 2020-08-01 23:32 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0003_auto_20200801_2332'), - ('uncloud_vm', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='vmdiskproduct', - name='order', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), - ), - migrations.AlterField( - model_name='vmproduct', - name='order', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), - ), - migrations.AlterField( - model_name='vmsnapshotproduct', - name='order', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), - ), - ] diff --git a/uncloud_vm/migrations/0003_auto_20200808_1953.py b/uncloud_vm/migrations/0003_auto_20200808_1953.py deleted file mode 100644 index e4e4431..0000000 --- a/uncloud_vm/migrations/0003_auto_20200808_1953.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 3.1 on 2020-08-08 19:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0002_auto_20200801_2332'), - ] - - operations = [ - migrations.AlterField( - model_name='vmcluster', - name='extra_data', - field=models.JSONField(blank=True, editable=False, null=True), - ), - migrations.AlterField( - model_name='vmdiskimageproduct', - name='extra_data', - field=models.JSONField(blank=True, editable=False, null=True), - ), - migrations.AlterField( - model_name='vmdiskproduct', - name='extra_data', - field=models.JSONField(blank=True, editable=False, null=True), - ), - migrations.AlterField( - model_name='vmhost', - name='extra_data', - field=models.JSONField(blank=True, editable=False, null=True), - ), - migrations.AlterField( - model_name='vmproduct', - name='extra_data', - field=models.JSONField(blank=True, editable=False, null=True), - ), - migrations.AlterField( - model_name='vmsnapshotproduct', - name='extra_data', - field=models.JSONField(blank=True, editable=False, null=True), - ), - ] diff --git a/uncloud_vm/migrations/0004_auto_20200809_1237.py b/uncloud_vm/migrations/0004_auto_20200809_1237.py deleted file mode 100644 index b89a920..0000000 --- a/uncloud_vm/migrations/0004_auto_20200809_1237.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 3.1 on 2020-08-09 12:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0013_auto_20200809_1237'), - ('uncloud_vm', '0003_auto_20200808_1953'), - ] - - operations = [ - migrations.RemoveField( - model_name='vmdiskproduct', - name='order', - ), - migrations.RemoveField( - model_name='vmproduct', - name='order', - ), - migrations.RemoveField( - model_name='vmsnapshotproduct', - name='order', - ), - migrations.AddField( - model_name='vmdiskproduct', - name='orders', - field=models.ManyToManyField(to='uncloud_pay.Order'), - ), - migrations.AddField( - model_name='vmproduct', - name='orders', - field=models.ManyToManyField(to='uncloud_pay.Order'), - ), - migrations.AddField( - model_name='vmsnapshotproduct', - name='orders', - field=models.ManyToManyField(to='uncloud_pay.Order'), - ), - ] diff --git a/uncloud_vm/migrations/0005_auto_20200928_1844.py b/uncloud_vm/migrations/0005_auto_20200928_1844.py deleted file mode 100644 index 0a28188..0000000 --- a/uncloud_vm/migrations/0005_auto_20200928_1844.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 18:44 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0004_auto_20200809_1237'), - ] - - operations = [ - migrations.RemoveField( - model_name='vmdiskproduct', - name='orders', - ), - migrations.RemoveField( - model_name='vmproduct', - name='orders', - ), - migrations.RemoveField( - model_name='vmsnapshotproduct', - name='orders', - ), - ] diff --git a/uncloud_vm/migrations/0006_auto_20200928_1858.py b/uncloud_vm/migrations/0006_auto_20200928_1858.py deleted file mode 100644 index 96725d4..0000000 --- a/uncloud_vm/migrations/0006_auto_20200928_1858.py +++ /dev/null @@ -1,49 +0,0 @@ -# Generated by Django 3.1 on 2020-09-28 18:58 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0005_auto_20200928_1844'), - ] - - operations = [ - migrations.RemoveField( - model_name='vmdiskproduct', - name='extra_data', - ), - migrations.RemoveField( - model_name='vmdiskproduct', - name='owner', - ), - migrations.RemoveField( - model_name='vmdiskproduct', - name='status', - ), - migrations.RemoveField( - model_name='vmproduct', - name='extra_data', - ), - migrations.RemoveField( - model_name='vmproduct', - name='owner', - ), - migrations.RemoveField( - model_name='vmproduct', - name='status', - ), - migrations.RemoveField( - model_name='vmsnapshotproduct', - name='extra_data', - ), - migrations.RemoveField( - model_name='vmsnapshotproduct', - name='owner', - ), - migrations.RemoveField( - model_name='vmsnapshotproduct', - name='status', - ), - ] From 5716cae9008fd607dacb5683eb1cd7ef840bb37b Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 13 Dec 2020 11:43:49 +0100 Subject: [PATCH 48/93] [vpn] add selector for size --- uncloud_net/forms.py | 4 ++-- uncloud_net/selectors.py | 16 ++++++++-------- ...nnetwork_form.html => wireguardvpn_form.html} | 0 3 files changed, 10 insertions(+), 10 deletions(-) rename uncloud_net/templates/uncloud_net/{vpnnetwork_form.html => wireguardvpn_form.html} (100%) diff --git a/uncloud_net/forms.py b/uncloud_net/forms.py index 6125143..4bdcd79 100644 --- a/uncloud_net/forms.py +++ b/uncloud_net/forms.py @@ -4,9 +4,9 @@ from .models import * from .selectors import * class WireGuardVPNForm(forms.ModelForm): + network_size = forms.ChoiceField(choices=allowed_vpn_network_reservation_size) + class Meta: model = WireGuardVPN - network_size = forms.ChoiceField(choices=allowed_vpn_network_reservation_size) - fields = [ "wireguard_public_key" ] diff --git a/uncloud_net/selectors.py b/uncloud_net/selectors.py index 4888f60..b2f697c 100644 --- a/uncloud_net/selectors.py +++ b/uncloud_net/selectors.py @@ -4,7 +4,7 @@ from django.db.models import Count, F from .models import * -def get_suitable_pool(subnetwork_size): +def get_suitable_pool(subnetwork_mask): """ Find suitable pools for a certain network size. @@ -18,11 +18,11 @@ def get_suitable_pool(subnetwork_size): """ - return VPNPool.objects.annotate( - num_reservations=Count('vpnnetworkreservation'), - max_reservations=2**(F('subnetwork_size')-F('network_size'))).filter( + return WireGuardVPNPool.objects.annotate( + num_reservations=Count('wireguardvpn'), + max_reservations=2**(F('subnetwork_mask')-F('network_mask'))).filter( num_reservations__lt=F('max_reservations'), - subnetwork_size=subnetwork_size) + subnetwork_mask=subnetwork_mask) def allowed_vpn_network_reservation_size(): @@ -35,8 +35,8 @@ def allowed_vpn_network_reservation_size(): """ - pools = VPNPool.objects.annotate(num_reservations=Count('vpnnetworkreservation'), - max_reservations=2**(F('subnetwork_size')-F('network_size'))).filter( + pools = WireGuardVPNPool.objects.annotate(num_reservations=Count('wireguardvpn'), + max_reservations=2**(F('subnetwork_mask')-F('network_mask'))).filter( num_reservations__lt=F('max_reservations')) - return set([ pool.subnetwork_size for pool in pools ]) + return set([ pool.subnetwork_mask for pool in pools ]) diff --git a/uncloud_net/templates/uncloud_net/vpnnetwork_form.html b/uncloud_net/templates/uncloud_net/wireguardvpn_form.html similarity index 100% rename from uncloud_net/templates/uncloud_net/vpnnetwork_form.html rename to uncloud_net/templates/uncloud_net/wireguardvpn_form.html From cf948b03a829cc1c49294bf1ed4edf92ec5f726d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 13 Dec 2020 13:28:43 +0100 Subject: [PATCH 49/93] ++vpn network --- uncloud/urls.py | 19 +++++++++---- uncloud_net/forms.py | 1 - uncloud_net/models.py | 2 ++ uncloud_net/selectors.py | 4 ++- uncloud_net/serializers.py | 10 +++++++ uncloud_net/services.py | 58 +++++++++++--------------------------- uncloud_net/views.py | 33 +++++++++++++--------- 7 files changed, 65 insertions(+), 62 deletions(-) diff --git a/uncloud/urls.py b/uncloud/urls.py index bc24d81..7e8167d 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -41,10 +41,6 @@ router.register(r'v1/service/matrix', serviceviews.MatrixServiceProductViewSet, router.register(r'v1/service/generic', serviceviews.GenericServiceProductViewSet, basename='genericserviceproduct') -# Net -#router.register(r'v1/net/vpn', netviews.VPNNetworkViewSet, basename='vpnnetwork') -#router.register(r'v1/admin/vpnreservation', netviews.VPNNetworkReservationViewSet, basename='vpnnetreservation') - # Pay router.register(r'v1/my/address', payviews.BillingAddressViewSet, basename='billingaddress') @@ -67,9 +63,18 @@ router.register(r'v1/my/user', authviews.UserViewSet, basename='user') router.register(r'v1/admin/user', authviews.AdminUserViewSet, basename='useradmin') router.register(r'v1/user/register', authviews.AccountManagementViewSet, basename='user/register') + +################################################################################ +# v2 + +# Net +router.register(r'v2/net/wireguardvpn', netviews.WireGuardVPNViewSet, basename='wireguardvpnnetwork') +#router.register(r'v1/admin/vpnreservation', netviews.VPNNetworkReservationViewSet, basename='vpnnetreservation') + + + urlpatterns = [ path(r'api/', include(router.urls)), - # web/ = stuff to view in the browser path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), # for login to REST API path('openapi', get_schema_view( @@ -77,7 +82,9 @@ urlpatterns = [ description="uncloud API", version="1.0.0" ), name='openapi-schema'), - path('vpn/create/', netviews.WireGuardVPNCreateView.as_view(), name="vpncreate"), + + # web/ = stuff to view in the browser +# path('web/vpn/create/', netviews.WireGuardVPNCreateView.as_view(), name="vpncreate"), path('login/', authviews.LoginView.as_view(), name="login"), path('logout/', authviews.LogoutView.as_view(), name="logout"), diff --git a/uncloud_net/forms.py b/uncloud_net/forms.py index 4bdcd79..ad4e013 100644 --- a/uncloud_net/forms.py +++ b/uncloud_net/forms.py @@ -8,5 +8,4 @@ class WireGuardVPNForm(forms.ModelForm): class Meta: model = WireGuardVPN - fields = [ "wireguard_public_key" ] diff --git a/uncloud_net/models.py b/uncloud_net/models.py index 819d3b8..2f573bd 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -29,6 +29,8 @@ class WireGuardVPN(models.Model): """ Created VPNNetworks """ + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE) vpnpool = models.ForeignKey(WireGuardVPNPool, on_delete=models.CASCADE) diff --git a/uncloud_net/selectors.py b/uncloud_net/selectors.py index b2f697c..70fafd2 100644 --- a/uncloud_net/selectors.py +++ b/uncloud_net/selectors.py @@ -39,4 +39,6 @@ def allowed_vpn_network_reservation_size(): max_reservations=2**(F('subnetwork_mask')-F('network_mask'))).filter( num_reservations__lt=F('max_reservations')) - return set([ pool.subnetwork_mask for pool in pools ]) + # Need to return set of tuples, see + # https://docs.djangoproject.com/en/3.1/ref/models/fields/#field-choices + return set([ (pool.subnetwork_mask, pool.subnetwork_mask) for pool in pools ]) diff --git a/uncloud_net/serializers.py b/uncloud_net/serializers.py index e2555d5..8c6c567 100644 --- a/uncloud_net/serializers.py +++ b/uncloud_net/serializers.py @@ -5,6 +5,16 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from .models import * +from .services import * + +class WireGuardVPNSerializer(serializers.ModelSerializer): + class Meta: + model = WireGuardVPN + fields = [ 'wireguard_public_key' ] + read_only_fields = [ 'address ' ] + + def create(self, validated_data): + pass # class WireGuardVPNPoolSerializer(serializers.ModelSerializer): # class Meta: diff --git a/uncloud_net/services.py b/uncloud_net/services.py index 68c1e79..45e14c9 100644 --- a/uncloud_net/services.py +++ b/uncloud_net/services.py @@ -1,59 +1,35 @@ from django.db import transaction + from .models import * +from .selectors import * @transaction.atomic -def create_vpn(*, +def create_wireguard_vpn(*, public_key: str, - network_size: int - ) -> VPNNetwork: + network_mask: int + ) -> WireGuardVPN: - # Select suitable pool - pools = VPNPool.objects.filter(subnetwork_size=network_size) + pool = get_suitable_pool(network_mask)[0] # FIXME: exception - which? if not pools: return None + # last_net = ipaddress.ip_network(self.used_networks.last().address) + # last_net_ip = last_net[0] - # Find all pools with the correct size + # if last_net_ip.version == 6: + # offset_to_next = 2**(128 - self.subnetwork_size) + # elif last_net_ip.version == 4: + # offset_to_next = 2**(32 - self.subnetwork_size) - # For each pool see if it has still space: - # num network reversations < 2**(subnetwork_size-network_size) + # next_net_ip = last_net_ip + offset_to_next - - def next_free_network(self): - if self.num_free_networks == 0: - # FIXME: use right exception - raise Exception("No free networks") - - if len(self.free_networks) > 0: - return self.free_networks[0].address - - if len(self.used_networks) > 0: - """ - sample: - - pool = 2a0a:e5c1:200::/40 - last_used = 2a0a:e5c1:204::/48 - - next: - """ - - last_net = ipaddress.ip_network(self.used_networks.last().address) - last_net_ip = last_net[0] - - if last_net_ip.version == 6: - offset_to_next = 2**(128 - self.subnetwork_size) - elif last_net_ip.version == 4: - offset_to_next = 2**(32 - self.subnetwork_size) - - next_net_ip = last_net_ip + offset_to_next - - return str(next_net_ip) - else: - # first network to be created - return self.network + # return str(next_net_ip) + # else: + # # first network to be created + # return self.network @property def wireguard_config_filename(self): diff --git a/uncloud_net/views.py b/uncloud_net/views.py index d52b076..dee3fac 100644 --- a/uncloud_net/views.py +++ b/uncloud_net/views.py @@ -23,17 +23,17 @@ from .forms import * # queryset = VPNNetworkReservation.objects.all() -# class VPNNetworkViewSet(viewsets.ModelViewSet): -# serializer_class = VPNNetworkSerializer -# permission_classes = [permissions.IsAuthenticated] +class WireGuardVPNViewSet(viewsets.ModelViewSet): + serializer_class = WireGuardVPNSerializer + permission_classes = [permissions.IsAuthenticated] -# def get_queryset(self): -# if self.request.user.is_superuser: -# obj = VPNNetwork.objects.all() -# else: -# obj = VPNNetwork.objects.filter(owner=self.request.user) + def get_queryset(self): + if self.request.user.is_superuser: + obj = WireGuardVPN.objects.all() + else: + obj = WireGuardVPN.objects.filter(owner=self.request.user) -# return obj + return obj class WireGuardVPNCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): @@ -49,7 +49,14 @@ class WireGuardVPNCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView return self.success_message % dict(cleaned_data, the_prefix = self.object.prefix) - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['available_sizes'] = 2 - return context + # def get_context_data(self, **kwargs): + # context = super().get_context_data(**kwargs) + # context['available_sizes'] = 2 + # return context + + # def post(request, *args, **kwargs): + # print(request) + # print(*args) + # print(*kwargs) + + # def post(self, request, *args, **kwargs): From cd19c47fdb967f5c7e286f5c657a5ae63912de77 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 13 Dec 2020 17:59:35 +0100 Subject: [PATCH 50/93] [vpn] implement creating vpns --- uncloud_net/migrations/0001_initial.py | 14 ++++++- uncloud_net/models.py | 49 +++++++++++++++++++++++- uncloud_net/selectors.py | 18 ++++++++- uncloud_net/serializers.py | 20 ++++------ uncloud_net/services.py | 52 ++++++++++++++++---------- uncloud_net/views.py | 32 +++++++--------- 6 files changed, 130 insertions(+), 55 deletions(-) diff --git a/uncloud_net/migrations/0001_initial.py b/uncloud_net/migrations/0001_initial.py index 36ba522..6794156 100644 --- a/uncloud_net/migrations/0001_initial.py +++ b/uncloud_net/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1 on 2020-12-13 10:38 +# Generated by Django 3.1 on 2020-12-13 13:42 from django.conf import settings import django.core.validators @@ -32,11 +32,21 @@ class Migration(migrations.Migration): ('wireguard_private_key', models.CharField(max_length=48)), ], ), + migrations.CreateModel( + name='WireGuardVPNFreeLeases', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('pool_index', models.IntegerField(unique=True)), + ('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.wireguardvpnpool')), + ], + ), migrations.CreateModel( name='WireGuardVPN', fields=[ - ('address', models.GenericIPAddressField(primary_key=True, serialize=False)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('pool_index', models.IntegerField(unique=True)), ('wireguard_public_key', models.CharField(max_length=48)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.wireguardvpnpool')), ], ), diff --git a/uncloud_net/models.py b/uncloud_net/models.py index 2f573bd..1b69a9e 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -25,6 +25,24 @@ class WireGuardVPNPool(models.Model): vpn_server_hostname = models.CharField(max_length=256) wireguard_private_key = models.CharField(max_length=48) + @property + def max_pool_index(self): + """ + Return the highest possible network / last network id + """ + + bits = self.subnetwork_mask - self.network_mask + + return (2**bits)-1 + + @property + def ip_network(self): + return ipaddress.ip_network(f"{self.network}/{self.network_mask}") + + def __str__(self): + return f"{self.ip_network} (subnets: /{self.subnetwork_mask})" + + class WireGuardVPN(models.Model): """ Created VPNNetworks @@ -34,10 +52,39 @@ class WireGuardVPN(models.Model): vpnpool = models.ForeignKey(WireGuardVPNPool, on_delete=models.CASCADE) - address = models.GenericIPAddressField(primary_key=True) + pool_index = models.IntegerField(unique=True) wireguard_public_key = models.CharField(max_length=48) + @property + def network_mask(self): + return self.vpnpool.subnetwork_mask + + @property + def address(self): + """ + Locate the correct subnet in the supernet + + First get the network itself + + """ + + net = self.vpnpool.ip_network + subnet = net[(2**(128-self.vpnpool.subnetwork_mask)) * self.pool_index] + + return str(subnet) + + def __str__(self): + return f"{self.address} ({self.pool_index})" + +class WireGuardVPNFreeLeases(models.Model): + """ + Previously used VPNNetworks + """ + vpnpool = models.ForeignKey(WireGuardVPNPool, + on_delete=models.CASCADE) + + pool_index = models.IntegerField(unique=True) ################################################################################ diff --git a/uncloud_net/selectors.py b/uncloud_net/selectors.py index 70fafd2..bcb1ea8 100644 --- a/uncloud_net/selectors.py +++ b/uncloud_net/selectors.py @@ -4,7 +4,10 @@ from django.db.models import Count, F from .models import * -def get_suitable_pool(subnetwork_mask): +# def get_num_used_networks(pool): +# return pool.wireguardvpn_set.count() + +def get_suitable_pools(subnetwork_mask): """ Find suitable pools for a certain network size. @@ -42,3 +45,16 @@ def allowed_vpn_network_reservation_size(): # Need to return set of tuples, see # https://docs.djangoproject.com/en/3.1/ref/models/fields/#field-choices return set([ (pool.subnetwork_mask, pool.subnetwork_mask) for pool in pools ]) + + +#def get_next_vpnnetwork(pool): + # get all associated networks + # look for the lowest free number + # return that + + + # select last used one + # try to increment by one -> get new network + + # if that fails search through the existing vpns for the first unused number + # diff --git a/uncloud_net/serializers.py b/uncloud_net/serializers.py index 8c6c567..46d2344 100644 --- a/uncloud_net/serializers.py +++ b/uncloud_net/serializers.py @@ -8,23 +8,17 @@ from .models import * from .services import * class WireGuardVPNSerializer(serializers.ModelSerializer): + address = serializers.CharField(read_only=True) + network_mask = serializers.IntegerField() + class Meta: model = WireGuardVPN - fields = [ 'wireguard_public_key' ] + fields = [ 'wireguard_public_key', 'address', 'network_mask' ] read_only_fields = [ 'address ' ] - def create(self, validated_data): - pass - -# class WireGuardVPNPoolSerializer(serializers.ModelSerializer): -# class Meta: -# model = WireGuardVPNPool -# fields = '__all__' - -# class WireGuardVPNSerializer(serializers.ModelSerializer): -# class Meta: -# model = VPNNetworkReservation -# fields = '__all__' + extra_kwargs = { + 'network_mask': {'write_only': True } + } # class VPNNetworkSerializer(serializers.ModelSerializer): diff --git a/uncloud_net/services.py b/uncloud_net/services.py index 45e14c9..ec7a266 100644 --- a/uncloud_net/services.py +++ b/uncloud_net/services.py @@ -4,32 +4,46 @@ from .models import * from .selectors import * @transaction.atomic -def create_wireguard_vpn(*, - public_key: str, - network_mask: int - ) -> WireGuardVPN: +def create_wireguard_vpn(owner, public_key, network_mask): - pool = get_suitable_pool(network_mask)[0] + pool = get_suitable_pools(network_mask)[0] + count = pool.wireguardvpn_set.count() - # FIXME: exception - which? - if not pools: - return None + # First object + if count == 0: + return WireGuardVPN.objects.create(owner=owner, + vpnpool=pool, + pool_index=0, + wireguard_public_key=public_key) - # last_net = ipaddress.ip_network(self.used_networks.last().address) - # last_net_ip = last_net[0] + else: # Select last network and try +1 it + last_net = WireGuardVPN.objects.filter(vpnpool=pool).order_by('pool_index').last() - # if last_net_ip.version == 6: - # offset_to_next = 2**(128 - self.subnetwork_size) - # elif last_net_ip.version == 4: - # offset_to_next = 2**(32 - self.subnetwork_size) + next_index = last_net.pool_index + 1 - # next_net_ip = last_net_ip + offset_to_next + if next_index <= pool.max_pool_index: + return WireGuardVPN.objects.create(owner=owner, + vpnpool=pool, + pool_index=next_index, + wireguard_public_key=public_key) - # return str(next_net_ip) - # else: - # # first network to be created - # return self.network + + # Still there? Then we need to lookup previously used networks + try: + free_lease = WireGuardVPNFreeLeases.objects.get(vpnpool=pool) + + vpn = WireGuardVPN.objects.create(owner=owner, + vpnpool=pool, + pool_index=free_lease.pool_index, + wireguard_public_key=public_key) + + free_lease.delete() + + return vpn + + except WireGuardVPNFreeLeases.DoesNotExist: + pass @property def wireguard_config_filename(self): diff --git a/uncloud_net/views.py b/uncloud_net/views.py index dee3fac..f91ff7c 100644 --- a/uncloud_net/views.py +++ b/uncloud_net/views.py @@ -1,15 +1,16 @@ from django.views.generic.edit import CreateView from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.messages.views import SuccessMessageMixin +from rest_framework.response import Response from django.shortcuts import render from rest_framework import viewsets, permissions - from .models import * from .serializers import * from .selectors import * +from .services import * from .forms import * # class VPNPoolViewSet(viewsets.ModelViewSet): @@ -17,12 +18,6 @@ from .forms import * # permission_classes = [permissions.IsAdminUser] # queryset = VPNPool.objects.all() -# class VPNNetworkReservationViewSet(viewsets.ModelViewSet): -# serializer_class = VPNNetworkReservationSerializer -# permission_classes = [permissions.IsAdminUser] -# queryset = VPNNetworkReservation.objects.all() - - class WireGuardVPNViewSet(viewsets.ModelViewSet): serializer_class = WireGuardVPNSerializer permission_classes = [permissions.IsAuthenticated] @@ -35,6 +30,17 @@ class WireGuardVPNViewSet(viewsets.ModelViewSet): return obj + def create(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + vpn = create_wireguard_vpn( + owner=self.request.user, + public_key=serializer.validated_data['wireguard_public_key'], + network_mask=serializer.validated_data['network_mask'] + ) + return Response(WireGuardVPNSerializer(vpn).data) + class WireGuardVPNCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView): model = WireGuardVPN @@ -48,15 +54,3 @@ class WireGuardVPNCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView def get_success_message(self, cleaned_data): return self.success_message % dict(cleaned_data, the_prefix = self.object.prefix) - - # def get_context_data(self, **kwargs): - # context = super().get_context_data(**kwargs) - # context['available_sizes'] = 2 - # return context - - # def post(request, *args, **kwargs): - # print(request) - # print(*args) - # print(*kwargs) - - # def post(self, request, *args, **kwargs): From aec79cba74604f57c07b8a99f0a043045378901b Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 13 Dec 2020 18:05:48 +0100 Subject: [PATCH 51/93] [vpn] include vpn server public key --- ...2_wireguardvpnpool_wireguard_public_key.py | 19 +++++++++++++++++++ uncloud_net/models.py | 10 ++++++++++ uncloud_net/serializers.py | 6 ++++-- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 uncloud_net/migrations/0002_wireguardvpnpool_wireguard_public_key.py diff --git a/uncloud_net/migrations/0002_wireguardvpnpool_wireguard_public_key.py b/uncloud_net/migrations/0002_wireguardvpnpool_wireguard_public_key.py new file mode 100644 index 0000000..479aba1 --- /dev/null +++ b/uncloud_net/migrations/0002_wireguardvpnpool_wireguard_public_key.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2020-12-13 17:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='wireguardvpnpool', + name='wireguard_public_key', + field=models.CharField(default='', max_length=48), + preserve_default=False, + ), + ] diff --git a/uncloud_net/models.py b/uncloud_net/models.py index 1b69a9e..88c0ec8 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -24,6 +24,7 @@ class WireGuardVPNPool(models.Model): vpn_server_hostname = models.CharField(max_length=256) wireguard_private_key = models.CharField(max_length=48) + wireguard_public_key = models.CharField(max_length=48) @property def max_pool_index(self): @@ -60,6 +61,14 @@ class WireGuardVPN(models.Model): def network_mask(self): return self.vpnpool.subnetwork_mask + @property + def vpn_server(self): + return self.vpnpool.vpn_server_hostname + + @property + def vpn_server_public_key(self): + return self.vpnpool.wireguard_public_key + @property def address(self): """ @@ -77,6 +86,7 @@ class WireGuardVPN(models.Model): def __str__(self): return f"{self.address} ({self.pool_index})" + class WireGuardVPNFreeLeases(models.Model): """ Previously used VPNNetworks diff --git a/uncloud_net/serializers.py b/uncloud_net/serializers.py index 46d2344..6965aa7 100644 --- a/uncloud_net/serializers.py +++ b/uncloud_net/serializers.py @@ -9,12 +9,14 @@ from .services import * class WireGuardVPNSerializer(serializers.ModelSerializer): address = serializers.CharField(read_only=True) + vpn_server = serializers.CharField(read_only=True) + vpn_server_public_key = serializers.CharField(read_only=True) network_mask = serializers.IntegerField() class Meta: model = WireGuardVPN - fields = [ 'wireguard_public_key', 'address', 'network_mask' ] - read_only_fields = [ 'address ' ] + fields = [ 'wireguard_public_key', 'address', 'network_mask', 'vpn_server', + 'vpn_server_public_key' ] extra_kwargs = { 'network_mask': {'write_only': True } From 2d62388eb17b785b23979c91b7cd74624be11b82 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 13 Dec 2020 18:34:43 +0100 Subject: [PATCH 52/93] phasing in celery for configuring the vpn server --- requirements.txt | 3 + uncloud/__init__.py | 4 + uncloud/celery.py | 22 +++++ .../0003_wireguardvpnpool_wg_name.py | 19 ++++ .../migrations/0004_auto_20201213_1734.py | 17 ++++ uncloud_net/models.py | 10 ++ uncloud_net/services.py | 99 ++++--------------- uncloud_net/tasks.py | 66 +++++++++++++ 8 files changed, 160 insertions(+), 80 deletions(-) create mode 100644 uncloud/celery.py create mode 100644 uncloud_net/migrations/0003_wireguardvpnpool_wg_name.py create mode 100644 uncloud_net/migrations/0004_auto_20201213_1734.py create mode 100644 uncloud_net/tasks.py diff --git a/requirements.txt b/requirements.txt index 12e01f0..2394073 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,3 +23,6 @@ uritemplate # Comprehensive interface to validate VAT numbers, making use of the VIES # service for European countries. vat-validator + +# Tasks +celery diff --git a/uncloud/__init__.py b/uncloud/__init__.py index 4bda45f..e073dd5 100644 --- a/uncloud/__init__.py +++ b/uncloud/__init__.py @@ -1,5 +1,6 @@ from django.utils.translation import gettext_lazy as _ import decimal +from .celery import app as celery_app # Define DecimalField properties, used to represent amounts of money. AMOUNT_MAX_DIGITS=10 @@ -248,3 +249,6 @@ COUNTRIES = ( ('ZR', _('Zaire')), ('ZW', _('Zimbabwe')), ) + + +__all__ = ('celery_app',) diff --git a/uncloud/celery.py b/uncloud/celery.py new file mode 100644 index 0000000..7bcaaae --- /dev/null +++ b/uncloud/celery.py @@ -0,0 +1,22 @@ +import os + +from celery import Celery + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings') + +app = Celery('uncloud') + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() + + +@app.task(bind=True) +def debug_task(self): + print(f'Request: {self.request!r}') diff --git a/uncloud_net/migrations/0003_wireguardvpnpool_wg_name.py b/uncloud_net/migrations/0003_wireguardvpnpool_wg_name.py new file mode 100644 index 0000000..9ecf52c --- /dev/null +++ b/uncloud_net/migrations/0003_wireguardvpnpool_wg_name.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2020-12-13 17:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0002_wireguardvpnpool_wireguard_public_key'), + ] + + operations = [ + migrations.AddField( + model_name='wireguardvpnpool', + name='wg_name', + field=models.CharField(default='wg0', max_length=15), + preserve_default=False, + ), + ] diff --git a/uncloud_net/migrations/0004_auto_20201213_1734.py b/uncloud_net/migrations/0004_auto_20201213_1734.py new file mode 100644 index 0000000..24e46e7 --- /dev/null +++ b/uncloud_net/migrations/0004_auto_20201213_1734.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-12-13 17:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0003_wireguardvpnpool_wg_name'), + ] + + operations = [ + migrations.AddConstraint( + model_name='wireguardvpnpool', + constraint=models.UniqueConstraint(fields=('wg_name', 'vpn_server_hostname'), name='unique_interface_name_per_host'), + ), + ] diff --git a/uncloud_net/models.py b/uncloud_net/models.py index 88c0ec8..5c1da3d 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -13,6 +13,16 @@ class WireGuardVPNPool(models.Model): Network address pools from which VPNs can be created """ + class Meta: + constraints = [ + models.UniqueConstraint(fields=['wg_name', 'vpn_server_hostname' ], + name='unique_interface_name_per_host') + ] + + + # Linux interface naming is restricing to max 15 characters + wg_name = models.CharField(max_length=15) + network = models.GenericIPAddressField(unique=True) network_mask = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(128)]) diff --git a/uncloud_net/services.py b/uncloud_net/services.py index ec7a266..4f80c44 100644 --- a/uncloud_net/services.py +++ b/uncloud_net/services.py @@ -1,7 +1,8 @@ from django.db import transaction -from .models import * +from .models import * from .selectors import * +from .tasks import * @transaction.atomic def create_wireguard_vpn(owner, public_key, network_mask): @@ -9,27 +10,7 @@ def create_wireguard_vpn(owner, public_key, network_mask): pool = get_suitable_pools(network_mask)[0] count = pool.wireguardvpn_set.count() - # First object - if count == 0: - return WireGuardVPN.objects.create(owner=owner, - vpnpool=pool, - pool_index=0, - wireguard_public_key=public_key) - - - else: # Select last network and try +1 it - last_net = WireGuardVPN.objects.filter(vpnpool=pool).order_by('pool_index').last() - - next_index = last_net.pool_index + 1 - - if next_index <= pool.max_pool_index: - return WireGuardVPN.objects.create(owner=owner, - vpnpool=pool, - pool_index=next_index, - wireguard_public_key=public_key) - - - # Still there? Then we need to lookup previously used networks + # Try re-using previously used networks first try: free_lease = WireGuardVPNFreeLeases.objects.get(vpnpool=pool) @@ -40,69 +21,27 @@ def create_wireguard_vpn(owner, public_key, network_mask): free_lease.delete() - return vpn - except WireGuardVPNFreeLeases.DoesNotExist: - pass - @property - def wireguard_config_filename(self): - return '/etc/wireguard/{}.conf'.format(self.network) + # First object + if count == 0: + vpn = WireGuardVPN.objects.create(owner=owner, + vpnpool=pool, + pool_index=0, + wireguard_public_key=public_key) - @property - def wireguard_config(self): - wireguard_config = [ - """ -[Interface] -ListenPort = 51820 -PrivateKey = {privatekey} -""".format(privatekey=self.wireguard_private_key) ] + else: # Select last network and try +1 it + last_net = WireGuardVPN.objects.filter(vpnpool=pool).order_by('pool_index').last() - peers = [] + next_index = last_net.pool_index + 1 - for reservation in self.vpnnetworkreservation_set.filter(status='used'): - public_key = reservation.vpnnetwork_set.first().wireguard_public_key - peer_network = "{}/{}".format(reservation.address, self.subnetwork_size) - owner = reservation.vpnnetwork_set.first().owner - - peers.append(""" -# Owner: {owner} -[Peer] -PublicKey = {public_key} -AllowedIPs = {peer_network} -""".format( - owner=owner, - public_key=public_key, - peer_network=peer_network)) - - wireguard_config.extend(peers) - - return "\n".join(wireguard_config) - - - def configure_wireguard_vpnserver(self): - """ - This method is designed to run as a celery task and should - not be called directly from the web - """ - - # subprocess, ssh - - pass + if next_index <= pool.max_pool_index: + vpn = WireGuardVPN.objects.create(owner=owner, + vpnpool=pool, + pool_index=next_index, + wireguard_public_key=public_key) - def num_maximum_networks(self): - """ - sample: - network_size = 40 - subnetwork_size = 48 - maximum_networks = 2^(48-40) - - 2nd sample: - network_size = 8 - subnetwork_size = 24 - maximum_networks = 2^(24-8) - """ - - return 2**(self.subnetwork_mask - self.network_mask) + configure_wireguard_server(pool) + return vpn diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py new file mode 100644 index 0000000..ca2a05d --- /dev/null +++ b/uncloud_net/tasks.py @@ -0,0 +1,66 @@ +from celery import shared_task +from .models import * + +@shared_task +def configure_wireguard_server(vpnpool): + print(f"Configuring {vpnpool.vpn_server_hostname}") + + wireguard_config_filename = '/etc/wireguard/{}.conf'.format(vpnpool.network) + + @property + def wireguard_config(self): + wireguard_config = [ + """ +[Interface] +ListenPort = 51820 +PrivateKey = {privatekey} +""".format(privatekey=self.wireguard_private_key) ] + + peers = [] + + for reservation in self.vpnnetworkreservation_set.filter(status='used'): + public_key = reservation.vpnnetwork_set.first().wireguard_public_key + peer_network = "{}/{}".format(reservation.address, self.subnetwork_size) + owner = reservation.vpnnetwork_set.first().owner + + peers.append(""" +# Owner: {owner} +[Peer] +PublicKey = {public_key} +AllowedIPs = {peer_network} +""".format( + owner=owner, + public_key=public_key, + peer_network=peer_network)) + + wireguard_config.extend(peers) + + return "\n".join(wireguard_config) + + + def configure_wireguard_vpnserver(self): + """ + This method is designed to run as a celery task and should + not be called directly from the web + """ + + # subprocess, ssh + + pass + + + + def num_maximum_networks(self): + """ + sample: + network_size = 40 + subnetwork_size = 48 + maximum_networks = 2^(48-40) + + 2nd sample: + network_size = 8 + subnetwork_size = 24 + maximum_networks = 2^(24-8) + """ + + return 2**(self.subnetwork_mask - self.network_mask) From 16f3adef93d0e36095efbcd5ebf737b3e203be40 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 13 Dec 2020 18:56:47 +0100 Subject: [PATCH 53/93] [doc] ++requirements alpine --- doc/uncloud-manual-2020-08-01.org | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index 8c8d7e5..d48ce5e 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -2,7 +2,7 @@ ** Pre-requisites by operating system *** Alpine #+BEGIN_SRC sh -apk add openldap-dev postgresql-dev libxml2-dev libxslt-dev +apk add openldap-dev postgresql-dev libxml2-dev libxslt-dev gcc python3-dev musl-dev #+END_SRC *** Debian/Devuan: #+BEGIN_SRC sh From 372fe800cd894b891c6dca5098c18e9afa4ddddf Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 13 Dec 2020 19:06:22 +0100 Subject: [PATCH 54/93] fill in template values for settings --- uncloud/settings.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/uncloud/settings.py b/uncloud/settings.py index 9a9f0a6..2e1db55 100644 --- a/uncloud/settings.py +++ b/uncloud/settings.py @@ -170,7 +170,6 @@ OPENNEBULA_URL = 'https://opennebula.example.com:2634/RPC2' # user:pass for accessing opennebula OPENNEBULA_USER_PASS = 'user:password' - # Stripe (Credit Card payments) STRIPE_KEY="" STRIPE_PUBLIC_KEY="" @@ -189,6 +188,18 @@ UNCLOUD_ADMIN_NAME = "uncloud-admin" LOGIN_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/' +# replace these in local_settings.py +AUTH_LDAP_SERVER_URI = "ldaps://ldap1.example.com,ldaps://ldap2.example.com" +AUTH_LDAP_BIND_DN="uid=django,ou=system,dc=example,dc=com" +AUTH_LDAP_BIND_PASSWORD="a very secure ldap password" +AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=example,dc=com", + ldap.SCOPE_SUBTREE, + "(uid=%(user)s)") + +# where to create customers +LDAP_CUSTOMER_DN="ou=customer,dc=example,dc=com" + + # Overwrite settings with local settings, if existing try: from uncloud.local_settings import * From e2b36c8bca029542d041e537e5fa9ec658304336 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 13 Dec 2020 19:50:36 +0100 Subject: [PATCH 55/93] celery test --- requirements.txt | 1 + uncloud/settings.py | 10 ++++++++++ uncloud_net/tasks.py | 35 +++++++---------------------------- 3 files changed, 18 insertions(+), 28 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2394073..d5e6a78 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,3 +26,4 @@ vat-validator # Tasks celery +redis diff --git a/uncloud/settings.py b/uncloud/settings.py index 2e1db55..cd60354 100644 --- a/uncloud/settings.py +++ b/uncloud/settings.py @@ -199,6 +199,16 @@ AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=example,dc=com", # where to create customers LDAP_CUSTOMER_DN="ou=customer,dc=example,dc=com" +CELERY_TASK_ROUTES = { + '*': { + 'queue': 'vpn1' + } +} + +CELERY_BROKER_URL = 'redis://bridge.place7.ungleich.ch:6379/0' +CELERY_RESULT_BACKEND = 'redis://bridge.place7.ungleich.ch:6379/0' + +# CELERY_TASK_CREATE_MISSING_QUEUES = False # Overwrite settings with local settings, if existing try: diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index ca2a05d..60c1e7d 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -1,6 +1,13 @@ from celery import shared_task from .models import * +import os + +@shared_task +def whereami(): + print(os.uname()) + return os.uname() + @shared_task def configure_wireguard_server(vpnpool): print(f"Configuring {vpnpool.vpn_server_hostname}") @@ -36,31 +43,3 @@ AllowedIPs = {peer_network} wireguard_config.extend(peers) return "\n".join(wireguard_config) - - - def configure_wireguard_vpnserver(self): - """ - This method is designed to run as a celery task and should - not be called directly from the web - """ - - # subprocess, ssh - - pass - - - - def num_maximum_networks(self): - """ - sample: - network_size = 40 - subnetwork_size = 48 - maximum_networks = 2^(48-40) - - 2nd sample: - network_size = 8 - subnetwork_size = 24 - maximum_networks = 2^(24-8) - """ - - return 2**(self.subnetwork_mask - self.network_mask) From 054886fd9c486c231b3ee9d919d09b9e8da705d2 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 12:20:54 +0100 Subject: [PATCH 56/93] begin phasing in config of vpn via cdist --- doc/uncloud-manual-2020-08-01.org | 49 +++++++++++++++++++++++++++- uncloud/settings.py | 30 +++++++++++++---- uncloud_net/models.py | 23 +++++++++++++ uncloud_net/tasks.py | 54 +++++++++++++------------------ uncloud_net/views.py | 2 ++ 5 files changed, 119 insertions(+), 39 deletions(-) diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index d48ce5e..83fa65e 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -170,7 +170,7 @@ VPNNetworks can be managed by all authenticated users. * Developer Handbook The following section describe decisions / architecture of uncloud. These chapters are intended to be read by developers. -** Documentation +** This Documentation This documentation is written in org-mode. To compile it to html/pdf, just open emacs and press *C-c C-e l p*. ** Models @@ -234,6 +234,53 @@ VPNNetworks can be managed by all authenticated users. *** Decision We use integers, because they are easy. +** Distributing/Dispatching/Orchestrating +*** Variant 1: using cdist + - The uncloud server can git commit things + - The uncloud server loads cdist and configures the server + - Advantages + - Fully integrated into normal flow + - Disadvantage + - web frontend has access to more data than it needs + - On compromise of the machine, more data leaks + - Some cdist usual delay +*** Variant 2: via celery + - The uncloud server dispatches via celery + - Every decentral node also runs celery/connects to the broker + - Summary brokers: + - If local only celery -> good to use redis - Broker + - If remote: probably better to use rabbitmq + - redis + - simpler + - rabbitmq + - more versatile + - made for remote connections + - quorom queues would be nice, but not clear if supported + - https://github.com/celery/py-amqp/issues/302 + - https://github.com/celery/celery/issues/6067 + - Cannot be installed on alpine Linux at the moment + - Advantage + - Very python / django integrated + - Rather instant + - Disadvantages + - Every decentral node needs to have the uncloud code available + - Decentral nodes *might* need to access the database + - Tasks can probably be written to work without that + (i.e. only strings/bytes) + +**** log/tests + (venv) [19:54] vpn-2a0ae5c1200:~/uncloud$ celery -A uncloud -b redis://bridge.place7.ungleich.ch worker -n worker1@%h --logfile ~/celery.log - +Q vpn-2a0ae5c1200.ungleich.ch + + +*** Variant 3: dedicated cdist instance via message broker + - A separate VM/machine + - Has Checkout of ~/.cdist + - Has cdist checkout + - Tiny API for management + - Not directly web accessible + - "cdist" queue + ** Milestones :uncloud: *** 1.1 (cleanup 1) **** TODO [#C] Unify ValidationError, FieldError - define proper Exception diff --git a/uncloud/settings.py b/uncloud/settings.py index cd60354..bc99743 100644 --- a/uncloud/settings.py +++ b/uncloud/settings.py @@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/3.0/ref/settings/ """ import os +import re import ldap from django.core.management.utils import get_random_secret_key @@ -199,14 +200,29 @@ AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=example,dc=com", # where to create customers LDAP_CUSTOMER_DN="ou=customer,dc=example,dc=com" -CELERY_TASK_ROUTES = { - '*': { - 'queue': 'vpn1' - } -} +def route_task(name, args, kwargs, options, task=None, **kw): + print(f"{name} - {args} - {kwargs}") +# if name == 'myapp.tasks.compress_video': + return {'queue': 'vpn1' } +# 'exchange_type': 'topic', +# 'routing_key': 'video.compress'} -CELERY_BROKER_URL = 'redis://bridge.place7.ungleich.ch:6379/0' -CELERY_RESULT_BACKEND = 'redis://bridge.place7.ungleich.ch:6379/0' + +CELERY_TASK_ROUTES = (route_task,) + +# CELERY_TASK_ROUTES = { +# '*': { +# 'queue': 'vpn1' +# } +# } + + +CELERY_BROKER_URL = 'redis://:uncloud.example.com:6379/0' +CELERY_RESULT_BACKEND = 'redis://:uncloud.example.com:6379/0' + +CELERY_TASK_ROUTES = { + (re.compile(r'.*\.cdist\..*'), { 'queue': 'cdist' } + } # CELERY_TASK_CREATE_MISSING_QUEUES = False diff --git a/uncloud_net/models.py b/uncloud_net/models.py index 5c1da3d..92499dd 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -53,6 +53,29 @@ class WireGuardVPNPool(models.Model): def __str__(self): return f"{self.ip_network} (subnets: /{self.subnetwork_mask})" + @property + def wireguard_config(self): + wireguard_config = [ + "[Interface]\nListenPort = 51820\nPrivateKey = {self.wireguard_private_key}\n".format( + privatekey=self.wireguard_private_key) + ] + + peers = [] + + for vpn in self.wireguardvpn_set.all(): + public_key = vpn.wireguard_public_key + peer_network = "{}/{}".format(vpn.address, self.subnetwork_mask) + owner = vpn.owner + + peers.append("# Owner: {owner}\n[Peer]\nPublicKey = {public_key}\nAllowedIPs = {peer_network}\n\n".format( + owner=owner, + public_key=public_key, + peer_network=peer_network)) + + wireguard_config.extend(peers) + + return "\n".join(wireguard_config) + class WireGuardVPN(models.Model): """ diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 60c1e7d..3b07b48 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -8,38 +8,30 @@ def whereami(): print(os.uname()) return os.uname() +def configure_wireguard_server(wireguardvpnpool): + """ + - Create wireguard config (DB query -> string) + - Submit config to cdist worker + - Change config locally on worker / commit / shared + + """ + + config = wireguardvpnpool.wireguard_config + server = wireguardvpnpool.vpn_server_hostname + + print(f"Configuring {vpnpool.vpn_server_hostname}: {osa}") + cdist_configure_wireguard_server(config, server): + + @shared_task -def configure_wireguard_server(vpnpool): - print(f"Configuring {vpnpool.vpn_server_hostname}") +def cdist_configure_wireguard_server(config, server): + """ + Create config and configure server. - wireguard_config_filename = '/etc/wireguard/{}.conf'.format(vpnpool.network) + To be executed on the cdist workers. + """ - @property - def wireguard_config(self): - wireguard_config = [ - """ -[Interface] -ListenPort = 51820 -PrivateKey = {privatekey} -""".format(privatekey=self.wireguard_private_key) ] + fname = f"/home/app/.cdist/type/__ungleich_wireguard/files/{server}" - peers = [] - - for reservation in self.vpnnetworkreservation_set.filter(status='used'): - public_key = reservation.vpnnetwork_set.first().wireguard_public_key - peer_network = "{}/{}".format(reservation.address, self.subnetwork_size) - owner = reservation.vpnnetwork_set.first().owner - - peers.append(""" -# Owner: {owner} -[Peer] -PublicKey = {public_key} -AllowedIPs = {peer_network} -""".format( - owner=owner, - public_key=public_key, - peer_network=peer_network)) - - wireguard_config.extend(peers) - - return "\n".join(wireguard_config) + with open(fname, "w") as fd: + fd.write(config) diff --git a/uncloud_net/views.py b/uncloud_net/views.py index f91ff7c..e06e9bd 100644 --- a/uncloud_net/views.py +++ b/uncloud_net/views.py @@ -12,6 +12,7 @@ from .serializers import * from .selectors import * from .services import * from .forms import * +from .tasks import * # class VPNPoolViewSet(viewsets.ModelViewSet): # serializer_class = VPNPoolSerializer @@ -39,6 +40,7 @@ class WireGuardVPNViewSet(viewsets.ModelViewSet): public_key=serializer.validated_data['wireguard_public_key'], network_mask=serializer.validated_data['network_mask'] ) + configure_wireguard_server.apply_async((vpn.vpnpool,)) return Response(WireGuardVPNSerializer(vpn).data) From 179baee96d37d1d7d9c3f9ccec731e80dc906769 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 12:22:50 +0100 Subject: [PATCH 57/93] fix celery task routes syntax error --- uncloud/settings.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/uncloud/settings.py b/uncloud/settings.py index bc99743..970c3e5 100644 --- a/uncloud/settings.py +++ b/uncloud/settings.py @@ -220,9 +220,7 @@ CELERY_TASK_ROUTES = (route_task,) CELERY_BROKER_URL = 'redis://:uncloud.example.com:6379/0' CELERY_RESULT_BACKEND = 'redis://:uncloud.example.com:6379/0' -CELERY_TASK_ROUTES = { - (re.compile(r'.*\.cdist\..*'), { 'queue': 'cdist' } - } +CELERY_TASK_ROUTES = ( [ (re.compile(r'.*\.cdist\..*'), { 'queue': 'cdist' }), ], ) # CELERY_TASK_CREATE_MISSING_QUEUES = False From b3626369a20f570c8bc68c3843df4515c8fbf833 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 12:24:35 +0100 Subject: [PATCH 58/93] --syntax error --- uncloud_net/tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 3b07b48..5d1afe7 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -19,8 +19,8 @@ def configure_wireguard_server(wireguardvpnpool): config = wireguardvpnpool.wireguard_config server = wireguardvpnpool.vpn_server_hostname - print(f"Configuring {vpnpool.vpn_server_hostname}: {osa}") - cdist_configure_wireguard_server(config, server): + print(f"Configuring VPN server {server}") + cdist_configure_wireguard_server(config, server) @shared_task From 2e6c72c093c8ec9a523c2dad289748f5cb09fb7e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 12:45:36 +0100 Subject: [PATCH 59/93] wireguard/celery fixes --- uncloud_net/models.py | 12 +++--------- uncloud_net/tasks.py | 11 ++++++++++- uncloud_net/views.py | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/uncloud_net/models.py b/uncloud_net/models.py index 92499dd..b4e1e8d 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -55,22 +55,16 @@ class WireGuardVPNPool(models.Model): @property def wireguard_config(self): - wireguard_config = [ - "[Interface]\nListenPort = 51820\nPrivateKey = {self.wireguard_private_key}\n".format( - privatekey=self.wireguard_private_key) - ] + wireguard_config = [ f"[Interface]\nListenPort = 51820\nPrivateKey = {self.wireguard_private_key}\n" ] peers = [] for vpn in self.wireguardvpn_set.all(): public_key = vpn.wireguard_public_key - peer_network = "{}/{}".format(vpn.address, self.subnetwork_mask) + peer_network = f"{vpn.address}/{self.subnetwork_mask}" owner = vpn.owner - peers.append("# Owner: {owner}\n[Peer]\nPublicKey = {public_key}\nAllowedIPs = {peer_network}\n\n".format( - owner=owner, - public_key=public_key, - peer_network=peer_network)) + peers.append(f"# Owner: {owner}\n[Peer]\nPublicKey = {public_key}\nAllowedIPs = {peer_network}\n\n") wireguard_config.extend(peers) diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 5d1afe7..9c1071b 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -20,7 +20,12 @@ def configure_wireguard_server(wireguardvpnpool): server = wireguardvpnpool.vpn_server_hostname print(f"Configuring VPN server {server}") - cdist_configure_wireguard_server(config, server) + #res = cdist_configure_wireguard_server.delay(config, server) + res = cdist_configure_wireguard_server.apply_async((config, server), queue='cdist') + + print(f"res={res}") + res2= res.get() + print(f"res2={res2}") @shared_task @@ -33,5 +38,9 @@ def cdist_configure_wireguard_server(config, server): fname = f"/home/app/.cdist/type/__ungleich_wireguard/files/{server}" + print(os.uname()) with open(fname, "w") as fd: fd.write(config) + + + return "All good" diff --git a/uncloud_net/views.py b/uncloud_net/views.py index e06e9bd..72ff681 100644 --- a/uncloud_net/views.py +++ b/uncloud_net/views.py @@ -40,7 +40,7 @@ class WireGuardVPNViewSet(viewsets.ModelViewSet): public_key=serializer.validated_data['wireguard_public_key'], network_mask=serializer.validated_data['network_mask'] ) - configure_wireguard_server.apply_async((vpn.vpnpool,)) + configure_wireguard_server(vpn.vpnpool) return Response(WireGuardVPNSerializer(vpn).data) From 1922a0d92d08016d6024475efcf6d88e3391bd61 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 12:54:02 +0100 Subject: [PATCH 60/93] ++routing tests --- uncloud/settings.py | 18 ++++++++++-------- uncloud_net/tasks.py | 12 ++++-------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/uncloud/settings.py b/uncloud/settings.py index 970c3e5..ae73e72 100644 --- a/uncloud/settings.py +++ b/uncloud/settings.py @@ -200,15 +200,15 @@ AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=example,dc=com", # where to create customers LDAP_CUSTOMER_DN="ou=customer,dc=example,dc=com" -def route_task(name, args, kwargs, options, task=None, **kw): - print(f"{name} - {args} - {kwargs}") -# if name == 'myapp.tasks.compress_video': - return {'queue': 'vpn1' } -# 'exchange_type': 'topic', -# 'routing_key': 'video.compress'} +# def route_task(name, args, kwargs, options, task=None, **kw): +# print(f"{name} - {args} - {kwargs}") +# # if name == 'myapp.tasks.compress_video': +# return {'queue': 'vpn1' } +# # 'exchange_type': 'topic', +# # 'routing_key': 'video.compress'} -CELERY_TASK_ROUTES = (route_task,) +# CELERY_TASK_ROUTES = (route_task,) # CELERY_TASK_ROUTES = { # '*': { @@ -220,7 +220,9 @@ CELERY_TASK_ROUTES = (route_task,) CELERY_BROKER_URL = 'redis://:uncloud.example.com:6379/0' CELERY_RESULT_BACKEND = 'redis://:uncloud.example.com:6379/0' -CELERY_TASK_ROUTES = ( [ (re.compile(r'.*\.cdist\..*'), { 'queue': 'cdist' }), ], ) +CELERY_TASK_ROUTES = { + re.compile(r'cdist.*'): { 'queue': 'cdist' } +} # CELERY_TASK_CREATE_MISSING_QUEUES = False diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 9c1071b..4a82910 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -19,14 +19,9 @@ def configure_wireguard_server(wireguardvpnpool): config = wireguardvpnpool.wireguard_config server = wireguardvpnpool.vpn_server_hostname - print(f"Configuring VPN server {server}") - #res = cdist_configure_wireguard_server.delay(config, server) - res = cdist_configure_wireguard_server.apply_async((config, server), queue='cdist') - - print(f"res={res}") - res2= res.get() - print(f"res2={res2}") - + print(f"Configuring VPN server {server} (async)") +# cdist_configure_wireguard_server.apply_async((config, server), queue='cdist') + cdist_configure_wireguard_server.apply_async((config, server)) @shared_task def cdist_configure_wireguard_server(config, server): @@ -39,6 +34,7 @@ def cdist_configure_wireguard_server(config, server): fname = f"/home/app/.cdist/type/__ungleich_wireguard/files/{server}" print(os.uname()) + print(f"Configuring VPN server {server} (on cdist host)") with open(fname, "w") as fd: fd.write(config) From 03c0b344461437ba93f33b517cee6c6cd6ca4b17 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 13:00:36 +0100 Subject: [PATCH 61/93] ++config vpn server --- uncloud/settings.py | 2 +- uncloud_net/tasks.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/uncloud/settings.py b/uncloud/settings.py index ae73e72..ea90c22 100644 --- a/uncloud/settings.py +++ b/uncloud/settings.py @@ -221,7 +221,7 @@ CELERY_BROKER_URL = 'redis://:uncloud.example.com:6379/0' CELERY_RESULT_BACKEND = 'redis://:uncloud.example.com:6379/0' CELERY_TASK_ROUTES = { - re.compile(r'cdist.*'): { 'queue': 'cdist' } + re.compile(r'.*.tasks.cdist.*'): { 'queue': 'cdist' } # cdist tasks go into cdist queue } # CELERY_TASK_CREATE_MISSING_QUEUES = False diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 4a82910..81be402 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -2,6 +2,7 @@ from celery import shared_task from .models import * import os +import subprocess @shared_task def whereami(): @@ -31,12 +32,20 @@ def cdist_configure_wireguard_server(config, server): To be executed on the cdist workers. """ - fname = f"/home/app/.cdist/type/__ungleich_wireguard/files/{server}" + dirname= "/home/app/.cdist/type/__ungleich_wireguard/files/" + fname = os.path.join(dirname,server) - print(os.uname()) print(f"Configuring VPN server {server} (on cdist host)") with open(fname, "w") as fd: fd.write(config) + subprocess.run(f"cd ${dirname} && git pull && git add {server} && git commit -m 'Updating config for ${server}' && git push", + shell=True) + + subprocess.run(f"cdist config -vv {server}", shell=True) + + # FIXME: + # ensure logs are on the server + # ensure exit codes are known return "All good" From 63191c0a8801f20f69291555f946e495ddbf5bc9 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 13:24:55 +0100 Subject: [PATCH 62/93] Remove $ that is not needed in python... --- uncloud_net/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 81be402..ee72cbc 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -40,7 +40,7 @@ def cdist_configure_wireguard_server(config, server): fd.write(config) - subprocess.run(f"cd ${dirname} && git pull && git add {server} && git commit -m 'Updating config for ${server}' && git push", + subprocess.run(f"cd {dirname} && git pull && git add {server} && git commit -m 'Updating config for ${server}' && git push", shell=True) subprocess.run(f"cdist config -vv {server}", shell=True) From 5e870f04b157be293e423f90989e9737b13510c2 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 18:36:46 +0100 Subject: [PATCH 63/93] ++celery/tasks --- uncloud/admin.py | 4 ++-- uncloud/migrations/0002_uncloudtasks.py | 19 +++++++++++++++ uncloud/migrations/0003_auto_20201220_1728.py | 17 +++++++++++++ uncloud/models.py | 8 +++++++ uncloud_net/tasks.py | 24 +++++++++++++------ 5 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 uncloud/migrations/0002_uncloudtasks.py create mode 100644 uncloud/migrations/0003_auto_20201220_1728.py diff --git a/uncloud/admin.py b/uncloud/admin.py index 4ecc53a..a89a574 100644 --- a/uncloud/admin.py +++ b/uncloud/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import UncloudProvider, UncloudNetwork +from .models import * -for m in [ UncloudProvider, UncloudNetwork ]: +for m in [ UncloudProvider, UncloudNetwork, UncloudTask ]: admin.site.register(m) diff --git a/uncloud/migrations/0002_uncloudtasks.py b/uncloud/migrations/0002_uncloudtasks.py new file mode 100644 index 0000000..9c69606 --- /dev/null +++ b/uncloud/migrations/0002_uncloudtasks.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2020-12-20 17:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='UncloudTasks', + fields=[ + ('task_id', models.UUIDField(primary_key=True, serialize=False)), + ], + ), + ] diff --git a/uncloud/migrations/0003_auto_20201220_1728.py b/uncloud/migrations/0003_auto_20201220_1728.py new file mode 100644 index 0000000..2ec0eec --- /dev/null +++ b/uncloud/migrations/0003_auto_20201220_1728.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-12-20 17:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud', '0002_uncloudtasks'), + ] + + operations = [ + migrations.RenameModel( + old_name='UncloudTasks', + new_name='UncloudTask', + ), + ] diff --git a/uncloud/models.py b/uncloud/models.py index 9f977e8..5545303 100644 --- a/uncloud/models.py +++ b/uncloud/models.py @@ -162,3 +162,11 @@ class UncloudProvider(UncloudAddress): def __str__(self): return f"{self.full_name} {self.country}" + + +class UncloudTask(models.Model): + """ + Class to store dispatched tasks to be handled + """ + + task_id = models.UUIDField(primary_key=True) diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index ee72cbc..0dcf6f3 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -1,8 +1,14 @@ from celery import shared_task from .models import * +from uncloud.models import UncloudTask + import os import subprocess +import logging +import uuid + +log = logging.getLogger(__name__) @shared_task def whereami(): @@ -20,9 +26,11 @@ def configure_wireguard_server(wireguardvpnpool): config = wireguardvpnpool.wireguard_config server = wireguardvpnpool.vpn_server_hostname - print(f"Configuring VPN server {server} (async)") -# cdist_configure_wireguard_server.apply_async((config, server), queue='cdist') - cdist_configure_wireguard_server.apply_async((config, server)) + log.info(f"Configuring VPN server {server} (async)") + + task_id = uuid.UUID(cdist_configure_wireguard_server.apply_async((config, server)).id) + UncloudTasks.objects.create(task_id=task_id) + @shared_task def cdist_configure_wireguard_server(config, server): @@ -35,15 +43,17 @@ def cdist_configure_wireguard_server(config, server): dirname= "/home/app/.cdist/type/__ungleich_wireguard/files/" fname = os.path.join(dirname,server) - print(f"Configuring VPN server {server} (on cdist host)") + log.info(f"Configuring VPN server {server} (on cdist host)") with open(fname, "w") as fd: fd.write(config) - subprocess.run(f"cd {dirname} && git pull && git add {server} && git commit -m 'Updating config for ${server}' && git push", - shell=True) + log.debug("git committing wireguard changes") + subprocess.run(f"cd /aa{dirname} && git pull && git add {server} && git commit -m 'Updating config for ${server}' && git push", + shell=True, check=True) - subprocess.run(f"cdist config -vv {server}", shell=True) + log.debug(f"Configuring VPN server {server} with cdist") + subprocess.run(f"cdist config {server}", shell=True, check=True) # FIXME: # ensure logs are on the server From 8f83679c487982581026154a1cb50483e3f4e186 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 19:01:37 +0100 Subject: [PATCH 64/93] test cleaning tasks in a task fails: [2020-12-20 18:01:50,264: WARNING/ForkPoolWorker-7] Pruning UncloudTask object (571ffc76-8b40-4cb6-9658-87030834bc6c)... [2020-12-20 18:01:50,265: ERROR/ForkPoolWorker-7] Task uncloud.tasks.cleanup_tasks[f9fb1480-f122-41c9-bec1-3d6d0f92a22e] raised unexpected: RuntimeError('Never call result.get() within a task!\nSee http://docs.celeryq.org/en/latest/userguide/tasks.html#task-synchronous-subtasks\n') Traceback (most recent call last): File "/home/nico/vcs/uncloud/venv/lib/python3.8/site-packages/celery/app/trace.py", line 405, in trace_task R = retval = fun(*args, **kwargs) File "/home/nico/vcs/uncloud/venv/lib/python3.8/site-packages/celery/app/trace.py", line 697, in __protected_call__ return self.run(*args, **kwargs) File "/home/nico/vcs/uncloud/uncloud/tasks.py", line 13, in cleanup_tasks print(res.get()) File "/home/nico/vcs/uncloud/venv/lib/python3.8/site-packages/celery/result.py", line 209, in get assert_will_not_block() File "/home/nico/vcs/uncloud/venv/lib/python3.8/site-packages/celery/result.py", line 37, in assert_will_not_block raise RuntimeError(E_WOULDBLOCK) RuntimeError: Never call result.get() within a task! See http://docs.celeryq.org/en/latest/userguide/tasks.html#task-synchronous-subtasks --- uncloud/celery.py | 5 ----- uncloud/settings.py | 7 +++++++ uncloud/tasks.py | 14 ++++++++++++++ uncloud_net/tasks.py | 2 +- 4 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 uncloud/tasks.py diff --git a/uncloud/celery.py b/uncloud/celery.py index 7bcaaae..3408634 100644 --- a/uncloud/celery.py +++ b/uncloud/celery.py @@ -15,8 +15,3 @@ app.config_from_object('django.conf:settings', namespace='CELERY') # Load task modules from all registered Django app configs. app.autodiscover_tasks() - - -@app.task(bind=True) -def debug_task(self): - print(f'Request: {self.request!r}') diff --git a/uncloud/settings.py b/uncloud/settings.py index ea90c22..ae734dc 100644 --- a/uncloud/settings.py +++ b/uncloud/settings.py @@ -224,6 +224,13 @@ CELERY_TASK_ROUTES = { re.compile(r'.*.tasks.cdist.*'): { 'queue': 'cdist' } # cdist tasks go into cdist queue } +CELERY_BEAT_SCHEDULE = { + 'cleanup_tasks': { + 'task': 'uncloud.tasks.cleanup_tasks', + 'schedule': 10 + } +} + # CELERY_TASK_CREATE_MISSING_QUEUES = False # Overwrite settings with local settings, if existing diff --git a/uncloud/tasks.py b/uncloud/tasks.py new file mode 100644 index 0000000..8350354 --- /dev/null +++ b/uncloud/tasks.py @@ -0,0 +1,14 @@ +from celery import shared_task +from celery.result import AsyncResult + +from .models import UncloudTask + +@shared_task +def cleanup_tasks(): + print("Cleanup time") + for task in UncloudTask.objects.all(): + print(f"Pruning {task}...") + res = AsyncResult(id=str(task.task_id)) + if res.ready(): + print(res.get()) + task.delete() diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 0dcf6f3..529c525 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -29,7 +29,7 @@ def configure_wireguard_server(wireguardvpnpool): log.info(f"Configuring VPN server {server} (async)") task_id = uuid.UUID(cdist_configure_wireguard_server.apply_async((config, server)).id) - UncloudTasks.objects.create(task_id=task_id) + UncloudTask.objects.create(task_id=task_id) @shared_task From 689375a2fe8bec7927f24060f00632078174010c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 19:17:03 +0100 Subject: [PATCH 65/93] Fix the config task --- uncloud/tasks.py | 11 ++++++++--- uncloud_net/tasks.py | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/uncloud/tasks.py b/uncloud/tasks.py index 8350354..5a13ec5 100644 --- a/uncloud/tasks.py +++ b/uncloud/tasks.py @@ -3,11 +3,16 @@ from celery.result import AsyncResult from .models import UncloudTask -@shared_task -def cleanup_tasks(): - print("Cleanup time") +@shared_task(bind=True) +def cleanup_tasks(self): + print(f"Cleanup time from {self}: {self.request.id}") for task in UncloudTask.objects.all(): print(f"Pruning {task}...") + + if str(task.task_id) == str(self.request.id): + print("Skipping myself") + continue + res = AsyncResult(id=str(task.task_id)) if res.ready(): print(res.get()) diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 529c525..67d11ae 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -49,7 +49,7 @@ def cdist_configure_wireguard_server(config, server): log.debug("git committing wireguard changes") - subprocess.run(f"cd /aa{dirname} && git pull && git add {server} && git commit -m 'Updating config for ${server}' && git push", + subprocess.run(f"cd {dirname} && git pull && git add {server} && git commit -m 'Updating config for ${server}' && git push", shell=True, check=True) log.debug(f"Configuring VPN server {server} with cdist") @@ -58,4 +58,4 @@ def cdist_configure_wireguard_server(config, server): # FIXME: # ensure logs are on the server # ensure exit codes are known - return "All good" + return True From cdab685269d1b6c9a1091c69ebc0df2a8a62a71a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 19:37:12 +0100 Subject: [PATCH 66/93] [vpn/doc] update docs --- doc/uncloud-manual-2020-08-01.org | 9 ++- uncloud_net/models.py | 2 +- uncloud_net/serializers.py | 91 +++++-------------------------- uncloud_net/tasks.py | 2 +- 4 files changed, 24 insertions(+), 80 deletions(-) diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index 83fa65e..5c6a9f7 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -1,8 +1,15 @@ * Bootstrap / Installation ** Pre-requisites by operating system +*** General + To run uncloud you need: + - ldap development libraries + - libxml2-dev libxslt-dev + - gcc / libc headers: for compiling things + - python3-dev + - wireguard: wg (for checking keys) *** Alpine #+BEGIN_SRC sh -apk add openldap-dev postgresql-dev libxml2-dev libxslt-dev gcc python3-dev musl-dev +apk add openldap-dev postgresql-dev libxml2-dev libxslt-dev gcc python3-dev musl-dev wireguard-tools-wg #+END_SRC *** Debian/Devuan: #+BEGIN_SRC sh diff --git a/uncloud_net/models.py b/uncloud_net/models.py index b4e1e8d..d0dd60b 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -82,7 +82,7 @@ class WireGuardVPN(models.Model): pool_index = models.IntegerField(unique=True) - wireguard_public_key = models.CharField(max_length=48) + wireguard_public_key = models.CharField(max_length=48, unique=True) @property def network_mask(self): diff --git a/uncloud_net/serializers.py b/uncloud_net/serializers.py index 6965aa7..88aedff 100644 --- a/uncloud_net/serializers.py +++ b/uncloud_net/serializers.py @@ -23,84 +23,21 @@ class WireGuardVPNSerializer(serializers.ModelSerializer): } -# class VPNNetworkSerializer(serializers.ModelSerializer): -# class Meta: -# model = VPNNetwork -# fields = '__all__' + def validate_wireguard_public_key(self, value): + msg = _("Supplied key is not a valid wireguard public key") -# # This is required for finding the VPN pool, but does not -# # exist in the model -# network_size = serializers.IntegerField(min_value=0, -# max_value=128, -# write_only=True) + """ + FIXME: verify that this does not create broken wireguard config files, + i.e. contains \n or similar! + We might even need to be more strict to not break wireguard... + """ -# def validate_wireguard_public_key(self, value): -# msg = _("Supplied key is not a valid wireguard public key") + try: + base64.standard_b64decode(value) + except Exception as e: + raise serializers.ValidationError(msg) -# """ FIXME: verify that this does not create broken wireguard config files, -# i.e. contains \n or similar! -# We might even need to be more strict to not break wireguard... -# """ + if '\n' in value: + raise serializers.ValidationError(msg) -# try: -# base64.standard_b64decode(value) -# except Exception as e: -# raise serializers.ValidationError(msg) - -# if '\n' in value: -# raise serializers.ValidationError(msg) - -# return value - -# def validate(self, data): - -# # FIXME: filter for status = active or similar -# all_pools = VPNPool.objects.all() -# sizes = [ p.subnetwork_size for p in all_pools ] - -# pools = VPNPool.objects.filter(subnetwork_size=data['network_size']) - -# if len(pools) == 0: -# msg = _("No pool available for networks with size = {}. Available are: {}".format(data['network_size'], sizes)) -# raise serializers.ValidationError(msg) - -# return data - -# def create(self, validated_data): -# """ -# Creating a new vpnnetwork - there are a couple of race conditions, -# especially when run in parallel. - -# What we should be doing: - -# - create a reservation race free -# - map the reservation to a network (?) -# """ - -# pools = VPNPool.objects.filter(subnetwork_size=validated_data['network_size']) - -# vpn_network = None - -# for pool in pools: -# if pool.num_free_networks > 0: -# next_address = pool.next_free_network - -# reservation, created = VPNNetworkReservation.objects.update_or_create( -# vpnpool=pool, address=next_address, -# defaults = { -# 'status': 'used' -# }) - -# vpn_network = VPNNetwork.objects.create( -# owner=self.context['request'].user, -# network=reservation, -# wireguard_public_key=validated_data['wireguard_public_key'] -# ) - -# break -# if not vpn_network: -# # FIXME: use correct exception -# raise Exception("Did not find any free pool") - - -# return vpn_network + return value diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 67d11ae..28b90c7 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -49,7 +49,7 @@ def cdist_configure_wireguard_server(config, server): log.debug("git committing wireguard changes") - subprocess.run(f"cd {dirname} && git pull && git add {server} && git commit -m 'Updating config for ${server}' && git push", + subprocess.run(f"cd {dirname} && git pull && git add {server} && git commit -m 'Updating config for {server}' && git push", shell=True, check=True) log.debug(f"Configuring VPN server {server} with cdist") From ece2bca8312c7698f8f327e6667cf76cc790ca57 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 21:45:47 +0100 Subject: [PATCH 67/93] add new /sizes endpoint --- uncloud/urls.py | 2 +- .../migrations/0005_auto_20201220_1837.py | 18 ++++++++++++++++ uncloud_net/selectors.py | 21 ++----------------- uncloud_net/serializers.py | 19 +++++++++++++++++ uncloud_net/services.py | 2 ++ uncloud_net/views.py | 10 +++++++++ 6 files changed, 52 insertions(+), 20 deletions(-) create mode 100644 uncloud_net/migrations/0005_auto_20201220_1837.py diff --git a/uncloud/urls.py b/uncloud/urls.py index 7e8167d..cce19be 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -69,7 +69,7 @@ router.register(r'v1/user/register', authviews.AccountManagementViewSet, basenam # Net router.register(r'v2/net/wireguardvpn', netviews.WireGuardVPNViewSet, basename='wireguardvpnnetwork') -#router.register(r'v1/admin/vpnreservation', netviews.VPNNetworkReservationViewSet, basename='vpnnetreservation') +router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basename='wireguardvpnnetworksizes') diff --git a/uncloud_net/migrations/0005_auto_20201220_1837.py b/uncloud_net/migrations/0005_auto_20201220_1837.py new file mode 100644 index 0000000..1dbabe6 --- /dev/null +++ b/uncloud_net/migrations/0005_auto_20201220_1837.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-12-20 18:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0004_auto_20201213_1734'), + ] + + operations = [ + migrations.AlterField( + model_name='wireguardvpn', + name='wireguard_public_key', + field=models.CharField(max_length=48, unique=True), + ), + ] diff --git a/uncloud_net/selectors.py b/uncloud_net/selectors.py index bcb1ea8..6e12e8b 100644 --- a/uncloud_net/selectors.py +++ b/uncloud_net/selectors.py @@ -1,12 +1,7 @@ from django.db import transaction from django.db.models import Count, F - - from .models import * -# def get_num_used_networks(pool): -# return pool.wireguardvpn_set.count() - def get_suitable_pools(subnetwork_mask): """ Find suitable pools for a certain network size. @@ -44,17 +39,5 @@ def allowed_vpn_network_reservation_size(): # Need to return set of tuples, see # https://docs.djangoproject.com/en/3.1/ref/models/fields/#field-choices - return set([ (pool.subnetwork_mask, pool.subnetwork_mask) for pool in pools ]) - - -#def get_next_vpnnetwork(pool): - # get all associated networks - # look for the lowest free number - # return that - - - # select last used one - # try to increment by one -> get new network - - # if that fails search through the existing vpns for the first unused number - # +# return set([ (pool.subnetwork_mask, pool.subnetwork_mask) for pool in pools ]) + return set([pool.subnetwork_mask for pool in pools ]) diff --git a/uncloud_net/serializers.py b/uncloud_net/serializers.py index 88aedff..8287047 100644 --- a/uncloud_net/serializers.py +++ b/uncloud_net/serializers.py @@ -6,6 +6,8 @@ from rest_framework import serializers from .models import * from .services import * +from .selectors import * + class WireGuardVPNSerializer(serializers.ModelSerializer): address = serializers.CharField(read_only=True) @@ -23,6 +25,14 @@ class WireGuardVPNSerializer(serializers.ModelSerializer): } + def validate_network_mask(self, value): + msg = _(f"No pool for network size {value}") + sizes = allowed_vpn_network_reservation_size() + + if not value in sizes: + raise serializers.ValidationError(msg) + + def validate_wireguard_public_key(self, value): msg = _("Supplied key is not a valid wireguard public key") @@ -41,3 +51,12 @@ class WireGuardVPNSerializer(serializers.ModelSerializer): raise serializers.ValidationError(msg) return value + + +class WireGuardVPNSizesSerializer(serializers.Serializer): + + size = serializers.IntegerField(min_value=0, max_value=128) + + # sizes = serializers.ListField( + # child=serializers.IntegerField(min_value=0, max_value=128) + # ) diff --git a/uncloud_net/services.py b/uncloud_net/services.py index 4f80c44..a23d48f 100644 --- a/uncloud_net/services.py +++ b/uncloud_net/services.py @@ -7,6 +7,8 @@ from .tasks import * @transaction.atomic def create_wireguard_vpn(owner, public_key, network_mask): + + pool = get_suitable_pools(network_mask)[0] count = pool.wireguardvpn_set.count() diff --git a/uncloud_net/views.py b/uncloud_net/views.py index 72ff681..8a5c6fb 100644 --- a/uncloud_net/views.py +++ b/uncloud_net/views.py @@ -56,3 +56,13 @@ class WireGuardVPNCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView def get_success_message(self, cleaned_data): return self.success_message % dict(cleaned_data, the_prefix = self.object.prefix) + +class WireGuardVPNSizes(viewsets.ViewSet): + def list(self, request): + sizes = allowed_vpn_network_reservation_size() + print(sizes) + + sizes = [ { 'size': size } for size in sizes ] + print(sizes) + + return Response(WireGuardVPNSizesSerializer(sizes, many=True).data) From 858aabb5ba9c9e0e1fa64181839942824de0f717 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 20 Dec 2020 22:03:43 +0100 Subject: [PATCH 68/93] Return value from validation --- uncloud_net/serializers.py | 6 +----- uncloud_net/services.py | 2 -- uncloud_net/tasks.py | 1 - 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/uncloud_net/serializers.py b/uncloud_net/serializers.py index 8287047..fc87c71 100644 --- a/uncloud_net/serializers.py +++ b/uncloud_net/serializers.py @@ -32,6 +32,7 @@ class WireGuardVPNSerializer(serializers.ModelSerializer): if not value in sizes: raise serializers.ValidationError(msg) + return value def validate_wireguard_public_key(self, value): msg = _("Supplied key is not a valid wireguard public key") @@ -54,9 +55,4 @@ class WireGuardVPNSerializer(serializers.ModelSerializer): class WireGuardVPNSizesSerializer(serializers.Serializer): - size = serializers.IntegerField(min_value=0, max_value=128) - - # sizes = serializers.ListField( - # child=serializers.IntegerField(min_value=0, max_value=128) - # ) diff --git a/uncloud_net/services.py b/uncloud_net/services.py index a23d48f..4f80c44 100644 --- a/uncloud_net/services.py +++ b/uncloud_net/services.py @@ -7,8 +7,6 @@ from .tasks import * @transaction.atomic def create_wireguard_vpn(owner, public_key, network_mask): - - pool = get_suitable_pools(network_mask)[0] count = pool.wireguardvpn_set.count() diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 28b90c7..78ae80c 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -47,7 +47,6 @@ def cdist_configure_wireguard_server(config, server): with open(fname, "w") as fd: fd.write(config) - log.debug("git committing wireguard changes") subprocess.run(f"cd {dirname} && git pull && git add {server} && git commit -m 'Updating config for {server}' && git push", shell=True, check=True) From a0fbe2d6ed9b5a9b510c95193c6e4bb1deaf46ca Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 24 Dec 2020 17:26:53 +0100 Subject: [PATCH 69/93] [wireguard] add unique constrain for keys in pool --- .../migrations/0006_auto_20201224_1626.py | 17 +++++++++++++++++ uncloud_net/models.py | 7 +++++++ 2 files changed, 24 insertions(+) create mode 100644 uncloud_net/migrations/0006_auto_20201224_1626.py diff --git a/uncloud_net/migrations/0006_auto_20201224_1626.py b/uncloud_net/migrations/0006_auto_20201224_1626.py new file mode 100644 index 0000000..c0dd2ef --- /dev/null +++ b/uncloud_net/migrations/0006_auto_20201224_1626.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-12-24 16:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_net', '0005_auto_20201220_1837'), + ] + + operations = [ + migrations.AddConstraint( + model_name='wireguardvpn', + constraint=models.UniqueConstraint(fields=('vpnpool', 'wireguard_public_key'), name='wg_key_unique_per_pool'), + ), + ] diff --git a/uncloud_net/models.py b/uncloud_net/models.py index d0dd60b..0c8b02a 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -84,6 +84,13 @@ class WireGuardVPN(models.Model): wireguard_public_key = models.CharField(max_length=48, unique=True) + class Meta: + constraints = [ + models.UniqueConstraint(fields=['vpnpool', 'wireguard_public_key'], + name='wg_key_unique_per_pool') + ] + + @property def network_mask(self): return self.vpnpool.subnetwork_mask From 663d72269ab0169b1ac92da09a81e647fe579573 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 25 Dec 2020 10:08:34 +0100 Subject: [PATCH 70/93] [wireguard] verify key length --- uncloud_net/serializers.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/uncloud_net/serializers.py b/uncloud_net/serializers.py index fc87c71..09baa59 100644 --- a/uncloud_net/serializers.py +++ b/uncloud_net/serializers.py @@ -38,17 +38,16 @@ class WireGuardVPNSerializer(serializers.ModelSerializer): msg = _("Supplied key is not a valid wireguard public key") """ - FIXME: verify that this does not create broken wireguard config files, - i.e. contains \n or similar! - We might even need to be more strict to not break wireguard... + Verify wireguard key. + See https://lists.zx2c4.com/pipermail/wireguard/2020-December/006221.html """ try: - base64.standard_b64decode(value) + decoded_key = base64.standard_b64decode(value) except Exception as e: raise serializers.ValidationError(msg) - if '\n' in value: + if not len(decoded_key) == 32: raise serializers.ValidationError(msg) return value From 50a395c8ecc34a7961608c6976d8973a549285fa Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 25 Dec 2020 10:10:57 +0100 Subject: [PATCH 71/93] sort requirements.txt --- requirements.txt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index d5e6a78..adbda9c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,13 @@ +# Django basics django djangorestframework django-auth-ldap -stripe -xmltodict + psycopg2 ldap3 +xmltodict + parsedatetime # Follow are for creating graph models @@ -20,9 +22,10 @@ django-hardcopy pyyaml uritemplate -# Comprehensive interface to validate VAT numbers, making use of the VIES -# service for European countries. +# Payment & VAT vat-validator +stripe + # Tasks celery From 8dd4b712fb7d19d963bb23c2570b1e71af1ad55d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 25 Dec 2020 10:11:13 +0100 Subject: [PATCH 72/93] [views] add index view for uncloud --- uncloud/templates/uncloud/index.html | 15 +++++++++++++++ uncloud/views.py | 4 ++++ 2 files changed, 19 insertions(+) create mode 100644 uncloud/templates/uncloud/index.html create mode 100644 uncloud/views.py diff --git a/uncloud/templates/uncloud/index.html b/uncloud/templates/uncloud/index.html new file mode 100644 index 0000000..b40c3b4 --- /dev/null +++ b/uncloud/templates/uncloud/index.html @@ -0,0 +1,15 @@ +{% extends 'uncloud/base.html' %} +{% block title %}{% endblock %} + +{% block body %} +
+

Welcome to uncloud

+ Welcome to uncloud, checkout the following locations: + + + +
+{% endblock %} diff --git a/uncloud/views.py b/uncloud/views.py new file mode 100644 index 0000000..198abd0 --- /dev/null +++ b/uncloud/views.py @@ -0,0 +1,4 @@ +from django.views.generic.base import TemplateView + +class UncloudIndex(TemplateView): + template_name = "uncloud/index.html" From df4c0c3060778cd41f7f988ccce05686fe185c71 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 25 Dec 2020 10:31:42 +0100 Subject: [PATCH 73/93] in between commit to update for cc tests --- uncloud/urls.py | 4 +- uncloud_net/views.py | 12 ++-- uncloud_pay/admin.py | 2 +- uncloud_pay/models.py | 2 + uncloud_pay/templates/uncloud_pay/stripe.html | 72 +++++++++++++++++++ uncloud_pay/views.py | 23 ++++++ 6 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 uncloud_pay/templates/uncloud_pay/stripe.html diff --git a/uncloud/urls.py b/uncloud/urls.py index cce19be..169be7f 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -13,6 +13,7 @@ from rest_framework import routers from rest_framework.schemas import get_schema_view #from opennebula import views as oneviews +from uncloud import views as uncloudviews from uncloud_auth import views as authviews from uncloud_net import views as netviews from uncloud_pay import views as payviews @@ -87,6 +88,7 @@ urlpatterns = [ # path('web/vpn/create/', netviews.WireGuardVPNCreateView.as_view(), name="vpncreate"), path('login/', authviews.LoginView.as_view(), name="login"), path('logout/', authviews.LogoutView.as_view(), name="logout"), - path('admin/', admin.site.urls), + path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"), + path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"), ] diff --git a/uncloud_net/views.py b/uncloud_net/views.py index 8a5c6fb..77ba952 100644 --- a/uncloud_net/views.py +++ b/uncloud_net/views.py @@ -14,11 +14,6 @@ from .services import * from .forms import * from .tasks import * -# class VPNPoolViewSet(viewsets.ModelViewSet): -# serializer_class = VPNPoolSerializer -# permission_classes = [permissions.IsAdminUser] -# queryset = VPNPool.objects.all() - class WireGuardVPNViewSet(viewsets.ModelViewSet): serializer_class = WireGuardVPNSerializer permission_classes = [permissions.IsAuthenticated] @@ -66,3 +61,10 @@ class WireGuardVPNSizes(viewsets.ViewSet): print(sizes) return Response(WireGuardVPNSizesSerializer(sizes, many=True).data) + + + +# class VPNPoolViewSet(viewsets.ModelViewSet): +# serializer_class = VPNPoolSerializer +# permission_classes = [permissions.IsAdminUser] +# queryset = VPNPool.objects.all() diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index 2123397..2c72274 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -88,5 +88,5 @@ admin.site.register(Bill, BillAdmin) admin.site.register(ProductToRecurringPeriod) admin.site.register(Product, ProductAdmin) -for m in [ Order, BillRecord, BillingAddress, RecurringPeriod, VATRate ]: +for m in [ Order, BillRecord, BillingAddress, RecurringPeriod, VATRate, StripeCustomer ]: admin.site.register(m) diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index f0e469d..18e6f85 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -89,6 +89,8 @@ class StripeCustomer(models.Model): on_delete=models.CASCADE) stripe_id = models.CharField(max_length=32) + def __str__(self): + return self.owner.username ### # Payments and Payment Methods. diff --git a/uncloud_pay/templates/uncloud_pay/stripe.html b/uncloud_pay/templates/uncloud_pay/stripe.html new file mode 100644 index 0000000..3051bf0 --- /dev/null +++ b/uncloud_pay/templates/uncloud_pay/stripe.html @@ -0,0 +1,72 @@ +{% extends 'uncloud/base.html' %} + +{% block header %} + + +{% endblock %} + +{% block body %} +
+

Registering Stripe Credit Card

+ + + +
+
+ +
+ + +
+
+ + + + + + + +{% endblock %} diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py index edfb189..53d6ef4 100644 --- a/uncloud_pay/views.py +++ b/uncloud_pay/views.py @@ -1,3 +1,7 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic.base import TemplateView + + from django.shortcuts import render from django.db import transaction from django.contrib.auth import get_user_model @@ -43,6 +47,25 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return Order.objects.filter(owner=self.request.user) + +class RegisterCard(LoginRequiredMixin, TemplateView): + login_url = '/login/' + + # This is not supposed to be "static" -- + # the idea is to be able to switch the provider when needed + template_name = "uncloud_pay/stripe.html" + + def get_context_data(self, **kwargs): + customer_id = uncloud_stripe.get_customer_id_for(self.request.user) + setup_intent = uncloud_stripe.create_setup_intent(customer_id) + + context = super().get_context_data(**kwargs) + context['client_secret'] = setup_intent.client_secret + context['username'] = self.request.user + context['stripe_pk'] = uncloud_stripe.public_api_key + return context + + class PaymentMethodViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] From 6efedcb38155598c8c054565ad57a521a47d9de7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 25 Dec 2020 17:29:17 +0100 Subject: [PATCH 74/93] hackish way of registering works --- uncloud/urls.py | 2 + uncloud_pay/serializers.py | 12 +- uncloud_pay/stripe.py | 9 -- uncloud_pay/templates/uncloud_pay/stripe.html | 107 +++++++++++------- uncloud_pay/views.py | 6 +- 5 files changed, 80 insertions(+), 56 deletions(-) diff --git a/uncloud/urls.py b/uncloud/urls.py index 169be7f..9097b4c 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -86,9 +86,11 @@ urlpatterns = [ # web/ = stuff to view in the browser # path('web/vpn/create/', netviews.WireGuardVPNCreateView.as_view(), name="vpncreate"), + path('login/', authviews.LoginView.as_view(), name="login"), path('logout/', authviews.LogoutView.as_view(), name="logout"), path('admin/', admin.site.urls), path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"), + path('cc/submit/', payviews.RegisterCard.as_view(), name="cc_register"), path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"), ] diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py index 9214105..94f833e 100644 --- a/uncloud_pay/serializers.py +++ b/uncloud_pay/serializers.py @@ -5,6 +5,14 @@ from django.utils.translation import gettext_lazy as _ from .models import * +### +# Checked code + + +################################################################################ +# Unchecked code + + ### # Payments and Payment Methods. @@ -18,7 +26,7 @@ class PaymentMethodSerializer(serializers.ModelSerializer): class Meta: model = PaymentMethod - fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4', 'active'] + fields = [ 'source', 'description', 'primary', 'stripe_card_last4', 'active'] class UpdatePaymentMethodSerializer(serializers.ModelSerializer): class Meta: @@ -30,10 +38,12 @@ class ChargePaymentMethodSerializer(serializers.Serializer): class CreatePaymentMethodSerializer(serializers.ModelSerializer): please_visit = serializers.CharField(read_only=True) + class Meta: model = PaymentMethod fields = ['source', 'description', 'primary', 'please_visit'] + ### # Orders & Products. diff --git a/uncloud_pay/stripe.py b/uncloud_pay/stripe.py index 2ed4ef2..f4c467a 100644 --- a/uncloud_pay/stripe.py +++ b/uncloud_pay/stripe.py @@ -7,17 +7,10 @@ from django.conf import settings import uncloud_pay.models -# Static stripe configuration used below. CURRENCY = 'chf' -# README: We use the Payment Intent API as described on -# https://stripe.com/docs/payments/save-and-reuse - -# For internal use only. stripe.api_key = settings.STRIPE_KEY -# Helper (decorator) used to catch errors raised by stripe logic. -# Catch errors that should not be displayed to the end user, raise again. def handle_stripe_error(f): def handle_problems(*args, **kwargs): response = { @@ -61,8 +54,6 @@ def handle_stripe_error(f): return handle_problems -# Actual Stripe logic. - def public_api_key(): return settings.STRIPE_PUBLIC_KEY diff --git a/uncloud_pay/templates/uncloud_pay/stripe.html b/uncloud_pay/templates/uncloud_pay/stripe.html index 3051bf0..775ed53 100644 --- a/uncloud_pay/templates/uncloud_pay/stripe.html +++ b/uncloud_pay/templates/uncloud_pay/stripe.html @@ -1,7 +1,9 @@ + {% extends 'uncloud/base.html' %} {% block header %} + + + {% endblock %} {% block body %}
-

Registering Stripe Credit Card

+

Registering Credit Card with Stripe

+

+ By submitting I authorise to send instructions to + the financial institution that issued my card to take + payments from my card account in accordance with the + terms of my agreement with you. +

-
-
- -
- -
+
+ + +
The card will be registered with stripe.
+
- - - - {% endblock %} diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py index 53d6ef4..78a1a15 100644 --- a/uncloud_pay/views.py +++ b/uncloud_pay/views.py @@ -51,12 +51,12 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): class RegisterCard(LoginRequiredMixin, TemplateView): login_url = '/login/' - # This is not supposed to be "static" -- - # the idea is to be able to switch the provider when needed template_name = "uncloud_pay/stripe.html" def get_context_data(self, **kwargs): + customer_id = uncloud_stripe.get_customer_id_for(self.request.user) + setup_intent = uncloud_stripe.create_setup_intent(customer_id) context = super().get_context_data(**kwargs) @@ -159,7 +159,7 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): # TODO: find a way to use reverse properly: # https://www.django-rest-framework.org/api-guide/reverse/ callback_path= "payment-method/{}/activate-stripe-cc/".format( - payment_method.uuid) + payment_method.id) callback = reverse('api-root', request=request) + callback_path # Render stripe card registration form. From f7c68b5ca53aa9c01d98da223e7e2aa469b54223 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 25 Dec 2020 17:33:01 +0100 Subject: [PATCH 75/93] Rename template --- .../uncloud_pay/register_stripe.html | 68 ++++++++++++++ uncloud_pay/templates/uncloud_pay/stripe.html | 93 ------------------- uncloud_pay/views.py | 2 +- 3 files changed, 69 insertions(+), 94 deletions(-) create mode 100644 uncloud_pay/templates/uncloud_pay/register_stripe.html delete mode 100644 uncloud_pay/templates/uncloud_pay/stripe.html diff --git a/uncloud_pay/templates/uncloud_pay/register_stripe.html b/uncloud_pay/templates/uncloud_pay/register_stripe.html new file mode 100644 index 0000000..9fd82ae --- /dev/null +++ b/uncloud_pay/templates/uncloud_pay/register_stripe.html @@ -0,0 +1,68 @@ +{% extends 'uncloud/base.html' %} + +{% block header %} + + + +{% endblock %} + +{% block body %} +
+

Register Credit Card with Stripe

+

+ By submitting I authorise to send instructions to + the financial institution that issued my card to take + payments from my card account in accordance with the + terms of my agreement with you. +

+ + + + +
+ + +
The card will be registered with stripe.
+ +
+ + + +{% endblock %} diff --git a/uncloud_pay/templates/uncloud_pay/stripe.html b/uncloud_pay/templates/uncloud_pay/stripe.html deleted file mode 100644 index 775ed53..0000000 --- a/uncloud_pay/templates/uncloud_pay/stripe.html +++ /dev/null @@ -1,93 +0,0 @@ - -{% extends 'uncloud/base.html' %} - -{% block header %} - - - - - -{% endblock %} - -{% block body %} -
-

Registering Credit Card with Stripe

-

- By submitting I authorise to send instructions to - the financial institution that issued my card to take - payments from my card account in accordance with the - terms of my agreement with you. -

- - - - -
- - -
The card will be registered with stripe.
- -
- - - - - -{% endblock %} diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py index 78a1a15..99d176e 100644 --- a/uncloud_pay/views.py +++ b/uncloud_pay/views.py @@ -51,7 +51,7 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): class RegisterCard(LoginRequiredMixin, TemplateView): login_url = '/login/' - template_name = "uncloud_pay/stripe.html" + template_name = "uncloud_pay/register_stripe.html" def get_context_data(self, **kwargs): From e51edab2f5fc8f107db0860c28337d9c668b1347 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 26 Dec 2020 11:22:51 +0100 Subject: [PATCH 76/93] cleanup/in between commit --- uncloud/settings.py | 4 + uncloud/tasks.py | 3 + uncloud/urls.py | 5 +- uncloud_pay/stripe.py | 26 ++++++- uncloud_pay/tasks.py | 11 +++ uncloud_pay/templates/error.html.j2 | 18 ----- uncloud_pay/templates/stripe-payment.html.j2 | 76 ------------------- .../templates/{ => uncloud_pay}/bill.html.j2 | 0 .../templates/uncloud_pay/list_stripe.html | 30 ++++++++ .../uncloud_pay/register_stripe.html | 3 + uncloud_pay/views.py | 30 ++++++++ 11 files changed, 107 insertions(+), 99 deletions(-) create mode 100644 uncloud_pay/tasks.py delete mode 100644 uncloud_pay/templates/error.html.j2 delete mode 100644 uncloud_pay/templates/stripe-payment.html.j2 rename uncloud_pay/templates/{ => uncloud_pay}/bill.html.j2 (100%) create mode 100644 uncloud_pay/templates/uncloud_pay/list_stripe.html diff --git a/uncloud/settings.py b/uncloud/settings.py index ae734dc..afc6d65 100644 --- a/uncloud/settings.py +++ b/uncloud/settings.py @@ -228,6 +228,10 @@ CELERY_BEAT_SCHEDULE = { 'cleanup_tasks': { 'task': 'uncloud.tasks.cleanup_tasks', 'schedule': 10 + }, + 'check_balance': { + 'task': 'uncloud_pay.tasks.check_balance', + 'schedule': 15 } } diff --git a/uncloud/tasks.py b/uncloud/tasks.py index 5a13ec5..a42f359 100644 --- a/uncloud/tasks.py +++ b/uncloud/tasks.py @@ -14,6 +14,9 @@ def cleanup_tasks(self): continue res = AsyncResult(id=str(task.task_id)) + print(f"Task {task}: {res.state}") if res.ready(): print(res.get()) task.delete() + + res.forget() diff --git a/uncloud/urls.py b/uncloud/urls.py index 9097b4c..343a83c 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -90,7 +90,10 @@ urlpatterns = [ path('login/', authviews.LoginView.as_view(), name="login"), path('logout/', authviews.LogoutView.as_view(), name="logout"), path('admin/', admin.site.urls), + path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"), - path('cc/submit/', payviews.RegisterCard.as_view(), name="cc_register"), + path('cc/list/', payviews.ListCards.as_view(), name="cc_list"), + path('cc/delete/', payviews.DeleteCard.as_view(), name="cc_delete"), + path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"), ] diff --git a/uncloud_pay/stripe.py b/uncloud_pay/stripe.py index f4c467a..a3dcb23 100644 --- a/uncloud_pay/stripe.py +++ b/uncloud_pay/stripe.py @@ -47,10 +47,6 @@ def handle_stripe_error(f): # XXX: maybe send email logging.error(str(e)) raise Exception(common_message) - except Exception as e: - # maybe send email - logging.error(str(e)) - raise Exception(common_message) return handle_problems @@ -103,3 +99,25 @@ def create_customer(name, email): @handle_stripe_error def get_customer(customer_id): return stripe.Customer.retrieve(customer_id) + +@handle_stripe_error +def get_customer_cards(customer_id): + print(f"getting cards for: {customer_id}") + + cards = [] + stripe_cards = stripe.PaymentMethod.list( + customer=customer_id, + type="card", + ) + + for stripe_card in stripe_cards["data"]: + card = {} + card['brand'] = stripe_card["card"]["brand"] + card['last4'] = stripe_card["card"]["last4"] + card['month'] = stripe_card["card"]["exp_month"] + card['year'] = stripe_card["card"]["exp_year"] + card['id'] = stripe_card["card"]["id"] + + cards.append(card) + + return cards diff --git a/uncloud_pay/tasks.py b/uncloud_pay/tasks.py new file mode 100644 index 0000000..b88f494 --- /dev/null +++ b/uncloud_pay/tasks.py @@ -0,0 +1,11 @@ +from celery import shared_task +from .models import * +import uuid + +from uncloud.models import UncloudTask + +@shared_task(bind=True) +def check_balance(self): + UncloudTask.objects.create(task_id=self.id) + print("for each user res is 50") + return 50 diff --git a/uncloud_pay/templates/error.html.j2 b/uncloud_pay/templates/error.html.j2 deleted file mode 100644 index ba9209c..0000000 --- a/uncloud_pay/templates/error.html.j2 +++ /dev/null @@ -1,18 +0,0 @@ - - - - Error - - - -
-

Error

-

{{ error }}

-
- - diff --git a/uncloud_pay/templates/stripe-payment.html.j2 b/uncloud_pay/templates/stripe-payment.html.j2 deleted file mode 100644 index 6c59740..0000000 --- a/uncloud_pay/templates/stripe-payment.html.j2 +++ /dev/null @@ -1,76 +0,0 @@ - - - - Stripe Card Registration - - - - - - - - -
-

Registering Stripe Credit Card

- - - -
-
- -
- - -
-
- - - - - - - - diff --git a/uncloud_pay/templates/bill.html.j2 b/uncloud_pay/templates/uncloud_pay/bill.html.j2 similarity index 100% rename from uncloud_pay/templates/bill.html.j2 rename to uncloud_pay/templates/uncloud_pay/bill.html.j2 diff --git a/uncloud_pay/templates/uncloud_pay/list_stripe.html b/uncloud_pay/templates/uncloud_pay/list_stripe.html new file mode 100644 index 0000000..b5cba17 --- /dev/null +++ b/uncloud_pay/templates/uncloud_pay/list_stripe.html @@ -0,0 +1,30 @@ +{% extends 'uncloud/base.html' %} + +{% block header %} + +{% endblock %} + +{% block body %} +
+

Your credit cards registered with Stripe

+ + + + +

List of stripe credit cards: +

    + {% for card in cards %} +
  • {{ card.brand }} ending in {{ card.last4 }} expiring + {{ card.year }}-{{ card.month }} + {% endfor %} + +
+

+
+ +{% endblock %} diff --git a/uncloud_pay/templates/uncloud_pay/register_stripe.html b/uncloud_pay/templates/uncloud_pay/register_stripe.html index 9fd82ae..82aca74 100644 --- a/uncloud_pay/templates/uncloud_pay/register_stripe.html +++ b/uncloud_pay/templates/uncloud_pay/register_stripe.html @@ -45,6 +45,9 @@ var clientSecret = '{{ client_secret }}'; cardButton.addEventListener('click', function(ev) { + document.getElementById("ungleichmessage").innerHTML + = "Registering card with Stripe, please wait ..." + stripe.confirmCardSetup( clientSecret, { diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py index 99d176e..2f4ba8d 100644 --- a/uncloud_pay/views.py +++ b/uncloud_pay/views.py @@ -65,6 +65,36 @@ class RegisterCard(LoginRequiredMixin, TemplateView): context['stripe_pk'] = uncloud_stripe.public_api_key return context +class ListCards(LoginRequiredMixin, TemplateView): + login_url = '/login/' + + template_name = "uncloud_pay/list_stripe.html" + + def get_context_data(self, **kwargs): + customer_id = uncloud_stripe.get_customer_id_for(self.request.user) + cards = uncloud_stripe.get_customer_cards(customer_id) + + context = super().get_context_data(**kwargs) + context['cards'] = cards + context['username'] = self.request.user + + return context + +class DeleteCard(LoginRequiredMixin, TemplateView): + login_url = '/login/' + + template_name = "uncloud_pay/delete_stripe_card.html" + + def get_context_data(self, **kwargs): + customer_id = uncloud_stripe.get_customer_id_for(self.request.user) + cards = uncloud_stripe.get_customer_cards(customer_id) + + context = super().get_context_data(**kwargs) + context['cards'] = cards + context['username'] = self.request.user + + return context + class PaymentMethodViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] From 18d4c995717c64af75cdd6208bff153f2a00987c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 26 Dec 2020 13:42:20 +0100 Subject: [PATCH 77/93] [doc] workers need access to the database --- doc/uncloud-manual-2020-08-01.org | 63 +++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index 5c6a9f7..2fefca6 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -32,6 +32,8 @@ pip install -r requirements.txt The database can run on the same host as uncloud, but can also run a different server. Consult the usual postgresql documentation for a secure configuration. + + The database needs to be accessible from all worker nodes. **** Alpine #+BEGIN_SRC sh apk add postgresql-server @@ -60,6 +62,67 @@ postgres=# create database uncloud owner nico; python manage.py migrate #+END_SRC +*** Configuring remote access + - Get a letsencrypt certificate + - Expose SSL ports + - Create a user + + #+BEGIN_SRC sh + certbot certonly --standalone \ + -d -m your@email.come \ + --agree-tos --no-eff-email + #+END_SRC + + - Configuring postgresql.conf: + #+BEGIN_SRC sh +listen_addresses = '*' # what IP address(es) to listen on; +ssl = on +ssl_cert_file = '/etc/postgresql/server.crt' +ssl_key_file = '/etc/postgresql/server.key' + + #+END_SRC + + - Cannot load directly due to permission error: +2020-12-26 13:01:55.235 CET [27805] FATAL: could not load server +certificate file +"/etc/letsencrypt/live/2a0a-e5c0-0013-0000-9f4b-e619-efe5-a4ac.has-a.name/fullchain.pem": +Permission denied + + - hook + #+BEGIN_SRC sh +bridge:/etc/letsencrypt/renewal-hooks/deploy# cat /etc/letsencrypt/renewal-hooks/deploy/postgresql +#!/bin/sh + +umask 0177 +export DOMAIN=2a0a-e5c0-0013-0000-9f4b-e619-efe5-a4ac.has-a.name +export DATA_DIR=/etc/postgresql + +cp /etc/letsencrypt/live/$DOMAIN/fullchain.pem $DATA_DIR/server.crt +cp /etc/letsencrypt/live/$DOMAIN/privkey.pem $DATA_DIR/server.key +chown postgres:postgres $DATA_DIR/server.crt $DATA_DIR/server.key + #+END_SRC + + - Allowing access with md5 encrypted password encrypted via TLS + #+BEGIN_SRC sh +hostssl all all ::/0 md5 + #+END_SRC + + #+BEGIN_SRC sh + +postgres=# create role uncloud password '...'; +CREATE ROLE +postgres=# alter role uncloud login ; +ALTER ROLE + #+END_SRC + + Testing the connection: + + #+BEGIN_SRC sh +psql postgresql://uncloud@2a0a-e5c0-0013-0000-9f4b-e619-efe5-a4ac.has-a.name/uncloud?sslmode +=require + #+END_SRC + + ** Bootstrap - Login via a user so that the user object gets created - Run the following (replace nicocustomer with the username) From 93e5d39c7b4efe5480f58295868c390a3dd0de40 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 26 Dec 2020 14:42:53 +0100 Subject: [PATCH 78/93] moving vpn to direct configuration --- doc/uncloud-manual-2020-08-01.org | 22 ++++++++++++++++++++-- uncloud/models.py | 8 ++++++++ uncloud_net/models.py | 3 +++ uncloud_net/services.py | 8 ++++++-- uncloud_net/tasks.py | 28 ++++++++++++++++++++++++++-- uncloud_pay/tasks.py | 2 +- 6 files changed, 64 insertions(+), 7 deletions(-) diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index 2fefca6..21126bd 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -120,8 +120,7 @@ ALTER ROLE #+BEGIN_SRC sh psql postgresql://uncloud@2a0a-e5c0-0013-0000-9f4b-e619-efe5-a4ac.has-a.name/uncloud?sslmode =require - #+END_SRC - +g #+END_SRC ** Bootstrap - Login via a user so that the user object gets created @@ -145,6 +144,25 @@ psql postgresql://uncloud@2a0a-e5c0-0013-0000-9f4b-e619-efe5-a4ac.has-a.name/unc python manage.py import-vat-rates #+END_SRC +** Worker nodes + Nodes that realise services (VMHosts, VPNHosts, etc.) need to be + accessible from the main node and also need access to the database. + + Workers usually should have an "uncloud" user account, even though + strictly speaking the username can be any. + +*** WireGuardVPN Server + - Allow write access to /etc/wireguard for uncloud user + - Allow sudo access to "ip" and "wg" + + #+BEGIN_SRC sh + chown uncloud /etc/wireguard/ + [14:30] vpn-2a0ae5c1200:/etc/sudoers.d# cat uncloud + app ALL=(ALL) NOPASSWD:/sbin/ip + app ALL=(ALL) NOPASSWD:/usr/bin/wg + #+END_SRC + + * Testing / CLI Access Access via the commandline (CLI) can be done using curl or httpie. In our examples we will use httpie. diff --git a/uncloud/models.py b/uncloud/models.py index 5545303..535d920 100644 --- a/uncloud/models.py +++ b/uncloud/models.py @@ -170,3 +170,11 @@ class UncloudTask(models.Model): """ task_id = models.UUIDField(primary_key=True) + +# class UncloudRequestLog(models.Model): +# """ +# Class to store requests and logs +# """ + + +# log = models.CharField(max_length=256) diff --git a/uncloud_net/models.py b/uncloud_net/models.py index 0c8b02a..c768c17 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -48,6 +48,9 @@ class WireGuardVPNPool(models.Model): @property def ip_network(self): + """ + Return the IP network based on our address and mask + """ return ipaddress.ip_network(f"{self.network}/{self.network_mask}") def __str__(self): diff --git a/uncloud_net/services.py b/uncloud_net/services.py index 4f80c44..437601d 100644 --- a/uncloud_net/services.py +++ b/uncloud_net/services.py @@ -22,7 +22,6 @@ def create_wireguard_vpn(owner, public_key, network_mask): free_lease.delete() except WireGuardVPNFreeLeases.DoesNotExist: - # First object if count == 0: vpn = WireGuardVPN.objects.create(owner=owner, @@ -42,6 +41,11 @@ def create_wireguard_vpn(owner, public_key, network_mask): wireguard_public_key=public_key) + config = pool.wireguard_config + server = pool.vpn_server_hostname + wg_name = pool.wg_name + + configure_wireguard_server_on_host.apply_async((wg_name, config), + queue=server) - configure_wireguard_server(pool) return vpn diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 78ae80c..f6b8038 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -8,6 +8,7 @@ import subprocess import logging import uuid + log = logging.getLogger(__name__) @shared_task @@ -15,7 +16,30 @@ def whereami(): print(os.uname()) return os.uname() -def configure_wireguard_server(wireguardvpnpool): +@shared_task +def configure_wireguard_server_on_host(wg_name, config): + """ + - Create wireguard config (DB query -> string) + - Submit config to cdist worker + - Change config locally on worker / commit / shared + """ + + # Write config + fname = f"/etc/wireguard/{{wg_name}}.conf" + with open(fname, "w") as fd: + fd.write(config) + + # Ensure the device exists + subprocess.run(f"ip link show {{wg_name}} || sudo ip link add {{wg_name}} type wireguard", + shell=True, check=True) + + # Ensure the config is correct + subprocess.run(f"sudo wg setconf {{wg_name}} {{fname}}", + shell=True, check=True) + + + +def configure_wireguard_server_via_cdist(wireguardvpnpool): """ - Create wireguard config (DB query -> string) - Submit config to cdist worker @@ -37,7 +61,7 @@ def cdist_configure_wireguard_server(config, server): """ Create config and configure server. - To be executed on the cdist workers. + To be executed on the cdist worker. """ dirname= "/home/app/.cdist/type/__ungleich_wireguard/files/" diff --git a/uncloud_pay/tasks.py b/uncloud_pay/tasks.py index b88f494..c372366 100644 --- a/uncloud_pay/tasks.py +++ b/uncloud_pay/tasks.py @@ -6,6 +6,6 @@ from uncloud.models import UncloudTask @shared_task(bind=True) def check_balance(self): - UncloudTask.objects.create(task_id=self.id) + UncloudTask.objects.create(task_id=self.request.id) print("for each user res is 50") return 50 From 74749bf07cdf6afed02fc45d0f6616d6ba939add Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 26 Dec 2020 14:45:28 +0100 Subject: [PATCH 79/93] fix templating --- uncloud_net/tasks.py | 11 +++-------- uncloud_net/views.py | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index f6b8038..67cfa18 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -11,11 +11,6 @@ import uuid log = logging.getLogger(__name__) -@shared_task -def whereami(): - print(os.uname()) - return os.uname() - @shared_task def configure_wireguard_server_on_host(wg_name, config): """ @@ -25,16 +20,16 @@ def configure_wireguard_server_on_host(wg_name, config): """ # Write config - fname = f"/etc/wireguard/{{wg_name}}.conf" + fname = f"/etc/wireguard/{wg_name}.conf" with open(fname, "w") as fd: fd.write(config) # Ensure the device exists - subprocess.run(f"ip link show {{wg_name}} || sudo ip link add {{wg_name}} type wireguard", + subprocess.run(f"ip link show {wg_name} || sudo ip link add {{wg_name}} type wireguard", shell=True, check=True) # Ensure the config is correct - subprocess.run(f"sudo wg setconf {{wg_name}} {{fname}}", + subprocess.run(f"sudo wg setconf {wg_name} {fname}", shell=True, check=True) diff --git a/uncloud_net/views.py b/uncloud_net/views.py index 77ba952..7dadbf4 100644 --- a/uncloud_net/views.py +++ b/uncloud_net/views.py @@ -35,7 +35,7 @@ class WireGuardVPNViewSet(viewsets.ModelViewSet): public_key=serializer.validated_data['wireguard_public_key'], network_mask=serializer.validated_data['network_mask'] ) - configure_wireguard_server(vpn.vpnpool) + return Response(WireGuardVPNSerializer(vpn).data) From e2c4a19049fc1e8c97ef829db43888e02155916c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 26 Dec 2020 14:48:10 +0100 Subject: [PATCH 80/93] Less verbose --- uncloud_net/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 67cfa18..7d94f3b 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -25,7 +25,7 @@ def configure_wireguard_server_on_host(wg_name, config): fd.write(config) # Ensure the device exists - subprocess.run(f"ip link show {wg_name} || sudo ip link add {{wg_name}} type wireguard", + subprocess.run(f"ip link show {wg_name} >/dev/null || sudo ip link add {{wg_name}} type wireguard", shell=True, check=True) # Ensure the config is correct From e225bf1cc095c913d08f3e46aeb6871bd3f0675c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 28 Dec 2020 23:35:34 +0100 Subject: [PATCH 81/93] implement credit card listing --- doc/uncloud-manual-2020-08-01.org | 4 ++ uncloud/templates/uncloud/index.html | 9 ++- uncloud/urls.py | 6 +- uncloud_net/models.py | 15 +++- uncloud_net/services.py | 15 ++++ uncloud_pay/admin.py | 2 +- uncloud_pay/migrations/0001_initial.py | 23 +++++- uncloud_pay/models.py | 53 +++++++++++--- uncloud_pay/serializers.py | 18 ++--- uncloud_pay/stripe.py | 27 +++++-- .../uncloud_pay/register_stripe.html | 3 +- uncloud_pay/views.py | 71 ++++++++++--------- 12 files changed, 183 insertions(+), 63 deletions(-) diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index 21126bd..381bb62 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -373,6 +373,10 @@ Q vpn-2a0ae5c1200.ungleich.ch *** 1.1 (cleanup 1) **** TODO [#C] Unify ValidationError, FieldError - define proper Exception - What do we use for model errors +**** TODO [#C] Cleanup the results handling in celery + - Remove the results broker? + - Setup app to ignore results? + - Actually use results? *** 1.0 (initial release) **** TODO [#C] Initial Generic product support - Product diff --git a/uncloud/templates/uncloud/index.html b/uncloud/templates/uncloud/index.html index b40c3b4..e5a8318 100644 --- a/uncloud/templates/uncloud/index.html +++ b/uncloud/templates/uncloud/index.html @@ -7,8 +7,13 @@ Welcome to uncloud, checkout the following locations:
diff --git a/uncloud/urls.py b/uncloud/urls.py index 343a83c..f163136 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -72,10 +72,12 @@ router.register(r'v1/user/register', authviews.AccountManagementViewSet, basenam router.register(r'v2/net/wireguardvpn', netviews.WireGuardVPNViewSet, basename='wireguardvpnnetwork') router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basename='wireguardvpnnetworksizes') +# Payment related +router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='credit-card') urlpatterns = [ - path(r'api/', include(router.urls)), + path(r'api/', include(router.urls), name='api'), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), # for login to REST API path('openapi', get_schema_view( @@ -92,8 +94,6 @@ urlpatterns = [ path('admin/', admin.site.urls), path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"), - path('cc/list/', payviews.ListCards.as_view(), name="cc_list"), - path('cc/delete/', payviews.DeleteCard.as_view(), name="cc_delete"), path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"), ] diff --git a/uncloud_net/models.py b/uncloud_net/models.py index c768c17..9865a08 100644 --- a/uncloud_net/models.py +++ b/uncloud_net/models.py @@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator, MaxValueValidator from django.core.exceptions import FieldError, ValidationError -from uncloud_pay.models import Order +from uncloud_pay.models import Order, Product class WireGuardVPNPool(models.Model): """ @@ -123,6 +123,19 @@ class WireGuardVPN(models.Model): def __str__(self): return f"{self.address} ({self.pool_index})" + def create_product(self): + """ + Ensure we have a product for the WireguardVPN + """ + + pass + + # Product.objects.get_or_create( + # name="WireGuardVPN", + # description="Wireguard VPN", + # currency=Currency.CHF, + # config= + class WireGuardVPNFreeLeases(models.Model): """ diff --git a/uncloud_net/services.py b/uncloud_net/services.py index 437601d..9149f01 100644 --- a/uncloud_net/services.py +++ b/uncloud_net/services.py @@ -4,9 +4,24 @@ from .models import * from .selectors import * from .tasks import * + @transaction.atomic def create_wireguard_vpn(owner, public_key, network_mask): + # Check if the user has a membership. + #------------------------------------ + # If yes, user is eligible for API access and 2 VPNs + # If user already has 2 VPNs, we deduct from the credit + # If deduction is higher than the allowed credit, we fail + + # + # Check if the user has suitable balance + # Create order + # + return create_wireguard_vpn_tech(owner, public_key, network_mask) + +@transaction.atomic +def create_wireguard_vpn_tech(owner, public_key, network_mask): pool = get_suitable_pools(network_mask)[0] count = pool.wireguardvpn_set.count() diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index 2c72274..eb82fb7 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -88,5 +88,5 @@ admin.site.register(Bill, BillAdmin) admin.site.register(ProductToRecurringPeriod) admin.site.register(Product, ProductAdmin) -for m in [ Order, BillRecord, BillingAddress, RecurringPeriod, VATRate, StripeCustomer ]: +for m in [ Order, BillRecord, BillingAddress, RecurringPeriod, VATRate, StripeCustomer, StripeCreditCard ]: admin.site.register(m) diff --git a/uncloud_pay/migrations/0001_initial.py b/uncloud_pay/migrations/0001_initial.py index b1b68c5..e65f3dd 100644 --- a/uncloud_pay/migrations/0001_initial.py +++ b/uncloud_pay/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1 on 2020-12-13 10:38 +# Generated by Django 3.1 on 2020-12-28 22:19 from django.conf import settings import django.core.validators @@ -83,6 +83,18 @@ class Migration(migrations.Migration): ('description', models.TextField(blank=True, default='')), ], ), + migrations.CreateModel( + name='StripeCreditCard', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('card_name', models.CharField(default='My credit card', max_length=128)), + ('card_id', models.CharField(max_length=32)), + ('last4', models.CharField(max_length=4)), + ('brand', models.CharField(max_length=64)), + ('expiry_date', models.DateField()), + ('owner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), migrations.CreateModel( name='ProductToRecurringPeriod', fields=[ @@ -140,6 +152,15 @@ class Migration(migrations.Migration): ('replaces', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replaced_by', to='uncloud_pay.order')), ], ), + migrations.CreateModel( + name='Membership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('starting_date', models.DateField(blank=True, null=True)), + ('ending_date', models.DateField(blank=True, null=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), migrations.CreateModel( name='BillRecord', fields=[ diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 18e6f85..abf769c 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -17,7 +17,6 @@ from django.utils import timezone from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.conf import settings -import uncloud_pay.stripe from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS from uncloud.models import UncloudAddress @@ -92,6 +91,21 @@ class StripeCustomer(models.Model): def __str__(self): return self.owner.username + +class StripeCreditCard(models.Model): + owner = models.OneToOneField( get_user_model(), + on_delete=models.CASCADE) + + card_name = models.CharField(null=False, max_length=128, default="My credit card") + card_id = models.CharField(null=False, max_length=32) + last4 = models.CharField(null=False, max_length=4) + brand = models.CharField(null=False, max_length=64) + expiry_date = models.DateField(null=False) + + def __str__(self): + return f"{self.card_name}: {self.brand} {self.last4} ({self.expiry_date})" + + ### # Payments and Payment Methods. @@ -148,14 +162,14 @@ class PaymentMethod(models.Model): stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True) stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True) - @property - def stripe_card_last4(self): - if self.source == 'stripe' and self.active: - payment_method = uncloud_pay.stripe.get_payment_method( - self.stripe_payment_method_id) - return payment_method.card.last4 - else: - return None + # @property + # def stripe_card_last4(self): + # if self.source == 'stripe' and self.active: + # payment_method = uncloud_pay.stripe.get_payment_method( + # self.stripe_payment_method_id) + # return payment_method.card.last4 + # else: + # return None @property def active(self): @@ -1261,3 +1275,24 @@ class ProductToRecurringPeriod(models.Model): def __str__(self): return f"{self.product} - {self.recurring_period} (default: {self.is_default})" + + +class Membership(models.Model): + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE) + + starting_date = models.DateField(blank=True, null=True) + ending_date = models.DateField(blank=True, null=True) + + + @classmethod + def user_has_membership(user, when): + """ + Return true if user has membership at a point of time, + return false if that is not the case + """ + + pass + + # cls.objects.filter(owner=user, + # starting_date) diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py index 94f833e..84a23fd 100644 --- a/uncloud_pay/serializers.py +++ b/uncloud_pay/serializers.py @@ -6,13 +6,20 @@ from django.utils.translation import gettext_lazy as _ from .models import * ### -# Checked code +# 2020-12 Checked code + + +class StripeCreditCardSerializer(serializers.ModelSerializer): + class Meta: + model = StripeCreditCard + exclude = ['card_id', "owner" ] + read_only_fields = [ "last4", "brand", "expiry_date" ] + ################################################################################ # Unchecked code - ### # Payments and Payment Methods. @@ -21,13 +28,6 @@ class PaymentSerializer(serializers.ModelSerializer): model = Payment fields = '__all__' -class PaymentMethodSerializer(serializers.ModelSerializer): - stripe_card_last4 = serializers.IntegerField() - - class Meta: - model = PaymentMethod - fields = [ 'source', 'description', 'primary', 'stripe_card_last4', 'active'] - class UpdatePaymentMethodSerializer(serializers.ModelSerializer): class Meta: model = PaymentMethod diff --git a/uncloud_pay/stripe.py b/uncloud_pay/stripe.py index a3dcb23..5b3bb00 100644 --- a/uncloud_pay/stripe.py +++ b/uncloud_pay/stripe.py @@ -1,11 +1,13 @@ import stripe import stripe.error import logging +import datetime from django.core.exceptions import ObjectDoesNotExist from django.conf import settings +from django.contrib.auth import get_user_model -import uncloud_pay.models +from .models import StripeCustomer, StripeCreditCard CURRENCY = 'chf' @@ -56,12 +58,12 @@ def public_api_key(): def get_customer_id_for(user): try: # .get() raise if there is no matching entry. - return uncloud_pay.models.StripeCustomer.objects.get(owner=user).stripe_id + return StripeCustomer.objects.get(owner=user).stripe_id except ObjectDoesNotExist: # No entry yet - making a new one. try: customer = create_customer(user.username, user.email) - uncloud_stripe_mapping = uncloud_pay.models.StripeCustomer.objects.create( + uncloud_stripe_mapping = StripeCustomer.objects.create( owner=user, stripe_id=customer.id) return uncloud_stripe_mapping.stripe_id except Exception as e: @@ -109,6 +111,7 @@ def get_customer_cards(customer_id): customer=customer_id, type="card", ) + print(stripe_cards["data"]) for stripe_card in stripe_cards["data"]: card = {} @@ -116,8 +119,24 @@ def get_customer_cards(customer_id): card['last4'] = stripe_card["card"]["last4"] card['month'] = stripe_card["card"]["exp_month"] card['year'] = stripe_card["card"]["exp_year"] - card['id'] = stripe_card["card"]["id"] + card['id'] = stripe_card["id"] cards.append(card) return cards + +def sync_cards_for_user(user): + customer_id = get_customer_id_for(user) + cards = get_customer_cards(customer_id) + + for card in cards: + StripeCreditCard.objects.get_or_create(card_id=card['id'], + owner = user, + defaults = { + 'last4': card['last4'], + 'brand': card['brand'], + 'expiry_date': datetime.date(card['year'], + card['month'], + 1) + } + ) diff --git a/uncloud_pay/templates/uncloud_pay/register_stripe.html b/uncloud_pay/templates/uncloud_pay/register_stripe.html index 82aca74..76265fa 100644 --- a/uncloud_pay/templates/uncloud_pay/register_stripe.html +++ b/uncloud_pay/templates/uncloud_pay/register_stripe.html @@ -63,7 +63,8 @@ } else { // Return to API on success. document.getElementById("ungleichmessage").innerHTML - = "Registered credit card with Stripe." + = "Registered credit card with + Stripe. Return to the main page." } }); }); diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py index 2f4ba8d..246e922 100644 --- a/uncloud_pay/views.py +++ b/uncloud_pay/views.py @@ -31,22 +31,7 @@ import uncloud_pay.stripe as uncloud_stripe logger = logging.getLogger(__name__) ### -# Payments and Payment Methods. - -class PaymentViewSet(viewsets.ReadOnlyModelViewSet): - serializer_class = PaymentSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - return Payment.objects.filter(owner=self.request.user) - -class OrderViewSet(viewsets.ReadOnlyModelViewSet): - serializer_class = OrderSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - return Order.objects.filter(owner=self.request.user) - +# 2020-12 checked code class RegisterCard(LoginRequiredMixin, TemplateView): login_url = '/login/' @@ -65,6 +50,44 @@ class RegisterCard(LoginRequiredMixin, TemplateView): context['stripe_pk'] = uncloud_stripe.public_api_key return context + +class CreditCardViewSet(mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet): + + serializer_class = StripeCreditCardSerializer + permission_classes = [permissions.IsAuthenticated] + + def list(self, request): + uncloud_stripe.sync_cards_for_user(self.request.user) + return super().list(request) + + def get_queryset(self): + return StripeCreditCard.objects.filter(owner=self.request.user) + + + +### +# Payments and Payment Methods. + +class PaymentViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = PaymentSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Payment.objects.filter(owner=self.request.user) + +class OrderViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = OrderSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Order.objects.filter(owner=self.request.user) + + + class ListCards(LoginRequiredMixin, TemplateView): login_url = '/login/' @@ -80,22 +103,6 @@ class ListCards(LoginRequiredMixin, TemplateView): return context -class DeleteCard(LoginRequiredMixin, TemplateView): - login_url = '/login/' - - template_name = "uncloud_pay/delete_stripe_card.html" - - def get_context_data(self, **kwargs): - customer_id = uncloud_stripe.get_customer_id_for(self.request.user) - cards = uncloud_stripe.get_customer_cards(customer_id) - - context = super().get_context_data(**kwargs) - context['cards'] = cards - context['username'] = self.request.user - - return context - - class PaymentMethodViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] From 1b06d8ee0353ca1b4ef97afa930f38a553b3d05c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 29 Dec 2020 01:43:33 +0100 Subject: [PATCH 82/93] [credit card] implement payment --- uncloud/urls.py | 12 ++-- uncloud_pay/admin.py | 15 +++-- .../migrations/0002_auto_20201228_2244.py | 24 +++++++ .../migrations/0003_auto_20201228_2256.py | 28 +++++++++ .../0004_stripecreditcard_active.py | 18 ++++++ .../migrations/0005_auto_20201228_2335.py | 18 ++++++ .../migrations/0006_auto_20201228_2337.py | 21 +++++++ .../migrations/0007_auto_20201228_2338.py | 17 +++++ .../0008_payment_external_reference.py | 18 ++++++ .../migrations/0009_auto_20201229_0037.py | 18 ++++++ .../migrations/0010_auto_20201229_0042.py | 19 ++++++ uncloud_pay/models.py | 45 +++++++------ uncloud_pay/serializers.py | 24 +++++-- uncloud_pay/stripe.py | 63 ++++++++++++++----- .../uncloud_pay/register_stripe.html | 3 +- uncloud_pay/views.py | 11 ++-- 16 files changed, 290 insertions(+), 64 deletions(-) create mode 100644 uncloud_pay/migrations/0002_auto_20201228_2244.py create mode 100644 uncloud_pay/migrations/0003_auto_20201228_2256.py create mode 100644 uncloud_pay/migrations/0004_stripecreditcard_active.py create mode 100644 uncloud_pay/migrations/0005_auto_20201228_2335.py create mode 100644 uncloud_pay/migrations/0006_auto_20201228_2337.py create mode 100644 uncloud_pay/migrations/0007_auto_20201228_2338.py create mode 100644 uncloud_pay/migrations/0008_payment_external_reference.py create mode 100644 uncloud_pay/migrations/0009_auto_20201229_0037.py create mode 100644 uncloud_pay/migrations/0010_auto_20201229_0042.py diff --git a/uncloud/urls.py b/uncloud/urls.py index f163136..3ee5988 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -47,12 +47,12 @@ router.register(r'v1/service/generic', serviceviews.GenericServiceProductViewSet router.register(r'v1/my/address', payviews.BillingAddressViewSet, basename='billingaddress') router.register(r'v1/my/bill', payviews.BillViewSet, basename='bill') router.register(r'v1/my/order', payviews.OrderViewSet, basename='order') -router.register(r'v1/my/payment', payviews.PaymentViewSet, basename='payment') +#router.register(r'v1/my/payment', payviews.PaymentViewSet, basename='payment') router.register(r'v1/my/payment-method', payviews.PaymentMethodViewSet, basename='payment-method') # admin/staff urls router.register(r'v1/admin/bill', payviews.AdminBillViewSet, basename='admin/bill') -router.register(r'v1/admin/payment', payviews.AdminPaymentViewSet, basename='admin/payment') +#router.register(r'v1/admin/payment', payviews.AdminPaymentViewSet, basename='admin/payment') router.register(r'v1/admin/order', payviews.AdminOrderViewSet, basename='admin/order') router.register(r'v1/admin/vmhost', vmviews.VMHostViewSet) router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet) @@ -74,6 +74,7 @@ router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basenam # Payment related router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='credit-card') +router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment') urlpatterns = [ @@ -83,16 +84,13 @@ urlpatterns = [ path('openapi', get_schema_view( title="uncloud", description="uncloud API", - version="1.0.0" + version="2.0.0" ), name='openapi-schema'), - # web/ = stuff to view in the browser -# path('web/vpn/create/', netviews.WireGuardVPNCreateView.as_view(), name="vpncreate"), + path('admin/', admin.site.urls), path('login/', authviews.LoginView.as_view(), name="login"), path('logout/', authviews.LogoutView.as_view(), name="logout"), - path('admin/', admin.site.urls), - path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"), path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"), diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index eb82fb7..d8b09da 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -10,10 +10,8 @@ from django.core.files.temp import NamedTemporaryFile from django.http import FileResponse from django.template.loader import render_to_string - from uncloud_pay.models import * - class BillRecordInline(admin.TabularInline): model = BillRecord @@ -85,8 +83,17 @@ class BillAdmin(admin.ModelAdmin): admin.site.register(Bill, BillAdmin) -admin.site.register(ProductToRecurringPeriod) admin.site.register(Product, ProductAdmin) -for m in [ Order, BillRecord, BillingAddress, RecurringPeriod, VATRate, StripeCustomer, StripeCreditCard ]: +for m in [ + BillRecord, + BillingAddress, + Order, + Payment, + ProductToRecurringPeriod, + RecurringPeriod, + StripeCreditCard, + StripeCustomer, + VATRate, +]: admin.site.register(m) diff --git a/uncloud_pay/migrations/0002_auto_20201228_2244.py b/uncloud_pay/migrations/0002_auto_20201228_2244.py new file mode 100644 index 0000000..4665553 --- /dev/null +++ b/uncloud_pay/migrations/0002_auto_20201228_2244.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1 on 2020-12-28 22:44 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='payment', + name='currency', + field=models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32), + ), + migrations.AlterField( + model_name='payment', + name='amount', + field=models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/uncloud_pay/migrations/0003_auto_20201228_2256.py b/uncloud_pay/migrations/0003_auto_20201228_2256.py new file mode 100644 index 0000000..b516bd5 --- /dev/null +++ b/uncloud_pay/migrations/0003_auto_20201228_2256.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1 on 2020-12-28 22:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0002_auto_20201228_2244'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='currency', + field=models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32), + ), + migrations.AlterField( + model_name='payment', + name='currency', + field=models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32), + ), + migrations.AlterField( + model_name='product', + name='currency', + field=models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32), + ), + ] diff --git a/uncloud_pay/migrations/0004_stripecreditcard_active.py b/uncloud_pay/migrations/0004_stripecreditcard_active.py new file mode 100644 index 0000000..3fb8015 --- /dev/null +++ b/uncloud_pay/migrations/0004_stripecreditcard_active.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-12-28 23:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0003_auto_20201228_2256'), + ] + + operations = [ + migrations.AddField( + model_name='stripecreditcard', + name='active', + field=models.BooleanField(default=True), + ), + ] diff --git a/uncloud_pay/migrations/0005_auto_20201228_2335.py b/uncloud_pay/migrations/0005_auto_20201228_2335.py new file mode 100644 index 0000000..814752e --- /dev/null +++ b/uncloud_pay/migrations/0005_auto_20201228_2335.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-12-28 23:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0004_stripecreditcard_active'), + ] + + operations = [ + migrations.AlterField( + model_name='stripecreditcard', + name='active', + field=models.BooleanField(default=False), + ), + ] diff --git a/uncloud_pay/migrations/0006_auto_20201228_2337.py b/uncloud_pay/migrations/0006_auto_20201228_2337.py new file mode 100644 index 0000000..a164767 --- /dev/null +++ b/uncloud_pay/migrations/0006_auto_20201228_2337.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1 on 2020-12-28 23:37 + +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), + ('uncloud_pay', '0005_auto_20201228_2335'), + ] + + operations = [ + migrations.AlterField( + model_name='stripecreditcard', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/uncloud_pay/migrations/0007_auto_20201228_2338.py b/uncloud_pay/migrations/0007_auto_20201228_2338.py new file mode 100644 index 0000000..315a74b --- /dev/null +++ b/uncloud_pay/migrations/0007_auto_20201228_2338.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-12-28 23:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0006_auto_20201228_2337'), + ] + + operations = [ + migrations.AddConstraint( + model_name='stripecreditcard', + constraint=models.UniqueConstraint(condition=models.Q(active=True), fields=('owner',), name='one_active_card_per_user'), + ), + ] diff --git a/uncloud_pay/migrations/0008_payment_external_reference.py b/uncloud_pay/migrations/0008_payment_external_reference.py new file mode 100644 index 0000000..0de20b6 --- /dev/null +++ b/uncloud_pay/migrations/0008_payment_external_reference.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-12-29 00:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0007_auto_20201228_2338'), + ] + + operations = [ + migrations.AddField( + model_name='payment', + name='external_reference', + field=models.CharField(default='', max_length=256), + ), + ] diff --git a/uncloud_pay/migrations/0009_auto_20201229_0037.py b/uncloud_pay/migrations/0009_auto_20201229_0037.py new file mode 100644 index 0000000..fc195e4 --- /dev/null +++ b/uncloud_pay/migrations/0009_auto_20201229_0037.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-12-29 00:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0008_payment_external_reference'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='external_reference', + field=models.CharField(blank=True, default='', max_length=256, null=True), + ), + ] diff --git a/uncloud_pay/migrations/0010_auto_20201229_0042.py b/uncloud_pay/migrations/0010_auto_20201229_0042.py new file mode 100644 index 0000000..6dd6a60 --- /dev/null +++ b/uncloud_pay/migrations/0010_auto_20201229_0042.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2020-12-29 00:42 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0009_auto_20201229_0037'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='timestamp', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index abf769c..0c880c3 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -64,8 +64,8 @@ class Currency(models.TextChoices): Possible currencies to be billed """ CHF = 'CHF', _('Swiss Franc') - EUR = 'EUR', _('Euro') - USD = 'USD', _('US Dollar') +# EUR = 'EUR', _('Euro') +# USD = 'USD', _('US Dollar') def get_balance_for_user(user): @@ -93,28 +93,30 @@ class StripeCustomer(models.Model): class StripeCreditCard(models.Model): - owner = models.OneToOneField( get_user_model(), - on_delete=models.CASCADE) + owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) card_name = models.CharField(null=False, max_length=128, default="My credit card") card_id = models.CharField(null=False, max_length=32) last4 = models.CharField(null=False, max_length=4) brand = models.CharField(null=False, max_length=64) expiry_date = models.DateField(null=False) + active = models.BooleanField(default=False) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['owner'], + condition=Q(active=True), + name='one_active_card_per_user') + ] + def __str__(self): return f"{self.card_name}: {self.brand} {self.last4} ({self.expiry_date})" - -### -# Payments and Payment Methods. - class Payment(models.Model): - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE) + owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) amount = models.DecimalField( - default=0.0, max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS, validators=[MinValueValidator(0)]) @@ -128,21 +130,18 @@ class Payment(models.Model): ('unknown', 'Unknown') ), default='unknown') - timestamp = models.DateTimeField(editable=False, auto_now_add=True) - # We override save() in order to active products awaiting payment. - def save(self, *args, **kwargs): - # _state.adding is switched to false after super(...) call. - being_created = self._state.adding + timestamp = models.DateTimeField(default=timezone.now) - unpaid_bills_before_payment = Bill.get_unpaid_for(self.owner) - super(Payment, self).save(*args, **kwargs) # Save payment in DB. - unpaid_bills_after_payment = Bill.get_unpaid_for(self.owner) + currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF) - newly_paid_bills = list( - set(unpaid_bills_before_payment) - set(unpaid_bills_after_payment)) - for bill in newly_paid_bills: - bill.activate_products() + external_reference = models.CharField(max_length=256, default="", null=True, blank=True) + + def __str__(self): + return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}" + +### +# Payments and Payment Methods. class PaymentMethod(models.Model): diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py index 84a23fd..14cac0b 100644 --- a/uncloud_pay/serializers.py +++ b/uncloud_pay/serializers.py @@ -4,17 +4,33 @@ from uncloud_auth.serializers import UserSerializer from django.utils.translation import gettext_lazy as _ from .models import * +import uncloud_pay.stripe as uncloud_stripe ### # 2020-12 Checked code - class StripeCreditCardSerializer(serializers.ModelSerializer): class Meta: model = StripeCreditCard - exclude = ['card_id', "owner" ] + exclude = [ "card_id", "owner" ] read_only_fields = [ "last4", "brand", "expiry_date" ] +class PaymentSerializer(serializers.ModelSerializer): + owner = serializers.HiddenField(default=serializers.CurrentUserDefault()) + + class Meta: + model = Payment + fields = '__all__' + read_only_fields = [ "external_reference", "source", "timestamp" ] + + def validate(self, data): + payment_intent = uncloud_stripe.charge_customer(data['owner'], + data['amount']) + + data["external_reference"] = payment_intent["id"] + data["source"] = "stripe" + + return data ################################################################################ @@ -23,10 +39,6 @@ class StripeCreditCardSerializer(serializers.ModelSerializer): ### # Payments and Payment Methods. -class PaymentSerializer(serializers.ModelSerializer): - class Meta: - model = Payment - fields = '__all__' class UpdatePaymentMethodSerializer(serializers.ModelSerializer): class Meta: diff --git a/uncloud_pay/stripe.py b/uncloud_pay/stripe.py index 5b3bb00..ed95c82 100644 --- a/uncloud_pay/stripe.py +++ b/uncloud_pay/stripe.py @@ -3,7 +3,7 @@ import stripe.error import logging import datetime -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.conf import settings from django.contrib.auth import get_user_model @@ -80,20 +80,6 @@ def get_setup_intent(setup_intent_id): def get_payment_method(payment_method_id): return stripe.PaymentMethod.retrieve(payment_method_id) -@handle_stripe_error -def charge_customer(amount, customer_id, card_id): - # Amount is in CHF but stripes requires smallest possible unit. - # https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount - adjusted_amount = int(amount * 100) - return stripe.PaymentIntent.create( - amount=adjusted_amount, - currency=CURRENCY, - customer=customer_id, - payment_method=card_id, - off_session=True, - confirm=True, - ) - @handle_stripe_error def create_customer(name, email): return stripe.Customer.create(name=name, email=email) @@ -111,7 +97,6 @@ def get_customer_cards(customer_id): customer=customer_id, type="card", ) - print(stripe_cards["data"]) for stripe_card in stripe_cards["data"]: card = {} @@ -129,7 +114,21 @@ def sync_cards_for_user(user): customer_id = get_customer_id_for(user) cards = get_customer_cards(customer_id) + active_cards = StripeCreditCard.objects.filter(owner=user, + active=True) + + if len(active_cards) > 0: + has_active_card = True + else: + has_active_card = False + for card in cards: + active = False + + if not has_active_card: + active = True + has_active_card = True + StripeCreditCard.objects.get_or_create(card_id=card['id'], owner = user, defaults = { @@ -137,6 +136,36 @@ def sync_cards_for_user(user): 'brand': card['brand'], 'expiry_date': datetime.date(card['year'], card['month'], - 1) + 1), + 'active': active } ) + +@handle_stripe_error +def charge_customer(user, amount, currency='CHF'): + # Amount is in CHF but stripes requires smallest possible unit. + # https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount + # FIXME: might need to be adjusted for other currencies + + if currency == 'CHF': + adjusted_amount = int(amount * 100) + else: + return Exception("Programming error: unsupported currency") + + try: + card = StripeCreditCard.objects.get(owner=user, + active=True) + + except StripeCreditCard.DoesNotExist: + raise ValidationError("No active credit card - cannot create payment") + + customer_id = get_customer_id_for(user) + + return stripe.PaymentIntent.create( + amount=adjusted_amount, + currency=currency, + customer=customer_id, + payment_method=card.card_id, + off_session=True, + confirm=True, + ) diff --git a/uncloud_pay/templates/uncloud_pay/register_stripe.html b/uncloud_pay/templates/uncloud_pay/register_stripe.html index 76265fa..82aca74 100644 --- a/uncloud_pay/templates/uncloud_pay/register_stripe.html +++ b/uncloud_pay/templates/uncloud_pay/register_stripe.html @@ -63,8 +63,7 @@ } else { // Return to API on success. document.getElementById("ungleichmessage").innerHTML - = "Registered credit card with - Stripe. Return to the main page." + = "Registered credit card with Stripe." } }); }); diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py index 246e922..48c24a8 100644 --- a/uncloud_pay/views.py +++ b/uncloud_pay/views.py @@ -68,17 +68,18 @@ class CreditCardViewSet(mixins.RetrieveModelMixin, return StripeCreditCard.objects.filter(owner=self.request.user) - -### -# Payments and Payment Methods. - -class PaymentViewSet(viewsets.ReadOnlyModelViewSet): +class PaymentViewSet(viewsets.ModelViewSet): serializer_class = PaymentSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): return Payment.objects.filter(owner=self.request.user) + +### +# Payments and Payment Methods. + + class OrderViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = OrderSerializer permission_classes = [permissions.IsAuthenticated] From 6c15d2086e00455447730a07e6d749b9da704a04 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 1 Jan 2021 12:41:54 +0100 Subject: [PATCH 83/93] implement balance getting --- uncloud/urls.py | 1 + uncloud_pay/models.py | 10 ------- uncloud_pay/selectors.py | 23 ++++++++++++++ uncloud_pay/serializers.py | 23 +++----------- .../templates/uncloud_pay/list_stripe.html | 30 ------------------- .../uncloud_pay/register_stripe.html | 18 +++++++---- uncloud_pay/views.py | 13 ++++++++ 7 files changed, 53 insertions(+), 65 deletions(-) create mode 100644 uncloud_pay/selectors.py delete mode 100644 uncloud_pay/templates/uncloud_pay/list_stripe.html diff --git a/uncloud/urls.py b/uncloud/urls.py index 3ee5988..15623bd 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -75,6 +75,7 @@ router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basenam # Payment related router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='credit-card') router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment') +router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance') urlpatterns = [ diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 0c880c3..4eb6698 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -68,16 +68,6 @@ class Currency(models.TextChoices): # USD = 'USD', _('US Dollar') -def get_balance_for_user(user): - bills = reduce( - lambda acc, entry: acc + entry.total, - Bill.objects.filter(owner=user), - 0) - payments = reduce( - lambda acc, entry: acc + entry.amount, - Payment.objects.filter(owner=user), - 0) - return payments - bills ### # Stripe diff --git a/uncloud_pay/selectors.py b/uncloud_pay/selectors.py new file mode 100644 index 0000000..ba53c74 --- /dev/null +++ b/uncloud_pay/selectors.py @@ -0,0 +1,23 @@ +from django.utils import timezone +from django.db import transaction + +from .models import * + +def get_payments_for_user(user): + payments = [ payment.amount for payment in Payment.objects.filter(owner=user) ] + + return sum(payments) + +def get_spendings_for_user(user): + orders = Order.objects.filter(owner=user) + + amount = 0 + for order in orders: + amount += order.one_time_price + amount += order.recurring_price * order.count_used(when=timezone.now()) + + return amount + +@transaction.atomic +def get_balance_for_user(user): + return get_payments_for_user(user) - get_spendings_for_user(user) diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py index 14cac0b..361ff1c 100644 --- a/uncloud_pay/serializers.py +++ b/uncloud_pay/serializers.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from .models import * import uncloud_pay.stripe as uncloud_stripe +from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS ### # 2020-12 Checked code @@ -32,29 +33,13 @@ class PaymentSerializer(serializers.ModelSerializer): return data +class BalanceSerializer(serializers.Serializer): + balance = serializers.DecimalField(max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS) + ################################################################################ # Unchecked code -### -# Payments and Payment Methods. - - -class UpdatePaymentMethodSerializer(serializers.ModelSerializer): - class Meta: - model = PaymentMethod - fields = ['description', 'primary'] - -class ChargePaymentMethodSerializer(serializers.Serializer): - amount = serializers.DecimalField(max_digits=10, decimal_places=2) - -class CreatePaymentMethodSerializer(serializers.ModelSerializer): - please_visit = serializers.CharField(read_only=True) - - class Meta: - model = PaymentMethod - fields = ['source', 'description', 'primary', 'please_visit'] - ### # Orders & Products. diff --git a/uncloud_pay/templates/uncloud_pay/list_stripe.html b/uncloud_pay/templates/uncloud_pay/list_stripe.html deleted file mode 100644 index b5cba17..0000000 --- a/uncloud_pay/templates/uncloud_pay/list_stripe.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends 'uncloud/base.html' %} - -{% block header %} - -{% endblock %} - -{% block body %} -
-

Your credit cards registered with Stripe

- - - - -

List of stripe credit cards: -

    - {% for card in cards %} -
  • {{ card.brand }} ending in {{ card.last4 }} expiring - {{ card.year }}-{{ card.month }} - {% endfor %} - -
-

-
- -{% endblock %} diff --git a/uncloud_pay/templates/uncloud_pay/register_stripe.html b/uncloud_pay/templates/uncloud_pay/register_stripe.html index 82aca74..eaf1da4 100644 --- a/uncloud_pay/templates/uncloud_pay/register_stripe.html +++ b/uncloud_pay/templates/uncloud_pay/register_stripe.html @@ -21,17 +21,17 @@ terms of my agreement with you.

- -
- - +
The card will be registered with stripe.
+
+ {% endblock %} diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py index 48c24a8..073f7c9 100644 --- a/uncloud_pay/views.py +++ b/uncloud_pay/views.py @@ -24,12 +24,15 @@ import logging from .models import * from .serializers import * +from .selectors import * + from datetime import datetime from vat_validator import sanitize_vat import uncloud_pay.stripe as uncloud_stripe logger = logging.getLogger(__name__) + ### # 2020-12 checked code @@ -75,6 +78,16 @@ class PaymentViewSet(viewsets.ModelViewSet): def get_queryset(self): return Payment.objects.filter(owner=self.request.user) +class BalanceViewSet(viewsets.ViewSet): + permission_classes = [permissions.IsAuthenticated] + + def list(self, request): + serializer = BalanceSerializer(data={ + 'balance': get_balance_for_user(self.request.user) + }) + serializer.is_valid() + return Response(serializer.data) + ### # Payments and Payment Methods. From 48ce21f833a812c797caca15ea2fec465ddf950e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 1 Jan 2021 13:25:52 +0100 Subject: [PATCH 84/93] integrate bootstrap --- requirements.txt | 1 + uncloud/settings.py | 6 ++ uncloud/templates/uncloud/base.html | 16 ++++- uncloud/templates/uncloud/index.html | 60 ++++++++++++++++--- uncloud/urls.py | 41 +++++-------- uncloud_pay/serializers.py | 2 +- .../uncloud_pay/register_stripe.html | 7 --- 7 files changed, 88 insertions(+), 45 deletions(-) diff --git a/requirements.txt b/requirements.txt index adbda9c..2a84047 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ django djangorestframework django-auth-ldap +django-bootstrap4 psycopg2 ldap3 diff --git a/uncloud/settings.py b/uncloud/settings.py index afc6d65..2ac6d69 100644 --- a/uncloud/settings.py +++ b/uncloud/settings.py @@ -51,6 +51,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django_extensions', 'rest_framework', + 'bootstrap4', 'uncloud', 'uncloud_pay', 'uncloud_auth', @@ -164,6 +165,11 @@ USE_TZ = True # https://docs.djangoproject.com/en/3.0/howto/static-files/ STATIC_URL = '/static/' STATICFILES_DIRS = [ os.path.join(BASE_DIR, "static") ] +STATICFILES_FINDERS = [ + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +] + # XML-RPC interface of opennebula OPENNEBULA_URL = 'https://opennebula.example.com:2634/RPC2' diff --git a/uncloud/templates/uncloud/base.html b/uncloud/templates/uncloud/base.html index 034fa7c..2273591 100644 --- a/uncloud/templates/uncloud/base.html +++ b/uncloud/templates/uncloud/base.html @@ -1,3 +1,8 @@ +{% extends 'bootstrap4/bootstrap4.html' %} + +{% load bootstrap4 %} + + @@ -5,10 +10,17 @@ - {% block title %}Welcome to uncloud{% endblock %} + {% block bootstrap4_title %}{% block title %}Welcome to uncloud{% endblock %}{% endblock %} + {% block header %}{% endblock %} - {% block body %}{% endblock %} +{% block bootstrap4_content %} +
+ {% block body %}{% endblock %} +
+ + {% autoescape off %}{% bootstrap_messages %}{% endautoescape %} +{% endblock %} diff --git a/uncloud/templates/uncloud/index.html b/uncloud/templates/uncloud/index.html index e5a8318..6a88f99 100644 --- a/uncloud/templates/uncloud/index.html +++ b/uncloud/templates/uncloud/index.html @@ -2,19 +2,61 @@ {% block title %}{% endblock %} {% block body %} -
+

Welcome to uncloud

- Welcome to uncloud, checkout the following locations: +
+ Welcome to uncloud, the Open Source cloud management + system by ungleich. + It is an API driven system with + some convience views provided by + the Django Rest + Framework. You can + freely access + the source code of uncloud. -
    -
  • The uncloud API -
  • Register a credit card - (this is required to be done via Javascript so that we never see - your credit card, but it is sent directly to stripe) - You can list your credit card via the API. +
    +

    Credit cards

    +
    + Credit cards are registered with stripe. We only save a the + last 4 digits and the expiry date of the card to make + identification for you easier. +
    +
    +
      +
    • Register a credit card + (this is required to be done via Javascript so that we never see + your credit card, but it is sent directly to stripe) +
    • You can list your + credit cards + By default the first credit card is used for charging + ("active: true") and later added cards will not be + used. To change this, first disable the active flag and + then set it on another credit card. +
    +
    +
    +

    Payments and Balance

    + To trigger a payment + + +
    +
    +

    Networking

    + With uncloud you can use a variety of network related services. + + +
    -
{% endblock %} diff --git a/uncloud/urls.py b/uncloud/urls.py index 15623bd..82bce86 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -26,38 +26,27 @@ router = routers.DefaultRouter() router.register(r'beta/vm', vmviews.NicoVMProductViewSet, basename='nicovmproduct') # VM -router.register(r'v1/vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct') -router.register(r'v1/vm/diskimage', vmviews.VMDiskImageProductViewSet, basename='vmdiskimageproduct') -router.register(r'v1/vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') -router.register(r'v1/vm/vm', vmviews.VMProductViewSet, basename='vmproduct') - - -# creates VM from os image -#router.register(r'vm/ipv6onlyvm', vmviews.VMProductViewSet, basename='vmproduct') -# ... AND adds IPv4 mapping -#router.register(r'vm/dualstackvm', vmviews.VMProductViewSet, basename='vmproduct') +# router.register(r'v1/vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct') +# router.register(r'v1/vm/diskimage', vmviews.VMDiskImageProductViewSet, basename='vmdiskimageproduct') +# router.register(r'v1/vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') +# router.register(r'v1/vm/vm', vmviews.VMProductViewSet, basename='vmproduct') # Services -router.register(r'v1/service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct') -router.register(r'v1/service/generic', serviceviews.GenericServiceProductViewSet, basename='genericserviceproduct') - +# router.register(r'v1/service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct') +# router.register(r'v1/service/generic', serviceviews.GenericServiceProductViewSet, basename='genericserviceproduct') # Pay -router.register(r'v1/my/address', payviews.BillingAddressViewSet, basename='billingaddress') -router.register(r'v1/my/bill', payviews.BillViewSet, basename='bill') -router.register(r'v1/my/order', payviews.OrderViewSet, basename='order') -#router.register(r'v1/my/payment', payviews.PaymentViewSet, basename='payment') -router.register(r'v1/my/payment-method', payviews.PaymentMethodViewSet, basename='payment-method') +# router.register(r'v1/my/address', payviews.BillingAddressViewSet, basename='billingaddress') +# router.register(r'v1/my/bill', payviews.BillViewSet, basename='bill') +# router.register(r'v1/my/order', payviews.OrderViewSet, basename='order') +# router.register(r'v1/my/payment-method', payviews.PaymentMethodViewSet, basename='payment-method') # admin/staff urls -router.register(r'v1/admin/bill', payviews.AdminBillViewSet, basename='admin/bill') -#router.register(r'v1/admin/payment', payviews.AdminPaymentViewSet, basename='admin/payment') -router.register(r'v1/admin/order', payviews.AdminOrderViewSet, basename='admin/order') -router.register(r'v1/admin/vmhost', vmviews.VMHostViewSet) -router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet) -#router.register(r'v1/admin/vpnpool', netviews.VPNPoolViewSet) -#router.register(r'v1/admin/opennebula', oneviews.VMViewSet, basename='opennebula') +# router.register(r'v1/admin/bill', payviews.AdminBillViewSet, basename='admin/bill') +# router.register(r'v1/admin/order', payviews.AdminOrderViewSet, basename='admin/order') +# router.register(r'v1/admin/vmhost', vmviews.VMHostViewSet) +# router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet) # User/Account router.register(r'v1/my/user', authviews.UserViewSet, basename='user') @@ -73,7 +62,7 @@ router.register(r'v2/net/wireguardvpn', netviews.WireGuardVPNViewSet, basename=' router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basename='wireguardvpnnetworksizes') # Payment related -router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='credit-card') +router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='stripecreditcard') router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment') router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance') diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py index 361ff1c..00e969b 100644 --- a/uncloud_pay/serializers.py +++ b/uncloud_pay/serializers.py @@ -10,7 +10,7 @@ from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS ### # 2020-12 Checked code -class StripeCreditCardSerializer(serializers.ModelSerializer): +class StripeCreditCardSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = StripeCreditCard exclude = [ "card_id", "owner" ] diff --git a/uncloud_pay/templates/uncloud_pay/register_stripe.html b/uncloud_pay/templates/uncloud_pay/register_stripe.html index eaf1da4..7bb0110 100644 --- a/uncloud_pay/templates/uncloud_pay/register_stripe.html +++ b/uncloud_pay/templates/uncloud_pay/register_stripe.html @@ -2,13 +2,6 @@ {% block header %} - - {% endblock %} {% block body %} From 6b9b15e66375c8ff049f5ba7c5ccd8b9c853ab82 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 17 Jan 2021 15:47:37 +0100 Subject: [PATCH 85/93] Add deploy.sh --- bin/deploy.sh | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100755 bin/deploy.sh diff --git a/bin/deploy.sh b/bin/deploy.sh new file mode 100755 index 0000000..5def21a --- /dev/null +++ b/bin/deploy.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Nico Schottelius, 2021-01-17 + +set -e + +if [ $# -ne 1 ]; then + echo "$0 target-host" + exit 1 +fi + +target_host=$1; shift +user=app + +dir=${0%/*} +uncloud_base=$(cd ${dir}/.. && pwd -P) +conf_name=local_settings-${target_host}.py +conf_file=${uncloud_base}/uncloud/${conf_name} + +if [ ! -e ${conf_file} ]; then + echo "No settings for ${target_host}." + echo "Create ${conf_file} before using this script." + exit 1 +fi + +# Deploy +rsync -av \ + --exclude venv/ \ + --exclude '*.pyc' \ + --delete \ + ${uncloud_base}/ ${user}@${target_host}:app/ + +ssh "${user}@${target_host}" ". ~/pyvenv/bin/activate; cd ~/app; pip install -r requirements.txt" + +# Config +ssh "${user}@${target_host}" "cd ~/app/uncloud; ln -sf ${conf_name} local_settings.py" + +# Restart / Apply +ssh "${user}@${target_host}" "sudo /etc/init.d/uwsgi restart" From a92088710080f842630ccc14df08a8d42e055081 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 17 Jan 2021 15:53:30 +0100 Subject: [PATCH 86/93] ++bridge update --- bin/make-migrations-from-scratch.sh | 0 doc/uncloud-manual-2020-08-01.org | 26 +++- uncloud/migrations/0004_auto_20210101_1308.py | 19 +++ uncloud/models.py | 2 +- uncloud/static/uncloud/uncloud.css | 4 + uncloud/templates/uncloud/base.html | 29 +++- uncloud/templates/uncloud/index.html | 137 +++++++++++++----- uncloud/urls.py | 10 +- .../templates/uncloud_auth/login.html | 13 +- uncloud_pay/admin.py | 12 +- .../migrations/0011_auto_20210101_1308.py | 19 +++ uncloud_pay/models.py | 85 +++-------- uncloud_pay/selectors.py | 3 + uncloud_pay/serializers.py | 10 +- uncloud_pay/services.py | 32 ++++ .../templates/uncloud_pay/bill.html.j2 | 12 +- 16 files changed, 275 insertions(+), 138 deletions(-) mode change 100644 => 100755 bin/make-migrations-from-scratch.sh create mode 100644 uncloud/migrations/0004_auto_20210101_1308.py create mode 100644 uncloud/static/uncloud/uncloud.css create mode 100644 uncloud_pay/migrations/0011_auto_20210101_1308.py create mode 100644 uncloud_pay/services.py diff --git a/bin/make-migrations-from-scratch.sh b/bin/make-migrations-from-scratch.sh old mode 100644 new mode 100755 diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org index 381bb62..b997600 100644 --- a/doc/uncloud-manual-2020-08-01.org +++ b/doc/uncloud-manual-2020-08-01.org @@ -1,4 +1,4 @@ -* Bootstrap / Installation +* Bootstrap / Installation / Deployment ** Pre-requisites by operating system *** General To run uncloud you need: @@ -150,7 +150,6 @@ g #+END_SRC Workers usually should have an "uncloud" user account, even though strictly speaking the username can be any. - *** WireGuardVPN Server - Allow write access to /etc/wireguard for uncloud user - Allow sudo access to "ip" and "wg" @@ -161,7 +160,11 @@ g #+END_SRC app ALL=(ALL) NOPASSWD:/sbin/ip app ALL=(ALL) NOPASSWD:/usr/bin/wg #+END_SRC - +** Typical source code based deployment + - Deploy using bin/deploy.sh on a remote server + - Remote server should have + - postgresql running, accessible via TLS from outside + - rabbitmq-configured [in progress] * Testing / CLI Access Access via the commandline (CLI) can be done using curl or @@ -462,6 +465,21 @@ Q vpn-2a0ae5c1200.ungleich.ch - query on that flag - verify it every time - ***** TODO Generating bill for admins/staff - + + + + +**** Bill fixes needed +***** TODO Double bill in bill id +***** TODO Name the currency +***** TODO Maybe remove the chromium pdf rendering artefacts + - date on the top + - title on the top + - filename bottom left + - page number could even stay +***** TODO Try to shorten the timestamp (remove time zone?) +***** TODO Bill date might be required +***** TODO Total and VAT are empty +***** TODO Line below detail/ heading diff --git a/uncloud/migrations/0004_auto_20210101_1308.py b/uncloud/migrations/0004_auto_20210101_1308.py new file mode 100644 index 0000000..8385b16 --- /dev/null +++ b/uncloud/migrations/0004_auto_20210101_1308.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2021-01-01 13:08 + +from django.db import migrations +import uncloud.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud', '0003_auto_20201220_1728'), + ] + + operations = [ + migrations.AlterField( + model_name='uncloudprovider', + name='country', + field=uncloud.models.CountryField(choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2), + ), + ] diff --git a/uncloud/models.py b/uncloud/models.py index 535d920..d956637 100644 --- a/uncloud/models.py +++ b/uncloud/models.py @@ -61,7 +61,7 @@ class UncloudAddress(models.Model): street = models.CharField(max_length=256) city = models.CharField(max_length=256) postal_code = models.CharField(max_length=64) - country = CountryField(blank=True) + country = CountryField(blank=False, null=False) class Meta: abstract = True diff --git a/uncloud/static/uncloud/uncloud.css b/uncloud/static/uncloud/uncloud.css new file mode 100644 index 0000000..51d93ef --- /dev/null +++ b/uncloud/static/uncloud/uncloud.css @@ -0,0 +1,4 @@ +#content { + width: 400px; + margin: auto; +} diff --git a/uncloud/templates/uncloud/base.html b/uncloud/templates/uncloud/base.html index 2273591..44b5008 100644 --- a/uncloud/templates/uncloud/base.html +++ b/uncloud/templates/uncloud/base.html @@ -2,7 +2,6 @@ {% load bootstrap4 %} - @@ -15,7 +14,33 @@ {% block header %}{% endblock %} -{% block bootstrap4_content %} + {% block bootstrap4_content %} + + +
{% block body %}{% endblock %}
diff --git a/uncloud/templates/uncloud/index.html b/uncloud/templates/uncloud/index.html index 6a88f99..97fda34 100644 --- a/uncloud/templates/uncloud/index.html +++ b/uncloud/templates/uncloud/index.html @@ -3,51 +3,109 @@ {% block body %}
-

Welcome to uncloud

-
- Welcome to uncloud, the Open Source cloud management - system by ungleich. - It is an API driven system with - some convience views provided by - the Django Rest - Framework. You can - freely access - the source code of uncloud. +
+
+

Welcome to uncloud

+
+
+
+

About uncloud

+
+

+ Welcome to uncloud, the Open Source cloud management + system by ungleich. + It is an API driven system with + some convience views provided by + the Django Rest + Framework. You can + freely access + the source code of uncloud. +

+
+
+
+

Getting started

+
+

uncloud is designed to be as easy as possible to use. However, + there are some "real world" requirements that need to be met to + start using uncloud: -

-

Credit cards

-
+
    +
  • First you need + to register an + account. If you already have one, you can + login. +
  • If you have forgotten your password or other issues with + logging in, you can contact the ungleich support + via support at ungleich.ch. + +
  • Secondy you will need to + create a billing + address. This is required for determining the correct + tax. +
  • Next you will need to + register a credit card + from which payments can be made. Your credit card will not + be charged without your consent. +
+
+
+ +
+

Credit cards

+
+

Credit cards are registered with stripe. We only save a the last 4 digits and the expiry date of the card to make identification for you easier. -

-
-
    -
  • Register a credit card - (this is required to be done via Javascript so that we never see - your credit card, but it is sent directly to stripe) -
  • You can list your - credit cards - By default the first credit card is used for charging - ("active: true") and later added cards will not be - used. To change this, first disable the active flag and - then set it on another credit card. -
-
-
-

Payments and Balance

- To trigger a payment - +

+
  • Register a credit card + (this is required to be done via Javascript so that we never see + your credit card, but it is sent directly to stripe) +
  • You can list your + credit cards + By default the first credit card is used for charging + ("active: true") and later added cards will not be + used. To change this, first disable the active flag and + then set it on another credit card. +
  • -
    -

    Networking

    - With uncloud you can use a variety of network related services. +
    +

    Billing Address, Payments and Balance

    +
    +

    Billing addresses behave similar to credit cards: you can + have many of them, but only one can be active. The active + billing address is taken for creating new orders.

    + +

    In uncloud we use the pre-paid model: you can add money to + your account via payments. You can always check your + balance. The products you use will automatically be charged from + your existing balance. +

    + +

    In the future you will be able opt-in to automatically + recharging your account at a certain time frame or whenever it + is below a certain amount

    + + + +
    +
    + +
    +

    Networking

    +
    +

    + With uncloud you can use a variety of network related + services. +

    +
    - -
    {% endblock %} diff --git a/uncloud/urls.py b/uncloud/urls.py index 82bce86..bf3672c 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -37,7 +37,7 @@ router.register(r'beta/vm', vmviews.NicoVMProductViewSet, basename='nicovmproduc # Pay -# router.register(r'v1/my/address', payviews.BillingAddressViewSet, basename='billingaddress') + # router.register(r'v1/my/bill', payviews.BillViewSet, basename='bill') # router.register(r'v1/my/order', payviews.OrderViewSet, basename='order') # router.register(r'v1/my/payment-method', payviews.PaymentMethodViewSet, basename='payment-method') @@ -49,9 +49,9 @@ router.register(r'beta/vm', vmviews.NicoVMProductViewSet, basename='nicovmproduc # router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet) # User/Account -router.register(r'v1/my/user', authviews.UserViewSet, basename='user') -router.register(r'v1/admin/user', authviews.AdminUserViewSet, basename='useradmin') -router.register(r'v1/user/register', authviews.AccountManagementViewSet, basename='user/register') +# router.register(r'v1/my/user', authviews.UserViewSet, basename='user') +# router.register(r'v1/admin/user', authviews.AdminUserViewSet, basename='useradmin') +# router.register(r'v1/user/register', authviews.AccountManagementViewSet, basename='user/register') ################################################################################ @@ -65,7 +65,7 @@ router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basenam router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='stripecreditcard') router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment') router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance') - +router.register(r'v2/payment/address', payviews.BillingAddressViewSet, basename='billingaddress') urlpatterns = [ path(r'api/', include(router.urls), name='api'), diff --git a/uncloud_auth/templates/uncloud_auth/login.html b/uncloud_auth/templates/uncloud_auth/login.html index 04f9a15..887467b 100644 --- a/uncloud_auth/templates/uncloud_auth/login.html +++ b/uncloud_auth/templates/uncloud_auth/login.html @@ -1,13 +1,14 @@ {% extends 'uncloud/base.html' %} +{% load bootstrap4 %} {% block body %} -
    - -
    +

    Login to uncloud

    + {% csrf_token %} - {{ form }} - + {% bootstrap_form form %} + {% buttons %} + + {% endbuttons %}
    -
    {% endblock %} diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index d8b09da..f604283 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -47,9 +47,13 @@ class BillAdmin(admin.ModelAdmin): raise self._get_404_exception(object_id) output_file = NamedTemporaryFile() - bill_html = render_to_string("bill.html.j2", {'bill': bill, - 'bill_records': bill.billrecord_set.all() - }) + bill_html = render_to_string( + "uncloud_pay/bill.html.j2", + { + 'bill': bill, + 'bill_records': bill.billrecord_set.all() + } + ) bytestring_to_pdf(bill_html.encode('utf-8'), output_file) response = FileResponse(output_file, content_type="application/pdf") @@ -63,7 +67,7 @@ class BillAdmin(admin.ModelAdmin): if bill is None: raise self._get_404_exception(object_id) - return render(request, 'bill.html.j2', + return render(request, 'uncloud_pay/bill.html.j2', {'bill': bill, 'bill_records': bill.billrecord_set.all() }) diff --git a/uncloud_pay/migrations/0011_auto_20210101_1308.py b/uncloud_pay/migrations/0011_auto_20210101_1308.py new file mode 100644 index 0000000..942f430 --- /dev/null +++ b/uncloud_pay/migrations/0011_auto_20210101_1308.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2021-01-01 13:08 + +from django.db import migrations +import uncloud.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0010_auto_20201229_0042'), + ] + + operations = [ + migrations.AlterField( + model_name='billingaddress', + name='country', + field=uncloud.models.CountryField(choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2), + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 4eb6698..adaabef 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -1,24 +1,23 @@ import logging -import itertools import datetime + from math import ceil from calendar import monthrange from decimal import Decimal -from functools import reduce -from django.db import models -from django.db.models import Q -from django.contrib.auth import get_user_model -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType -from django.utils.translation import gettext_lazy as _ -from django.core.validators import MinValueValidator -from django.utils import timezone -from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.validators import MinValueValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone + +# Verify whether or not to use them here +from django.core.exceptions import ObjectDoesNotExist, ValidationError from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS from uncloud.models import UncloudAddress +from .services import * # Used to generate bill due dates. BILL_PAYMENT_DELAY=datetime.timedelta(days=10) @@ -26,36 +25,6 @@ BILL_PAYMENT_DELAY=datetime.timedelta(days=10) # Initialize logger. logger = logging.getLogger(__name__) -def start_of_month(a_day): - """ Returns first of the month of a given datetime object""" - return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0) - -def end_of_month(a_day): - """ Returns first of the month of a given datetime object""" - - _, last_day = monthrange(a_day.year, a_day.month) - return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0) - -def start_of_this_month(): - """ Returns first of this month""" - a_day = timezone.now() - return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0) - -def end_of_this_month(): - """ Returns first of this month""" - a_day = timezone.now() - - _, last_day = monthrange(a_day.year, a_day.month) - return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0) - -def end_before(a_date): - """ Return suitable datetimefield for ending just before a_date """ - return a_date - datetime.timedelta(seconds=1) - -def start_after(a_date): - """ Return suitable datetimefield for starting just after a_date """ - return a_date + datetime.timedelta(seconds=1) - def default_payment_delay(): return timezone.now() + BILL_PAYMENT_DELAY @@ -68,7 +37,6 @@ class Currency(models.TextChoices): # USD = 'USD', _('US Dollar') - ### # Stripe @@ -95,7 +63,7 @@ class StripeCreditCard(models.Model): class Meta: constraints = [ models.UniqueConstraint(fields=['owner'], - condition=Q(active=True), + condition=models.Q(active=True), name='one_active_card_per_user') ] @@ -117,9 +85,7 @@ class Payment(models.Model): ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), - ('unknown', 'Unknown') - ), - default='unknown') + )) timestamp = models.DateTimeField(default=timezone.now) @@ -135,6 +101,11 @@ class Payment(models.Model): class PaymentMethod(models.Model): + """ + Not sure if this is still in use + + """ + owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False) @@ -151,15 +122,6 @@ class PaymentMethod(models.Model): stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True) stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True) - # @property - # def stripe_card_last4(self): - # if self.source == 'stripe' and self.active: - # payment_method = uncloud_pay.stripe.get_payment_method( - # self.stripe_payment_method_id) - # return payment_method.card.last4 - # else: - # return None - @property def active(self): if self.source == 'stripe' and self.stripe_payment_method_id != None: @@ -276,7 +238,7 @@ class BillingAddress(UncloudAddress): class Meta: constraints = [ models.UniqueConstraint(fields=['owner'], - condition=Q(active=True), + condition=models.Q(active=True), name='one_active_billing_address_per_user') ] @@ -297,18 +259,13 @@ class BillingAddress(UncloudAddress): if not billing_address: billing_address = cls.objects.create(owner=owner, organization="uncloud admins", - name="Uncloud Admin", + full_name="Uncloud Admin", street="Uncloudstreet. 42", city="Luchsingen", postal_code="8775", country="CH", active=True) - - @staticmethod - def get_address_for(user): - return BillingAddress.objects.get(owner=user, active=True) - def __str__(self): return "{} - {}, {}, {} {}, {}".format( self.owner, @@ -1186,7 +1143,7 @@ class Bill(models.Model): return bill def __str__(self): - return f"Bill {self.owner}-{self.id}" + return f"{self.owner}-{self.id}" class BillRecord(models.Model): @@ -1256,7 +1213,7 @@ class ProductToRecurringPeriod(models.Model): class Meta: constraints = [ models.UniqueConstraint(fields=['product'], - condition=Q(is_default=True), + condition=models.Q(is_default=True), name='one_default_recurring_period_per_product'), models.UniqueConstraint(fields=['product', 'recurring_period'], name='recurring_period_once_per_product') diff --git a/uncloud_pay/selectors.py b/uncloud_pay/selectors.py index ba53c74..5f86657 100644 --- a/uncloud_pay/selectors.py +++ b/uncloud_pay/selectors.py @@ -21,3 +21,6 @@ def get_spendings_for_user(user): @transaction.atomic def get_balance_for_user(user): return get_payments_for_user(user) - get_spendings_for_user(user) + +def get_billing_address_for_user(user): + return BillingAddress.objects.get(owner=user, active=True) diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py index 00e969b..3906482 100644 --- a/uncloud_pay/serializers.py +++ b/uncloud_pay/serializers.py @@ -36,6 +36,11 @@ class PaymentSerializer(serializers.ModelSerializer): class BalanceSerializer(serializers.Serializer): balance = serializers.DecimalField(max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS) +class BillingAddressSerializer(serializers.ModelSerializer): + class Meta: + model = BillingAddress + exclude = [ "owner" ] + ################################################################################ # Unchecked code @@ -96,11 +101,6 @@ class BillRecordSerializer(serializers.Serializer): amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) total = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS) -class BillingAddressSerializer(serializers.ModelSerializer): - class Meta: - model = BillingAddress - fields = ['uuid', 'organization', 'name', 'street', 'city', 'postal_code', 'country', 'vat_number'] - class BillSerializer(serializers.ModelSerializer): billing_address = BillingAddressSerializer(read_only=True) records = BillRecordSerializer(many=True, read_only=True) diff --git a/uncloud_pay/services.py b/uncloud_pay/services.py new file mode 100644 index 0000000..ed97c39 --- /dev/null +++ b/uncloud_pay/services.py @@ -0,0 +1,32 @@ +from django.utils import timezone + + +def start_of_month(a_day): + """ Returns first of the month of a given datetime object""" + return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0) + +def end_of_month(a_day): + """ Returns first of the month of a given datetime object""" + + _, last_day = monthrange(a_day.year, a_day.month) + return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0) + +def start_of_this_month(): + """ Returns first of this month""" + a_day = timezone.now() + return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0) + +def end_of_this_month(): + """ Returns first of this month""" + a_day = timezone.now() + + _, last_day = monthrange(a_day.year, a_day.month) + return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0) + +def end_before(a_date): + """ Return suitable datetimefield for ending just before a_date """ + return a_date - datetime.timedelta(seconds=1) + +def start_after(a_date): + """ Return suitable datetimefield for starting just after a_date """ + return a_date + datetime.timedelta(seconds=1) diff --git a/uncloud_pay/templates/uncloud_pay/bill.html.j2 b/uncloud_pay/templates/uncloud_pay/bill.html.j2 index c227f43..7cf10f8 100644 --- a/uncloud_pay/templates/uncloud_pay/bill.html.j2 +++ b/uncloud_pay/templates/uncloud_pay/bill.html.j2 @@ -680,11 +680,9 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7
    - {{ bill.starting_date|date:"c" }} - - {{ bill.ending_date|date:"c" }} -
    Bill id: {{ bill }} -
    Due: {{ bill.due_date }} - + Bill id: {{ bill }} +
    {{ bill.starting_date|date:"Ymd" }} - + {{ bill.ending_date|date:"Ymd" }}
    @@ -703,8 +701,8 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7 {% for record in bill_records %} - {{ record.starting_date|date:"c" }} - - {{ record.ending_date|date:"c" }} + {{ record.starting_date|date:"Ymd-H:i:s" }} + - {{ record.ending_date|date:"Ymd-H:i:s" }} {{ record.order }} {{ record.price|floatformat:2 }} From c8ce7dbb40ef5c1de4a13c8b496974bd61a48116 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 17 Jan 2021 15:54:16 +0100 Subject: [PATCH 87/93] do not touch local_settings.py on deploy --- bin/deploy.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/deploy.sh b/bin/deploy.sh index 5def21a..99f7ba0 100755 --- a/bin/deploy.sh +++ b/bin/deploy.sh @@ -26,6 +26,7 @@ fi rsync -av \ --exclude venv/ \ --exclude '*.pyc' \ + --exclude uncloud/local_settings.py \ --delete \ ${uncloud_base}/ ${user}@${target_host}:app/ From 49f52fd41d88281ad5033dd8b8c75fff55239ad8 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 13 Feb 2021 18:50:28 +0100 Subject: [PATCH 88/93] [bootstrap] update to bootstrap5 --- requirements.txt | 2 +- uncloud/settings.py | 2 +- uncloud/templates/uncloud/base.html | 72 ++---- uncloud/templates/uncloud/index.html | 235 ++++++++++-------- .../uncloud_pay/register_stripe.html | 34 +-- 5 files changed, 176 insertions(+), 169 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2a84047..0c153a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ django djangorestframework django-auth-ldap -django-bootstrap4 +django-bootstrap-v5 psycopg2 ldap3 diff --git a/uncloud/settings.py b/uncloud/settings.py index 2ac6d69..a103ca0 100644 --- a/uncloud/settings.py +++ b/uncloud/settings.py @@ -51,7 +51,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django_extensions', 'rest_framework', - 'bootstrap4', + 'bootstrap5', 'uncloud', 'uncloud_pay', 'uncloud_auth', diff --git a/uncloud/templates/uncloud/base.html b/uncloud/templates/uncloud/base.html index 44b5008..8c5c867 100644 --- a/uncloud/templates/uncloud/base.html +++ b/uncloud/templates/uncloud/base.html @@ -1,51 +1,27 @@ -{% extends 'bootstrap4/bootstrap4.html' %} +{% extends 'bootstrap5/bootstrap5.html' %} +{% block bootstrap5_before_content %} -{% load bootstrap4 %} + {% endblock %} - - diff --git a/uncloud/templates/uncloud/index.html b/uncloud/templates/uncloud/index.html index 97fda34..19fc436 100644 --- a/uncloud/templates/uncloud/index.html +++ b/uncloud/templates/uncloud/index.html @@ -1,119 +1,148 @@ {% extends 'uncloud/base.html' %} -{% block title %}{% endblock %} +{% block title %}Welcome to uncloud [beta]{% endblock %} -{% block body %} -
    - -
    -
    -

    Welcome to uncloud

    +{% block bootstrap5_content %} +
    +
    +
    +
    +

    Welcome to uncloud [beta]

    +
    -
    -
    -

    About uncloud

    -
    -

    - Welcome to uncloud, the Open Source cloud management - system by ungleich. - It is an API driven system with - some convience views provided by - the Django Rest - Framework. You can - freely access - the source code of uncloud. -

    + +
    +

    About uncloud

    +
    +

    + Welcome to uncloud, the Open Source cloud management + system by ungleich. + It is an API driven system with + some convience views provided by + the Django Rest + Framework. You can + freely access + the source code of uncloud. + This is a BETA service. As such, some + functionality might not be very sophisticated. +

    +
    -
    -
    -

    Getting started

    -
    -

    uncloud is designed to be as easy as possible to use. However, - there are some "real world" requirements that need to be met to - start using uncloud: +

    +

    Getting started

    +
    +

    uncloud is designed to be as easy as possible to use. However, + there are some "real world" requirements that need to be met to + start using uncloud: -

      -
    • First you need - to register an - account. If you already have one, you can - login. -
    • If you have forgotten your password or other issues with - logging in, you can contact the ungleich support - via support at ungleich.ch. +
        +
      • First you need + to register an + account. If you already have one, you can + login. +
      • If you have forgotten your password or other issues with + logging in, you can contact the ungleich support + via support at ungleich.ch. -
      • Secondy you will need to - create a billing - address. This is required for determining the correct - tax. -
      • Next you will need to - register a credit card - from which payments can be made. Your credit card will not - be charged without your consent. -
      +
    • Secondy you will need to + create a billing + address. This is required for determining the correct + tax. +
    • Next you will need to + register a credit card + from which payments can be made. Your credit card will not + be charged without your consent. +
    +
    -
    - -
    -

    Credit cards

    -
    -

    - Credit cards are registered with stripe. We only save a the - last 4 digits and the expiry date of the card to make - identification for you easier. -

    -
      -
    • Register a credit card - (this is required to be done via Javascript so that we never see - your credit card, but it is sent directly to stripe) -
    • You can list your - credit cards - By default the first credit card is used for charging - ("active: true") and later added cards will not be - used. To change this, first disable the active flag and - then set it on another credit card. +
      +

      Introduction to uncloud concepts

      +
      +

      We plan to offer many services on uncloud ranging from + for free, for a small amount or regular charges. As transfer + fees are a major challenge for our business, we based uncloud + on the pre-paid account model. Which means + that you can charge your account and then use your balance to + pay for product usage.

      +
      -
    -
    -

    Billing Address, Payments and Balance

    -
    -

    Billing addresses behave similar to credit cards: you can - have many of them, but only one can be active. The active - billing address is taken for creating new orders.

    -

    In uncloud we use the pre-paid model: you can add money to - your account via payments. You can always check your - balance. The products you use will automatically be charged from - your existing balance. -

    - -

    In the future you will be able opt-in to automatically - recharging your account at a certain time frame or whenever it - is below a certain amount

    - - - +
    +

    Credit cards

    +
    +

    + Credit cards are registered with stripe. We only save a the + last 4 digits and the expiry date of the card to make + identification for you easier. +

    +
      +
    • Register a credit card + (this is required to be done via Javascript so that we never see + your credit card, but it is sent directly to stripe) +
    • You can list your + credit cards + By default the first credit card is used for charging + ("active: true") and later added cards will not be + used. To change this, first disable the active flag and + then set it on another credit card. +
    -
    +
    +

    Billing Address, Payments and Balance

    +
    +

    Billing addresses behave similar to credit cards: you can + have many of them, but only one can be active. The active + billing address is taken for creating new orders.

    -
    -

    Networking

    -
    -

    - With uncloud you can use a variety of network related - services. -

    +

    In uncloud we use the pre-paid model: you can add money to + your account via payments. You can always check your + balance. The products you use will automatically be charged from + your existing balance. +

    - +

    In the future you will be able opt-in to automatically + recharging your account at a certain time frame or whenever it + is below a certain amount

    + + + +
    + +
    +

    Networking

    +
    +

    + With uncloud you can use a variety of network related + services. +

    + + +
    +
    + +
    +

    Current limitations

    +
    +
      +
    • Payments are only possible in CHF. +
    • Bills are not yet visible (payments are, though) +
    +
    +
    +
    {% endblock %} diff --git a/uncloud_pay/templates/uncloud_pay/register_stripe.html b/uncloud_pay/templates/uncloud_pay/register_stripe.html index 7bb0110..7205261 100644 --- a/uncloud_pay/templates/uncloud_pay/register_stripe.html +++ b/uncloud_pay/templates/uncloud_pay/register_stripe.html @@ -1,28 +1,30 @@ {% extends 'uncloud/base.html' %} -{% block header %} +{% block bootstrap5_extra_head %} {% endblock %} -{% block body %} -
    -

    Register Credit Card with Stripe

    -

    - By submitting I authorise to send instructions to - the financial institution that issued my card to take - payments from my card account in accordance with the +{% block bootstrap5_content %} +

    + +
    +

    Register Credit Card with Stripe

    +

    + By submitting I authorise to send instructions to + the financial institution that issued my card to take + payments from my card account in accordance with the terms of my agreement with you. -

    +

    - -
    - -
    The card will be registered with stripe.
    + +
    + +
    The card will be registered with stripe.
    - -
    From 745abc48efdab2c7fbac74dc675c66d293329038 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 May 2021 19:55:13 +0200 Subject: [PATCH 89/93] Add balance if user is logged in --- uncloud/templates/uncloud/base.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/uncloud/templates/uncloud/base.html b/uncloud/templates/uncloud/base.html index 8c5c867..5915bed 100644 --- a/uncloud/templates/uncloud/base.html +++ b/uncloud/templates/uncloud/base.html @@ -11,7 +11,8 @@
    @@ -138,11 +141,30 @@
    • Payments are only possible in CHF. -
    • Bills are not yet visible (payments are, though)
    - + {% if user.is_authenticated %} +
    +

    Account Settings

    +
    +
      +
      + {% csrf_token %} +
      + Delete User Account +

      Are you sure you want to delete your account? This will permanently delete your + profile and any orders you have generated.

      + {{ delete_form }} +
      +
      + +
      +
      +
    +
    +
    + {% endif %}
    {% endblock %} diff --git a/uncloud/urls.py b/uncloud/urls.py index f72a286..14e45fd 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -19,6 +19,7 @@ from uncloud_net import views as netviews from uncloud_pay import views as payviews from uncloud_vm import views as vmviews from uncloud_service import views as serviceviews +from matrixhosting import views as matrixviews router = routers.DefaultRouter() @@ -37,6 +38,9 @@ router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename= router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment') router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance') router.register(r'v2/payment/address', payviews.BillingAddressViewSet, basename='billingaddress') +router.register(r'v2/orders', payviews.OrderViewSet, basename='orders') +router.register(r'v2/bill', payviews.BillViewSet, basename='bills') +router.register(r'v2/machines', matrixviews.MachineViewSet, basename='machines') # Generic helper views that are usually not needed router.register(r'v2/generic/vat-rate', payviews.VATRateViewSet, basename='vatrate') @@ -54,9 +58,8 @@ urlpatterns = [ path('admin/', admin.site.urls), - path('login/', authviews.LoginView.as_view(), name="login"), - path('logout/', authviews.LogoutView.as_view(), name="logout"), + path('accounts/', include('allauth.urls')), path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"), - + path('matrix/', include('matrixhosting.urls', namespace='matrix')), path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"), ] diff --git a/uncloud/views.py b/uncloud/views.py index 37542bb..a4bf683 100644 --- a/uncloud/views.py +++ b/uncloud/views.py @@ -1,13 +1,23 @@ from django.views.generic.base import TemplateView +from django.contrib import messages +from django.shortcuts import redirect from uncloud_pay.selectors import get_balance_for_user +from .forms import UserDeleteForm class UncloudIndex(TemplateView): template_name = "uncloud/index.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - print(context) if self.request.user.is_authenticated: context['balance'] = get_balance_for_user(self.request.user) + context['delete_form'] = UserDeleteForm(instance=self.request.user) return context + + def post(self, request, *args, **kwargs): + UserDeleteForm(request.POST, instance=request.user) + user = request.user + user.delete() + messages.info(request, 'Your account has been deleted.') + return redirect('uncloudindex') \ No newline at end of file diff --git a/uncloud_net/services.py b/uncloud_net/services.py index 9149f01..8449394 100644 --- a/uncloud_net/services.py +++ b/uncloud_net/services.py @@ -3,7 +3,7 @@ from django.db import transaction from .models import * from .selectors import * from .tasks import * - +from django_q.tasks import async_task, result @transaction.atomic def create_wireguard_vpn(owner, public_key, network_mask): @@ -60,7 +60,6 @@ def create_wireguard_vpn_tech(owner, public_key, network_mask): server = pool.vpn_server_hostname wg_name = pool.wg_name - configure_wireguard_server_on_host.apply_async((wg_name, config), - queue=server) + async_task(configure_wireguard_server_on_host, (wg_name, config), queue=server) return vpn diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 7d94f3b..5684871 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -1,17 +1,14 @@ -from celery import shared_task from .models import * -from uncloud.models import UncloudTask - import os import subprocess import logging import uuid - +from django_q.tasks import async_task, result log = logging.getLogger(__name__) -@shared_task + def configure_wireguard_server_on_host(wg_name, config): """ - Create wireguard config (DB query -> string) @@ -47,11 +44,9 @@ def configure_wireguard_server_via_cdist(wireguardvpnpool): log.info(f"Configuring VPN server {server} (async)") - task_id = uuid.UUID(cdist_configure_wireguard_server.apply_async((config, server)).id) - UncloudTask.objects.create(task_id=task_id) + async_task(cdist_configure_wireguard_server,config, server).id -@shared_task def cdist_configure_wireguard_server(config, server): """ Create config and configure server. diff --git a/uncloud_net/tests.py b/uncloud_net/tests.py index 4491551..75bdafa 100644 --- a/uncloud_net/tests.py +++ b/uncloud_net/tests.py @@ -37,7 +37,7 @@ class VPNTests(TestCase): self.vpn_wireguard_public_key = 'B2b78eWBIXPMM1x4DDjkCDZepS0qDgcLN3T3PjcgXkY=' - self.vpnpool = VPNPool.objects.get_or_create(network=self.pool_network, + self.vpnpool = WireGuardVPNPool.objects.get_or_create(network=self.pool_network, network_size=self.pool_network_size, subnetwork_size=self.pool_subnetwork_size, vpn_hostname=self.pool_vpn_hostname, @@ -47,55 +47,6 @@ class VPNTests(TestCase): self.factory = APIRequestFactory() - def test_create_vpnpool(self): - url = reverse("vpnpool-list") - view = VPNPoolViewSet.as_view({'post': 'create'}) - request = self.factory.post(url, { 'network': self.pool_network2, - 'network_size': self.pool_network_size, - 'subnetwork_size': self.pool_subnetwork_size, - 'vpn_hostname': self.pool_vpn_hostname, - 'wireguard_private_key': self.pool_wireguard_private_key - - }) - force_authenticate(request, user=self.admin_user) - response = view(request) - - # This raises an exception if the request was not successful - # No assert needed - pool = VPNPool.objects.get(network=self.pool_network2) - - # def test_create_vpn(self): - # url = reverse("vpnnetwork-list") - # view = VPNNetworkViewSet.as_view({'post': 'create'}) - # request = self.factory.post(url, { 'network_size': self.pool_subnetwork_size, - # 'wireguard_public_key': self.vpn_wireguard_public_key - - # }) - # force_authenticate(request, user=self.user) - - - # # we don't have a billing address -> should raise an error - # # with self.assertRaises(ValidationError): - # # response = view(request) - - # addr = BillingAddress.objects.get_or_create( - # owner=self.user, - # active=True, - # defaults={'organization': 'ungleich', - # 'name': 'Nico Schottelius', - # 'street': 'Hauptstrasse 14', - # 'city': 'Luchsingen', - # 'postal_code': '8775', - # 'country': 'CH' } - # ) - - # # This should work now - # response = view(request) - - # # Verify that an order was created successfully - there should only be one order at - # # this point in time - # order = Order.objects.get(owner=self.user) - def tearDown(self): self.user.delete() diff --git a/uncloud_net/views.py b/uncloud_net/views.py index 7dadbf4..8e7e81b 100644 --- a/uncloud_net/views.py +++ b/uncloud_net/views.py @@ -61,10 +61,3 @@ class WireGuardVPNSizes(viewsets.ViewSet): print(sizes) return Response(WireGuardVPNSizesSerializer(sizes, many=True).data) - - - -# class VPNPoolViewSet(viewsets.ModelViewSet): -# serializer_class = VPNPoolSerializer -# permission_classes = [permissions.IsAdminUser] -# queryset = VPNPool.objects.all() diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index f604283..cb7b650 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -4,7 +4,6 @@ from django.urls import path from django.shortcuts import render from django.conf.urls import url -from uncloud_pay.views import BillViewSet from hardcopy import bytestring_to_pdf from django.core.files.temp import NamedTemporaryFile from django.http import FileResponse @@ -90,14 +89,15 @@ admin.site.register(Bill, BillAdmin) admin.site.register(Product, ProductAdmin) for m in [ - BillRecord, BillingAddress, Order, + BillRecord, Payment, ProductToRecurringPeriod, RecurringPeriod, StripeCreditCard, StripeCustomer, - VATRate, + PricingPlan, + VATRate ]: admin.site.register(m) diff --git a/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud_pay/management/commands/charge-negative-balance.py index 8ee8736..8405bd3 100644 --- a/uncloud_pay/management/commands/charge-negative-balance.py +++ b/uncloud_pay/management/commands/charge-negative-balance.py @@ -1,6 +1,7 @@ from django.core.management.base import BaseCommand from uncloud_auth.models import User -from uncloud_pay.models import Order, Bill, PaymentMethod, get_balance_for_user +from uncloud_pay.models import Order, Bill, get_balance_for_user +import uncloud_pay.stripe as uncloud_stripe from datetime import timedelta from django.utils import timezone @@ -18,14 +19,10 @@ class Command(BaseCommand): balance = get_balance_for_user(user) if balance < 0: print("User {} has negative balance ({}), charging.".format(user.username, balance)) - payment_method = PaymentMethod.get_primary_for(user) - if payment_method != None: - amount_to_be_charged = abs(balance) - charge_ok = payment_method.charge(amount_to_be_charged) - if not charge_ok: - print("ERR: charging {} with method {} failed" - .format(user.username, payment_method.uuid) - ) - else: - print("ERR: no payment method registered for {}".format(user.username)) + amount_to_be_charged = abs(balance) + result = uncloud_stripe.charge_customer(user, amount_to_be_charged) + if result.status != 'succeeded': + print("ERR: charging {} with method {} failed" + .format(user.username, result) + ) print("=> Done.") diff --git a/uncloud_pay/management/commands/import-vat-rates.py b/uncloud_pay/management/commands/import-vat-rates.py index 46848cd..a741740 100644 --- a/uncloud_pay/management/commands/import-vat-rates.py +++ b/uncloud_pay/management/commands/import-vat-rates.py @@ -1,11 +1,14 @@ from django.core.management.base import BaseCommand from uncloud_pay.models import VATRate +import logging import urllib import csv import sys import io +logger = logging.getLogger(__name__) + class Command(BaseCommand): help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv''' vat_url = "https://raw.githubusercontent.com/ungleich/vat-rates/main/vat_rates.csv" @@ -23,13 +26,25 @@ class Command(BaseCommand): reader = csv.DictReader(csv_file) for row in reader: -# print(row) - obj, created = VATRate.objects.get_or_create( - starting_date=row["start_date"], - ending_date=row["stop_date"] if row["stop_date"] != "" else None, - territory_codes=row["territory_codes"], - currency_code=row["currency_code"], - rate=row["rate"], - rate_type=row["rate_type"], - description=row["description"] - ) + if row["territory_codes"] and len(row["territory_codes"].splitlines()) > 1: + for code in row["territory_codes"].splitlines(): + VATRate.objects.get_or_create( + starting_date=row["start_date"], + ending_date=row["stop_date"] if row["stop_date"] != "" else None, + territory_codes=code, + currency_code=row["currency_code"], + rate=row["rate"], + rate_type=row["rate_type"], + description=row["description"] + ) + else: + VATRate.objects.get_or_create( + starting_date=row["start_date"], + ending_date=row["stop_date"] if row["stop_date"] != "" else None, + territory_codes=row["territory_codes"], + currency_code=row["currency_code"], + rate=row["rate"], + rate_type=row["rate_type"], + description=row["description"] + ) + logger.info('All VAT Rates have been added!') diff --git a/uncloud_pay/migrations/0012_auto_20210630_0742.py b/uncloud_pay/migrations/0012_auto_20210630_0742.py new file mode 100644 index 0000000..45e3dfe --- /dev/null +++ b/uncloud_pay/migrations/0012_auto_20210630_0742.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.4 on 2021-06-30 07:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0011_auto_20210101_1308'), + ] + + operations = [ + migrations.AddField( + model_name='billingaddress', + name='vat_number_verified', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='payment', + name='source', + field=models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral')], max_length=256), + ), + ] diff --git a/uncloud_pay/migrations/0013_alter_billingaddress_owner.py b/uncloud_pay/migrations/0013_alter_billingaddress_owner.py new file mode 100644 index 0000000..7597129 --- /dev/null +++ b/uncloud_pay/migrations/0013_alter_billingaddress_owner.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.4 on 2021-07-03 15:23 + +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), + ('uncloud_pay', '0012_auto_20210630_0742'), + ] + + operations = [ + migrations.AlterField( + model_name='billingaddress', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='billing_addresses', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/uncloud_pay/migrations/0014_auto_20210703_1747.py b/uncloud_pay/migrations/0014_auto_20210703_1747.py new file mode 100644 index 0000000..1c004d0 --- /dev/null +++ b/uncloud_pay/migrations/0014_auto_20210703_1747.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.4 on 2021-07-03 17:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0013_alter_billingaddress_owner'), + ] + + operations = [ + migrations.AddField( + model_name='billingaddress', + name='stripe_tax_id', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='billingaddress', + name='vat_number_validated_on', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/uncloud_pay/migrations/0015_auto_20210705_0849.py b/uncloud_pay/migrations/0015_auto_20210705_0849.py new file mode 100644 index 0000000..dfb6d80 --- /dev/null +++ b/uncloud_pay/migrations/0015_auto_20210705_0849.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.4 on 2021-07-05 08:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0014_auto_20210703_1747'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='customer', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.stripecustomer'), + ), + migrations.AddField( + model_name='order', + name='status', + field=models.CharField(choices=[('draft', 'Draft'), ('declined', 'Declined'), ('approved', 'Approved')], default='draft', max_length=100), + ), + migrations.AddField( + model_name='order', + name='stripe_charge_id', + field=models.CharField(max_length=100, null=True), + ), + migrations.AddField( + model_name='order', + name='vm_id', + field=models.IntegerField(default=0), + ), + ] diff --git a/uncloud_pay/migrations/0016_pricingplan.py b/uncloud_pay/migrations/0016_pricingplan.py new file mode 100644 index 0000000..505c141 --- /dev/null +++ b/uncloud_pay/migrations/0016_pricingplan.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.4 on 2021-07-06 13:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0015_auto_20210705_0849'), + ] + + operations = [ + migrations.CreateModel( + name='PricingPlan', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ('vat_inclusive', models.BooleanField(default=True)), + ('vat_percentage', models.DecimalField(blank=True, decimal_places=5, default=0, max_digits=7)), + ('set_up_fees', models.DecimalField(decimal_places=2, default=0, max_digits=7)), + ('cores_unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=7)), + ('ram_unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=7)), + ('storage_unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=7)), + ('discount_name', models.CharField(blank=True, max_length=255, null=True)), + ('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('stripe_coupon_id', models.CharField(blank=True, max_length=255, null=True)), + ], + ), + ] diff --git a/uncloud_pay/migrations/0017_auto_20210706_1728.py b/uncloud_pay/migrations/0017_auto_20210706_1728.py new file mode 100644 index 0000000..1571b10 --- /dev/null +++ b/uncloud_pay/migrations/0017_auto_20210706_1728.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.4 on 2021-07-06 17:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0016_pricingplan'), + ] + + operations = [ + migrations.RemoveField( + model_name='paymentmethod', + name='owner', + ), + migrations.DeleteModel( + name='Payment', + ), + migrations.DeleteModel( + name='PaymentMethod', + ), + ] diff --git a/uncloud_pay/migrations/0018_payment.py b/uncloud_pay/migrations/0018_payment.py new file mode 100644 index 0000000..47d6e3a --- /dev/null +++ b/uncloud_pay/migrations/0018_payment.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.4 on 2021-07-06 17:47 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0017_auto_20210706_1728'), + ] + + operations = [ + migrations.CreateModel( + name='Payment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), + ('source', models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral')], max_length=256)), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('currency', models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32)), + ('external_reference', models.CharField(blank=True, default='', max_length=256, null=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/uncloud_pay/migrations/0019_order_pricing_plan.py b/uncloud_pay/migrations/0019_order_pricing_plan.py new file mode 100644 index 0000000..5392ce6 --- /dev/null +++ b/uncloud_pay/migrations/0019_order_pricing_plan.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.4 on 2021-07-06 19:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0018_payment'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='pricing_plan', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.pricingplan'), + ), + ] diff --git a/uncloud_pay/migrations/0020_rename_is_final_bill_is_closed.py b/uncloud_pay/migrations/0020_rename_is_final_bill_is_closed.py new file mode 100644 index 0000000..f3419eb --- /dev/null +++ b/uncloud_pay/migrations/0020_rename_is_final_bill_is_closed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2021-07-07 20:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0019_order_pricing_plan'), + ] + + operations = [ + migrations.RenameField( + model_name='bill', + old_name='is_final', + new_name='is_closed', + ), + ] diff --git a/uncloud_pay/migrations/0021_auto_20210709_0914.py b/uncloud_pay/migrations/0021_auto_20210709_0914.py new file mode 100644 index 0000000..66e3dcb --- /dev/null +++ b/uncloud_pay/migrations/0021_auto_20210709_0914.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.4 on 2021-07-09 09:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0020_rename_is_final_bill_is_closed'), + ] + + operations = [ + migrations.RemoveField( + model_name='order', + name='stripe_charge_id', + ), + migrations.RemoveField( + model_name='order', + name='vm_id', + ), + ] diff --git a/uncloud_pay/migrations/0022_remove_order_status.py b/uncloud_pay/migrations/0022_remove_order_status.py new file mode 100644 index 0000000..2b51be8 --- /dev/null +++ b/uncloud_pay/migrations/0022_remove_order_status.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.4 on 2021-07-11 08:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0021_auto_20210709_0914'), + ] + + operations = [ + migrations.RemoveField( + model_name='order', + name='status', + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 49e8da2..a4cf007 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -1,5 +1,6 @@ import logging import datetime +import json from math import ceil from calendar import monthrange @@ -9,18 +10,22 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator from django.db import models +from django.db.models import Q from django.utils.translation import gettext_lazy as _ from django.utils import timezone - +from django_q.tasks import schedule +from django_q.models import Schedule # Verify whether or not to use them here from django.core.exceptions import ObjectDoesNotExist, ValidationError +import uncloud_pay from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS -from uncloud.models import UncloudAddress +from uncloud.models import UncloudAddress, UncloudProvider +from uncloud.selectors import filter_for_when from .services import * # Used to generate bill due dates. -BILL_PAYMENT_DELAY=datetime.timedelta(days=10) +BILL_PAYMENT_DELAY=datetime.timedelta(days=settings.BILL_PAYMENT_DELAY) # Initialize logger. logger = logging.getLogger(__name__) @@ -96,84 +101,18 @@ class Payment(models.Model): def __str__(self): return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}" -### -# Payments and Payment Methods. - - -class PaymentMethod(models.Model): - """ - Not sure if this is still in use - - """ - - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) - source = models.CharField(max_length=256, - choices = ( - ('stripe', 'Stripe'), - ('unknown', 'Unknown'), - ), - default='stripe') - description = models.TextField() - primary = models.BooleanField(default=False, editable=False) - - # Only used for "Stripe" source - stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True) - stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True) - - @property - def active(self): - if self.source == 'stripe' and self.stripe_payment_method_id != None: - return True - else: - return False - - def charge(self, amount): - if not self.active: - raise Exception('This payment method is inactive.') - - if amount < 0: # Make sure we don't charge negative amount by errors... - raise Exception('Cannot charge negative amount.') - + def save(self, *args, **kwargs): + # Try to charge the user via the active card before saving otherwise throw payment Error if self.source == 'stripe': - stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id - stripe_payment = uncloud_pay.stripe.charge_customer( - amount, stripe_customer, self.stripe_payment_method_id) - if 'paid' in stripe_payment and stripe_payment['paid'] == False: - raise Exception(stripe_payment['error']) - else: - payment = Payment.objects.create( - owner=self.owner, source=self.source, amount=amount) - - return payment - else: - raise Exception('This payment method is unsupported/cannot be charged.') - - def set_as_primary_for(self, user): - methods = PaymentMethod.objects.filter(owner=user, primary=True) - for method in methods: - print(method) - method.primary = False - method.save() - - self.primary = True - self.save() - - def get_primary_for(user): - methods = PaymentMethod.objects.filter(owner=user) - for method in methods: - # Do we want to do something with non-primary method? - if method.active and method.primary: - return method - - return None - - class Meta: - # TODO: limit to one primary method per user. - # unique_together is no good since it won't allow more than one - # non-primary method. - pass + try: + result = uncloud_pay.stripe.charge_customer(self.owner, self.amount, self.currency,) + if not result.status or result.status != 'succeeded': + raise Exception("The payment has been failed, please try to activate another card") + super().save(*args, **kwargs) + except Exception as e: + raise e + + # See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types class RecurringPeriodDefaultChoices(models.IntegerChoices): @@ -231,9 +170,11 @@ class RecurringPeriod(models.Model): # Bills. class BillingAddress(UncloudAddress): - owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='billing_addresses') vat_number = models.CharField(max_length=100, default="", blank=True) vat_number_verified = models.BooleanField(default=False) + vat_number_validated_on = models.DateTimeField(blank=True, null=True) + stripe_tax_id = models.CharField(max_length=100, default="", blank=True) active = models.BooleanField(default=False) class Meta: @@ -272,6 +213,10 @@ class BillingAddress(UncloudAddress): self.owner, self.full_name, self.street, self.postal_code, self.city, self.country) + + @staticmethod + def get_address_for(user): + return BillingAddress.objects.get(owner=user) ### # VAT @@ -297,10 +242,44 @@ class VATRate(models.Model): logger.debug(str(dne)) logger.debug("Did not find VAT rate for %s, returning 0" % country_code) return 0 + + @staticmethod + def get_vat_rate(billing_address, when=None): + """ + Returns the VAT rate for business to customer. + + B2B is always 0% with the exception of trading within the own country + """ + + country = billing_address.country + + # Need to have a provider country + providers = UncloudProvider.objects.all() + vatrate = filter_for_when(VATRate.objects.filter(territory_codes=country), when).first() + + if not providers and not vatrate: + return 0 + + uncloud_provider = filter_for_when(providers).get() + + # By default we charge VAT. This affects: + # - Same country sales (VAT applied) + # - B2C to EU (VAT applied) + rate = vatrate.rate if vatrate else 0 + + # Exception: if... + # - the billing_address is in EU, + # - the vat_number has been set + # - the vat_number has been verified + # Then we do not charge VAT + + if uncloud_provider.country != country and billing_address.vat_number and billing_address.vat_number_verified: + rate = 0 + return rate def __str__(self): - return f"{self.territory_codes}: {self.starting_date} - {self.ending_date}: {self.rate_type}" + return f"{self.territory_codes}: {self.starting_date} - {self.ending_date or ''}: {self.rate_type}" ### # Products @@ -342,30 +321,20 @@ class Product(models.Model): 'features': { 'cores': { 'min': 1, - 'max': 48, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 3 + 'max': 48 }, 'ram_gb': { 'min': 1, - 'max': 256, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 4 + 'max': 256 }, 'ssd_gb': - { 'min': 10, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 0.35 + { 'min': 10 }, 'hdd_gb': { 'min': 0, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 15/1000 }, 'additional_ipv4_address': { 'min': 0, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 8 }, } } @@ -381,36 +350,23 @@ class Product(models.Model): 'base': { 'min': 1, 'max': 1, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 1 }, 'cores': { 'min': 1, 'max': 48, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 3 }, 'ram_gb': { 'min': 1, 'max': 256, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 4 }, 'ssd_gb': - { 'min': 10, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 0.35 + { 'min': 10 }, 'hdd_gb': - { 'min': 0, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 15/1000 + { 'min': 0 }, 'additional_ipv4_address': - { 'min': 0, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 9 - }, + { 'min': 0,}, } } ) @@ -433,7 +389,7 @@ class Product(models.Model): @property def recurring_orders(self): - return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.objects.get(name="ONE_TIME")) + return self.orders.order_by('id').exclude(recurring_price=0) @property def last_recurring_order(self): @@ -441,56 +397,12 @@ class Product(models.Model): @property def one_time_orders(self): - return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.objects.get(name="ONE_TIME")) + return self.orders.order_by('id').filter(recurring_price=0) @property def last_one_time_order(self): return self.one_time_orders.last() - def create_order(self, when_to_start=None, recurring_period=None): - billing_address = BillingAddress.get_address_for(self.owner) - - if not billing_address: - raise ValidationError("Cannot order without a billing address") - - if not when_to_start: - when_to_start = timezone.now() - - if not recurring_period: - recurring_period = self.default_recurring_period - - - # Create one time order if we did not create one already - if self.one_time_price > 0 and not self.last_one_time_order: - one_time_order = Order.objects.create(owner=self.owner, - billing_address=billing_address, - starting_date=when_to_start, - price=self.one_time_price, - recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"), - description=str(self)) - self.orders.add(one_time_order) - else: - one_time_order = None - - if recurring_period != RecurringPeriod.objects.get(name="ONE_TIME"): - if one_time_order: - recurring_order = Order.objects.create(owner=self.owner, - billing_address=billing_address, - starting_date=when_to_start, - price=self.recurring_price, - recurring_period=recurring_period, - depends_on=one_time_order, - description=str(self)) - else: - recurring_order = Order.objects.create(owner=self.owner, - billing_address=billing_address, - starting_date=when_to_start, - price=self.recurring_price, - recurring_period=recurring_period, - description=str(self)) - self.orders.add(recurring_order) - - # FIXME: this could/should be part of Order (?) def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None): if not self.recurring_price: @@ -618,10 +530,83 @@ class Product(models.Model): super().save(*args, **kwargs) +### +# Pricing +###### +import logging + +from django.db import models + +logger = logging.getLogger(__name__) + +class PricingPlan(models.Model): + name = models.CharField(max_length=255, unique=True) + vat_inclusive = models.BooleanField(default=True) + vat_percentage = models.DecimalField( + max_digits=7, decimal_places=5, blank=True, default=0 + ) + set_up_fees = models.DecimalField( + max_digits=7, decimal_places=2, default=0 + ) + cores_unit_price = models.DecimalField( + max_digits=7, decimal_places=2, default=0 + ) + ram_unit_price = models.DecimalField( + max_digits=7, decimal_places=2, default=0 + ) + storage_unit_price = models.DecimalField( + max_digits=7, decimal_places=2, default=0 + ) + discount_name = models.CharField(max_length=255, null=True, blank=True) + discount_amount = models.DecimalField( + max_digits=6, decimal_places=2, default=0 + ) + stripe_coupon_id = models.CharField(max_length=255, null=True, blank=True) + + def __str__(self): + display_str = self.name + ' => ' + ' - '.join([ + '{} Setup'.format(self.set_up_fees.normalize()), + '{}/Core'.format(self.cores_unit_price.normalize()), + '{}/GB RAM'.format(self.ram_unit_price.normalize()), + '{}/GB SSD'.format(self.storage_unit_price.normalize()), + '{}% VAT'.format(self.vat_percentage.normalize()) + if not self.vat_inclusive else 'VAT-Incl', + ]) + if self.discount_amount: + display_str = ' - '.join([ + display_str, + '{} {}'.format( + self.discount_amount, + self.discount_name if self.discount_name else 'Discount' + ) + ]) + return display_str + + @classmethod + def get_by_name(cls, name): + try: + pricing = PricingPlan.objects.get(name=name) + except Exception as e: + logger.error( + "Error getting VMPricing with name {name}. " + "Details: {details}. Attempting to return default" + "pricing.".format(name=name, details=str(e)) + ) + pricing = PricingPlan.get_default_pricing() + return pricing + + @classmethod + def get_default_pricing(cls): + """ Returns the default pricing or None """ + try: + default_pricing = PricingPlan.objects.get(name='default') + except Exception as e: + logger.error(str(e)) + default_pricing = None + return default_pricing ### # Orders. - class Order(models.Model): """ Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating @@ -650,6 +635,8 @@ class Order(models.Model): billing_address = models.ForeignKey(BillingAddress, on_delete=models.CASCADE) + customer = models.ForeignKey(StripeCustomer, on_delete=models.CASCADE, null=True) + description = models.TextField() product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE) @@ -686,6 +673,7 @@ class Order(models.Model): on_delete=models.CASCADE, blank=True, null=True) + pricing_plan = models.ForeignKey(PricingPlan, blank=False, null=True, on_delete=models.CASCADE) should_be_billed = models.BooleanField(default=True) @@ -750,6 +738,17 @@ class Order(models.Model): """ return sum([ br.quantity for br in self.bill_records.all() ]) + + def cancel(self): + self.ending_date = timezone.now() + self.should_be_billed = False + self.save() + if self.instance_id: + last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last() + schedule('matrixhosting.tasks.delete_instance', + self.instance_id, + schedule_type=Schedule.ONCE, + next_run=last_bill_record.ending_date or (timezone.now() + datetime.timedelta(hours=1))) def count_used(self, when=None): """ @@ -790,7 +789,7 @@ class Order(models.Model): @property def is_recurring(self): - return not self.recurring_period == RecurringPeriod.objects.get(name="ONE_TIME") + return self.recurring_price > 0 @property def is_one_time(self): @@ -814,14 +813,12 @@ class Order(models.Model): description=self.description, product=self.product, config=config, + pricing_plan=self.pricing_plan, starting_date=starting_date, currency=self.currency ) - (new_order.one_time_price, new_order.recurring_price, new_order.config) = new_order.calculate_prices_and_config() - - - + new_order.recurring_price = new_order.calculate_recurring_price() new_order.replaces = self new_order.save() @@ -830,26 +827,28 @@ class Order(models.Model): return new_order - + def create_bill_record(self, bill): br = None - # Note: check for != 0 not > 0, as we allow discounts to be expressed with < 0 - if self.one_time_price != 0 and self.billrecord_set.count() == 0: - br = BillRecord.objects.create(bill=bill, - order=self, - starting_date=self.starting_date, - ending_date=self.starting_date, - is_recurring_record=False) - if self.recurring_price != 0: - br = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first() - - if br: - self.update_bill_record_for_recurring_order(br, bill) + records = BillRecord.objects.filter(order=self).all() + if not records: + if self.one_time_price: + br = BillRecord.objects.create(bill=bill, + order=self, + starting_date=self.starting_date, + ending_date=bill.ending_date, + is_recurring_record=False) + else: + br = self.create_new_bill_record_for_recurring_order(bill) else: - br = self.create_new_bill_record_for_recurring_order(bill) - + opened_recurring_record = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first() + if opened_recurring_record: + br = opened_recurring_record + self.update_bill_record_for_recurring_order(br, bill) + else: + br = self.create_new_bill_record_for_recurring_order(bill) return br def update_bill_record_for_recurring_order(self, @@ -861,22 +860,21 @@ class Order(models.Model): # If the order has an ending date set, we might need to adjust the bill_record if self.ending_date: - if bill_record_for_this_bill.ending_date != self.ending_date: - bill_record_for_this_bill.ending_date = self.ending_date + if bill_record.ending_date != self.ending_date: + bill_record.ending_date = self.ending_date else: # recurring, not terminated, should go until at least end of bill - if bill_record_for_this_bill.ending_date < bill.ending_date: - bill_record_for_this_bill.ending_date = bill.ending_date + if bill_record.ending_date < bill.ending_date: + bill_record.ending_date = bill.ending_date - bill_record_for_this_bill.save() + bill_record.save() def create_new_bill_record_for_recurring_order(self, bill): """ Create a new bill record """ - - last_bill_record = BillRecord.objects.filter(order=self, is_recurring_record=True).order_by('id').last() + last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last() starting_date=self.starting_date @@ -892,7 +890,6 @@ class Order(models.Model): return starting_date = start_after(last_bill_record.ending_date) - ending_date = self.get_ending_date_for_bill(bill) return BillRecord.objects.create(bill=bill, @@ -901,47 +898,27 @@ class Order(models.Model): ending_date=ending_date, is_recurring_record=True) - def calculate_prices_and_config(self): - one_time_price = 0 - recurring_price = 0 + def calculate_recurring_price(self): + try: + config = json.loads(self.config) + recurring_price = 0 + if 'cores' in config: + recurring_price += self.pricing_plan.cores_unit_price * int(config['cores']) + if 'memory' in config: + recurring_price += self.pricing_plan.ram_unit_price * int(config['memory']) + if 'storage' in config: + recurring_price += self.pricing_plan.storage_unit_price * int(config['storage']) - if self.config: - config = self.config - - if 'features' not in self.config: - self.config['features'] = {} - - else: - config = { - 'features': {} - } - - # FIXME: adjust prices to the selected recurring_period to the - - if 'features' in self.product.config: - for feature in self.product.config['features']: - - # Set min to 0 if not specified - min_val = self.product.config['features'][feature].get('min', 0) - - # We might not even have 'features' cannot use .get() on it - try: - value = self.config['features'][feature] - except (KeyError, TypeError): - value = self.product.config['features'][feature]['min'] - - # Set max to current value if not specified - max_val = self.product.config['features'][feature].get('max', value) - - - if value < min_val or value > max_val: - raise ValidationError(f"Feature '{feature}' must be at least {min_val} and at maximum {max_val}. Value is: {value}") - - one_time_price += self.product.config['features'][feature]['one_time_price_per_unit'] * value - recurring_price += self.product.config['features'][feature]['recurring_price_per_unit'] * value - config['features'][feature] = value - - return (one_time_price, recurring_price, config) + vat_rate = VATRate.get_vat_rate(self.billing_address) + vat_validation_status = "verified" if self.billing_address.vat_number_validated_on and self.billing_address.vat_number_verified else False + subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, discount = uncloud_pay.utils.apply_vat_discount( + recurring_price, self.pricing_plan, + vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status + ) + return price_after_discount_with_vat + except Exception as e: + logger.error("An error occurred while parsing the config obj", e) + return 0 def check_parameters(self): if 'parameters' in self.product.config: @@ -955,7 +932,7 @@ class Order(models.Model): # IMMUTABLE fields -- need to create new order to modify them # However this is not enforced here... if self._state.adding: - (self.one_time_price, self.recurring_price, self.config) = self.calculate_prices_and_config() + self.recurring_price = self.calculate_recurring_price() if self.recurring_period_id is None: self.recurring_period = self.product.default_recurring_period @@ -975,12 +952,7 @@ class Order(models.Model): def __str__(self): - try: - conf = " ".join([ f"{key}:{val}" for key,val in self.config['features'].items() if val != 0 ]) - except KeyError: - conf = "" - - return f"Order {self.id}: {self.description} {conf}" + return f"Order {self.id}: {self.description}" class Bill(models.Model): """ @@ -1003,7 +975,7 @@ class Bill(models.Model): # FIXME: editable=True -> is in the admin, but also editable in DRF # Maybe filter fields in the serializer? - is_final = models.BooleanField(default=False) + is_closed = models.BooleanField(default=False) class Meta: constraints = [ @@ -1017,8 +989,9 @@ class Bill(models.Model): """ Close/finish a bill """ - - self.is_final = True + self.is_closed = True + if not self.ending_date: + self.ending_date = timezone.now() self.save() @property @@ -1028,34 +1001,7 @@ class Bill(models.Model): @property def vat_rate(self): - """ - Handling VAT is a tricky business - thus we only implement the cases - that we clearly now and leave it open to fellow developers to implement - correct handling for other cases. - - Case CH: - - - If the customer is in .ch -> apply standard rate - - If the customer is in EU AND private -> apply country specific rate - - If the customer is in EU AND business -> do not apply VAT - - If the customer is outside EU and outside CH -> do not apply VAT - """ - - provider = UncloudProvider.objects.get() - - # Assume always VAT inside the country - if provider.country == self.billing_address.country: - vat_rate = VATRate.objects.get(country=provider.country, - when=self.ending_date) - elif self.billing_address.country in EU: - # FIXME: need to check for validated vat number - if self.billing_address.vat_number: - return 0 - else: - return VATRate.objects.get(country=self.biling_address.country, - when=self.ending_date) - else: # non-EU, non-national - return 0 + return VATRate.get_vat_rate(self.billing_address, when=self.ending_date) @classmethod @@ -1075,9 +1021,10 @@ class Bill(models.Model): """ bills = [] - for billing_address in BillingAddress.objects.filter(owner=owner): - bills.append(cls.create_next_bill_for_user_address(billing_address, ending_date)) + bill = cls.create_next_bill_for_user_address(billing_address, ending_date) + if bill: + bills.append(bill) return bills @@ -1089,15 +1036,18 @@ class Bill(models.Model): owner = billing_address.owner - all_orders = Order.objects.filter(owner=owner, - billing_address=billing_address).order_by('id') - - bill = cls.get_or_create_bill(billing_address, ending_date=ending_date) - - for order in all_orders: - order.create_bill_record(bill) - - return bill + all_orders = Order.objects.filter(Q(owner__id=owner.id), Q(should_be_billed=True), + Q(billing_address__id=billing_address.id) + ).order_by('id') + + if len(all_orders) > 0: + bill = cls.get_or_create_bill(billing_address, ending_date=ending_date) + for order in all_orders: + order.create_bill_record(bill) + return bill + else: + # This Customer Hasn't any active orders + return False @classmethod @@ -1117,7 +1067,7 @@ class Bill(models.Model): # Get date & bill from previous bill, if it exists if last_bill: - if not last_bill.is_final: + if not last_bill.is_closed: bill = last_bill starting_date = last_bill.starting_date ending_date = bill.ending_date @@ -1142,7 +1092,7 @@ class Bill(models.Model): return bill - + def __str__(self): return f"{self.owner}-{self.id}" @@ -1167,9 +1117,11 @@ class BillRecord(models.Model): if not self.is_recurring_record: return 1 - record_delta = self.ending_date - self.starting_date - - return record_delta.total_seconds()/self.order.recurring_period.duration_seconds + record_delta = self.ending_date.date() - self.starting_date.date() + if self.order.recurring_period and self.order.recurring_period.duration_seconds > 0: + return int(record_delta.total_seconds() / self.order.recurring_period.duration_seconds) + else: + return 1 @property def sum(self): diff --git a/uncloud_pay/selectors.py b/uncloud_pay/selectors.py index 2a5ad4a..a634e30 100644 --- a/uncloud_pay/selectors.py +++ b/uncloud_pay/selectors.py @@ -1,9 +1,5 @@ from django.utils import timezone from django.db import transaction -from django.db.models import Q - -from uncloud.selectors import filter_for_when -from uncloud.models import UncloudProvider from .models import * def get_payments_for_user(user): @@ -12,12 +8,11 @@ def get_payments_for_user(user): return sum(payments) def get_spendings_for_user(user): - orders = Order.objects.filter(owner=user) + bills = Bill.objects.filter(owner=user) amount = 0 - for order in orders: - amount += order.one_time_price - amount += order.recurring_price * order.count_used(when=timezone.now()) + for bill in bills: + amount += bill.sum return amount @@ -25,34 +20,12 @@ def get_spendings_for_user(user): def get_balance_for_user(user): return get_payments_for_user(user) - get_spendings_for_user(user) +@transaction.atomic +def has_enough_balance(user, due_amount): + balance = get_balance_for_user(user) + if balance >= due_amount: + return True + return False + def get_billing_address_for_user(user): - return BillingAddress.objects.get(owner=user, active=True) - -def get_vat_rate(billing_address, when=None): - """ - Returns the VAT rate for business to customer. - - B2B is always 0% with the exception of trading within the own country - """ - - country = billing_address.country - - # Need to have a provider country - uncloud_provider = filter_for_when(UncloudProvider.objects.all()).get() - vatrate = filter_for_when(VATRate.objects.filter(territory_codes=country), when).first() - - # By default we charge VAT. This affects: - # - Same country sales (VAT applied) - # - B2C to EU (VAT applied) - rate = vatrate.rate - - # Exception: if... - # - the billing_address is in EU, - # - the vat_number has been set - # - the vat_number has been verified - # Then we do not charge VAT - - if uncloud_provider.country != country and billing_address.vat_number and billing_address.vat_number_verified: - rate = 0 - - return rate + return BillingAddress.objects.filter(owner=user, active=True).first() diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py index b5de192..4ea4104 100644 --- a/uncloud_pay/serializers.py +++ b/uncloud_pay/serializers.py @@ -86,8 +86,9 @@ class OrderSerializer(serializers.ModelSerializer): class Meta: model = Order read_only_fields = ['replaced_by', 'depends_on'] - fields = ['uuid', 'owner', 'description', 'creation_date', 'starting_date', 'ending_date', - 'bill', 'recurring_period', 'recurring_price', 'one_time_price'] + read_only_fields + fields = ['owner', 'description', 'creation_date', 'starting_date', 'ending_date', + 'recurring_period', 'recurring_price', 'one_time_price', + 'config', 'pricing_plan', 'should_be_billed'] + read_only_fields ### @@ -114,13 +115,13 @@ class BillSerializer(serializers.ModelSerializer): class Meta: model = Bill - fields = ['uuid', 'reference', 'owner', 'amount', 'vat_amount', 'total', + fields = ['owner', 'sum', 'vat_rate', 'due_date', 'creation_date', 'starting_date', 'ending_date', - 'records', 'final', 'billing_address'] + 'records', 'is_closed', 'billing_address'] # We do not want users to mutate the country / VAT number of an address, as it # will change VAT on existing bills. class UpdateBillingAddressSerializer(serializers.ModelSerializer): class Meta: model = BillingAddress - fields = ['uuid', 'street', 'city', 'postal_code'] + fields = ['street', 'city', 'postal_code'] diff --git a/uncloud_pay/services.py b/uncloud_pay/services.py index ed97c39..84a7c8d 100644 --- a/uncloud_pay/services.py +++ b/uncloud_pay/services.py @@ -1,3 +1,5 @@ +import datetime +from calendar import monthrange from django.utils import timezone diff --git a/uncloud_pay/stripe.py b/uncloud_pay/stripe.py index ed95c82..a59456e 100644 --- a/uncloud_pay/stripe.py +++ b/uncloud_pay/stripe.py @@ -9,6 +9,8 @@ from django.contrib.auth import get_user_model from .models import StripeCustomer, StripeCreditCard +logger = logging.getLogger(__name__) + CURRENCY = 'chf' stripe.api_key = settings.STRIPE_KEY @@ -77,9 +79,24 @@ def create_setup_intent(customer_id): def get_setup_intent(setup_intent_id): return stripe.SetupIntent.retrieve(setup_intent_id) +@handle_stripe_error def get_payment_method(payment_method_id): return stripe.PaymentMethod.retrieve(payment_method_id) +@handle_stripe_error +def get_card_from_payment(user, payment_method_id): + payment_method = stripe.PaymentMethod.retrieve(payment_method_id) + if payment_method: + if 'card' in payment_method: + sync_cards_for_user(user) + return payment_method['card'] + return False + + +@handle_stripe_error +def attach_payment_method(payment_method_id, customer_id): + return stripe.PaymentMethod.attach(payment_method_id, customer=customer_id) + @handle_stripe_error def create_customer(name, email): return stripe.Customer.create(name=name, email=email) @@ -142,7 +159,7 @@ def sync_cards_for_user(user): ) @handle_stripe_error -def charge_customer(user, amount, currency='CHF'): +def charge_customer(user, amount, currency='CHF', card=False): # Amount is in CHF but stripes requires smallest possible unit. # https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount # FIXME: might need to be adjusted for other currencies @@ -153,14 +170,14 @@ def charge_customer(user, amount, currency='CHF'): return Exception("Programming error: unsupported currency") try: - card = StripeCreditCard.objects.get(owner=user, + card = card or StripeCreditCard.objects.get(owner=user, active=True) except StripeCreditCard.DoesNotExist: raise ValidationError("No active credit card - cannot create payment") customer_id = get_customer_id_for(user) - + return stripe.PaymentIntent.create( amount=adjusted_amount, currency=currency, @@ -169,3 +186,64 @@ def charge_customer(user, amount, currency='CHF'): off_session=True, confirm=True, ) + +@handle_stripe_error +def get_payment_intent(user, amount, currency='CHF', card=False): + # Amount is in CHF but stripes requires smallest possible unit. + # https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount + # FIXME: might need to be adjusted for other currencies + + if currency == 'CHF': + adjusted_amount = int(amount * 100) + else: + return Exception("Programming error: unsupported currency") + + try: + card = card or StripeCreditCard.objects.get(owner=user, + active=True) + + except StripeCreditCard.DoesNotExist: + raise ValidationError("No active credit card - cannot create payment") + + customer_id = get_customer_id_for(user) + + return stripe.PaymentIntent.create( + amount=adjusted_amount, + currency=currency, + customer=customer_id, + payment_method=card.card_id, + setup_future_usage='off_session', + confirm=False, + ) + +@handle_stripe_error +def get_or_create_tax_id_for_user(stripe_customer_id, vat_number, + type="eu_vat", country=""): + def compare_vat_numbers(vat1, vat2): + _vat1 = vat1.replace(" ", "").replace(".", "").replace("-","") + _vat2 = vat2.replace(" ", "").replace(".", "").replace("-","") + return True if _vat1 == _vat2 else False + + tax_ids_list = stripe.Customer.list_tax_ids( + stripe_customer_id, + limit=100, + ) + for tax_id_obj in tax_ids_list.data: + if compare_vat_numbers(tax_id_obj.value, vat_number): + return tax_id_obj + else: + logger.debug( + "{val1} is not equal to {val2} or {con1} not same as " + "{con2}".format(val1=tax_id_obj.value, val2=vat_number, + con1=tax_id_obj.country.lower(), + con2=country.lower().strip())) + logger.debug( + "tax id obj does not exist for {val}. Creating a new one".format( + val=vat_number + )) + tax_id_obj = stripe.Customer.create_tax_id( + stripe_customer_id, + type=type, + value=vat_number, + ) + return tax_id_obj diff --git a/uncloud_pay/tasks.py b/uncloud_pay/tasks.py deleted file mode 100644 index c372366..0000000 --- a/uncloud_pay/tasks.py +++ /dev/null @@ -1,11 +0,0 @@ -from celery import shared_task -from .models import * -import uuid - -from uncloud.models import UncloudTask - -@shared_task(bind=True) -def check_balance(self): - UncloudTask.objects.create(task_id=self.request.id) - print("for each user res is 50") - return 50 diff --git a/uncloud_pay/templates/uncloud_pay/register_stripe.html b/uncloud_pay/templates/uncloud_pay/register_stripe.html index 9613701..0eed76a 100644 --- a/uncloud_pay/templates/uncloud_pay/register_stripe.html +++ b/uncloud_pay/templates/uncloud_pay/register_stripe.html @@ -1,12 +1,11 @@ {% extends 'uncloud/base.html' %} - {% block bootstrap5_extra_head %} + {% endblock %} - {% block bootstrap5_content %}
    - + {% csrf_token %}

    Register Credit Card with Stripe

    @@ -18,10 +17,15 @@ -

    - - - + + +
    +
    + + +
    The card will be registered with stripe.