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 django.core.management.base import BaseCommand
from uncloud_pay.models import RecurringPeriod
from uncloud_pay.models import RecurringPeriod, Product
class Command(BaseCommand): class Command(BaseCommand):
help = 'Add standard uncloud values' help = 'Add standard uncloud values'
@ -8,4 +9,6 @@ class Command(BaseCommand):
pass pass
def handle(self, *args, **options): def handle(self, *args, **options):
# Order matters, objects are somewhat dependent on each other
RecurringPeriod.populate_db_defaults() 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 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): class BillRecordInline(admin.TabularInline):
# model = Bill.bill_records.through
model = BillRecord model = BillRecord
# AT some point in the future: expose REPLACED and orders that depend on us class RecurringPeriodInline(admin.TabularInline):
# class OrderInline(admin.TabularInline): model = ProductToRecurringPeriod
# model = Order
# fk_name = "replaces" class ProductAdmin(admin.ModelAdmin):
# class OrderAdmin(admin.ModelAdmin): inlines = [ RecurringPeriodInline ]
# inlines = [ OrderInline ]
class BillAdmin(admin.ModelAdmin): class BillAdmin(admin.ModelAdmin):
inlines = [ BillRecordInline ] inlines = [ BillRecordInline ]
@ -87,11 +85,13 @@ class BillAdmin(admin.ModelAdmin):
admin.site.register(Bill, BillAdmin) 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(Order)
admin.site.register(BillRecord) admin.site.register(BillRecord)
admin.site.register(BillingAddress) admin.site.register(BillingAddress)
admin.site.register(RecurringPeriod) 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 }) 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): 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) description = models.CharField(max_length=1024)
config = models.JSONField() config = models.JSONField()
recurring_periods = models.ManyToManyField(RecurringPeriod, through='ProductToRecurringPeriod')
# 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) 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 @property
def recurring_orders(self): def recurring_orders(self):
return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME) return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME)
@ -432,18 +500,6 @@ class Product(UncloudModel):
else: else:
self.create_order(when_to_start, recurring_period) 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 @property
def is_recurring(self): def is_recurring(self):
return self.recurring_price > 0 return self.recurring_price > 0
@ -452,13 +508,6 @@ class Product(UncloudModel):
def billing_address(self): def billing_address(self):
return self.order.billing_address 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): def discounted_price_by_period(self, requested_period):
""" """
Each product has a standard recurring period for which 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 Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
bills. Do **NOT** mutate then! bills. Do **NOT** mutate then!
An one time order is "closed" (does not need to be billed anymore) 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 if it has one bill record. Having more than one is a programming
error. error.
@ -586,10 +634,9 @@ class Order(models.Model):
starting_date = models.DateTimeField(default=timezone.now) starting_date = models.DateTimeField(default=timezone.now)
ending_date = models.DateTimeField(blank=True, null=True) ending_date = models.DateTimeField(blank=True, null=True)
# FIXME: ensure the period is defined in the product recurring_period = models.ForeignKey(RecurringPeriod,
# recurring_period = models.IntegerField(choices = RecurringPeriod.choices, on_delete=models.CASCADE,
# default = RecurringPeriod.PER_30D) editable=True)
recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE, editable=True)
one_time_price = models.DecimalField(default=0.0, one_time_price = models.DecimalField(default=0.0,
max_digits=AMOUNT_MAX_DIGITS, max_digits=AMOUNT_MAX_DIGITS,
@ -823,24 +870,33 @@ class Order(models.Model):
ending_date=ending_date, ending_date=ending_date,
is_recurring_record=True) is_recurring_record=True)
@property @property
def prices(self): def prices(self):
one_time_price = 0 one_time_price = 0
recurring_price = 0 recurring_price = 0
# FIXME: support amount independent one time prices
# FIXME: support a base price
# FIXME: adjust to the selected recurring_period # FIXME: adjust to the selected recurring_period
if 'features' in self.product.config: if 'features' in self.product.config:
for feature in self.product.config['features']: 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] # Set min to 0 if not specified
recurring_price += self.product.config['features'][feature]['recurring_price'] * self.config['features'][feature] 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) return (one_time_price, recurring_price)
@ -851,6 +907,11 @@ class Order(models.Model):
if self._state.adding: if self._state.adding:
(self.one_time_price, self.recurring_price) = self.prices (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) super().save(*args, **kwargs)
@ -1187,6 +1248,28 @@ class BillRecord(models.Model):
super().save(*args, **kwargs) 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 # # Sample products included into uncloud
# class SampleOneTimeProduct(models.Model): # class SampleOneTimeProduct(models.Model):
# """ # """

View File

@ -66,7 +66,6 @@ vm_order_upgrade_config = {
} }
class ProductTestCase(TestCase): class ProductTestCase(TestCase):
""" """
Test products and products <-> order interaction Test products and products <-> order interaction
@ -85,6 +84,9 @@ class ProductTestCase(TestCase):
postal_code="somewhere else", postal_code="somewhere else",
active=True) active=True)
RecurringPeriod.populate_db_defaults()
self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
def test_create_product(self): def test_create_product(self):
""" """
Create a sample product Create a sample product
@ -92,7 +94,8 @@ class ProductTestCase(TestCase):
p = Product.objects.create(name="Testproduct", p = Product.objects.create(name="Testproduct",
description="Only for testing", description="Only for testing",
config=vm_product_config) config=vm_product_config,
default_recurring_period=self.default_recurring_period)
class OrderTestCase(TestCase): class OrderTestCase(TestCase):
@ -113,6 +116,10 @@ class OrderTestCase(TestCase):
postal_code="somewhere else", postal_code="somewhere else",
active=True) active=True)
RecurringPeriod.populate_db_defaults()
self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
def test_order_product(self): def test_order_product(self):
""" """
Order a product, ensure the order has correct price setup Order a product, ensure the order has correct price setup
@ -120,7 +127,8 @@ class OrderTestCase(TestCase):
p = Product.objects.create(name="Testproduct", p = Product.objects.create(name="Testproduct",
description="Only for testing", 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, o = Order.objects.create(owner=self.user,
billing_address=self.ba, billing_address=self.ba,
@ -139,7 +147,8 @@ class OrderTestCase(TestCase):
p = Product.objects.create(name="Testproduct", p = Product.objects.create(name="Testproduct",
description="Only for testing", 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, order1 = Order.objects.create(owner=self.user,
billing_address=self.ba, billing_address=self.ba,
@ -173,9 +182,13 @@ class ModifyOrderTestCase(TestCase):
postal_code="somewhere else", postal_code="somewhere else",
active=True) active=True)
RecurringPeriod.populate_db_defaults()
self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
self.product = Product.objects.create(name="Testproduct", self.product = Product.objects.create(name="Testproduct",
description="Only for testing", description="Only for testing",
config=vm_product_config) config=vm_product_config,
default_recurring_period=self.default_recurring_period)
def test_change_order(self): def test_change_order(self):