From 7b83efe995206b85b68bff0cfac4a404e17413aa Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 Aug 2020 21:11:28 +0200 Subject: [PATCH 01/27] [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 02/27] 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 03/27] 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 04/27] 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 05/27] 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 06/27] 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 07/27] 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 08/27] ++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 09/27] 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 10/27] 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 11/27] 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 12/27] 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 13/27] 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 14/27] [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 15/27] 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 16/27] 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 17/27] 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 18/27] 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 19/27] 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 20/27] ++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 21/27] 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 22/27] 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 23/27] 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 24/27] ++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 25/27] 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 26/27] 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 27/27] [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)