forked from uncloud/uncloud
Updating for products/recurring periods
This commit is contained in:
parent
c435639241
commit
9623a77907
7 changed files with 232 additions and 58 deletions
|
@ -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()
|
||||
|
|
|
@ -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 ]:
|
||||
|
|
36
uncloud_pay/migrations/0028_auto_20201006_1529.py
Normal file
36
uncloud_pay/migrations/0028_auto_20201006_1529.py
Normal 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'),
|
||||
),
|
||||
]
|
18
uncloud_pay/migrations/0029_auto_20201006_1540.py
Normal file
18
uncloud_pay/migrations/0029_auto_20201006_1540.py
Normal 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),
|
||||
),
|
||||
]
|
21
uncloud_pay/migrations/0030_auto_20201006_1640.py
Normal file
21
uncloud_pay/migrations/0030_auto_20201006_1640.py
Normal 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'),
|
||||
),
|
||||
]
|
|
@ -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):
|
||||
# """
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in a new issue