Merge branch 'master' of code.ungleich.ch:uncloud/uncloud
This commit is contained in:
		
				commit
				
					
						bc033a9087
					
				
			
		
					 12 changed files with 363 additions and 98 deletions
				
			
		| 
						 | 
				
			
			@ -27,8 +27,9 @@ except ModuleNotFoundError:
 | 
			
		|||
    DATABASES = {
 | 
			
		||||
        'default': {
 | 
			
		||||
            'ENGINE': 'django.db.backends.postgresql',
 | 
			
		||||
#            'HOST': '::1', # connecting via tcp, v6, to allow ssh forwarding to work
 | 
			
		||||
            'NAME': uncloud.secrets.POSTGRESQL_DB_NAME,
 | 
			
		||||
            'HOST': os.environ.get('DATABASE_HOST', '::1'),
 | 
			
		||||
            'USER': os.environ.get('DATABASE_USER', 'postgres'),
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
# Generated by Django 3.0.5 on 2020-04-09 12:25
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('uncloud_net', '0001_initial'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name='vpnnetworkreservation',
 | 
			
		||||
            name='status',
 | 
			
		||||
            field=models.CharField(choices=[('used', 'used'), ('free', 'free')], default='used', max_length=256),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='vpnnetwork',
 | 
			
		||||
            name='network',
 | 
			
		||||
            field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNNetworkReservation'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -100,6 +100,7 @@ class VPNNetworkReservation(UncloudModel):
 | 
			
		|||
    address = models.GenericIPAddressField(primary_key=True)
 | 
			
		||||
 | 
			
		||||
    status = models.CharField(max_length=256,
 | 
			
		||||
                              default='used',
 | 
			
		||||
                              choices = (
 | 
			
		||||
                                  ('used', 'used'),
 | 
			
		||||
                                  ('free', 'free')
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
# Generated by Django 3.0.5 on 2020-04-09 12:25
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('uncloud_pay', '0003_auto_20200305_1354'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='order',
 | 
			
		||||
            name='recurring_period',
 | 
			
		||||
            field=models.CharField(choices=[('ONCE', 'Onetime'), ('YEAR', 'Per Year'), ('MONTH', 'Per Month'), ('MINUTE', 'Per Minute'), ('WEEK', 'Per Week'), ('DAY', 'Per Day'), ('HOUR', 'Per Hour'), ('SECOND', 'Per Second')], default='MONTH', max_length=32),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='order',
 | 
			
		||||
            name='starting_date',
 | 
			
		||||
            field=models.DateTimeField(),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ from django.utils import timezone
 | 
			
		|||
from django.core.exceptions import ObjectDoesNotExist
 | 
			
		||||
 | 
			
		||||
import uuid
 | 
			
		||||
import logging
 | 
			
		||||
from functools import reduce
 | 
			
		||||
from math import ceil
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
| 
						 | 
				
			
			@ -19,15 +20,28 @@ from uncloud_pay.helpers import beginning_of_month, end_of_month
 | 
			
		|||
from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
 | 
			
		||||
from uncloud.models import UncloudModel, UncloudStatus
 | 
			
		||||
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
import decimal
 | 
			
		||||
 | 
			
		||||
# Define DecimalField properties, used to represent amounts of money.
 | 
			
		||||
AMOUNT_MAX_DIGITS=10
 | 
			
		||||
AMOUNT_DECIMALS=2
 | 
			
		||||
 | 
			
		||||
# FIXME: check why we need +1 here.
 | 
			
		||||
decimal.getcontext().prec = AMOUNT_DECIMALS + 1
 | 
			
		||||
 | 
			
		||||
# Used to generate bill due dates.
 | 
			
		||||
BILL_PAYMENT_DELAY=timedelta(days=10)
 | 
			
		||||
 | 
			
		||||
# Initialize logger.
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
 | 
			
		||||
class RecurringPeriod(models.TextChoices):
 | 
			
		||||
    ONE_TIME   = 'ONCE', _('Onetime')
 | 
			
		||||
    PER_YEAR   = 'YEAR', _('Per Year')
 | 
			
		||||
    PER_MONTH  = 'MONTH', _('Per Month')
 | 
			
		||||
    PER_WEEK   = 'WEEK', _('Per Week')
 | 
			
		||||
    PER_DAY    = 'DAY', _('Per Day')
 | 
			
		||||
    PER_HOUR   = 'HOUR', _('Per Hour')
 | 
			
		||||
    PER_MINUTE = 'MINUTE', _('Per Minute')
 | 
			
		||||
| 
						 | 
				
			
			@ -160,11 +174,18 @@ class PaymentMethod(models.Model):
 | 
			
		|||
    def get_primary_for(user):
 | 
			
		||||
        methods = PaymentMethod.objects.filter(owner=user)
 | 
			
		||||
        for method in methods:
 | 
			
		||||
            if method.primary:
 | 
			
		||||
            # Do we want to do something with non-primary method?
 | 
			
		||||
            if method.active and method.primary:
 | 
			
		||||
                return method
 | 
			
		||||
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        # TODO: limit to one primary method per user.
 | 
			
		||||
        # unique_together is no good since it won't allow more than one
 | 
			
		||||
        # non-primary method.
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
###
 | 
			
		||||
# Bills.
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -209,51 +230,108 @@ class Bill(models.Model):
 | 
			
		|||
    @staticmethod
 | 
			
		||||
    def generate_for(year, month, user):
 | 
			
		||||
        # /!\ We exclusively work on the specified year and month.
 | 
			
		||||
        generated_bills = []
 | 
			
		||||
 | 
			
		||||
        # Default values for next bill (if any). Only saved at the end of
 | 
			
		||||
        # this method, if relevant.
 | 
			
		||||
        next_bill = Bill(owner=user,
 | 
			
		||||
                starting_date=beginning_of_month(year, month),
 | 
			
		||||
                ending_date=end_of_month(year, month),
 | 
			
		||||
                creation_date=timezone.now(),
 | 
			
		||||
                due_date=timezone.now() + BILL_PAYMENT_DELAY)
 | 
			
		||||
        # Default values for next bill (if any).
 | 
			
		||||
        starting_date=beginning_of_month(year, month)
 | 
			
		||||
        ending_date=end_of_month(year, month)
 | 
			
		||||
        creation_date=timezone.now()
 | 
			
		||||
 | 
			
		||||
        # Select all orders active on the request period.
 | 
			
		||||
        # Select all orders active on the request period (i.e. starting on or after starting_date).
 | 
			
		||||
        orders = Order.objects.filter(
 | 
			
		||||
                Q(ending_date__gt=next_bill.starting_date) | Q(ending_date__isnull=True),
 | 
			
		||||
                Q(ending_date__gte=starting_date) | Q(ending_date__isnull=True),
 | 
			
		||||
                owner=user)
 | 
			
		||||
 | 
			
		||||
        # Check if there is already a bill covering the order and period pair:
 | 
			
		||||
        #   * Get latest bill by ending_date: previous_bill.ending_date
 | 
			
		||||
        #   * If previous_bill.ending_date is before next_bill.ending_date, a new
 | 
			
		||||
        #   bill has to be generated.
 | 
			
		||||
        unpaid_orders = []
 | 
			
		||||
        #   * For monthly bills: if previous_bill.ending_date is before
 | 
			
		||||
        #     (next_bill) ending_date, a new bill has to be generated.
 | 
			
		||||
        #   * For yearly bill: if previous_bill.ending_date is on working
 | 
			
		||||
        #     month, generate new bill.
 | 
			
		||||
        unpaid_orders = { 'monthly_or_less': [], 'yearly': {}}
 | 
			
		||||
        for order in orders:
 | 
			
		||||
            try:
 | 
			
		||||
                previous_bill = order.bill.latest('ending_date')
 | 
			
		||||
            except ObjectDoesNotExist:
 | 
			
		||||
                previous_bill = None
 | 
			
		||||
 | 
			
		||||
            if previous_bill == None or previous_bill.ending_date < next_bill.ending_date:
 | 
			
		||||
                unpaid_orders.append(order)
 | 
			
		||||
            # FIXME: control flow is confusing in this block.
 | 
			
		||||
            if order.recurring_period == RecurringPeriod.PER_YEAR:
 | 
			
		||||
                # We ignore anything smaller than a day in here.
 | 
			
		||||
                next_yearly_bill_start_on = None
 | 
			
		||||
                if previous_bill == None:
 | 
			
		||||
                    next_yearly_bill_start_on = order.starting_date
 | 
			
		||||
                elif previous_bill.ending_date <= ending_date:
 | 
			
		||||
                    next_yearly_bill_start_on = (previous_bill.ending_date + timedelta(days=1))
 | 
			
		||||
 | 
			
		||||
        # Commit next_bill if it there are 'unpaid' orders.
 | 
			
		||||
        if len(unpaid_orders) > 0:
 | 
			
		||||
            next_bill.save()
 | 
			
		||||
                # Store for bill generation. One bucket per day of month with a starting bill.
 | 
			
		||||
                # bucket is a reference here, no need to reassign.
 | 
			
		||||
                if next_yearly_bill_start_on:
 | 
			
		||||
                    # We want to group orders by date but keep using datetimes.
 | 
			
		||||
                    next_yearly_bill_start_on = next_yearly_bill_start_on.replace(
 | 
			
		||||
                            minute=0, hour=0, second=0, microsecond=0)
 | 
			
		||||
                    bucket = unpaid_orders['yearly'].get(next_yearly_bill_start_on)
 | 
			
		||||
                    if bucket == None:
 | 
			
		||||
                        unpaid_orders['yearly'][next_yearly_bill_start_on] = [order]
 | 
			
		||||
                    else:
 | 
			
		||||
                        unpaid_orders['yearly'][next_yearly_bill_start_on] = bucket + [order]
 | 
			
		||||
            else:
 | 
			
		||||
                if previous_bill == None or previous_bill.ending_date <= ending_date:
 | 
			
		||||
                    unpaid_orders['monthly_or_less'].append(order)
 | 
			
		||||
 | 
			
		||||
        # Handle working month's billing.
 | 
			
		||||
        if len(unpaid_orders['monthly_or_less']) > 0:
 | 
			
		||||
            # TODO: PREPAID billing is not supported yet.
 | 
			
		||||
            prepaid_due_date = min(creation_date, starting_date) + BILL_PAYMENT_DELAY
 | 
			
		||||
            postpaid_due_date = max(creation_date, ending_date) + BILL_PAYMENT_DELAY
 | 
			
		||||
 | 
			
		||||
            next_monthly_bill = Bill.objects.create(owner=user,
 | 
			
		||||
                    creation_date=creation_date,
 | 
			
		||||
                    starting_date=starting_date, # FIXME: this is a hack!
 | 
			
		||||
                    ending_date=ending_date,
 | 
			
		||||
                    due_date=postpaid_due_date)
 | 
			
		||||
 | 
			
		||||
            # It is not possible to register many-to-many relationship before
 | 
			
		||||
            # the two end-objects are saved in database.
 | 
			
		||||
            for order in unpaid_orders:
 | 
			
		||||
                order.bill.add(next_bill)
 | 
			
		||||
            for order in unpaid_orders['monthly_or_less']:
 | 
			
		||||
                order.bill.add(next_monthly_bill)
 | 
			
		||||
 | 
			
		||||
            # TODO: use logger.
 | 
			
		||||
            print("Generated bill {} (amount: {}) for user {}."
 | 
			
		||||
                    .format(next_bill.uuid, next_bill.total, user))
 | 
			
		||||
            logger.info("Generated monthly bill {} (amount: {}) for user {}."
 | 
			
		||||
                    .format(next_monthly_bill.uuid, next_monthly_bill.total, user))
 | 
			
		||||
 | 
			
		||||
            return next_bill
 | 
			
		||||
            # Add to output.
 | 
			
		||||
            generated_bills.append(next_monthly_bill)
 | 
			
		||||
 | 
			
		||||
        # Return None if no bill was created.
 | 
			
		||||
        return None
 | 
			
		||||
        # Handle yearly bills starting on working month.
 | 
			
		||||
        if len(unpaid_orders['yearly']) > 0:
 | 
			
		||||
 | 
			
		||||
            # For every starting date, generate new bill.
 | 
			
		||||
            for next_yearly_bill_start_on in unpaid_orders['yearly']:
 | 
			
		||||
                # No postpaid for yearly payments.
 | 
			
		||||
                prepaid_due_date = min(creation_date, next_yearly_bill_start_on) + BILL_PAYMENT_DELAY
 | 
			
		||||
                # Bump by one year, remove one day.
 | 
			
		||||
                ending_date = next_yearly_bill_start_on.replace(
 | 
			
		||||
                        year=next_yearly_bill_start_on.year+1) - timedelta(days=1)
 | 
			
		||||
 | 
			
		||||
                next_yearly_bill = Bill.objects.create(owner=user,
 | 
			
		||||
                        creation_date=creation_date,
 | 
			
		||||
                        starting_date=next_yearly_bill_start_on,
 | 
			
		||||
                        ending_date=ending_date,
 | 
			
		||||
                        due_date=prepaid_due_date)
 | 
			
		||||
 | 
			
		||||
                # It is not possible to register many-to-many relationship before
 | 
			
		||||
                # the two end-objects are saved in database.
 | 
			
		||||
                for order in unpaid_orders['yearly'][next_yearly_bill_start_on]:
 | 
			
		||||
                    order.bill.add(next_yearly_bill)
 | 
			
		||||
 | 
			
		||||
                logger.info("Generated yearly bill {} (amount: {}) for user {}."
 | 
			
		||||
                        .format(next_yearly_bill.uuid, next_yearly_bill.total, user))
 | 
			
		||||
 | 
			
		||||
                # Add to output.
 | 
			
		||||
                generated_bills.append(next_yearly_bill)
 | 
			
		||||
 | 
			
		||||
        # Return generated (monthly + yearly) bills.
 | 
			
		||||
        return generated_bills
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def get_unpaid_for(user):
 | 
			
		||||
| 
						 | 
				
			
			@ -296,7 +374,7 @@ class BillRecord():
 | 
			
		|||
        self.recurring_period = order_record.recurring_period
 | 
			
		||||
        self.description = order_record.description
 | 
			
		||||
 | 
			
		||||
        if self.order.starting_date > self.bill.starting_date:
 | 
			
		||||
        if self.order.starting_date >= self.bill.starting_date:
 | 
			
		||||
            self.one_time_price = order_record.one_time_price
 | 
			
		||||
        else:
 | 
			
		||||
            self.one_time_price = 0
 | 
			
		||||
| 
						 | 
				
			
			@ -305,7 +383,7 @@ class BillRecord():
 | 
			
		|||
    def recurring_count(self):
 | 
			
		||||
        # Compute billing delta.
 | 
			
		||||
        billed_until = self.bill.ending_date
 | 
			
		||||
        if self.order.ending_date != None and self.order.ending_date < self.order.ending_date:
 | 
			
		||||
        if self.order.ending_date != None and self.order.ending_date <= self.bill.ending_date:
 | 
			
		||||
            billed_until = self.order.ending_date
 | 
			
		||||
 | 
			
		||||
        billed_from = self.bill.starting_date
 | 
			
		||||
| 
						 | 
				
			
			@ -313,7 +391,7 @@ class BillRecord():
 | 
			
		|||
            billed_from = self.order.starting_date
 | 
			
		||||
 | 
			
		||||
        if billed_from > billed_until:
 | 
			
		||||
            # TODO: think about and check edges cases. This should not be
 | 
			
		||||
            # TODO: think about and check edge cases. This should not be
 | 
			
		||||
            # possible.
 | 
			
		||||
            raise Exception('Impossible billing delta!')
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -321,11 +399,14 @@ class BillRecord():
 | 
			
		|||
 | 
			
		||||
        # TODO: refactor this thing?
 | 
			
		||||
        # TODO: weekly
 | 
			
		||||
        # TODO: yearly
 | 
			
		||||
        if self.recurring_period == RecurringPeriod.PER_MONTH:
 | 
			
		||||
        if self.recurring_period == RecurringPeriod.PER_YEAR:
 | 
			
		||||
            # XXX: Should always be one => we do not bill for more than one year.
 | 
			
		||||
            # TODO: check billed_delta is ~365 days.
 | 
			
		||||
            return 1
 | 
			
		||||
        elif self.recurring_period == RecurringPeriod.PER_MONTH:
 | 
			
		||||
            days = ceil(billed_delta / timedelta(days=1))
 | 
			
		||||
 | 
			
		||||
            # XXX: we assume monthly bills for now.
 | 
			
		||||
            # Monthly bills always cover one single month.
 | 
			
		||||
            if (self.bill.starting_date.year != self.bill.starting_date.year or
 | 
			
		||||
                self.bill.starting_date.month != self.bill.ending_date.month):
 | 
			
		||||
                    raise Exception('Bill {} covers more than one month. Cannot bill PER_MONTH.'.
 | 
			
		||||
| 
						 | 
				
			
			@ -335,25 +416,28 @@ class BillRecord():
 | 
			
		|||
            (_, days_in_month) = monthrange(
 | 
			
		||||
                    self.bill.starting_date.year,
 | 
			
		||||
                    self.bill.starting_date.month)
 | 
			
		||||
            return Decimal(days / days_in_month)
 | 
			
		||||
            return days / days_in_month
 | 
			
		||||
        elif self.recurring_period == RecurringPeriod.PER_WEEK:
 | 
			
		||||
            weeks = ceil(billed_delta / timedelta(week=1))
 | 
			
		||||
            return weeks
 | 
			
		||||
        elif self.recurring_period == RecurringPeriod.PER_DAY:
 | 
			
		||||
            days = ceil(billed_delta / timedelta(days=1))
 | 
			
		||||
            return Decimal(days)
 | 
			
		||||
            return days
 | 
			
		||||
        elif self.recurring_period == RecurringPeriod.PER_HOUR:
 | 
			
		||||
            hours = ceil(billed_delta / timedelta(hours=1))
 | 
			
		||||
            return Decimal(hours)
 | 
			
		||||
            return hours
 | 
			
		||||
        elif self.recurring_period == RecurringPeriod.PER_SECOND:
 | 
			
		||||
            seconds = ceil(billed_delta / timedelta(seconds=1))
 | 
			
		||||
            return Decimal(seconds)
 | 
			
		||||
            return seconds
 | 
			
		||||
        elif self.recurring_period == RecurringPeriod.ONE_TIME:
 | 
			
		||||
            return Decimal(0)
 | 
			
		||||
            return 0
 | 
			
		||||
        else:
 | 
			
		||||
            raise Exception('Unsupported recurring period: {}.'.
 | 
			
		||||
                    format(record.recurring_period))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def amount(self):
 | 
			
		||||
        return self.recurring_price * self.recurring_count + self.one_time_price
 | 
			
		||||
        return Decimal(float(self.recurring_price) * self.recurring_count) + self.one_time_price
 | 
			
		||||
 | 
			
		||||
###
 | 
			
		||||
# Orders.
 | 
			
		||||
| 
						 | 
				
			
			@ -368,7 +452,7 @@ class Order(models.Model):
 | 
			
		|||
 | 
			
		||||
    # TODO: enforce ending_date - starting_date to be larger than recurring_period.
 | 
			
		||||
    creation_date = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
    starting_date = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
    starting_date = models.DateTimeField()
 | 
			
		||||
    ending_date = models.DateTimeField(blank=True,
 | 
			
		||||
                                       null=True)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,7 +20,7 @@ class PaymentMethodSerializer(serializers.ModelSerializer):
 | 
			
		|||
class UpdatePaymentMethodSerializer(serializers.ModelSerializer):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = PaymentMethod
 | 
			
		||||
        fields = ['description']
 | 
			
		||||
        fields = ['description', 'primary']
 | 
			
		||||
 | 
			
		||||
class ChargePaymentMethodSerializer(serializers.Serializer):
 | 
			
		||||
    amount = serializers.DecimalField(max_digits=10, decimal_places=2)
 | 
			
		||||
| 
						 | 
				
			
			@ -29,8 +29,7 @@ class CreatePaymentMethodSerializer(serializers.ModelSerializer):
 | 
			
		|||
    please_visit = serializers.CharField(read_only=True)
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = PaymentMethod
 | 
			
		||||
        fields = ['uuid', 'primary', 'source', 'description', 'please_visit']
 | 
			
		||||
        read_only_field = ['uuid', 'primary']
 | 
			
		||||
        fields = ['source', 'description', 'primary', 'please_visit']
 | 
			
		||||
 | 
			
		||||
###
 | 
			
		||||
# Orders & Products.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,118 @@
 | 
			
		|||
from django.test import TestCase
 | 
			
		||||
from django.contrib.auth import get_user_model
 | 
			
		||||
from datetime import datetime, date, timedelta
 | 
			
		||||
 | 
			
		||||
# Create your tests here.
 | 
			
		||||
from .models import *
 | 
			
		||||
 | 
			
		||||
class BillingTestCase(TestCase):
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.user = get_user_model().objects.create(
 | 
			
		||||
                username='jdoe',
 | 
			
		||||
                email='john.doe@domain.tld')
 | 
			
		||||
 | 
			
		||||
    def test_truth(self):
 | 
			
		||||
        self.assertEqual(1+1, 2)
 | 
			
		||||
 | 
			
		||||
    def test_basic_monthly_billing(self):
 | 
			
		||||
        one_time_price = 10
 | 
			
		||||
        recurring_price = 20
 | 
			
		||||
        description = "Test Product 1"
 | 
			
		||||
 | 
			
		||||
        # Three months: full, full, partial.
 | 
			
		||||
        starting_date = datetime.fromisoformat('2020-03-01')
 | 
			
		||||
        ending_date   = datetime.fromisoformat('2020-05-08')
 | 
			
		||||
 | 
			
		||||
        # Create order to be billed.
 | 
			
		||||
        order = Order.objects.create(
 | 
			
		||||
                owner=self.user,
 | 
			
		||||
                starting_date=starting_date,
 | 
			
		||||
                ending_date=ending_date,
 | 
			
		||||
                recurring_period=RecurringPeriod.PER_MONTH)
 | 
			
		||||
        order.add_record(one_time_price, recurring_price, description)
 | 
			
		||||
 | 
			
		||||
        # Generate & check bill for first month: full recurring_price + setup.
 | 
			
		||||
        first_month_bills = Bill.generate_for(2020, 3, self.user)
 | 
			
		||||
        self.assertEqual(len(first_month_bills), 1)
 | 
			
		||||
        self.assertEqual(first_month_bills[0].total, one_time_price + recurring_price)
 | 
			
		||||
 | 
			
		||||
        # Generate & check bill for second month: full recurring_price.
 | 
			
		||||
        second_month_bills = Bill.generate_for(2020, 4, self.user)
 | 
			
		||||
        self.assertEqual(len(second_month_bills), 1)
 | 
			
		||||
        self.assertEqual(second_month_bills[0].total, recurring_price)
 | 
			
		||||
 | 
			
		||||
        # Generate & check bill for third and last month: partial recurring_price.
 | 
			
		||||
        third_month_bills = Bill.generate_for(2020, 5, self.user)
 | 
			
		||||
        self.assertEqual(len(third_month_bills), 1)
 | 
			
		||||
        # 31 days in May.
 | 
			
		||||
        self.assertEqual(float(third_month_bills[0].total),
 | 
			
		||||
                round((7/31) * recurring_price, AMOUNT_DECIMALS))
 | 
			
		||||
 | 
			
		||||
        # Check that running Bill.generate_for() twice does not create duplicates.
 | 
			
		||||
        self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0)
 | 
			
		||||
 | 
			
		||||
    def test_basic_yearly_billing(self):
 | 
			
		||||
        one_time_price = 10
 | 
			
		||||
        recurring_price = 150
 | 
			
		||||
        description = "Test Product 1"
 | 
			
		||||
 | 
			
		||||
        starting_date = datetime.fromisoformat('2020-03-31T08:05:23')
 | 
			
		||||
 | 
			
		||||
        # Create order to be billed.
 | 
			
		||||
        order = Order.objects.create(
 | 
			
		||||
                owner=self.user,
 | 
			
		||||
                starting_date=starting_date,
 | 
			
		||||
                recurring_period=RecurringPeriod.PER_YEAR)
 | 
			
		||||
        order.add_record(one_time_price, recurring_price, description)
 | 
			
		||||
 | 
			
		||||
        # Generate & check bill for first year: recurring_price + setup.
 | 
			
		||||
        first_year_bills = Bill.generate_for(2020, 3, self.user)
 | 
			
		||||
        self.assertEqual(len(first_year_bills), 1)
 | 
			
		||||
        self.assertEqual(first_year_bills[0].starting_date.date(),
 | 
			
		||||
                date.fromisoformat('2020-03-31'))
 | 
			
		||||
        self.assertEqual(first_year_bills[0].ending_date.date(),
 | 
			
		||||
                date.fromisoformat('2021-03-30'))
 | 
			
		||||
        self.assertEqual(first_year_bills[0].total,
 | 
			
		||||
                recurring_price + one_time_price)
 | 
			
		||||
 | 
			
		||||
        # Generate & check bill for second year: recurring_price.
 | 
			
		||||
        second_year_bills = Bill.generate_for(2021, 3, self.user)
 | 
			
		||||
        self.assertEqual(len(second_year_bills), 1)
 | 
			
		||||
        self.assertEqual(second_year_bills[0].starting_date.date(),
 | 
			
		||||
                date.fromisoformat('2021-03-31'))
 | 
			
		||||
        self.assertEqual(second_year_bills[0].ending_date.date(),
 | 
			
		||||
                date.fromisoformat('2022-03-30'))
 | 
			
		||||
        self.assertEqual(second_year_bills[0].total, recurring_price)
 | 
			
		||||
 | 
			
		||||
        # Check that running Bill.generate_for() twice does not create duplicates.
 | 
			
		||||
        self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0)
 | 
			
		||||
        self.assertEqual(len(Bill.generate_for(2020, 4, self.user)), 0)
 | 
			
		||||
        self.assertEqual(len(Bill.generate_for(2020, 2, self.user)), 0)
 | 
			
		||||
        self.assertEqual(len(Bill.generate_for(2021, 3, self.user)), 0)
 | 
			
		||||
 | 
			
		||||
    def test_basic_hourly_billing(self):
 | 
			
		||||
        one_time_price = 10
 | 
			
		||||
        recurring_price = 1.4
 | 
			
		||||
        description = "Test Product 1"
 | 
			
		||||
 | 
			
		||||
        starting_date = datetime.fromisoformat('2020-03-31T08:05:23')
 | 
			
		||||
        ending_date   = datetime.fromisoformat('2020-04-01T11:13:32')
 | 
			
		||||
 | 
			
		||||
        # Create order to be billed.
 | 
			
		||||
        order = Order.objects.create(
 | 
			
		||||
                owner=self.user,
 | 
			
		||||
                starting_date=starting_date,
 | 
			
		||||
                ending_date=ending_date,
 | 
			
		||||
                recurring_period=RecurringPeriod.PER_HOUR)
 | 
			
		||||
        order.add_record(one_time_price, recurring_price, description)
 | 
			
		||||
 | 
			
		||||
        # Generate & check bill for first month: recurring_price + setup.
 | 
			
		||||
        first_month_bills = Bill.generate_for(2020, 3, self.user)
 | 
			
		||||
        self.assertEqual(len(first_month_bills), 1)
 | 
			
		||||
        self.assertEqual(float(first_month_bills[0].total),
 | 
			
		||||
                round(16 * recurring_price, AMOUNT_DECIMALS) + one_time_price)
 | 
			
		||||
 | 
			
		||||
        # Generate & check bill for first month: recurring_price.
 | 
			
		||||
        second_month_bills = Bill.generate_for(2020, 4, self.user)
 | 
			
		||||
        self.assertEqual(len(second_month_bills), 1)
 | 
			
		||||
        self.assertEqual(float(second_month_bills[0].total),
 | 
			
		||||
                round(12 * recurring_price, AMOUNT_DECIMALS))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -76,6 +76,8 @@ class VMProduct(Product):
 | 
			
		|||
            return self.cores * 3 + self.ram_in_gb * 4
 | 
			
		||||
        elif recurring_period == RecurringPeriod.PER_HOUR:
 | 
			
		||||
            return self.cores * 4.0/(30 * 24) + self.ram_in_gb * 4.5/(30* 24)
 | 
			
		||||
        elif recurring_period == RecurringPeriod.PER_YEAR:
 | 
			
		||||
            return (self.cores * 2.5 + self.ram_in_gb * 3.5) * 12
 | 
			
		||||
        else:
 | 
			
		||||
            raise Exception('Invalid recurring period for VM Product pricing.')
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -92,7 +94,8 @@ class VMProduct(Product):
 | 
			
		|||
    @staticmethod
 | 
			
		||||
    def allowed_recurring_periods():
 | 
			
		||||
        return list(filter(
 | 
			
		||||
            lambda pair: pair[0] in [RecurringPeriod.PER_MONTH, RecurringPeriod.PER_HOUR],
 | 
			
		||||
            lambda pair: pair[0] in [RecurringPeriod.PER_YEAR,
 | 
			
		||||
                RecurringPeriod.PER_MONTH, RecurringPeriod.PER_HOUR],
 | 
			
		||||
            RecurringPeriod.choices))
 | 
			
		||||
 | 
			
		||||
class VMWithOSProduct(VMProduct):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,7 +8,7 @@ from django.utils import timezone
 | 
			
		|||
from django.core.exceptions import ValidationError
 | 
			
		||||
 | 
			
		||||
from uncloud_vm.models import VMDiskImageProduct, VMDiskProduct, VMProduct, VMHost
 | 
			
		||||
from uncloud_pay.models import Order
 | 
			
		||||
from uncloud_pay.models import Order, RecurringPeriod
 | 
			
		||||
 | 
			
		||||
User = get_user_model()
 | 
			
		||||
cal = parsedatetime.Calendar()
 | 
			
		||||
| 
						 | 
				
			
			@ -52,31 +52,32 @@ class VMTestCase(TestCase):
 | 
			
		|||
                creation_date=datetime.datetime.now(tz=timezone.utc),
 | 
			
		||||
                starting_date=datetime.datetime.now(tz=timezone.utc),
 | 
			
		||||
                ending_date=datetime.datetime(*one_month_later[:6], tzinfo=timezone.utc),
 | 
			
		||||
                recurring_price=4.0, one_time_price=5.0, recurring_period='per_month'
 | 
			
		||||
                recurring_period=RecurringPeriod.PER_MONTH
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_disk_product(self):
 | 
			
		||||
        """Ensures that a VMDiskProduct can only be created from a VMDiskImageProduct
 | 
			
		||||
           that is in status 'active'"""
 | 
			
		||||
 | 
			
		||||
        vm = self.create_sample_vm(owner=self.user)
 | 
			
		||||
 | 
			
		||||
        pending_disk_image = VMDiskImageProduct.objects.create(
 | 
			
		||||
            owner=self.user, name='pending_disk_image', is_os_image=True, is_public=True, size_in_gb=10,
 | 
			
		||||
            status='pending'
 | 
			
		||||
        )
 | 
			
		||||
        try:
 | 
			
		||||
            vm_disk_product = VMDiskProduct.objects.create(
 | 
			
		||||
                owner=self.user, vm=vm, image=pending_disk_image, size_in_gb=10
 | 
			
		||||
            )
 | 
			
		||||
        except ValidationError:
 | 
			
		||||
            vm_disk_product = None
 | 
			
		||||
 | 
			
		||||
        self.assertIsNone(
 | 
			
		||||
            vm_disk_product,
 | 
			
		||||
            msg='VMDiskProduct created with disk image whose status is not active.'
 | 
			
		||||
        )
 | 
			
		||||
# TODO: the logic tested by this test is not implemented yet.
 | 
			
		||||
#    def test_disk_product(self):
 | 
			
		||||
#        """Ensures that a VMDiskProduct can only be created from a VMDiskImageProduct
 | 
			
		||||
#           that is in status 'active'"""
 | 
			
		||||
#
 | 
			
		||||
#        vm = self.create_sample_vm(owner=self.user)
 | 
			
		||||
#
 | 
			
		||||
#        pending_disk_image = VMDiskImageProduct.objects.create(
 | 
			
		||||
#            owner=self.user, name='pending_disk_image', is_os_image=True, is_public=True, size_in_gb=10,
 | 
			
		||||
#            status='pending'
 | 
			
		||||
#        )
 | 
			
		||||
#        try:
 | 
			
		||||
#            vm_disk_product = VMDiskProduct.objects.create(
 | 
			
		||||
#                owner=self.user, vm=vm, image=pending_disk_image, size_in_gb=10
 | 
			
		||||
#            )
 | 
			
		||||
#        except ValidationError:
 | 
			
		||||
#            vm_disk_product = None
 | 
			
		||||
#
 | 
			
		||||
#        self.assertIsNone(
 | 
			
		||||
#            vm_disk_product,
 | 
			
		||||
#            msg='VMDiskProduct created with disk image whose status is not active.'
 | 
			
		||||
#        )
 | 
			
		||||
 | 
			
		||||
    def test_vm_disk_product_creation(self):
 | 
			
		||||
        """Ensure that a user can only create a VMDiskProduct for an existing VM"""
 | 
			
		||||
| 
						 | 
				
			
			@ -94,19 +95,20 @@ class VMTestCase(TestCase):
 | 
			
		|||
                owner=self.user, vm=vm, image=disk_image, size_in_gb=10
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def test_vm_disk_product_creation_for_someone_else(self):
 | 
			
		||||
        """Ensure that a user can only create a VMDiskProduct for his/her own VM"""
 | 
			
		||||
 | 
			
		||||
        # Create a VM which is ownership of self.user2
 | 
			
		||||
        someone_else_vm = self.create_sample_vm(owner=self.user2)
 | 
			
		||||
 | 
			
		||||
        # 'self.user' would try to create a VMDiskProduct for 'user2's VM
 | 
			
		||||
        with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for someone else VM.'):
 | 
			
		||||
            vm_disk_product = VMDiskProduct.objects.create(
 | 
			
		||||
                owner=self.user, vm=someone_else_vm,
 | 
			
		||||
                size_in_gb=10,
 | 
			
		||||
                image=VMDiskImageProduct.objects.create(
 | 
			
		||||
                    owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10,
 | 
			
		||||
                    status='active'
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
# TODO: the logic tested by this test is not implemented yet.
 | 
			
		||||
#    def test_vm_disk_product_creation_for_someone_else(self):
 | 
			
		||||
#        """Ensure that a user can only create a VMDiskProduct for his/her own VM"""
 | 
			
		||||
#
 | 
			
		||||
#        # Create a VM which is ownership of self.user2
 | 
			
		||||
#        someone_else_vm = self.create_sample_vm(owner=self.user2)
 | 
			
		||||
#
 | 
			
		||||
#        # 'self.user' would try to create a VMDiskProduct for 'user2's VM
 | 
			
		||||
#        with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for someone else VM.'):
 | 
			
		||||
#            vm_disk_product = VMDiskProduct.objects.create(
 | 
			
		||||
#                owner=self.user, vm=someone_else_vm,
 | 
			
		||||
#                size_in_gb=10,
 | 
			
		||||
#                image=VMDiskImageProduct.objects.create(
 | 
			
		||||
#                    owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10,
 | 
			
		||||
#                    status='active'
 | 
			
		||||
#                )
 | 
			
		||||
#            )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
from django.db import transaction
 | 
			
		||||
from django.shortcuts import render
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
| 
						 | 
				
			
			@ -11,10 +12,7 @@ from rest_framework.exceptions import ValidationError
 | 
			
		|||
from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct, VMCluster
 | 
			
		||||
from uncloud_pay.models import Order
 | 
			
		||||
 | 
			
		||||
from .serializers import (VMHostSerializer, VMProductSerializer,
 | 
			
		||||
                          VMSnapshotProductSerializer, VMDiskImageProductSerializer,
 | 
			
		||||
                          VMDiskProductSerializer, DCLVMProductSerializer,
 | 
			
		||||
                          VMClusterSerializer)
 | 
			
		||||
from .serializers import *
 | 
			
		||||
from uncloud_pay.helpers import ProductViewSet
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -121,7 +119,8 @@ class VMProductViewSet(ProductViewSet):
 | 
			
		|||
        # Create base order.
 | 
			
		||||
        order = Order.objects.create(
 | 
			
		||||
                recurring_period=order_recurring_period,
 | 
			
		||||
                owner=request.user
 | 
			
		||||
                owner=request.user,
 | 
			
		||||
                starting_date=timezone.now()
 | 
			
		||||
                )
 | 
			
		||||
        order.save()
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
# Generated by Django 3.0.3 on 2020-03-17 11:45
 | 
			
		||||
# Generated by Django 3.0.3 on 2020-03-09 07:57
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
| 
						 | 
				
			
			@ -12,8 +12,8 @@ class Migration(migrations.Migration):
 | 
			
		|||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('uncloud_vm', '0003_remove_vmhost_vms'),
 | 
			
		||||
        ('uncloud_pay', '0002_auto_20200305_1524'),
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
        ('uncloud_pay', '0001_initial'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue