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):