Updating for products/recurring periods

This commit is contained in:
Nico Schottelius 2020-10-06 18:53:13 +02:00
parent c435639241
commit 9623a77907
7 changed files with 232 additions and 58 deletions

View File

@ -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()

View File

@ -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 ]:

View File

@ -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'),
),
]

View File

@ -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),
),
]

View File

@ -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'),
),
]

View File

@ -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):
# """

View File

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