From 36fcff5149c10e972116b9b64cfb5e9bc41f26ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 15:15:12 +0100 Subject: [PATCH 01/34] Add initial structure for payment methods --- uncloud/uncloud/urls.py | 1 + .../migrations/0002_auto_20200227_1404.py | 32 +++++++++++++++++++ .../migrations/0003_auto_20200227_1414.py | 28 ++++++++++++++++ uncloud/uncloud_pay/models.py | 15 +++++++++ uncloud/uncloud_pay/serializers.py | 7 +++- uncloud/uncloud_pay/views.py | 19 +++++++++-- 6 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0002_auto_20200227_1404.py create mode 100644 uncloud/uncloud_pay/migrations/0003_auto_20200227_1414.py diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 5ee9f07..40b1be5 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -33,6 +33,7 @@ router.register(r'user', payviews.UserViewSet, basename='user') router.register(r'bill', payviews.BillViewSet, basename='bill') router.register(r'order', payviews.OrderViewSet, basename='order') router.register(r'payment', payviews.PaymentViewSet, basename='payment') +router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-methods') # admin/staff urls router.register(r'admin/bill', payviews.AdminBillViewSet, basename='admin/bill') diff --git a/uncloud/uncloud_pay/migrations/0002_auto_20200227_1404.py b/uncloud/uncloud_pay/migrations/0002_auto_20200227_1404.py new file mode 100644 index 0000000..4a6e776 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0002_auto_20200227_1404.py @@ -0,0 +1,32 @@ +# Generated by Django 3.0.3 on 2020-02-27 14:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='source', + field=models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256), + ), + migrations.CreateModel( + name='PaymentMethod', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('source', models.CharField(choices=[('stripe', 'Stripe'), ('unknown', 'Unknown')], default='stripe', max_length=256)), + ('description', models.TextField()), + ('default', models.BooleanField()), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0003_auto_20200227_1414.py b/uncloud/uncloud_pay/migrations/0003_auto_20200227_1414.py new file mode 100644 index 0000000..1e16235 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0003_auto_20200227_1414.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.3 on 2020-02-27 14:14 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0002_auto_20200227_1404'), + ] + + operations = [ + migrations.AddField( + model_name='paymentmethod', + name='primary', + field=models.BooleanField(default=True), + ), + migrations.AlterUniqueTogether( + name='paymentmethod', + unique_together={('owner', 'primary')}, + ), + migrations.RemoveField( + model_name='paymentmethod', + name='default', + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 6a33fd5..643361a 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -68,7 +68,22 @@ class Order(models.Model): # return amount # you get the picture +class PaymentMethod(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + source = models.CharField(max_length=256, + choices = ( + ('stripe', 'Stripe'), + ('unknown', 'Unknown'), + ), + default='stripe') + description = models.TextField() + primary = models.BooleanField(default=True) + class Meta: + unique_together = [['owner', 'primary']] class Payment(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 130f683..93a3031 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -1,6 +1,6 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from .models import Bill, Payment, Order +from .models import * class BillSerializer(serializers.ModelSerializer): class Meta: @@ -13,6 +13,11 @@ class PaymentSerializer(serializers.ModelSerializer): model = Payment fields = ['owner', 'amount', 'source', 'timestamp'] +class PaymentMethodSerializer(serializers.ModelSerializer): + class Meta: + model = PaymentMethod + fields = ['owner', 'primary', 'source', 'description'] + class OrderSerializer(serializers.ModelSerializer): class Meta: model = Order diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index ae88861..0b39ff3 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -4,8 +4,8 @@ from rest_framework import viewsets, permissions, status from rest_framework.response import Response from rest_framework.decorators import action -from .models import Bill, Payment, Order -from .serializers import BillSerializer, PaymentSerializer, UserSerializer, OrderSerializer +from .models import * +from .serializers import * from datetime import datetime ### @@ -58,6 +58,21 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): def balance(self, request): return Response(status=status.HTTP_204_NO_CONTENT) +class PaymentMethodViewSet(viewsets.ModelViewSet): + serializer_class = PaymentMethodSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return PaymentMethod.objects.filter(owner=self.request.user) + + def create(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(owner=request.user) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + ### # Admin views. From 1dd33242756e469afb2779f7abcf11eb9d39d72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 15:50:46 +0100 Subject: [PATCH 02/34] Wiring initial user balance --- uncloud/uncloud_pay/models.py | 11 ++++++----- uncloud/uncloud_pay/serializers.py | 17 ++++++++++++++--- uncloud/uncloud_pay/views.py | 8 +++----- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 643361a..c824a00 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -9,8 +9,7 @@ AMOUNT_DECIMALS=2 class Bill(models.Model): owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) + on_delete=models.CASCADE) creation_date = models.DateTimeField() starting_date = models.DateTimeField() @@ -23,7 +22,7 @@ class Bill(models.Model): @property def amount(self): # iterate over all related orders - pass + return 20 class Order(models.Model): @@ -82,6 +81,9 @@ class PaymentMethod(models.Model): description = models.TextField() primary = models.BooleanField(default=True) + def charge(self, amount): + pass + class Meta: unique_together = [['owner', 'primary']] @@ -89,8 +91,7 @@ class Payment(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) + on_delete=models.CASCADE) amount = models.DecimalField( default=0.0, diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 93a3031..040c78a 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -2,6 +2,8 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import * +from functools import reduce + class BillSerializer(serializers.ModelSerializer): class Meta: model = Bill @@ -26,7 +28,16 @@ class OrderSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() - fields = ['username', 'email'] + fields = ['username', 'email', 'balance'] - def get_balance(self, obj): - return 666 + # Display current 'balance' + balance = serializers.SerializerMethodField('get_balance') + def __sum_balance(self, entries): + return reduce(lambda acc, entry: acc + entry.amount, entries, 0) + + def get_balance(self, user): + bills = self.__sum_balance(Bill.objects.filter(owner=user)) + payments = self.__sum_balance(Payment.objects.filter(owner=user)) + balance = payments - bills + + return balance diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 0b39ff3..ea3cca7 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -4,6 +4,8 @@ from rest_framework import viewsets, permissions, status from rest_framework.response import Response from rest_framework.decorators import action +import json + from .models import * from .serializers import * from datetime import datetime @@ -54,10 +56,6 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return get_user_model().objects.all() - @action(detail=True) - def balance(self, request): - return Response(status=status.HTTP_204_NO_CONTENT) - class PaymentMethodViewSet(viewsets.ModelViewSet): serializer_class = PaymentMethodSerializer permission_classes = [permissions.IsAuthenticated] @@ -104,7 +102,7 @@ class AdminBillViewSet(viewsets.ModelViewSet): def create(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save(created_at=datetime.now()) + serializer.save(creation_date=datetime.now()) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) From b2fe5014d84dc9c4f38cc273e6adf479c72228a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 17:13:56 +0100 Subject: [PATCH 03/34] Make recurring_period an Enum, VMProduct a Product, initial wire for order --- .../migrations/0004_auto_20200227_1532.py | 31 ++++++++++++++++ uncloud/uncloud_pay/models.py | 35 +++++++++++-------- uncloud/uncloud_pay/serializers.py | 20 +++++++++++ uncloud/uncloud_pay/views.py | 2 +- .../migrations/0005_auto_20200227_1532.py | 30 ++++++++++++++++ uncloud/uncloud_vm/models.py | 19 +++++----- 6 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0004_auto_20200227_1532.py create mode 100644 uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py diff --git a/uncloud/uncloud_pay/migrations/0004_auto_20200227_1532.py b/uncloud/uncloud_pay/migrations/0004_auto_20200227_1532.py new file mode 100644 index 0000000..f26b498 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0004_auto_20200227_1532.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.3 on 2020-02-27 15:32 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0003_auto_20200227_1414'), + ] + + operations = [ + migrations.AlterField( + model_name='bill', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='order', + name='recurring_period', + field=models.CharField(choices=[('ONCE', 'Onetime'), ('YEAR', 'Per Year'), ('MONTH', 'Per Month'), ('MINUTE', 'Per Minute'), ('DAY', 'Per Day'), ('HOUR', 'Per Hour'), ('SECOND', 'Per Second')], default='MONTH', max_length=32), + ), + migrations.AlterField( + model_name='payment', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index c824a00..d7c4ff1 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -1,12 +1,23 @@ from django.db import models from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator +from django.utils.translation import gettext_lazy as _ import uuid AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 +# 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_MINUTE = 'MINUTE', _('Per Minute') + PER_DAY = 'DAY', _('Per Day') + PER_HOUR = 'HOUR', _('Per Hour') + PER_SECOND = 'SECOND', _('Per Second') + class Bill(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) @@ -31,7 +42,7 @@ class Order(models.Model): on_delete=models.CASCADE, editable=False) - creation_date = models.DateTimeField() + creation_date = models.DateTimeField(auto_now_add=True) starting_date = models.DateTimeField() ending_date = models.DateTimeField(blank=True, null=True) @@ -46,19 +57,8 @@ class Order(models.Model): one_time_price = models.FloatField(editable=False) recurring_period = models.CharField(max_length=32, - choices = ( - ('onetime', 'Onetime'), - ('per_year', 'Per Year'), - ('per_month', 'Per Month'), - ('per_week', 'Per Week'), - ('per_day', 'Per Day'), - ('per_hour', 'Per Hour'), - ('per_minute', 'Per Minute'), - ('per_second', 'Per Second'), - ), - default='onetime' - - ) + choices = RecurringPeriod.choices, + default = RecurringPeriod.PER_MONTH) # def amount(self): # amount = recurring_price @@ -133,7 +133,12 @@ class Product(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, - editable=False) + editable=False, + null=True) + + @property + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + pass # To be implemented in child. class Meta: abstract = True diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 040c78a..4065fbd 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -3,6 +3,7 @@ from rest_framework import serializers from .models import * from functools import reduce +from uncloud_vm.serializers import VMProductSerializer class BillSerializer(serializers.ModelSerializer): class Meta: @@ -20,11 +21,30 @@ class PaymentMethodSerializer(serializers.ModelSerializer): model = PaymentMethod fields = ['owner', 'primary', 'source', 'description'] +class ProductSerializer(serializers.Serializer): + vms = VMProductSerializer(many=True, required=False) + class meta: + fields = ['vms'] + + def create(self, validated_data): + pass + class OrderSerializer(serializers.ModelSerializer): + products = ProductSerializer(many=True) class Meta: model = Order fields = '__all__' + def create(self, validated_data): + products_data = validated_data.pop('products') + order = Order.objects.create(**validated_data) + for product_data in products_data: + print("spouik") + print(product_data) + pass # TODO + + return order + class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index ea3cca7..c641991 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -42,7 +42,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return Payment.objects.filter(owner=self.request.user) -class OrderViewSet(viewsets.ReadOnlyModelViewSet): +class OrderViewSet(viewsets.ModelViewSet): serializer_class = OrderSerializer permission_classes = [permissions.IsAuthenticated] diff --git a/uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py new file mode 100644 index 0000000..b49d6e4 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py @@ -0,0 +1,30 @@ +# Generated by Django 3.0.3 on 2020-02-27 15:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0004_auto_20200227_1532'), + ('uncloud_vm', '0004_vmsnapshotproduct'), + ] + + operations = [ + migrations.AddField( + model_name='vmproduct', + name='order', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), + ), + migrations.AddField( + model_name='vmproduct', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='order', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 12d188e..2510837 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -2,7 +2,7 @@ from django.db import models from django.contrib.auth import get_user_model import uuid -from uncloud_pay.models import Product +from uncloud_pay.models import Product, RecurringPeriod class VMHost(models.Model): @@ -32,22 +32,25 @@ class VMHost(models.Model): ) -class VMProduct(models.Model): - uuid = models.UUIDField(primary_key=True, - default=uuid.uuid4, - editable=False) - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) +class VMProduct(Product): vmhost = models.ForeignKey(VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True) + description = "Virtual Machine" cores = models.IntegerField() ram_in_gb = models.FloatField() + @property + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + if recurring_period == RecurringPeriod.PER_MONTH: + # TODO: move magic numbers in variables + return self.cores * 3 + self.ram_in_gb * 2 + else: + raise Exception('Invalid recurring period for VM Product pricing.') + class VMWithOSProduct(VMProduct): pass From 809a55e1dd6799725d6d3de1c72b8c395f3de621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 18:54:13 +0100 Subject: [PATCH 04/34] Wire VMProduct creation to order --- uncloud/uncloud_pay/models.py | 7 +++++++ uncloud/uncloud_pay/serializers.py | 30 ++++++++++++++++++++++-------- uncloud/uncloud_vm/models.py | 1 + 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index d7c4ff1..c4506a2 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -60,6 +60,13 @@ class Order(models.Model): choices = RecurringPeriod.choices, default = RecurringPeriod.PER_MONTH) + @property + def products(self): + # Blows up due to circular dependency... + # vms = VMProduct.objects.filter(order=self) + vms = [] + return vms + # def amount(self): # amount = recurring_price # if recurring and first_month: diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 4065fbd..d08f9cf 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -4,6 +4,7 @@ from .models import * from functools import reduce from uncloud_vm.serializers import VMProductSerializer +from uncloud_vm.models import VMProduct class BillSerializer(serializers.ModelSerializer): class Meta: @@ -23,25 +24,38 @@ class PaymentMethodSerializer(serializers.ModelSerializer): class ProductSerializer(serializers.Serializer): vms = VMProductSerializer(many=True, required=False) - class meta: - fields = ['vms'] def create(self, validated_data): - pass + owner = validated_data.pop('owner') + order = validated_data.pop('order') + + vms = validated_data.pop('vms') + for vm in vms: + VMProduct.objects.create(owner=owner, order=order, **vm) + + return True # FIXME: shoudl return created objects + class OrderSerializer(serializers.ModelSerializer): - products = ProductSerializer(many=True) + products = ProductSerializer() class Meta: model = Order fields = '__all__' def create(self, validated_data): products_data = validated_data.pop('products') + validated_data['owner'] = self.context["request"].user + + # FIXME: find something to do with this: + validated_data['recurring_price'] = 0 + validated_data['one_time_price'] = 0 + order = Order.objects.create(**validated_data) - for product_data in products_data: - print("spouik") - print(product_data) - pass # TODO + + # Forward product creation to ProductSerializer. + products = ProductSerializer(data=products_data) + products.is_valid(raise_exception=True) + products.save(order=order,owner=order.owner) return order diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 2510837..2db99f3 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model import uuid from uncloud_pay.models import Product, RecurringPeriod +import uncloud_pay.models as pay_models class VMHost(models.Model): From 38d3a3a5d3619be4a755ec53f3b3d2cf4ab94170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 20:28:48 +0100 Subject: [PATCH 05/34] Commit WIP changes for /order, if needed at any point --- uncloud/uncloud_pay/helpers.py | 45 ++++++++++++++++++++++++++ uncloud/uncloud_pay/models.py | 52 +++--------------------------- uncloud/uncloud_pay/serializers.py | 4 +-- uncloud/uncloud_vm/models.py | 17 +++++----- 4 files changed, 61 insertions(+), 57 deletions(-) create mode 100644 uncloud/uncloud_pay/helpers.py diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py new file mode 100644 index 0000000..8daef2e --- /dev/null +++ b/uncloud/uncloud_pay/helpers.py @@ -0,0 +1,45 @@ +import uuid + +from django.db import models +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ + +# 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_MINUTE = 'MINUTE', _('Per Minute') + PER_DAY = 'DAY', _('Per Day') + PER_HOUR = 'HOUR', _('Per Hour') + PER_SECOND = 'SECOND', _('Per Second') + +class Product(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + + description = "" + + status = models.CharField(max_length=256, + choices = ( + ('pending', 'Pending'), + ('being_created', 'Being created'), + ('active', 'Active'), + ('deleted', 'Deleted') + ), + default='pending' + ) + + order = models.ForeignKey('uncloud_pay.Order', + on_delete=models.CASCADE, + editable=False, + null=True) + + @property + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + pass # To be implemented in child. + + class Meta: + abstract = True diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index c4506a2..5f05b9d 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -1,23 +1,15 @@ +import uuid + from django.db import models from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator -from django.utils.translation import gettext_lazy as _ -import uuid +from .helpers import RecurringPeriod +import uncloud_vm.models as vmmodels AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 -# 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_MINUTE = 'MINUTE', _('Per Minute') - PER_DAY = 'DAY', _('Per Day') - PER_HOUR = 'HOUR', _('Per Hour') - PER_SECOND = 'SECOND', _('Per Second') - class Bill(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) @@ -63,8 +55,7 @@ class Order(models.Model): @property def products(self): # Blows up due to circular dependency... - # vms = VMProduct.objects.filter(order=self) - vms = [] + vms = vmmodels.VMProduct.objects.all() #filter(order=self) return vms # def amount(self): @@ -116,36 +107,3 @@ class Payment(models.Model): ), default='unknown') timestamp = models.DateTimeField(editable=False) - - - - -class Product(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) - - description = "" - - status = models.CharField(max_length=256, - choices = ( - ('pending', 'Pending'), - ('being_created', 'Being created'), - ('active', 'Active'), - ('deleted', 'Deleted') - ), - default='pending' - ) - - order = models.ForeignKey(Order, - on_delete=models.CASCADE, - editable=False, - null=True) - - @property - def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): - pass # To be implemented in child. - - class Meta: - abstract = True diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index d08f9cf..406b751 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -23,7 +23,7 @@ class PaymentMethodSerializer(serializers.ModelSerializer): fields = ['owner', 'primary', 'source', 'description'] class ProductSerializer(serializers.Serializer): - vms = VMProductSerializer(many=True, required=False) + vms = VMProductSerializer(many=True, required=False, queryset=VMProduct.objects.all()) def create(self, validated_data): owner = validated_data.pop('owner') @@ -31,7 +31,7 @@ class ProductSerializer(serializers.Serializer): vms = validated_data.pop('vms') for vm in vms: - VMProduct.objects.create(owner=owner, order=order, **vm) + print(VMProduct.objects.create(owner=owner, order=order, **vm)) return True # FIXME: shoudl return created objects diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 2db99f3..02ec20f 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -1,9 +1,10 @@ -from django.db import models -from django.contrib.auth import get_user_model import uuid -from uncloud_pay.models import Product, RecurringPeriod -import uncloud_pay.models as pay_models +from django.db import models +from django.contrib.auth import get_user_model + +import uncloud_pay.models as paymodels +import uncloud_pay.helpers as payhelpers class VMHost(models.Model): @@ -33,7 +34,7 @@ class VMHost(models.Model): ) -class VMProduct(Product): +class VMProduct(payhelpers.Product): vmhost = models.ForeignKey(VMHost, on_delete=models.CASCADE, editable=False, @@ -45,8 +46,8 @@ class VMProduct(Product): ram_in_gb = models.FloatField() @property - def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): - if recurring_period == RecurringPeriod.PER_MONTH: + def recurring_price(self, recurring_period=paymodels.RecurringPeriod.PER_MONTH): + if recurring_period == paymodels.RecurringPeriod.PER_MONTH: # TODO: move magic numbers in variables return self.cores * 3 + self.ram_in_gb * 2 else: @@ -79,7 +80,7 @@ class VMNetworkCard(models.Model): mac_address = models.IntegerField() -class VMSnapshotProduct(Product): +class VMSnapshotProduct(payhelpers.Product): price_per_gb_ssd = 0.35 price_per_gb_hdd = 1.5/100 From 0e28e50baca121d1f014431f7206f9fcff7282c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 20:29:50 +0100 Subject: [PATCH 06/34] Revert "Commit WIP changes for /order, if needed at any point" This reverts commit 83794a1781a1b84506100b39a6997882c654b4f3. --- uncloud/uncloud_pay/helpers.py | 45 -------------------------- uncloud/uncloud_pay/models.py | 52 +++++++++++++++++++++++++++--- uncloud/uncloud_pay/serializers.py | 4 +-- uncloud/uncloud_vm/models.py | 15 ++++----- 4 files changed, 56 insertions(+), 60 deletions(-) delete mode 100644 uncloud/uncloud_pay/helpers.py diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py deleted file mode 100644 index 8daef2e..0000000 --- a/uncloud/uncloud_pay/helpers.py +++ /dev/null @@ -1,45 +0,0 @@ -import uuid - -from django.db import models -from django.contrib.auth import get_user_model -from django.utils.translation import gettext_lazy as _ - -# 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_MINUTE = 'MINUTE', _('Per Minute') - PER_DAY = 'DAY', _('Per Day') - PER_HOUR = 'HOUR', _('Per Hour') - PER_SECOND = 'SECOND', _('Per Second') - -class Product(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) - - description = "" - - status = models.CharField(max_length=256, - choices = ( - ('pending', 'Pending'), - ('being_created', 'Being created'), - ('active', 'Active'), - ('deleted', 'Deleted') - ), - default='pending' - ) - - order = models.ForeignKey('uncloud_pay.Order', - on_delete=models.CASCADE, - editable=False, - null=True) - - @property - def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): - pass # To be implemented in child. - - class Meta: - abstract = True diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 5f05b9d..c4506a2 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -1,15 +1,23 @@ -import uuid - from django.db import models from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator +from django.utils.translation import gettext_lazy as _ -from .helpers import RecurringPeriod -import uncloud_vm.models as vmmodels +import uuid AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 +# 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_MINUTE = 'MINUTE', _('Per Minute') + PER_DAY = 'DAY', _('Per Day') + PER_HOUR = 'HOUR', _('Per Hour') + PER_SECOND = 'SECOND', _('Per Second') + class Bill(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) @@ -55,7 +63,8 @@ class Order(models.Model): @property def products(self): # Blows up due to circular dependency... - vms = vmmodels.VMProduct.objects.all() #filter(order=self) + # vms = VMProduct.objects.filter(order=self) + vms = [] return vms # def amount(self): @@ -107,3 +116,36 @@ class Payment(models.Model): ), default='unknown') timestamp = models.DateTimeField(editable=False) + + + + +class Product(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + + description = "" + + status = models.CharField(max_length=256, + choices = ( + ('pending', 'Pending'), + ('being_created', 'Being created'), + ('active', 'Active'), + ('deleted', 'Deleted') + ), + default='pending' + ) + + order = models.ForeignKey(Order, + on_delete=models.CASCADE, + editable=False, + null=True) + + @property + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + pass # To be implemented in child. + + class Meta: + abstract = True diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 406b751..d08f9cf 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -23,7 +23,7 @@ class PaymentMethodSerializer(serializers.ModelSerializer): fields = ['owner', 'primary', 'source', 'description'] class ProductSerializer(serializers.Serializer): - vms = VMProductSerializer(many=True, required=False, queryset=VMProduct.objects.all()) + vms = VMProductSerializer(many=True, required=False) def create(self, validated_data): owner = validated_data.pop('owner') @@ -31,7 +31,7 @@ class ProductSerializer(serializers.Serializer): vms = validated_data.pop('vms') for vm in vms: - print(VMProduct.objects.create(owner=owner, order=order, **vm)) + VMProduct.objects.create(owner=owner, order=order, **vm) return True # FIXME: shoudl return created objects diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 02ec20f..2db99f3 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -1,10 +1,9 @@ -import uuid - from django.db import models from django.contrib.auth import get_user_model +import uuid -import uncloud_pay.models as paymodels -import uncloud_pay.helpers as payhelpers +from uncloud_pay.models import Product, RecurringPeriod +import uncloud_pay.models as pay_models class VMHost(models.Model): @@ -34,7 +33,7 @@ class VMHost(models.Model): ) -class VMProduct(payhelpers.Product): +class VMProduct(Product): vmhost = models.ForeignKey(VMHost, on_delete=models.CASCADE, editable=False, @@ -46,8 +45,8 @@ class VMProduct(payhelpers.Product): ram_in_gb = models.FloatField() @property - def recurring_price(self, recurring_period=paymodels.RecurringPeriod.PER_MONTH): - if recurring_period == paymodels.RecurringPeriod.PER_MONTH: + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + if recurring_period == RecurringPeriod.PER_MONTH: # TODO: move magic numbers in variables return self.cores * 3 + self.ram_in_gb * 2 else: @@ -80,7 +79,7 @@ class VMNetworkCard(models.Model): mac_address = models.IntegerField() -class VMSnapshotProduct(payhelpers.Product): +class VMSnapshotProduct(Product): price_per_gb_ssd = 0.35 price_per_gb_hdd = 1.5/100 From b1649a6228a052edfaf2b429b55b8489f8b4aef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 20:37:19 +0100 Subject: [PATCH 07/34] Remove product resolution from /order endpoint --- uncloud/uncloud_pay/models.py | 7 ------- uncloud/uncloud_pay/serializers.py | 31 +----------------------------- uncloud/uncloud_pay/views.py | 2 +- 3 files changed, 2 insertions(+), 38 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index c4506a2..d7c4ff1 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -60,13 +60,6 @@ class Order(models.Model): choices = RecurringPeriod.choices, default = RecurringPeriod.PER_MONTH) - @property - def products(self): - # Blows up due to circular dependency... - # vms = VMProduct.objects.filter(order=self) - vms = [] - return vms - # def amount(self): # amount = recurring_price # if recurring and first_month: diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index d08f9cf..9449ee6 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -23,42 +23,13 @@ class PaymentMethodSerializer(serializers.ModelSerializer): fields = ['owner', 'primary', 'source', 'description'] class ProductSerializer(serializers.Serializer): - vms = VMProductSerializer(many=True, required=False) - - def create(self, validated_data): - owner = validated_data.pop('owner') - order = validated_data.pop('order') - - vms = validated_data.pop('vms') - for vm in vms: - VMProduct.objects.create(owner=owner, order=order, **vm) - - return True # FIXME: shoudl return created objects - + vms = VMProductSerializer(many=True, read_only=True) class OrderSerializer(serializers.ModelSerializer): - products = ProductSerializer() class Meta: model = Order fields = '__all__' - def create(self, validated_data): - products_data = validated_data.pop('products') - validated_data['owner'] = self.context["request"].user - - # FIXME: find something to do with this: - validated_data['recurring_price'] = 0 - validated_data['one_time_price'] = 0 - - order = Order.objects.create(**validated_data) - - # Forward product creation to ProductSerializer. - products = ProductSerializer(data=products_data) - products.is_valid(raise_exception=True) - products.save(order=order,owner=order.owner) - - return order - class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index c641991..ea3cca7 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -42,7 +42,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return Payment.objects.filter(owner=self.request.user) -class OrderViewSet(viewsets.ModelViewSet): +class OrderViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = OrderSerializer permission_classes = [permissions.IsAuthenticated] From ef5e7e80355ae276cbb70b738d8e7b23e376f29b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 20:58:12 +0100 Subject: [PATCH 08/34] Quickly wire vm creation to orders --- uncloud/uncloud/urls.py | 5 ++++- uncloud/uncloud_pay/models.py | 2 +- uncloud/uncloud_vm/models.py | 1 - uncloud/uncloud_vm/views.py | 19 ++++++++++++++++--- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 40b1be5..d1a1cb8 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -35,13 +35,16 @@ router.register(r'order', payviews.OrderViewSet, basename='order') router.register(r'payment', payviews.PaymentViewSet, basename='payment') router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-methods') +# VMs +router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vm') + # admin/staff urls router.register(r'admin/bill', payviews.AdminBillViewSet, basename='admin/bill') router.register(r'admin/payment', payviews.AdminPaymentViewSet, basename='admin/payment') router.register(r'admin/order', payviews.AdminOrderViewSet, basename='admin/order') router.register(r'admin/vmhost', vmviews.VMHostViewSet) router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') -router.register(r'admin/opennebula_raw', oneviews.RawVMViewSet) +router.register(r'admin/opennebula_raw', oneviews.RawVMViewSet, basename='opennebula_raw') urlpatterns = [ diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index d7c4ff1..6077963 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -43,7 +43,7 @@ class Order(models.Model): editable=False) creation_date = models.DateTimeField(auto_now_add=True) - starting_date = models.DateTimeField() + starting_date = models.DateTimeField(auto_now_add=True) ending_date = models.DateTimeField(blank=True, null=True) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 2db99f3..26b369f 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -44,7 +44,6 @@ class VMProduct(Product): cores = models.IntegerField() ram_in_gb = models.FloatField() - @property def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): if recurring_period == RecurringPeriod.PER_MONTH: # TODO: move magic numbers in variables diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 444d134..a7171c9 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -7,8 +7,7 @@ from rest_framework import viewsets, permissions from rest_framework.response import Response from .models import VMHost, VMProduct, VMSnapshotProduct -from uncloud_pay.models import Order - +from uncloud_pay.models import Order, RecurringPeriod from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer import datetime @@ -27,9 +26,23 @@ class VMProductViewSet(viewsets.ModelViewSet): return VMProduct.objects.filter(owner=self.request.user) def create(self, request): + # Create base order. + order = Order.objects.create( + recurring_period=RecurringPeriod.PER_MONTH, + recurring_price=0, + one_time_price=0, + owner=request.user + ) + + # Create VM. serializer = VMProductSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) - serializer.save(owner=request.user) + vm = serializer.save(owner=request.user, order=order) + + # FIXME: commit everything (VM + order) at once. + order.recurring_price = vm.recurring_price(order.recurring_period) + order.one_time_price = 0 + order.save() return Response(serializer.data) From 059791e2f216c95b82bb115b84541520c702e688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 08:59:32 +0100 Subject: [PATCH 09/34] Add initial generate-bills and charge-negative-balance uncloud-pay commands --- uncloud/uncloud_pay/helpers.py | 12 +++ .../commands/charge-negative-balance.py | 23 +++++ .../management/commands/generate-bills.py | 48 +++++++++++ .../migrations/0005_auto_20200228_0737.py | 42 ++++++++++ .../migrations/0006_auto_20200228_0741.py | 18 ++++ .../migrations/0007_remove_order_bill.py | 17 ++++ .../uncloud_pay/migrations/0008_order_bill.py | 18 ++++ uncloud/uncloud_pay/models.py | 16 ++-- uncloud/uncloud_pay/serializers.py | 9 +- uncloud/uncloud_vm/views.py.orig | 84 +++++++++++++++++++ 10 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 uncloud/uncloud_pay/helpers.py create mode 100644 uncloud/uncloud_pay/management/commands/charge-negative-balance.py create mode 100644 uncloud/uncloud_pay/management/commands/generate-bills.py create mode 100644 uncloud/uncloud_pay/migrations/0005_auto_20200228_0737.py create mode 100644 uncloud/uncloud_pay/migrations/0006_auto_20200228_0741.py create mode 100644 uncloud/uncloud_pay/migrations/0007_remove_order_bill.py create mode 100644 uncloud/uncloud_pay/migrations/0008_order_bill.py create mode 100644 uncloud/uncloud_vm/views.py.orig diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py new file mode 100644 index 0000000..9dc39cd --- /dev/null +++ b/uncloud/uncloud_pay/helpers.py @@ -0,0 +1,12 @@ +from functools import reduce +from .models import Bill, Payment + +def sum_amounts(entries): + return reduce(lambda acc, entry: acc + entry.amount, entries, 0) + + +def get_balance_for(user): + bills = sum_amounts(Bill.objects.filter(owner=user)) + payments = sum_amounts(Payment.objects.filter(owner=user)) + return payments - bills + diff --git a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py new file mode 100644 index 0000000..ae4c8dc --- /dev/null +++ b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py @@ -0,0 +1,23 @@ +from django.core.management.base import BaseCommand +from uncloud_auth.models import User +from uncloud_pay.models import Order, Bill +from uncloud_pay.helpers import get_balance_for + +from datetime import timedelta +from django.utils import timezone + +class Command(BaseCommand): + help = 'Generate bills and charge customers if necessary.' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + users = User.objects.all() + print("Processing {} users.".format(users.count())) + for user in users: + balance = get_balance_for(user) + if balance < 0: + print("User {} has negative balance ({}), charging.".format(user.username, balance)) + # TODO: charge + print("=> Done.") diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud/uncloud_pay/management/commands/generate-bills.py new file mode 100644 index 0000000..92075ce --- /dev/null +++ b/uncloud/uncloud_pay/management/commands/generate-bills.py @@ -0,0 +1,48 @@ +from django.core.management.base import BaseCommand +from uncloud_auth.models import User +from uncloud_pay.models import Order, Bill + +from datetime import timedelta +from django.utils import timezone + +class Command(BaseCommand): + help = 'Generate bills and charge customers if necessary.' + + def add_arguments(self, parser): + pass + + # TODO: check for existing bills + def handle(self, *args, **options): + customers = User.objects.all() + print("Processing {} users.".format(customers.count())) + for customer in customers: + orders = Order.objects.filter(owner=customer) + + # Pay all non-billed usage untill now. + bill_starting_date = timezone.now() + bill_ending_date = timezone.now() + + billed_orders = [] + for order in orders: + print(order) + if True: # FIXME + billed_orders.append(order) + + # Update starting date if need be. + if order.starting_date < bill_starting_date: + bill_starting_date = order.starting_date + + if len(billed_orders) > 0: + bill = Bill(owner=customer, + starting_date=bill_starting_date, + ending_date=bill_starting_date, + due_date=timezone.now() + timedelta(days=10)) + bill.save() + + for order in billed_orders: + print(order) + order.bill.add(bill) + + print("Created bill {} for user {}".format(bill.uuid, customer.username)) + + print("=> Done.") diff --git a/uncloud/uncloud_pay/migrations/0005_auto_20200228_0737.py b/uncloud/uncloud_pay/migrations/0005_auto_20200228_0737.py new file mode 100644 index 0000000..c646724 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0005_auto_20200228_0737.py @@ -0,0 +1,42 @@ +# Generated by Django 3.0.3 on 2020-02-28 07:37 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0004_auto_20200227_1532'), + ] + + operations = [ + migrations.RemoveField( + model_name='bill', + name='id', + ), + migrations.RemoveField( + model_name='bill', + name='paid', + ), + migrations.AddField( + model_name='bill', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='bill', + name='creation_date', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='order', + name='creation_date', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='order', + name='starting_date', + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0006_auto_20200228_0741.py b/uncloud/uncloud_pay/migrations/0006_auto_20200228_0741.py new file mode 100644 index 0000000..ef03bda --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0006_auto_20200228_0741.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-28 07:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0005_auto_20200228_0737'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='bill', + field=models.ManyToManyField(blank=True, editable=False, to='uncloud_pay.Bill'), + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0007_remove_order_bill.py b/uncloud/uncloud_pay/migrations/0007_remove_order_bill.py new file mode 100644 index 0000000..ea79416 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0007_remove_order_bill.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.3 on 2020-02-28 07:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0006_auto_20200228_0741'), + ] + + operations = [ + migrations.RemoveField( + model_name='order', + name='bill', + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0008_order_bill.py b/uncloud/uncloud_pay/migrations/0008_order_bill.py new file mode 100644 index 0000000..315ac60 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0008_order_bill.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-28 07:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0007_remove_order_bill'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='bill', + field=models.ManyToManyField(blank=True, editable=False, to='uncloud_pay.Bill'), + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 6077963..f3de8c4 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -19,21 +19,26 @@ class RecurringPeriod(models.TextChoices): PER_SECOND = 'SECOND', _('Per Second') class Bill(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) - creation_date = models.DateTimeField() + creation_date = models.DateTimeField(auto_now_add=True) starting_date = models.DateTimeField() ending_date = models.DateTimeField() due_date = models.DateField() - paid = models.BooleanField(default=False) valid = models.BooleanField(default=True) @property def amount(self): - # iterate over all related orders - return 20 + orders = Order.objects.filter(bill=self) + amount = 0 + for order in orders: + amount += order.recurring_price + + return amount + class Order(models.Model): @@ -49,8 +54,7 @@ class Order(models.Model): bill = models.ManyToManyField(Bill, editable=False, - blank=True, - null=True) + blank=True) recurring_price = models.FloatField(editable=False) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 9449ee6..a4a1f1b 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import * +from .helpers import get_balance_for from functools import reduce from uncloud_vm.serializers import VMProductSerializer @@ -10,7 +11,7 @@ class BillSerializer(serializers.ModelSerializer): class Meta: model = Bill fields = ['owner', 'amount', 'due_date', 'creation_date', - 'starting_date', 'ending_date', 'paid'] + 'starting_date', 'ending_date'] class PaymentSerializer(serializers.ModelSerializer): class Meta: @@ -41,8 +42,4 @@ class UserSerializer(serializers.ModelSerializer): return reduce(lambda acc, entry: acc + entry.amount, entries, 0) def get_balance(self, user): - bills = self.__sum_balance(Bill.objects.filter(owner=user)) - payments = self.__sum_balance(Payment.objects.filter(owner=user)) - balance = payments - bills - - return balance + return get_balance_for(user) diff --git a/uncloud/uncloud_vm/views.py.orig b/uncloud/uncloud_vm/views.py.orig new file mode 100644 index 0000000..a311320 --- /dev/null +++ b/uncloud/uncloud_vm/views.py.orig @@ -0,0 +1,84 @@ +from django.shortcuts import render + +from django.contrib.auth.models import User +from django.shortcuts import get_object_or_404 + +from rest_framework import viewsets, permissions +from rest_framework.response import Response + +<<<<<<< HEAD +from .models import VMHost, VMProduct, VMSnapshotProduct +from uncloud_pay.models import Order + +======= +from uncloud_pay.models import Order, RecurringPeriod + +from .models import VMHost, VMProduct +>>>>>>> Quickly wire vm creation to orders +from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer + +import datetime + +class VMHostViewSet(viewsets.ModelViewSet): + serializer_class = VMHostSerializer + queryset = VMHost.objects.all() + permission_classes = [permissions.IsAdminUser] + + +class VMProductViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMProductSerializer + + def get_queryset(self): + return VMProduct.objects.filter(owner=self.request.user) + + def create(self, request): + # Create base order. + order = Order.objects.create( + recurring_period=RecurringPeriod.PER_MONTH, + recurring_price=0, + one_time_price=0, + owner=request.user + ) + + # Create VM. + serializer = VMProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + vm = serializer.save(owner=request.user, order=order) + + # FIXME: commit everything (VM + order) at once. + order.recurring_price = vm.recurring_price(order.recurring_period) + order.one_time_price = 0 + order.save() + + return Response(serializer.data) + + +class VMSnapshotProductViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMSnapshotProductSerializer + + def get_queryset(self): + return VMSnapshotProduct.objects.filter(owner=self.request.user) + + def create(self, request): + serializer = VMSnapshotProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + + # Create order + now = datetime.datetime.now() + order = Order(owner=request.user, + creation_date=now, + starting_date=now, + recurring_price=20, + one_time_price=0, + recurring_period="per_month") + order.save() + + # FIXME: calculate the gb_* values + serializer.save(owner=request.user, + order=order, + gb_ssd=12, + gb_hdd=20) + + return Response(serializer.data) From 4bed53c8a87c4220dc34d2d2ac2bb5b5ad225bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 09:10:36 +0100 Subject: [PATCH 10/34] Wire charge-negative-balance to payment methods --- uncloud/uncloud/urls.py | 1 + uncloud/uncloud_pay/helpers.py | 18 +++++++++++++----- .../commands/charge-negative-balance.py | 13 +++++++++++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index d1a1cb8..8244e0e 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -30,6 +30,7 @@ router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') # Pay router.register(r'user', payviews.UserViewSet, basename='user') +router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-method') router.register(r'bill', payviews.BillViewSet, basename='bill') router.register(r'order', payviews.OrderViewSet, basename='order') router.register(r'payment', payviews.PaymentViewSet, basename='payment') diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index 9dc39cd..2f68e9e 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -1,12 +1,20 @@ from functools import reduce -from .models import Bill, Payment +from .models import Bill, Payment, PaymentMethod def sum_amounts(entries): - return reduce(lambda acc, entry: acc + entry.amount, entries, 0) + return reduce(lambda acc, entry: acc + entry.amount, entries, 0) def get_balance_for(user): - bills = sum_amounts(Bill.objects.filter(owner=user)) - payments = sum_amounts(Payment.objects.filter(owner=user)) - return payments - bills + bills = sum_amounts(Bill.objects.filter(owner=user)) + payments = sum_amounts(Payment.objects.filter(owner=user)) + return payments - bills +def get_payment_method_for(user): + methods = PaymentMethod.objects.filter(owner=user) + for method in methods: + # Do we want to do something with non-primary method? + if method.primary: + return method + + return None diff --git a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py index ae4c8dc..3667a03 100644 --- a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py +++ b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py @@ -1,7 +1,7 @@ from django.core.management.base import BaseCommand from uncloud_auth.models import User from uncloud_pay.models import Order, Bill -from uncloud_pay.helpers import get_balance_for +from uncloud_pay.helpers import get_balance_for, get_payment_method_for from datetime import timedelta from django.utils import timezone @@ -19,5 +19,14 @@ class Command(BaseCommand): balance = get_balance_for(user) if balance < 0: print("User {} has negative balance ({}), charging.".format(user.username, balance)) - # TODO: charge + payment_method = get_payment_method_for(user) + if payment_method != None: + amount_to_be_charged = abs(balance) + charge_ok = payment_method.charge(amount_to_be_charged) + if not charge_ok: + print("ERR: charging {} with method {} failed" + .format(user.username, payment_method.uuid) + ) + else: + print("ERR: no payment method registered for {}".format(user.username)) print("=> Done.") From 37ed126bc17ebe387b63c21c052bb5a5b9217340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 09:26:18 +0100 Subject: [PATCH 11/34] Create payment on strip charging --- uncloud/uncloud_pay/models.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index f3de8c4..8e41e24 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -57,8 +57,16 @@ class Order(models.Model): blank=True) - recurring_price = models.FloatField(editable=False) - one_time_price = models.FloatField(editable=False) + recurring_price = models.DecimalField( + max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS, + validators=[MinValueValidator(0)], + editable=False) + one_time_price = models.DecimalField( + max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS, + validators=[MinValueValidator(0)], + editable=False) recurring_period = models.CharField(max_length=32, choices = RecurringPeriod.choices, @@ -86,7 +94,18 @@ class PaymentMethod(models.Model): primary = models.BooleanField(default=True) def charge(self, amount): - pass + if amount > 0: # Make sure we don't charge negative amount by errors... + if self.source == 'stripe': + # TODO: wire to strip, see meooow-payv1/strip_utils.py + payment = Payment(owner=self.owner, source=self.source, amount=amount) + payment.save() # TODO: Check return status + + return True + else: + # We do not handle that source yet. + return False + else: + return False class Meta: unique_together = [['owner', 'primary']] @@ -112,7 +131,7 @@ class Payment(models.Model): ('unknown', 'Unknown') ), default='unknown') - timestamp = models.DateTimeField(editable=False) + timestamp = models.DateTimeField(editable=False, auto_now_add=True) From adb57c55ca0c439a0577ebb1a0c24fbb678350ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 09:58:01 +0100 Subject: [PATCH 12/34] Revamp generate-bills logic to avoid overlapping --- .../management/commands/generate-bills.py | 69 ++++++++++++------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud/uncloud_pay/management/commands/generate-bills.py index 92075ce..aad7a82 100644 --- a/uncloud/uncloud_pay/management/commands/generate-bills.py +++ b/uncloud/uncloud_pay/management/commands/generate-bills.py @@ -1,48 +1,67 @@ from django.core.management.base import BaseCommand from uncloud_auth.models import User from uncloud_pay.models import Order, Bill +from django.core.exceptions import ObjectDoesNotExist from datetime import timedelta from django.utils import timezone +BILL_PAYMENT_DELAY=timedelta(days=10) + class Command(BaseCommand): help = 'Generate bills and charge customers if necessary.' def add_arguments(self, parser): pass - # TODO: check for existing bills def handle(self, *args, **options): - customers = User.objects.all() - print("Processing {} users.".format(customers.count())) - for customer in customers: - orders = Order.objects.filter(owner=customer) + users = User.objects.all() + print("Processing {} users.".format(users.count())) - # Pay all non-billed usage untill now. - bill_starting_date = timezone.now() - bill_ending_date = timezone.now() + for user in users: + # Fetch all the orders of a customer. + orders = Order.objects.filter(owner=user) - billed_orders = [] + # Default values for next bill (if any). Only saved at the end of + # this method, if relevant. + next_bill = Bill(owner=user, + starting_date=timezone.now(), # Will be set to oldest unpaid order (means unpaid starting date). + ending_date=timezone.now(), # Bill covers everything until today. + due_date=timezone.now() + BILL_PAYMENT_DELAY) + + unpaid_orders = [] # Store orders in need of a payment. for order in orders: - print(order) - if True: # FIXME - billed_orders.append(order) + # Only bill if there is an 'unpaid period' on an active order. + # XXX: Assume everything before latest bill is paid. => might be dangerous. + try: + previous_bill = order.bill.latest('ending_date') + except ObjectDoesNotExist: + previous_bill = None - # Update starting date if need be. - if order.starting_date < bill_starting_date: - bill_starting_date = order.starting_date + is_unpaid_period = True + if order.ending_date and previous_bill != None: + is_unpaid_period = previous_bill.ending_date < order.ending_date - if len(billed_orders) > 0: - bill = Bill(owner=customer, - starting_date=bill_starting_date, - ending_date=bill_starting_date, - due_date=timezone.now() + timedelta(days=10)) - bill.save() + if is_unpaid_period: + # Update bill starting date to match period. + if previous_bill == None: + next_bill.starting_date = order.starting_date + elif previous_bill.ending_date < next_bill.starting_date: + next_bill.starting_date = previous_bill.ending_date - for order in billed_orders: - print(order) - order.bill.add(bill) + # Add order to bill + unpaid_orders.append(order) - print("Created bill {} for user {}".format(bill.uuid, customer.username)) + # Save next_bill if it contains any unpaid product. + if len(unpaid_orders) > 0: + next_bill.save() + # 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) + + print("Created bill {} for user {}".format(next_bill.uuid, user.username)) + + # We're done for this round :-) print("=> Done.") From e12575e1de662578225397e6a7a42a8ac5132c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 09:59:13 +0100 Subject: [PATCH 13/34] Commit forgotten migration on Orders (Float->Decimal) --- .../migrations/0009_auto_20200228_0825.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 uncloud/uncloud_pay/migrations/0009_auto_20200228_0825.py diff --git a/uncloud/uncloud_pay/migrations/0009_auto_20200228_0825.py b/uncloud/uncloud_pay/migrations/0009_auto_20200228_0825.py new file mode 100644 index 0000000..66feb51 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0009_auto_20200228_0825.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.3 on 2020-02-28 08:25 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0008_order_bill'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='one_time_price', + field=models.DecimalField(decimal_places=2, editable=False, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='order', + name='recurring_price', + field=models.DecimalField(decimal_places=2, editable=False, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='payment', + name='timestamp', + field=models.DateTimeField(auto_now_add=True), + ), + ] From c0512e54b034666f227ff3165bb5e72c24cc47c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 10:18:24 +0100 Subject: [PATCH 14/34] Add handle-overdue-bills --- .../commands/handle-overdue-bills.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 uncloud/uncloud_pay/management/commands/handle-overdue-bills.py diff --git a/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py b/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py new file mode 100644 index 0000000..f4749f0 --- /dev/null +++ b/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py @@ -0,0 +1,43 @@ +from django.core.management.base import BaseCommand +from uncloud_auth.models import User +from uncloud_pay.models import Order, Bill +from uncloud_pay.helpers import get_balance_for, get_payment_method_for + +from datetime import timedelta +from django.utils import timezone + +class Command(BaseCommand): + help = 'Generate bills and charge customers if necessary.' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + users = User.objects.all() + print("Processing {} users.".format(users.count())) + for user in users: + balance = get_balance_for(user) + if balance < 0: + print("User {} has negative balance ({}), checking for overdue bills." + .format(user.username, balance)) + + # Get bills DESCENDING by creation date (= latest at top). + bills = Bill.objects.filter( + owner=user, + due_date__lt=timezone.now() + ).order_by('-creation_date') + overdue_balance = abs(balance) + overdue_bills = [] + for bill in bills: + if overdue_balance < 0: + break # XXX: I'm (fnux) not fond of breaks! + + overdue_balance -= bill.amount + overdue_bills.append(bill) + + for bill in overdue_bills: + print("/!\ Overdue bill for {}, {} with amount {}" + .format(user.username, bill.uuid, bill.amount)) + # TODO: take action? + + print("=> Done.") From 1cb1de4876953b3db7d3a9c0d29330514c753dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 11:10:31 +0100 Subject: [PATCH 15/34] Add (broken) charge method to payment method endpoint --- uncloud/uncloud_pay/serializers.py | 2 +- uncloud/uncloud_pay/views.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index a4a1f1b..3b8cc47 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -21,7 +21,7 @@ class PaymentSerializer(serializers.ModelSerializer): class PaymentMethodSerializer(serializers.ModelSerializer): class Meta: model = PaymentMethod - fields = ['owner', 'primary', 'source', 'description'] + fields = '__all__' class ProductSerializer(serializers.Serializer): vms = VMProductSerializer(many=True, read_only=True) diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index ea3cca7..9ed57c8 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -71,6 +71,20 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + # TODO: find a way to customize serializer for actions. + # drf-action-serializer module seems to do that. + @action(detail=True, methods=['post']) + def charge(self, request, pk=None): + payment_method = self.get_object() + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + amount = serializer.data['amount'] + if payment_method.charge(amount): + return Response({'charged', amount}) + else: + return Response(status=status.HTTP_500_INTERNAL_ERROR) + + ### # Admin views. From 3b87a4743053ef054704a7d6bcbea4f1189c9fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 14:46:33 +0100 Subject: [PATCH 16/34] Add initial ungleich_service app with MatrixServiceProduct shell --- uncloud/uncloud/settings.py | 1 + uncloud/uncloud/urls.py | 6 +++- uncloud/ungleich_service/__init__.py | 0 uncloud/ungleich_service/admin.py | 3 ++ uncloud/ungleich_service/apps.py | 5 +++ .../migrations/0001_initial.py | 33 +++++++++++++++++++ .../ungleich_service/migrations/__init__.py | 0 uncloud/ungleich_service/models.py | 20 +++++++++++ uncloud/ungleich_service/serializers.py | 7 ++++ uncloud/ungleich_service/tests.py | 3 ++ uncloud/ungleich_service/views.py | 14 ++++++++ 11 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 uncloud/ungleich_service/__init__.py create mode 100644 uncloud/ungleich_service/admin.py create mode 100644 uncloud/ungleich_service/apps.py create mode 100644 uncloud/ungleich_service/migrations/0001_initial.py create mode 100644 uncloud/ungleich_service/migrations/__init__.py create mode 100644 uncloud/ungleich_service/models.py create mode 100644 uncloud/ungleich_service/serializers.py create mode 100644 uncloud/ungleich_service/tests.py create mode 100644 uncloud/ungleich_service/views.py diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 179ff0b..24a425f 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -63,6 +63,7 @@ INSTALLED_APPS = [ 'uncloud_pay', 'uncloud_auth', 'uncloud_vm', + 'ungleich_service', 'opennebula' ] diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 8244e0e..e4abba5 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -20,14 +20,18 @@ from rest_framework import routers from uncloud_vm import views as vmviews from uncloud_pay import views as payviews +from ungleich_service import views as serviceviews from opennebula import views as oneviews router = routers.DefaultRouter() -# user / regular urls +# VM router.register(r'vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct') router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') +# Services +router.register(r'service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct') + # Pay router.register(r'user', payviews.UserViewSet, basename='user') router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-method') diff --git a/uncloud/ungleich_service/__init__.py b/uncloud/ungleich_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/ungleich_service/admin.py b/uncloud/ungleich_service/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/uncloud/ungleich_service/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/uncloud/ungleich_service/apps.py b/uncloud/ungleich_service/apps.py new file mode 100644 index 0000000..184e181 --- /dev/null +++ b/uncloud/ungleich_service/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UngleichServiceConfig(AppConfig): + name = 'ungleich_service' diff --git a/uncloud/ungleich_service/migrations/0001_initial.py b/uncloud/ungleich_service/migrations/0001_initial.py new file mode 100644 index 0000000..2e19344 --- /dev/null +++ b/uncloud/ungleich_service/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 3.0.3 on 2020-02-28 13:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('uncloud_pay', '0010_merge_20200228_1303'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_vm', '0007_auto_20200228_1344'), + ] + + operations = [ + migrations.CreateModel( + name='MatrixServiceProduct', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), + ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/uncloud/ungleich_service/migrations/__init__.py b/uncloud/ungleich_service/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/ungleich_service/models.py b/uncloud/ungleich_service/models.py new file mode 100644 index 0000000..ac1f90e --- /dev/null +++ b/uncloud/ungleich_service/models.py @@ -0,0 +1,20 @@ +import uuid + +from django.db import models +from uncloud_pay.models import Product, RecurringPeriod +from uncloud_vm.models import VMProduct + +class MatrixServiceProduct(Product): + monthly_managment_fee = 20 + setup_fee = 30 + + description = "Managed Matrix HomeServer" + vm = models.ForeignKey( + VMProduct, on_delete=models.CASCADE + ) + + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + if recurring_period == RecurringPeriod.PER_MONTH: + return monthly_managment_fee + vm.recurring_price(RecurringPeriod.PER_MONTH) + else: + raise Exception('Invalid recurring period for VM Product pricing.') diff --git a/uncloud/ungleich_service/serializers.py b/uncloud/ungleich_service/serializers.py new file mode 100644 index 0000000..54737e9 --- /dev/null +++ b/uncloud/ungleich_service/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers +from .models import MatrixServiceProduct + +class MatrixServiceProductSerializer(serializers.ModelSerializer): + class Meta: + model = MatrixServiceProduct + fields = '__all__' diff --git a/uncloud/ungleich_service/tests.py b/uncloud/ungleich_service/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/uncloud/ungleich_service/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/uncloud/ungleich_service/views.py b/uncloud/ungleich_service/views.py new file mode 100644 index 0000000..776b94c --- /dev/null +++ b/uncloud/ungleich_service/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets, permissions + +from .models import MatrixServiceProduct +from .serializers import MatrixServiceProductSerializer + +class MatrixServiceProductViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = MatrixServiceProductSerializer + + def get_queryset(self): + return MatrixServiceProduct.objects.filter(owner=self.request.user) + def create(self, request): + # TODO + pass From 33cc2b21114edb9dc5e56751871deab4aa9bf678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 14:48:01 +0100 Subject: [PATCH 17/34] Add uncloud_storage template app --- uncloud/uncloud/settings.py | 1 + uncloud/uncloud_storage/__init__.py | 0 uncloud/uncloud_storage/admin.py | 3 +++ uncloud/uncloud_storage/apps.py | 5 +++++ uncloud/uncloud_storage/migrations/__init__.py | 0 uncloud/uncloud_storage/models.py | 3 +++ uncloud/uncloud_storage/tests.py | 3 +++ uncloud/uncloud_storage/views.py | 3 +++ 8 files changed, 18 insertions(+) create mode 100644 uncloud/uncloud_storage/__init__.py create mode 100644 uncloud/uncloud_storage/admin.py create mode 100644 uncloud/uncloud_storage/apps.py create mode 100644 uncloud/uncloud_storage/migrations/__init__.py create mode 100644 uncloud/uncloud_storage/models.py create mode 100644 uncloud/uncloud_storage/tests.py create mode 100644 uncloud/uncloud_storage/views.py diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 24a425f..c6c89d5 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ 'rest_framework', 'uncloud_pay', 'uncloud_auth', + 'uncloud_storage', 'uncloud_vm', 'ungleich_service', 'opennebula' diff --git a/uncloud/uncloud_storage/__init__.py b/uncloud/uncloud_storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/uncloud_storage/admin.py b/uncloud/uncloud_storage/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/uncloud/uncloud_storage/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/uncloud/uncloud_storage/apps.py b/uncloud/uncloud_storage/apps.py new file mode 100644 index 0000000..38b2301 --- /dev/null +++ b/uncloud/uncloud_storage/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UncloudStorageConfig(AppConfig): + name = 'uncloud_storage' diff --git a/uncloud/uncloud_storage/migrations/__init__.py b/uncloud/uncloud_storage/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/uncloud_storage/models.py b/uncloud/uncloud_storage/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/uncloud/uncloud_storage/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/uncloud/uncloud_storage/tests.py b/uncloud/uncloud_storage/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/uncloud/uncloud_storage/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/uncloud/uncloud_storage/views.py b/uncloud/uncloud_storage/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/uncloud/uncloud_storage/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From b3bbfafa04db6aee9d7b496032520355cad2385d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 14:57:45 +0100 Subject: [PATCH 18/34] Introduce custom ProductViewSet preventing customer from updating products --- uncloud/uncloud_pay/helpers.py | 14 +++++++++++++- uncloud/uncloud_vm/views.py | 3 ++- uncloud/ungleich_service/views.py | 5 ++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index 2f68e9e..8ca82aa 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -1,10 +1,11 @@ from functools import reduce +from rest_framework import mixins +from rest_framework.viewsets import GenericViewSet from .models import Bill, Payment, PaymentMethod def sum_amounts(entries): return reduce(lambda acc, entry: acc + entry.amount, entries, 0) - def get_balance_for(user): bills = sum_amounts(Bill.objects.filter(owner=user)) payments = sum_amounts(Payment.objects.filter(owner=user)) @@ -18,3 +19,14 @@ def get_payment_method_for(user): return method return None + + +class ProductViewSet(mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + GenericViewSet): + """ + A customer-facing viewset that provides default `create()`, `retrieve()` + and `list()`. + """ + pass diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index e5fd4ba..c3704e1 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -9,6 +9,7 @@ from rest_framework.response import Response from .models import VMHost, VMProduct, VMSnapshotProduct from uncloud_pay.models import Order, RecurringPeriod from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer +from uncloud_pay.helpers import ProductViewSet import datetime @@ -19,7 +20,7 @@ class VMHostViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAdminUser] -class VMProductViewSet(viewsets.ModelViewSet): +class VMProductViewSet(ProductViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = VMProductSerializer diff --git a/uncloud/ungleich_service/views.py b/uncloud/ungleich_service/views.py index 776b94c..9c27df8 100644 --- a/uncloud/ungleich_service/views.py +++ b/uncloud/ungleich_service/views.py @@ -3,12 +3,15 @@ from rest_framework import viewsets, permissions from .models import MatrixServiceProduct from .serializers import MatrixServiceProductSerializer -class MatrixServiceProductViewSet(viewsets.ModelViewSet): +from uncloud_pay.helpers import ProductViewSet + +class MatrixServiceProductViewSet(ProductViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = MatrixServiceProductSerializer def get_queryset(self): return MatrixServiceProduct.objects.filter(owner=self.request.user) + def create(self, request): # TODO pass From 181005ad6c232b355ae01b62c29a53e3db00b6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 15:07:20 +0100 Subject: [PATCH 19/34] Cleanup VMProduct serializer, add name field to VMProduct --- uncloud/uncloud_vm/models.py | 4 ++++ uncloud/uncloud_vm/serializers.py | 8 +++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 663765a..be1178e 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -41,6 +41,10 @@ class VMProduct(Product): null=True) description = "Virtual Machine" + + # VM-specific. The name is only intended for customers: it's a pain te + # remember IDs (speaking from experience as ungleich customer)! + name = models.CharField(max_length=32) cores = models.IntegerField() ram_in_gb = models.FloatField() diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index b247709..cb60cfe 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -12,11 +12,9 @@ class VMHostSerializer(serializers.HyperlinkedModelSerializer): class VMProductSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VMProduct - fields = '__all__' - - -# def create(self, validated_data): -# return VMSnapshotProduct() + fields = ['uuid', 'description', 'order', 'owner', 'status', 'name', \ + 'cores', 'ram_in_gb'] + read_only_fields = ['uuid', 'description', 'order', 'owner', 'status'] class VMSnapshotProductSerializer(serializers.ModelSerializer): class Meta: From eaa483e018197ce019582e0b25b18ef38fffc391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 15:08:45 +0100 Subject: [PATCH 20/34] Commit forgottem uncloud_vm migrations --- .../migrations/0007_auto_20200228_1344.py | 23 +++++++++++++++++++ .../migrations/0008_vmproduct_name.py | 18 +++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py create mode 100644 uncloud/uncloud_vm/migrations/0008_vmproduct_name.py diff --git a/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py b/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py new file mode 100644 index 0000000..8867f2f --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-02-28 13:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0006_merge_20200228_1303'), + ] + + operations = [ + migrations.AddField( + model_name='vmnetworkcard', + name='ip_address', + field=models.GenericIPAddressField(blank=True, null=True), + ), + migrations.AddField( + model_name='vmproduct', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0008_vmproduct_name.py b/uncloud/uncloud_vm/migrations/0008_vmproduct_name.py new file mode 100644 index 0000000..75ff7d0 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0008_vmproduct_name.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-28 14:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0007_auto_20200228_1344'), + ] + + operations = [ + migrations.AddField( + model_name='vmproduct', + name='name', + field=models.CharField(blank=True, max_length=32), + ), + ] From af1265003eea2521fac647adc9c1b01805b52d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 16:26:45 +0100 Subject: [PATCH 21/34] Define custom fields and serializer for MatrixServiceProduct --- uncloud/uncloud_pay/models.py | 4 ++++ uncloud/uncloud_vm/serializers.py | 4 ++-- uncloud/ungleich_service/models.py | 6 ++++++ uncloud/ungleich_service/serializers.py | 6 +++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 8e41e24..f5639c4 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -163,5 +163,9 @@ class Product(models.Model): def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): pass # To be implemented in child. + @property + def setup_fee(self): + return 0 + class Meta: abstract = True diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index cb60cfe..4257a03 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -12,9 +12,9 @@ class VMHostSerializer(serializers.HyperlinkedModelSerializer): class VMProductSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VMProduct - fields = ['uuid', 'description', 'order', 'owner', 'status', 'name', \ + fields = ['uuid', 'order', 'owner', 'status', 'name', \ 'cores', 'ram_in_gb'] - read_only_fields = ['uuid', 'description', 'order', 'owner', 'status'] + read_only_fields = ['uuid', 'order', 'owner', 'status'] class VMSnapshotProductSerializer(serializers.ModelSerializer): class Meta: diff --git a/uncloud/ungleich_service/models.py b/uncloud/ungleich_service/models.py index ac1f90e..0e84f62 100644 --- a/uncloud/ungleich_service/models.py +++ b/uncloud/ungleich_service/models.py @@ -9,12 +9,18 @@ class MatrixServiceProduct(Product): setup_fee = 30 description = "Managed Matrix HomeServer" + + # Specific to Matrix-as-a-Service vm = models.ForeignKey( VMProduct, on_delete=models.CASCADE ) + domain = models.CharField(max_length=255, default='domain.tld') def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): if recurring_period == RecurringPeriod.PER_MONTH: return monthly_managment_fee + vm.recurring_price(RecurringPeriod.PER_MONTH) else: raise Exception('Invalid recurring period for VM Product pricing.') + + def setup_fee(self): + return setup_fee diff --git a/uncloud/ungleich_service/serializers.py b/uncloud/ungleich_service/serializers.py index 54737e9..ffd206f 100644 --- a/uncloud/ungleich_service/serializers.py +++ b/uncloud/ungleich_service/serializers.py @@ -1,7 +1,11 @@ from rest_framework import serializers from .models import MatrixServiceProduct +from uncloud_vm.serializers import VMProductSerializer class MatrixServiceProductSerializer(serializers.ModelSerializer): + vm = VMProductSerializer() + class Meta: model = MatrixServiceProduct - fields = '__all__' + fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain'] + read_only_fields = ['uuid', 'order', 'owner', 'status'] From e319d1d151f17d257527ce50fa7faa5a7f734e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sat, 29 Feb 2020 09:08:30 +0100 Subject: [PATCH 22/34] WIP revamped bill logic --- uncloud/uncloud_pay/helpers.py | 51 ++++++++++++++- .../management/commands/generate-bills.py | 59 +++++------------ uncloud/uncloud_pay/models.py | 64 ++++++++++++------- .../migrations/0009_auto_20200228_1416.py | 18 ++++++ .../0002_matrixserviceproduct_domain.py | 18 ++++++ uncloud/ungleich_service/serializers.py | 7 ++ uncloud/ungleich_service/views.py | 4 +- 7 files changed, 152 insertions(+), 69 deletions(-) create mode 100644 uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py create mode 100644 uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index 8ca82aa..248fbb4 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -1,7 +1,10 @@ from functools import reduce +from datetime import datetime from rest_framework import mixins from rest_framework.viewsets import GenericViewSet -from .models import Bill, Payment, PaymentMethod +from django.db.models import Q +from .models import Bill, Payment, PaymentMethod, Order +from django.utils import timezone def sum_amounts(entries): return reduce(lambda acc, entry: acc + entry.amount, entries, 0) @@ -20,6 +23,52 @@ def get_payment_method_for(user): return None +def beginning_of_month(date): + return datetime(year=date.year, date=now.month, day=0) + +def generate_bills_for(year, month, user, allowed_delay): + # /!\ We exclusively work on the specified year and month. + + # Default values for next bill (if any). Only saved at the end of + # this method, if relevant. + tz = timezone.get_current_timezone() + next_bill = Bill(owner=user, + starting_date=datetime(year=year, month=month, day=1, tzinfo=tz), + ending_date=datetime(year=year, month=month, day=28, tzinfo=tz), + creation_date=timezone.now(), + due_date=timezone.now() + allowed_delay) + + # Select all orders active on the request period. + orders = Order.objects.filter( + Q(ending_date__gt=next_bill.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 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) + + # Commit next_bill if it there are 'unpaid' orders. + if len(unpaid_orders) > 0: + next_bill.save() + + # 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) + + return next_bill + + # Return None if no bill was created. class ProductViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud/uncloud_pay/management/commands/generate-bills.py index aad7a82..34432d5 100644 --- a/uncloud/uncloud_pay/management/commands/generate-bills.py +++ b/uncloud/uncloud_pay/management/commands/generate-bills.py @@ -1,67 +1,38 @@ +import logging + from django.core.management.base import BaseCommand from uncloud_auth.models import User from uncloud_pay.models import Order, Bill from django.core.exceptions import ObjectDoesNotExist -from datetime import timedelta +from datetime import timedelta, date from django.utils import timezone +from uncloud_pay.helpers import generate_bills_for BILL_PAYMENT_DELAY=timedelta(days=10) +logger = logging.getLogger(__name__) + class Command(BaseCommand): help = 'Generate bills and charge customers if necessary.' def add_arguments(self, parser): pass + # TODO: use logger.* def handle(self, *args, **options): + # Iterate over all 'active' users. + # TODO: filter out inactive users. users = User.objects.all() print("Processing {} users.".format(users.count())) for user in users: - # Fetch all the orders of a customer. - orders = Order.objects.filter(owner=user) - - # Default values for next bill (if any). Only saved at the end of - # this method, if relevant. - next_bill = Bill(owner=user, - starting_date=timezone.now(), # Will be set to oldest unpaid order (means unpaid starting date). - ending_date=timezone.now(), # Bill covers everything until today. - due_date=timezone.now() + BILL_PAYMENT_DELAY) - - unpaid_orders = [] # Store orders in need of a payment. - for order in orders: - # Only bill if there is an 'unpaid period' on an active order. - # XXX: Assume everything before latest bill is paid. => might be dangerous. - try: - previous_bill = order.bill.latest('ending_date') - except ObjectDoesNotExist: - previous_bill = None - - is_unpaid_period = True - if order.ending_date and previous_bill != None: - is_unpaid_period = previous_bill.ending_date < order.ending_date - - if is_unpaid_period: - # Update bill starting date to match period. - if previous_bill == None: - next_bill.starting_date = order.starting_date - elif previous_bill.ending_date < next_bill.starting_date: - next_bill.starting_date = previous_bill.ending_date - - # Add order to bill - unpaid_orders.append(order) - - # Save next_bill if it contains any unpaid product. - if len(unpaid_orders) > 0: - next_bill.save() - - # 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) - - print("Created bill {} for user {}".format(next_bill.uuid, user.username)) + now = timezone.now() + generate_bills_for( + year=now.year, + month=now.month, + user=user, + allowed_delay=BILL_PAYMENT_DELAY) # We're done for this round :-) print("=> Done.") diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index f5639c4..f9e7c35 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -2,6 +2,7 @@ from django.db import models from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator from django.utils.translation import gettext_lazy as _ +from django.utils import timezone import uuid @@ -31,16 +32,49 @@ class Bill(models.Model): valid = models.BooleanField(default=True) @property - def amount(self): - orders = Order.objects.filter(bill=self) - amount = 0 - for order in orders: - amount += order.recurring_price - - return amount + def entries(self): + # TODO: return list of Bill entries, extract from linked order + # for each related order + # for each product + # build BillEntry + return [] + @property + def total(self): + #return helpers.sum_amounts(self.entries) + pass + +class BillEntry(): + start_date = timezone.now() + end_date = timezone.now() + recurring_period = RecurringPeriod.PER_MONTH + recurring_price = 0 + amount = 0 + description = "" +# /!\ BIG FAT WARNING /!\ # +# +# Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating +# bills. Do **NOT** mutate then! +# +# Why? We need to store the state somewhere since product are mutable (e.g. +# adding RAM to VM, changing price of 1GB of RAM, ...). An alternative could +# have been to only store the state in bills but would have been more +# confusing: the order is a 'contract' with the customer, were both parts +# agree on deal => That's what we want to keep archived. +# +# SOON: +# +# We'll need to add some kind of OrderEntry table (each order might have +# multiple entries) storing: recurring_price, recurring_period, setup_fee, description +# +# FOR NOW: +# +# We dynamically get pricing from linked product, as they are not updated in +# this stage of development. +# +# /!\ BIG FAT WARNING /!\ # class Order(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), @@ -56,22 +90,11 @@ class Order(models.Model): editable=False, blank=True) - - recurring_price = models.DecimalField( - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)], - editable=False) - one_time_price = models.DecimalField( - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)], - editable=False) - recurring_period = models.CharField(max_length=32, choices = RecurringPeriod.choices, default = RecurringPeriod.PER_MONTH) + # def amount(self): # amount = recurring_price # if recurring and first_month: @@ -133,9 +156,6 @@ class Payment(models.Model): default='unknown') timestamp = models.DateTimeField(editable=False, auto_now_add=True) - - - class Product(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), diff --git a/uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py b/uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py new file mode 100644 index 0000000..e29bfe9 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-28 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0008_vmproduct_name'), + ] + + operations = [ + migrations.AlterField( + model_name='vmproduct', + name='name', + field=models.CharField(max_length=32), + ), + ] diff --git a/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py b/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py new file mode 100644 index 0000000..fda0075 --- /dev/null +++ b/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-28 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ungleich_service', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='matrixserviceproduct', + name='domain', + field=models.CharField(default='domain.tld', max_length=255), + ), + ] diff --git a/uncloud/ungleich_service/serializers.py b/uncloud/ungleich_service/serializers.py index ffd206f..0c34dcf 100644 --- a/uncloud/ungleich_service/serializers.py +++ b/uncloud/ungleich_service/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from .models import MatrixServiceProduct from uncloud_vm.serializers import VMProductSerializer +from uncloud_vm.models import VMProduct class MatrixServiceProductSerializer(serializers.ModelSerializer): vm = VMProductSerializer() @@ -9,3 +10,9 @@ class MatrixServiceProductSerializer(serializers.ModelSerializer): model = MatrixServiceProduct fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain'] read_only_fields = ['uuid', 'order', 'owner', 'status'] + + def create(self, validated_data): + # Create VM + vm_data = validated_data.pop('vm') + vm = VMProduct.objects.create(**vm_data) + return MatrixServiceProduct.create(vm=vm, **validated_data) diff --git a/uncloud/ungleich_service/views.py b/uncloud/ungleich_service/views.py index 9c27df8..a8de2e0 100644 --- a/uncloud/ungleich_service/views.py +++ b/uncloud/ungleich_service/views.py @@ -13,5 +13,5 @@ class MatrixServiceProductViewSet(ProductViewSet): return MatrixServiceProduct.objects.filter(owner=self.request.user) def create(self, request): - # TODO - pass + # TODO: create order, register service + return Response('{"HIT!"}') From be2b0a88550f1212b08fbccf84ef81594cc40699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sun, 1 Mar 2020 12:23:04 +0100 Subject: [PATCH 23/34] Fix a few errors on preview billing rework Another WIP commit to sync with laptop, do not forget to rebase! --- uncloud/uncloud_pay/helpers.py | 2 ++ .../migrations/0011_auto_20200229_1459.py | 21 +++++++++++++++++++ uncloud/uncloud_pay/serializers.py | 2 +- uncloud/uncloud_vm/views.py | 8 +------ 4 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0011_auto_20200229_1459.py diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index 248fbb4..aaa1e11 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -5,6 +5,8 @@ from rest_framework.viewsets import GenericViewSet from django.db.models import Q from .models import Bill, Payment, PaymentMethod, Order from django.utils import timezone +from django.core.exceptions import ObjectDoesNotExist +from dateutil.relativedelta import relativedelta def sum_amounts(entries): return reduce(lambda acc, entry: acc + entry.amount, entries, 0) diff --git a/uncloud/uncloud_pay/migrations/0011_auto_20200229_1459.py b/uncloud/uncloud_pay/migrations/0011_auto_20200229_1459.py new file mode 100644 index 0000000..e4edbb0 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0011_auto_20200229_1459.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.3 on 2020-02-29 14:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0010_merge_20200228_1303'), + ] + + operations = [ + migrations.RemoveField( + model_name='order', + name='one_time_price', + ), + migrations.RemoveField( + model_name='order', + name='recurring_price', + ), + ] diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 3b8cc47..eeab444 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -10,7 +10,7 @@ from uncloud_vm.models import VMProduct class BillSerializer(serializers.ModelSerializer): class Meta: model = Bill - fields = ['owner', 'amount', 'due_date', 'creation_date', + fields = ['owner', 'total', 'due_date', 'creation_date', 'starting_date', 'ending_date'] class PaymentSerializer(serializers.ModelSerializer): diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index c3704e1..2dec2ae 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -31,21 +31,15 @@ class VMProductViewSet(ProductViewSet): # Create base order. order = Order.objects.create( recurring_period=RecurringPeriod.PER_MONTH, - recurring_price=0, - one_time_price=0, owner=request.user ) + order.save() # Create VM. serializer = VMProductSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) vm = serializer.save(owner=request.user, order=order) - # FIXME: commit everything (VM + order) at once. - order.recurring_price = vm.recurring_price(order.recurring_period) - order.one_time_price = 0 - order.save() - return Response(serializer.data) From 4f25086a63409700b4fffd872c17b93b3733e122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sun, 1 Mar 2020 15:47:27 +0100 Subject: [PATCH 24/34] Only generate bill if no overlap --- uncloud/uncloud_pay/helpers.py | 24 +++++++++++++++++------- uncloud/uncloud_pay/models.py | 17 ++++++++++------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index aaa1e11..9f775b7 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -6,7 +6,7 @@ from django.db.models import Q from .models import Bill, Payment, PaymentMethod, Order from django.utils import timezone from django.core.exceptions import ObjectDoesNotExist -from dateutil.relativedelta import relativedelta +from calendar import monthrange def sum_amounts(entries): return reduce(lambda acc, entry: acc + entry.amount, entries, 0) @@ -25,18 +25,24 @@ def get_payment_method_for(user): return None -def beginning_of_month(date): - return datetime(year=date.year, date=now.month, day=0) +def beginning_of_month(year, month): + tz = timezone.get_current_timezone() + return datetime(year=year, month=month, day=1, tzinfo=tz) + +def end_of_month(year, month): + (_, days) = monthrange(year, month) + tz = timezone.get_current_timezone() + return datetime(year=year, month=month, day=days, + hour=23, minute=59, second=59, tzinfo=tz) def generate_bills_for(year, month, user, allowed_delay): # /!\ We exclusively work on the specified year and month. # Default values for next bill (if any). Only saved at the end of # this method, if relevant. - tz = timezone.get_current_timezone() next_bill = Bill(owner=user, - starting_date=datetime(year=year, month=month, day=1, tzinfo=tz), - ending_date=datetime(year=year, month=month, day=28, tzinfo=tz), + starting_date=beginning_of_month(year, month), + ending_date=end_of_month(year, month), creation_date=timezone.now(), due_date=timezone.now() + allowed_delay) @@ -52,7 +58,7 @@ def generate_bills_for(year, month, user, allowed_delay): unpaid_orders = [] for order in orders: try: - previous_bill = order.bill.latest('-ending_date') + previous_bill = order.bill.latest('ending_date') except ObjectDoesNotExist: previous_bill = None @@ -68,6 +74,10 @@ def generate_bills_for(year, month, user, allowed_delay): for order in unpaid_orders: order.bill.add(next_bill) + # TODO: use logger. + print("Generated bill {} (amount: {}) for user {}." + .format(next_bill.uuid, next_bill.total, user)) + return next_bill # Return None if no bill was created. diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index f9e7c35..8d4f14c 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -1,4 +1,5 @@ from django.db import models +from functools import reduce from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator from django.utils.translation import gettext_lazy as _ @@ -41,8 +42,8 @@ class Bill(models.Model): @property def total(self): - #return helpers.sum_amounts(self.entries) - pass + orders = Order.objects.filter(bill=self) + return reduce(lambda acc, order: acc + order.amount, orders, 0) class BillEntry(): start_date = timezone.now() @@ -95,12 +96,14 @@ class Order(models.Model): default = RecurringPeriod.PER_MONTH) - # def amount(self): - # amount = recurring_price - # if recurring and first_month: - # amount += one_time_price + @property + def amount(self): + # amount = recurring_price + # if recurring and first_month: + # amount += one_time_price - # return amount # you get the picture + amount=1 + return amount class PaymentMethod(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) From 8e41b894c030ad549b8130c3eeec873005a44ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Mon, 2 Mar 2020 08:09:42 +0100 Subject: [PATCH 25/34] Add OrderRecord model --- .../migrations/0012_orderrecord.py | 25 +++++++++++++++++++ uncloud/uncloud_pay/models.py | 23 +++++++++++++---- 2 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0012_orderrecord.py diff --git a/uncloud/uncloud_pay/migrations/0012_orderrecord.py b/uncloud/uncloud_pay/migrations/0012_orderrecord.py new file mode 100644 index 0000000..7c655e4 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0012_orderrecord.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.3 on 2020-03-01 16:04 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0011_auto_20200229_1459'), + ] + + operations = [ + migrations.CreateModel( + name='OrderRecord', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('setup_fee', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), + ('recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), + ('description', models.TextField()), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), + ], + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 8d4f14c..2862940 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -98,12 +98,25 @@ class Order(models.Model): @property def amount(self): - # amount = recurring_price - # if recurring and first_month: - # amount += one_time_price + records = OrderRecord.objects.filter(order=self) + return 1 - amount=1 - return amount +class OrderRecord(models.Model): + order = models.ForeignKey(Order, on_delete=models.CASCADE) + setup_fee = models.DecimalField(default=0.0, + max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS, + validators=[MinValueValidator(0)]) + recurring_price = models.DecimalField(default=0.0, + max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS, + validators=[MinValueValidator(0)]) + + description = models.TextField() + + @property + def recurring_period(self): + return self.order.recurring_period class PaymentMethod(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) From 81bd54116a8d6d078a22d20df19aedbbc5cf3177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Mon, 2 Mar 2020 09:25:03 +0100 Subject: [PATCH 26/34] Add records to orders --- uncloud/uncloud_pay/models.py | 28 +++++++++++++++------------- uncloud/uncloud_pay/serializers.py | 9 ++++++++- uncloud/uncloud_vm/models.py | 6 ++++-- uncloud/uncloud_vm/views.py | 7 +++++++ 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 2862940..8b19c37 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -65,16 +65,6 @@ class BillEntry(): # confusing: the order is a 'contract' with the customer, were both parts # agree on deal => That's what we want to keep archived. # -# SOON: -# -# We'll need to add some kind of OrderEntry table (each order might have -# multiple entries) storing: recurring_price, recurring_period, setup_fee, description -# -# FOR NOW: -# -# We dynamically get pricing from linked product, as they are not updated in -# this stage of development. -# # /!\ BIG FAT WARNING /!\ # class Order(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -95,11 +85,23 @@ class Order(models.Model): choices = RecurringPeriod.choices, default = RecurringPeriod.PER_MONTH) + @property + def records(self): + return OrderRecord.objects.filter(order=self) @property - def amount(self): - records = OrderRecord.objects.filter(order=self) - return 1 + def setup_fee(self): + return reduce(lambda acc, record: acc + record.setup_fee, self.records, 0) + + @property + def recurring_price(self): + return reduce(lambda acc, record: acc + record.recurring_price, self.records, 0) + + def add_record(self, setup_fee, recurring_price, description): + OrderRecord.objects.create(order=self, + setup_fee=setup_fee, + recurring_price=recurring_price, + description=description) class OrderRecord(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index eeab444..83eebb6 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -26,10 +26,17 @@ class PaymentMethodSerializer(serializers.ModelSerializer): class ProductSerializer(serializers.Serializer): vms = VMProductSerializer(many=True, read_only=True) +class OrderRecordSerializer(serializers.ModelSerializer): + class Meta: + model = OrderRecord + fields = ['setup_fee', 'recurring_price', 'description'] + class OrderSerializer(serializers.ModelSerializer): + records = OrderRecordSerializer(many=True, read_only=True) class Meta: model = Order - fields = '__all__' + fields = ['uuid', 'creation_date', 'starting_date', 'ending_date', + 'bill', 'recurring_period', 'records', 'recurring_price', 'setup_fee'] class UserSerializer(serializers.ModelSerializer): class Meta: diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index be1178e..c32f3a5 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -40,8 +40,6 @@ class VMProduct(Product): blank=True, null=True) - description = "Virtual Machine" - # VM-specific. The name is only intended for customers: it's a pain te # remember IDs (speaking from experience as ungleich customer)! name = models.CharField(max_length=32) @@ -55,6 +53,10 @@ class VMProduct(Product): else: raise Exception('Invalid recurring period for VM Product pricing.') + @property + def description(self): + return "Virtual machine '{}': {} core(s), {}GB memory".format( + self.name, self.cores, self.ram_in_gb) class VMWithOSProduct(VMProduct): pass diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 2dec2ae..5eeec7b 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -28,6 +28,9 @@ class VMProductViewSet(ProductViewSet): return VMProduct.objects.filter(owner=self.request.user) def create(self, request): + # TODO: what if something blows-up midway? + # => need a transaction + # Create base order. order = Order.objects.create( recurring_period=RecurringPeriod.PER_MONTH, @@ -40,6 +43,10 @@ class VMProductViewSet(ProductViewSet): serializer.is_valid(raise_exception=True) vm = serializer.save(owner=request.user, order=order) + # Add Product record to order (VM is mutable, allows to keep history in order). + order.add_record(vm.setup_fee, + vm.recurring_price(order.recurring_period), vm.description) + return Response(serializer.data) From 9e253d497bfcdb41dc54df94ad9d85c55b554492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Mon, 2 Mar 2020 09:30:51 +0100 Subject: [PATCH 27/34] Wrap VM creation in database transaction --- uncloud/uncloud_vm/views.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 5eeec7b..5de904c 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -1,3 +1,4 @@ +from django.db import transaction from django.shortcuts import render from django.contrib.auth.models import User @@ -27,10 +28,10 @@ class VMProductViewSet(ProductViewSet): def get_queryset(self): return VMProduct.objects.filter(owner=self.request.user) + # Use a database transaction so that we do not get half-created structure + # if something goes wrong. + @transaction.atomic def create(self, request): - # TODO: what if something blows-up midway? - # => need a transaction - # Create base order. order = Order.objects.create( recurring_period=RecurringPeriod.PER_MONTH, From 9e9018060efac5e6536965222d44dd5a02e876fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Mon, 2 Mar 2020 10:46:04 +0100 Subject: [PATCH 28/34] Wire order records to bills, fix user balance --- uncloud/uncloud_pay/helpers.py | 13 +++++---- uncloud/uncloud_pay/models.py | 44 ++++++++++++++++++++---------- uncloud/uncloud_pay/serializers.py | 10 ++++++- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index 9f775b7..b4216f6 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -8,12 +8,15 @@ from django.utils import timezone from django.core.exceptions import ObjectDoesNotExist from calendar import monthrange -def sum_amounts(entries): - return reduce(lambda acc, entry: acc + entry.amount, entries, 0) - def get_balance_for(user): - bills = sum_amounts(Bill.objects.filter(owner=user)) - payments = sum_amounts(Payment.objects.filter(owner=user)) + bills = reduce( + lambda acc, entry: acc + entry.total, + Bill.objects.filter(owner=user), + 0) + payments = reduce( + lambda acc, entry: acc + entry.amount, + Payment.objects.filter(owner=user), + 0) return payments - bills def get_payment_method_for(user): diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 8b19c37..e257b9e 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -33,26 +33,40 @@ class Bill(models.Model): valid = models.BooleanField(default=True) @property - def entries(self): - # TODO: return list of Bill entries, extract from linked order - # for each related order - # for each product - # build BillEntry - return [] + def records(self): + bill_records = [] + orders = Order.objects.filter(bill=self) + for order in orders: + for order_record in order.records: + bill_record = BillRecord( + self, + order_record.setup_fee, + order_record.recurring_price, + order_record.recurring_period, + order_record.description) + bill_records.append(bill_record) + + return bill_records @property def total(self): - orders = Order.objects.filter(bill=self) - return reduce(lambda acc, order: acc + order.amount, orders, 0) + return reduce(lambda acc, record: acc + record.amount(), self.records, 0) -class BillEntry(): - start_date = timezone.now() - end_date = timezone.now() - recurring_period = RecurringPeriod.PER_MONTH - recurring_price = 0 - amount = 0 - description = "" +class BillRecord(): + def __init__(self, bill, setup_fee, recurring_price, recurring_period, description): + self.bill = bill + self.setup_fee = setup_fee + self.recurring_price = recurring_price + self.recurring_period = recurring_period + self.description = description + def amount(self): + # TODO: Billing logic here! + if self.recurring_period == RecurringPeriod.PER_MONTH: + return self.recurring_price # TODO + else: + raise Exception('Unsupported recurring period: {}.'. + format(record.recurring_period)) # /!\ BIG FAT WARNING /!\ # # diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 83eebb6..976ab6b 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -7,11 +7,19 @@ from functools import reduce from uncloud_vm.serializers import VMProductSerializer from uncloud_vm.models import VMProduct +# TODO: remove magic numbers for decimal fields +class BillRecordSerializer(serializers.Serializer): + description = serializers.CharField() + recurring_period = serializers.CharField() + recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2) + amount = serializers.DecimalField(max_digits=10, decimal_places=2) + class BillSerializer(serializers.ModelSerializer): + records = BillRecordSerializer(many=True, read_only=True) class Meta: model = Bill fields = ['owner', 'total', 'due_date', 'creation_date', - 'starting_date', 'ending_date'] + 'starting_date', 'ending_date', 'records'] class PaymentSerializer(serializers.ModelSerializer): class Meta: From c651c4ddaa7aca5b6e48aefb2a33520ed7a09201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Mon, 2 Mar 2020 16:41:49 +0100 Subject: [PATCH 29/34] Cleanup a bit BillRecord --- uncloud/uncloud_pay/models.py | 19 +++++++------------ uncloud/uncloud_pay/serializers.py | 1 + 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index e257b9e..9cbeb48 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -38,12 +38,7 @@ class Bill(models.Model): orders = Order.objects.filter(bill=self) for order in orders: for order_record in order.records: - bill_record = BillRecord( - self, - order_record.setup_fee, - order_record.recurring_price, - order_record.recurring_period, - order_record.description) + bill_record = BillRecord(order_record) bill_records.append(bill_record) return bill_records @@ -53,12 +48,12 @@ class Bill(models.Model): return reduce(lambda acc, record: acc + record.amount(), self.records, 0) class BillRecord(): - def __init__(self, bill, setup_fee, recurring_price, recurring_period, description): - self.bill = bill - self.setup_fee = setup_fee - self.recurring_price = recurring_price - self.recurring_period = recurring_period - self.description = description + def __init__(self, order_record): + self.order = order_record.order.uuid + self.setup_fee = order_record.setup_fee + self.recurring_price = order_record.recurring_price + self.recurring_period = order_record.recurring_period + self.description = order_record.description def amount(self): # TODO: Billing logic here! diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 976ab6b..e3ac0eb 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -9,6 +9,7 @@ from uncloud_vm.models import VMProduct # TODO: remove magic numbers for decimal fields class BillRecordSerializer(serializers.Serializer): + order = serializers.CharField() description = serializers.CharField() recurring_period = serializers.CharField() recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2) From 4ad737ed904a508601808a4623152dc76395471f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Mon, 2 Mar 2020 22:26:40 +0100 Subject: [PATCH 30/34] Initial stripe playground --- uncloud/uncloud/secrets_sample.py | 3 + uncloud/uncloud/settings.py | 5 ++ .../0013_paymentmethod_stripe_card_id.py | 18 ++++++ uncloud/uncloud_pay/models.py | 5 +- uncloud/uncloud_pay/serializers.py | 29 ++++++++- uncloud/{uncloud => uncloud_pay}/stripe.py | 59 +++++++++++++++++++ uncloud/uncloud_pay/views.py | 8 ++- 7 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0013_paymentmethod_stripe_card_id.py rename uncloud/{uncloud => uncloud_pay}/stripe.py (55%) diff --git a/uncloud/uncloud/secrets_sample.py b/uncloud/uncloud/secrets_sample.py index 36ff0df..464662f 100644 --- a/uncloud/uncloud/secrets_sample.py +++ b/uncloud/uncloud/secrets_sample.py @@ -14,4 +14,7 @@ LDAP_ADMIN_DN="" LDAP_ADMIN_PASSWORD="" LDAP_SERVER_URI = "" +# Stripe (Credit Card payments) +STRIPE_API_key="" + SECRET_KEY="dx$iqt=lc&yrp^!z5$ay^%g5lhx1y3bcu=jg(jx0yj0ogkfqvf" diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index c6c89d5..f28e0f4 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -176,3 +176,8 @@ USE_TZ = True STATIC_URL = '/static/' stripe.api_key = uncloud.secrets.STRIPE_KEY + +############ +# Stripe + +STRIPE_API_KEY = uncloud.secrets.STRIPE_API_KEY diff --git a/uncloud/uncloud_pay/migrations/0013_paymentmethod_stripe_card_id.py b/uncloud/uncloud_pay/migrations/0013_paymentmethod_stripe_card_id.py new file mode 100644 index 0000000..df7c065 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0013_paymentmethod_stripe_card_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-02 20:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0012_orderrecord'), + ] + + operations = [ + migrations.AddField( + model_name='paymentmethod', + name='stripe_card_id', + field=models.CharField(blank=True, max_length=32, null=True), + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 9cbeb48..a29dc3c 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -143,10 +143,13 @@ class PaymentMethod(models.Model): description = models.TextField() primary = models.BooleanField(default=True) + # Only used for "Stripe" source + stripe_card_id = models.CharField(max_length=32, blank=True, null=True) + def charge(self, amount): if amount > 0: # Make sure we don't charge negative amount by errors... if self.source == 'stripe': - # TODO: wire to strip, see meooow-payv1/strip_utils.py + # TODO: wire to stripe, see meooow-payv1/strip_utils.py payment = Payment(owner=self.owner, source=self.source, amount=amount) payment.save() # TODO: Check return status diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index e3ac0eb..6c6c04e 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -7,6 +7,8 @@ from functools import reduce from uncloud_vm.serializers import VMProductSerializer from uncloud_vm.models import VMProduct +import uncloud_pay.stripe as stripe + # TODO: remove magic numbers for decimal fields class BillRecordSerializer(serializers.Serializer): order = serializers.CharField() @@ -27,10 +29,35 @@ class PaymentSerializer(serializers.ModelSerializer): model = Payment fields = ['owner', 'amount', 'source', 'timestamp'] +class CreditCardSerializer(serializers.Serializer): + number = serializers.IntegerField() + exp_month = serializers.IntegerField() + exp_year = serializers.IntegerField() + cvc = serializers.IntegerField() + class PaymentMethodSerializer(serializers.ModelSerializer): class Meta: model = PaymentMethod - fields = '__all__' + fields = ['source', 'description', 'primary'] + +class CreatePaymentMethodSerializer(serializers.ModelSerializer): + credit_card = CreditCardSerializer() + + class Meta: + model = PaymentMethod + fields = ['source', 'description', 'primary', 'credit_card'] + + def create(self, validated_data): + credit_card = stripe.CreditCard(**validated_data.pop('credit_card')) + user = self.context['request'].user + customer = stripe.create_customer(user.username, user.email) + # TODO check customer error + customer_id = customer['response_object']['id'] + stripe_card = stripe.create_card(customer_id, credit_card) + # TODO: check credit card error + validated_data['stripe_card_id'] = stripe_card['response_object']['id'] + payment_method = PaymentMethod.objects.create(**validated_data) + return payment_method class ProductSerializer(serializers.Serializer): vms = VMProductSerializer(many=True, read_only=True) diff --git a/uncloud/uncloud/stripe.py b/uncloud/uncloud_pay/stripe.py similarity index 55% rename from uncloud/uncloud/stripe.py rename to uncloud/uncloud_pay/stripe.py index ce35fd9..6399a1a 100644 --- a/uncloud/uncloud/stripe.py +++ b/uncloud/uncloud_pay/stripe.py @@ -1,5 +1,16 @@ import stripe +import stripe.error +import logging +from django.conf import settings + +# Static stripe configuration used below. +CURRENCY = 'chf' + +# Register stripe (secret) API key from config. +stripe.api_key = settings.STRIPE_API_KEY + +# Helper (decorator) used to catch errors raised by stripe logic. def handle_stripe_error(f): def handle_problems(*args, **kwargs): response = { @@ -53,3 +64,51 @@ def handle_stripe_error(f): return response return handle_problems + +# Convenience CC container, also used for serialization. +class CreditCard(): + number = None + exp_year = None + exp_month = None + cvc = None + + def __init__(self, number, exp_month, exp_year, cvc): + self.number=number + self.exp_year = exp_year + self.exp_month = exp_month + self.cvc = cvc + +# Actual Stripe logic. + +@handle_stripe_error +def create_card(customer_id, credit_card): + # Test settings + credit_card.number = "5555555555554444" + + return stripe.Customer.create_source( + customer_id, + card={ + 'number': credit_card.number, + 'exp_month': credit_card.exp_month, + 'exp_year': credit_card.exp_year, + 'cvc': credit_card.cvc + }) + +@handle_stripe_error +def get_card(customer_id, card_id): + return stripe.Card.retrieve_source(customer_id, card_id) + +@handle_stripe_error +def charge_customer(amount, source): + return stripe.Charge.create( + amount=amount, + currenty=CURRENCY, + source=source) + +@handle_stripe_error +def create_customer(name, email): + return stripe.Customer.create(name=name, email=email) + +@handle_stripe_error +def get_customer(customer_id): + return stripe.Customer.retrieve(customer_id) diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 9ed57c8..aaee9de 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -57,9 +57,15 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): return get_user_model().objects.all() class PaymentMethodViewSet(viewsets.ModelViewSet): - serializer_class = PaymentMethodSerializer permission_classes = [permissions.IsAuthenticated] + def get_serializer_class(self): + if self.action == 'create': + return CreatePaymentMethodSerializer + else: + return PaymentMethodSerializer + + def get_queryset(self): return PaymentMethod.objects.filter(owner=self.request.user) From 4e51670a901ac28bf2d1d0691caa984afb293b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 08:53:19 +0100 Subject: [PATCH 31/34] Expand recurring period billing logic for DD/MM/hh/month --- uncloud/uncloud_pay/models.py | 69 ++++++++++++++++++++++++++++-- uncloud/uncloud_pay/serializers.py | 2 +- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index a29dc3c..3be3c2c 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -4,6 +4,9 @@ from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator from django.utils.translation import gettext_lazy as _ from django.utils import timezone +from math import ceil +from datetime import timedelta +from calendar import monthrange import uuid @@ -38,7 +41,7 @@ class Bill(models.Model): orders = Order.objects.filter(bill=self) for order in orders: for order_record in order.records: - bill_record = BillRecord(order_record) + bill_record = BillRecord(self, order_record) bill_records.append(bill_record) return bill_records @@ -47,18 +50,66 @@ class Bill(models.Model): def total(self): return reduce(lambda acc, record: acc + record.amount(), self.records, 0) + @property + def final(self): + # A bill is final when its ending date is passed. + return self.ending_date < timezone.now() + class BillRecord(): - def __init__(self, order_record): - self.order = order_record.order.uuid + def __init__(self, bill, order_record): + self.bill = bill + self.order = order_record.order self.setup_fee = order_record.setup_fee self.recurring_price = order_record.recurring_price self.recurring_period = order_record.recurring_period self.description = order_record.description def amount(self): - # TODO: Billing logic here! + # Compute billing delta. + billed_until = self.bill.ending_date + if self.order.ending_date != None and self.order.ending_date < self.order.ending_date: + billed_until = self.order.ending_date + + billed_from = self.bill.starting_date + if self.order.starting_date > self.bill.starting_date: + billed_from = self.order.starting_date + + if billed_from > billed_until: + # TODO: think about and check edges cases. This should not be + # possible. + raise Exception('Impossible billing delta!') + + billed_delta = billed_until - billed_from + + # TODO: refactor this thing? + # TODO: weekly + # TODO: yearly if self.recurring_period == RecurringPeriod.PER_MONTH: + days = ceil(billed_delta / timedelta(days=1)) + + # XXX: we assume monthly bills for now. + 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.'. + format(self.bill.uuid)) + + # XXX: minumal length of monthly order is to be enforced somewhere else. + (_, days_in_month) = monthrange( + self.bill.starting_date.year, + self.bill.starting_date.month) + adjusted_recurring_price = self.recurring_price / days_in_month + recurring_price = adjusted_recurring_price * days + return self.recurring_price # TODO + elif self.recurring_period == RecurringPeriod.PER_DAY: + days = ceil(billed_delta / timedelta(days=1)) + return self.recurring_price * days + elif self.recurring_period == RecurringPeriod.PER_HOUR: + hours = ceil(billed_delta / timedelta(hours=1)) + return self.recurring_price * hours + elif self.recurring_period == RecurringPeriod.PER_SECOND: + seconds = ceil(billed_delta / timedelta(seconds=1)) + return self.recurring_price * seconds else: raise Exception('Unsupported recurring period: {}.'. format(record.recurring_period)) @@ -75,12 +126,14 @@ class BillRecord(): # agree on deal => That's what we want to keep archived. # # /!\ BIG FAT WARNING /!\ # + class Order(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False) + # 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) ending_date = models.DateTimeField(blank=True, @@ -129,6 +182,14 @@ class OrderRecord(models.Model): def recurring_period(self): return self.order.recurring_period + @property + def starting_date(self): + return self.order.starting_date + + @property + def ending_date(self): + return self.order.ending_date + class PaymentMethod(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 6c6c04e..d523b7a 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -22,7 +22,7 @@ class BillSerializer(serializers.ModelSerializer): class Meta: model = Bill fields = ['owner', 'total', 'due_date', 'creation_date', - 'starting_date', 'ending_date', 'records'] + 'starting_date', 'ending_date', 'records', 'final'] class PaymentSerializer(serializers.ModelSerializer): class Meta: From 5559d600c7f36e374a440fecd4d76a07ed58008d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 09:13:04 +0100 Subject: [PATCH 32/34] Move things around for readability in uncloud_pay models and serializer --- uncloud/uncloud_pay/models.py | 131 +++++++++++++++++------------ uncloud/uncloud_pay/serializers.py | 93 ++++++++++++++------ 2 files changed, 141 insertions(+), 83 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 3be3c2c..52e5281 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -10,6 +10,7 @@ from calendar import monthrange import uuid +# Define DecimalField properties, used to represent amounts of money. AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 @@ -23,6 +24,70 @@ class RecurringPeriod(models.TextChoices): PER_HOUR = 'HOUR', _('Per Hour') PER_SECOND = 'SECOND', _('Per Second') +### +# Payments and Payment Methods. + +class Payment(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE) + + amount = models.DecimalField( + default=0.0, + max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS, + validators=[MinValueValidator(0)]) + + source = models.CharField(max_length=256, + choices = ( + ('wire', 'Wire Transfer'), + ('stripe', 'Stripe'), + ('voucher', 'Voucher'), + ('referral', 'Referral'), + ('unknown', 'Unknown') + ), + default='unknown') + timestamp = models.DateTimeField(editable=False, auto_now_add=True) + +class PaymentMethod(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + source = models.CharField(max_length=256, + choices = ( + ('stripe', 'Stripe'), + ('unknown', 'Unknown'), + ), + default='stripe') + description = models.TextField() + primary = models.BooleanField(default=True) + + # Only used for "Stripe" source + stripe_card_id = models.CharField(max_length=32, blank=True, null=True) + + def charge(self, amount): + if amount > 0: # Make sure we don't charge negative amount by errors... + if self.source == 'stripe': + # TODO: wire to stripe, see meooow-payv1/strip_utils.py + payment = Payment(owner=self.owner, source=self.source, amount=amount) + payment.save() # TODO: Check return status + + return True + else: + # We do not handle that source yet. + return False + else: + return False + + class Meta: + unique_together = [['owner', 'primary']] + + +### +# Bills & Payments. + class Bill(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), @@ -56,6 +121,10 @@ class Bill(models.Model): return self.ending_date < timezone.now() class BillRecord(): + """ + Entry of a bill, dynamically generated from order records. + """ + def __init__(self, bill, order_record): self.bill = bill self.order = order_record.order @@ -114,6 +183,9 @@ class BillRecord(): raise Exception('Unsupported recurring period: {}.'. format(record.recurring_period)) +### +# Orders. + # /!\ BIG FAT WARNING /!\ # # # Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating @@ -190,63 +262,12 @@ class OrderRecord(models.Model): def ending_date(self): return self.order.ending_date -class PaymentMethod(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) - source = models.CharField(max_length=256, - choices = ( - ('stripe', 'Stripe'), - ('unknown', 'Unknown'), - ), - default='stripe') - description = models.TextField() - primary = models.BooleanField(default=True) - # Only used for "Stripe" source - stripe_card_id = models.CharField(max_length=32, blank=True, null=True) - - def charge(self, amount): - if amount > 0: # Make sure we don't charge negative amount by errors... - if self.source == 'stripe': - # TODO: wire to stripe, see meooow-payv1/strip_utils.py - payment = Payment(owner=self.owner, source=self.source, amount=amount) - payment.save() # TODO: Check return status - - return True - else: - # We do not handle that source yet. - return False - else: - return False - - class Meta: - unique_together = [['owner', 'primary']] - -class Payment(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE) - - amount = models.DecimalField( - default=0.0, - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)]) - - source = models.CharField(max_length=256, - choices = ( - ('wire', 'Wire Transfer'), - ('stripe', 'Stripe'), - ('voucher', 'Voucher'), - ('referral', 'Referral'), - ('unknown', 'Unknown') - ), - default='unknown') - timestamp = models.DateTimeField(editable=False, auto_now_add=True) +### +# Products +# Abstract (= no database representation) class used as parent for products +# (e.g. uncloud_vm.models.VMProduct). class Product(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index d523b7a..6e4b2d3 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -9,36 +9,62 @@ from uncloud_vm.models import VMProduct import uncloud_pay.stripe as stripe -# TODO: remove magic numbers for decimal fields -class BillRecordSerializer(serializers.Serializer): - order = serializers.CharField() - description = serializers.CharField() - recurring_period = serializers.CharField() - recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2) - amount = serializers.DecimalField(max_digits=10, decimal_places=2) +### +# Users. -class BillSerializer(serializers.ModelSerializer): - records = BillRecordSerializer(many=True, read_only=True) +class UserSerializer(serializers.ModelSerializer): class Meta: - model = Bill - fields = ['owner', 'total', 'due_date', 'creation_date', - 'starting_date', 'ending_date', 'records', 'final'] + model = get_user_model() + fields = ['username', 'email', 'balance'] + + # Display current 'balance' + balance = serializers.SerializerMethodField('get_balance') + def __sum_balance(self, entries): + return reduce(lambda acc, entry: acc + entry.amount, entries, 0) + + def get_balance(self, user): + return get_balance_for(user) + +### +# Payments and Payment Methods. class PaymentSerializer(serializers.ModelSerializer): class Meta: model = Payment fields = ['owner', 'amount', 'source', 'timestamp'] +class PaymentMethodSerializer(serializers.ModelSerializer): + class Meta: + model = PaymentMethod + fields = ['source', 'description', 'primary'] + class CreditCardSerializer(serializers.Serializer): number = serializers.IntegerField() exp_month = serializers.IntegerField() exp_year = serializers.IntegerField() cvc = serializers.IntegerField() -class PaymentMethodSerializer(serializers.ModelSerializer): +class CreatePaymentMethodSerializer(serializers.ModelSerializer): + credit_card = CreditCardSerializer() + class Meta: model = PaymentMethod - fields = ['source', 'description', 'primary'] + fields = ['source', 'description', 'primary', 'credit_card'] + + def create(self, validated_data): + credit_card = stripe.CreditCard(**validated_data.pop('credit_card')) + user = self.context['request'].user + customer = stripe.create_customer(user.username, user.email) + # TODO check customer error + customer_id = customer['response_object']['id'] + stripe_card = stripe.create_card(customer_id, credit_card) + # TODO: check credit card error + validated_data['stripe_card_id'] = stripe_card['response_object']['id'] +class CreditCardSerializer(serializers.Serializer): + number = serializers.IntegerField() + exp_month = serializers.IntegerField() + exp_year = serializers.IntegerField() + cvc = serializers.IntegerField() class CreatePaymentMethodSerializer(serializers.ModelSerializer): credit_card = CreditCardSerializer() @@ -58,15 +84,36 @@ class CreatePaymentMethodSerializer(serializers.ModelSerializer): validated_data['stripe_card_id'] = stripe_card['response_object']['id'] payment_method = PaymentMethod.objects.create(**validated_data) return payment_method + payment_method = PaymentMethod.objects.create(**validated_data) + return payment_method -class ProductSerializer(serializers.Serializer): - vms = VMProductSerializer(many=True, read_only=True) +### +# Bills + +# TODO: remove magic numbers for decimal fields +class BillRecordSerializer(serializers.Serializer): + order = serializers.CharField() + description = serializers.CharField() + recurring_period = serializers.CharField() + recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2) + amount = serializers.DecimalField(max_digits=10, decimal_places=2) + +class BillSerializer(serializers.ModelSerializer): + records = BillRecordSerializer(many=True, read_only=True) + class Meta: + model = Bill + fields = ['owner', 'total', 'due_date', 'creation_date', + 'starting_date', 'ending_date', 'records', 'final'] + +### +# Orders & Products. class OrderRecordSerializer(serializers.ModelSerializer): class Meta: model = OrderRecord fields = ['setup_fee', 'recurring_price', 'description'] + class OrderSerializer(serializers.ModelSerializer): records = OrderRecordSerializer(many=True, read_only=True) class Meta: @@ -74,15 +121,5 @@ class OrderSerializer(serializers.ModelSerializer): fields = ['uuid', 'creation_date', 'starting_date', 'ending_date', 'bill', 'recurring_period', 'records', 'recurring_price', 'setup_fee'] -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = get_user_model() - fields = ['username', 'email', 'balance'] - - # Display current 'balance' - balance = serializers.SerializerMethodField('get_balance') - def __sum_balance(self, entries): - return reduce(lambda acc, entry: acc + entry.amount, entries, 0) - - def get_balance(self, user): - return get_balance_for(user) +class ProductSerializer(serializers.Serializer): + vms = VMProductSerializer(many=True, read_only=True) From b31aa72f8405f72a4a00b86072d3f8bf8dc52996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 10:14:56 +0100 Subject: [PATCH 33/34] Allow to select billing period when registering VM --- uncloud/uncloud_pay/models.py | 4 ++++ uncloud/uncloud_vm/models.py | 4 +++- uncloud/uncloud_vm/serializers.py | 12 +++++++++++- uncloud/uncloud_vm/views.py | 10 +++++++--- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 52e5281..f4bd4f0 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -299,5 +299,9 @@ class Product(models.Model): def setup_fee(self): return 0 + @property + def recurring_period(self): + return self.order.recurring_period + class Meta: abstract = True diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index c32f3a5..7732964 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -47,9 +47,11 @@ class VMProduct(Product): ram_in_gb = models.FloatField() def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + # TODO: move magic numbers in variables if recurring_period == RecurringPeriod.PER_MONTH: - # TODO: move magic numbers in variables return self.cores * 3 + self.ram_in_gb * 2 + elif recurring_period == RecurringPeriod.PER_HOUR: + return self.cores * 4.0/(30 * 24) + self.ram_in_gb * 3.0/(30* 24) else: raise Exception('Invalid recurring period for VM Product pricing.') diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 4257a03..daf36ab 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import VMHost, VMProduct, VMSnapshotProduct +from uncloud_pay.models import RecurringPeriod class VMHostSerializer(serializers.HyperlinkedModelSerializer): class Meta: @@ -10,10 +11,19 @@ class VMHostSerializer(serializers.HyperlinkedModelSerializer): class VMProductSerializer(serializers.HyperlinkedModelSerializer): + # TODO: move this to VMProduct. + allowed_recurring_periods=list(filter( + lambda pair: pair[0] in [RecurringPeriod.PER_MONTH, RecurringPeriod.PER_HOUR], + RecurringPeriod.choices)) + + # Custom field used at creation (= ordering) only. + recurring_period = serializers.ChoiceField( + choices=allowed_recurring_periods) + class Meta: model = VMProduct fields = ['uuid', 'order', 'owner', 'status', 'name', \ - 'cores', 'ram_in_gb'] + 'cores', 'ram_in_gb', 'recurring_period'] read_only_fields = ['uuid', 'order', 'owner', 'status'] class VMSnapshotProductSerializer(serializers.ModelSerializer): diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 5de904c..107f23e 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -32,19 +32,23 @@ class VMProductViewSet(ProductViewSet): # if something goes wrong. @transaction.atomic def create(self, request): + # Extract serializer data. + serializer = VMProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + order_recurring_period = serializer.validated_data.pop("recurring_period") + # Create base order. order = Order.objects.create( - recurring_period=RecurringPeriod.PER_MONTH, + recurring_period=order_recurring_period, owner=request.user ) order.save() # Create VM. - serializer = VMProductSerializer(data=request.data, context={'request': request}) - serializer.is_valid(raise_exception=True) vm = serializer.save(owner=request.user, order=order) # Add Product record to order (VM is mutable, allows to keep history in order). + # XXX: Move this to some kind of on_create hook in parent Product class? order.add_record(vm.setup_fee, vm.recurring_price(order.recurring_period), vm.description) From 9fdf66ed744192a2d25164bdfec45719423b0420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 10:51:16 +0100 Subject: [PATCH 34/34] Fix MatrixService ordering --- uncloud/uncloud_pay/models.py | 4 +++ uncloud/uncloud_pay/views.py | 1 - uncloud/uncloud_vm/models.py | 6 ++++ uncloud/uncloud_vm/serializers.py | 16 +++++---- uncloud/ungleich_service/models.py | 12 +++++-- uncloud/ungleich_service/serializers.py | 17 +++++---- uncloud/ungleich_service/views.py | 48 +++++++++++++++++++++++-- 7 files changed, 83 insertions(+), 21 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index f4bd4f0..8964cb3 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -303,5 +303,9 @@ class Product(models.Model): def recurring_period(self): return self.order.recurring_period + @staticmethod + def allowed_recurring_periods(): + return RecurringPeriod.choices + class Meta: abstract = True diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index aaee9de..936d4c7 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -90,7 +90,6 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): else: return Response(status=status.HTTP_500_INTERNAL_ERROR) - ### # Admin views. diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 7732964..2f048ec 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -60,6 +60,12 @@ class VMProduct(Product): return "Virtual machine '{}': {} core(s), {}GB memory".format( self.name, self.cores, self.ram_in_gb) + @staticmethod + def allowed_recurring_periods(): + return list(filter( + lambda pair: pair[0] in [RecurringPeriod.PER_MONTH, RecurringPeriod.PER_HOUR], + RecurringPeriod.choices)) + class VMWithOSProduct(VMProduct): pass diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index daf36ab..490a8d2 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -11,14 +11,9 @@ class VMHostSerializer(serializers.HyperlinkedModelSerializer): class VMProductSerializer(serializers.HyperlinkedModelSerializer): - # TODO: move this to VMProduct. - allowed_recurring_periods=list(filter( - lambda pair: pair[0] in [RecurringPeriod.PER_MONTH, RecurringPeriod.PER_HOUR], - RecurringPeriod.choices)) - # Custom field used at creation (= ordering) only. recurring_period = serializers.ChoiceField( - choices=allowed_recurring_periods) + choices=VMProduct.allowed_recurring_periods()) class Meta: model = VMProduct @@ -26,6 +21,15 @@ class VMProductSerializer(serializers.HyperlinkedModelSerializer): 'cores', 'ram_in_gb', 'recurring_period'] read_only_fields = ['uuid', 'order', 'owner', 'status'] +class ManagedVMProductSerializer(serializers.ModelSerializer): + """ + Managed VM serializer used in ungleich_service app. + """ + class Meta: + model = VMProduct + fields = [ 'cores', 'ram_in_gb'] + + class VMSnapshotProductSerializer(serializers.ModelSerializer): class Meta: model = VMSnapshotProduct diff --git a/uncloud/ungleich_service/models.py b/uncloud/ungleich_service/models.py index 0e84f62..8f95973 100644 --- a/uncloud/ungleich_service/models.py +++ b/uncloud/ungleich_service/models.py @@ -6,7 +6,6 @@ from uncloud_vm.models import VMProduct class MatrixServiceProduct(Product): monthly_managment_fee = 20 - setup_fee = 30 description = "Managed Matrix HomeServer" @@ -18,9 +17,16 @@ class MatrixServiceProduct(Product): def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): if recurring_period == RecurringPeriod.PER_MONTH: - return monthly_managment_fee + vm.recurring_price(RecurringPeriod.PER_MONTH) + return self.monthly_managment_fee else: raise Exception('Invalid recurring period for VM Product pricing.') + @staticmethod + def allowed_recurring_periods(): + return list(filter( + lambda pair: pair[0] in [RecurringPeriod.PER_MONTH], + RecurringPeriod.choices)) + + @property def setup_fee(self): - return setup_fee + return 30 diff --git a/uncloud/ungleich_service/serializers.py b/uncloud/ungleich_service/serializers.py index 0c34dcf..b4038b7 100644 --- a/uncloud/ungleich_service/serializers.py +++ b/uncloud/ungleich_service/serializers.py @@ -1,18 +1,17 @@ from rest_framework import serializers from .models import MatrixServiceProduct -from uncloud_vm.serializers import VMProductSerializer +from uncloud_vm.serializers import ManagedVMProductSerializer from uncloud_vm.models import VMProduct +from uncloud_pay.models import RecurringPeriod class MatrixServiceProductSerializer(serializers.ModelSerializer): - vm = VMProductSerializer() + vm = ManagedVMProductSerializer() + + # Custom field used at creation (= ordering) only. + recurring_period = serializers.ChoiceField( + choices=MatrixServiceProduct.allowed_recurring_periods()) class Meta: model = MatrixServiceProduct - fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain'] + fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain', 'recurring_period'] read_only_fields = ['uuid', 'order', 'owner', 'status'] - - def create(self, validated_data): - # Create VM - vm_data = validated_data.pop('vm') - vm = VMProduct.objects.create(**vm_data) - return MatrixServiceProduct.create(vm=vm, **validated_data) diff --git a/uncloud/ungleich_service/views.py b/uncloud/ungleich_service/views.py index a8de2e0..d5191a2 100644 --- a/uncloud/ungleich_service/views.py +++ b/uncloud/ungleich_service/views.py @@ -1,9 +1,13 @@ from rest_framework import viewsets, permissions +from rest_framework.response import Response +from django.db import transaction from .models import MatrixServiceProduct from .serializers import MatrixServiceProductSerializer from uncloud_pay.helpers import ProductViewSet +from uncloud_pay.models import Order +from uncloud_vm.models import VMProduct class MatrixServiceProductViewSet(ProductViewSet): permission_classes = [permissions.IsAuthenticated] @@ -12,6 +16,46 @@ class MatrixServiceProductViewSet(ProductViewSet): def get_queryset(self): return MatrixServiceProduct.objects.filter(owner=self.request.user) + @transaction.atomic def create(self, request): - # TODO: create order, register service - return Response('{"HIT!"}') + # Extract serializer data. + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + order_recurring_period = serializer.validated_data.pop("recurring_period") + + # Create base order. + order = Order.objects.create( + recurring_period=order_recurring_period, + owner=request.user + ) + order.save() + + # Create unerderlying VM. + # TODO: move this logic to a method for use with other + # products. + vm_data = serializer.validated_data.pop('vm') + vm_data['owner'] = request.user + vm_data['order'] = order + vm = VMProduct.objects.create(**vm_data) + + # XXX: Move this to some kind of on_create hook in parent + # Product class? + order.add_record( + vm.setup_fee, + vm.recurring_price(order.recurring_period), + vm.description) + + # Create service. + service = serializer.save( + order=order, + owner=self.request.user, + vm=vm) + + # XXX: Move this to some kind of on_create hook in parent + # Product class? + order.add_record( + service.setup_fee, + service.recurring_price(order.recurring_period), + service.description) + + return Response(serializer.data)