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 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()
|
||||||
|
|
|
@ -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 ]:
|
|
||||||
|
|
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 })
|
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):
|
||||||
# """
|
# """
|
||||||
|
|
|
@ -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):
|
||||||
|
|
Loading…
Reference in a new issue