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 179ff0b..f28e0f4 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -62,7 +62,9 @@ INSTALLED_APPS = [ 'rest_framework', 'uncloud_pay', 'uncloud_auth', + 'uncloud_storage', 'uncloud_vm', + 'ungleich_service', 'opennebula' ] @@ -174,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/urls.py b/uncloud/uncloud/urls.py index 02862a1..d7ee153 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -20,11 +20,12 @@ 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/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') router.register(r'vm/image/mine', vmviews.VMDiskImageProductMineViewSet, basename='vmdiskimagemineproduct') @@ -38,6 +39,7 @@ router.register(r'vm/image/public', vmviews.VMDiskImageProductPublicViewSet, bas router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') + # TBD #router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') @@ -49,11 +51,20 @@ router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') # allow vm creation from own images +# 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') 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') + +# VMs +router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vm') # admin/staff urls router.register(r'admin/bill', payviews.AdminBillViewSet, basename='admin/bill') @@ -61,7 +72,7 @@ router.register(r'admin/payment', payviews.AdminPaymentViewSet, basename='admin/ 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/helpers.py b/uncloud/uncloud_pay/helpers.py new file mode 100644 index 0000000..b4216f6 --- /dev/null +++ b/uncloud/uncloud_pay/helpers.py @@ -0,0 +1,96 @@ +from functools import reduce +from datetime import datetime +from rest_framework import mixins +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 calendar import monthrange + +def get_balance_for(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): + 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 + +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. + next_bill = Bill(owner=user, + starting_date=beginning_of_month(year, month), + ending_date=end_of_month(year, month), + creation_date=timezone.now(), + due_date=timezone.now() + 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) + + # 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. + +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_pay/management/commands/charge-negative-balance.py b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py new file mode 100644 index 0000000..3667a03 --- /dev/null +++ b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py @@ -0,0 +1,32 @@ +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 ({}), charging.".format(user.username, balance)) + 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.") 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..34432d5 --- /dev/null +++ b/uncloud/uncloud_pay/management/commands/generate-bills.py @@ -0,0 +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, 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: + 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/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.") 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/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/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/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), + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0010_merge_20200228_1303.py b/uncloud/uncloud_pay/migrations/0010_merge_20200228_1303.py new file mode 100644 index 0000000..2ea423c --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0010_merge_20200228_1303.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.3 on 2020-02-28 13:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0002_auto_20200227_1230'), + ('uncloud_pay', '0009_auto_20200228_0825'), + ] + + operations = [ + ] 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/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/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 6a33fd5..8964cb3 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -1,81 +1,37 @@ 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 _ +from django.utils import timezone +from math import ceil +from datetime import timedelta +from calendar import monthrange import uuid +# Define DecimalField properties, used to represent amounts of money. AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 -class Bill(models.Model): - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) - - creation_date = models.DateTimeField() - 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 - pass - - -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) - - creation_date = models.DateTimeField() - starting_date = models.DateTimeField() - ending_date = models.DateTimeField(blank=True, - null=True) - - bill = models.ManyToManyField(Bill, - editable=False, - blank=True, - null=True) - - - recurring_price = models.FloatField(editable=False) - 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' - - ) - - # def amount(self): - # amount = recurring_price - # if recurring and first_month: - # amount += one_time_price - - # return amount # you get the picture - +# 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') +### +# 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, - editable=False) + on_delete=models.CASCADE) amount = models.DecimalField( default=0.0, @@ -92,11 +48,226 @@ class Payment(models.Model): ('unknown', 'Unknown') ), default='unknown') - timestamp = models.DateTimeField(editable=False) + 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(), + on_delete=models.CASCADE) + + creation_date = models.DateTimeField(auto_now_add=True) + starting_date = models.DateTimeField() + ending_date = models.DateTimeField() + due_date = models.DateField() + + valid = models.BooleanField(default=True) + + @property + 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) + bill_records.append(bill_record) + + return bill_records + + @property + 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(): + """ + Entry of a bill, dynamically generated from order records. + """ + + 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): + # 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)) + +### +# Orders. + +# /!\ 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. +# +# /!\ 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, + null=True) + + bill = models.ManyToManyField(Bill, + editable=False, + blank=True) + + recurring_period = models.CharField(max_length=32, + choices = RecurringPeriod.choices, + default = RecurringPeriod.PER_MONTH) + + @property + def records(self): + return OrderRecord.objects.filter(order=self) + + @property + 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) + 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 + + @property + def starting_date(self): + return self.order.starting_date + + @property + def ending_date(self): + return self.order.ending_date +### +# 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(), @@ -117,7 +288,24 @@ 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. + + @property + def setup_fee(self): + return 0 + + @property + 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/serializers.py b/uncloud/uncloud_pay/serializers.py index 130f683..6e4b2d3 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -1,27 +1,125 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from .models import Bill, Payment, Order +from .models import * +from .helpers import get_balance_for -class BillSerializer(serializers.ModelSerializer): +from functools import reduce +from uncloud_vm.serializers import VMProductSerializer +from uncloud_vm.models import VMProduct + +import uncloud_pay.stripe as stripe + +### +# Users. + +class UserSerializer(serializers.ModelSerializer): class Meta: - model = Bill - fields = ['owner', 'amount', 'due_date', 'creation_date', - 'starting_date', 'ending_date', 'paid'] + 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 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'] +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() + + 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 + payment_method = PaymentMethod.objects.create(**validated_data) + return payment_method + +### +# 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: 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: - model = get_user_model() - fields = ['username', 'email'] - - def get_balance(self, obj): - return 666 +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 ae88861..936d4c7 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -4,8 +4,10 @@ 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 +import json + +from .models import * +from .serializers import * from datetime import datetime ### @@ -54,9 +56,39 @@ 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): + 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) + + 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) + + # 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. @@ -89,7 +121,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) 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. diff --git a/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py index 1bd711b..5535071 100644 --- a/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py +++ b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py @@ -16,17 +16,6 @@ class Migration(migrations.Migration): model_name='vmsnapshotproduct', name='vm_uuid', ), - migrations.AddField( - model_name='vmproduct', - name='order', - field=models.ForeignKey(default=0, editable=False, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), - preserve_default=False, - ), - 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.AddField( model_name='vmsnapshotproduct', name='vm', 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..3ebd472 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py @@ -0,0 +1,25 @@ +# 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.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/migrations/0006_merge_20200228_1303.py b/uncloud/uncloud_vm/migrations/0006_merge_20200228_1303.py new file mode 100644 index 0000000..29411ca --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0006_merge_20200228_1303.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.3 on 2020-02-28 13:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0005_auto_20200227_1532'), + ('uncloud_vm', '0005_auto_20200227_1230'), + ] + + operations = [ + ] 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), + ), + ] 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/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 4b0d511..02fb13e 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -6,7 +6,8 @@ from django.contrib.auth import get_user_model # Uncomment if you override model's clean method # from django.core.exceptions import ValidationError -from uncloud_pay.models import Product +from uncloud_pay.models import Product, RecurringPeriod +import uncloud_pay.models as pay_models STATUS_CHOICES = ( ('pending', 'Pending'), # Initial state @@ -45,9 +46,31 @@ class VMProduct(Product): VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True ) + # 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() + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + # TODO: move magic numbers in variables + if recurring_period == RecurringPeriod.PER_MONTH: + 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.') + + @property + def description(self): + 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 07d6c51..3bb9298 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -1,7 +1,9 @@ from django.contrib.auth import get_user_model from rest_framework import serializers + from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct +from uncloud_pay.models import RecurringPeriod GB_SSD_PER_DAY=0.012 GB_HDD_PER_DAY=0.0006 @@ -16,14 +18,7 @@ class VMHostSerializer(serializers.ModelSerializer): fields = '__all__' -class VMProductSerializer(serializers.ModelSerializer): - class Meta: - model = VMProduct - fields = '__all__' - class VMDiskProductSerializer(serializers.ModelSerializer): -# vm = VMProductSerializer() - class Meta: model = VMDiskProduct fields = '__all__' @@ -33,6 +28,25 @@ class VMDiskImageProductSerializer(serializers.ModelSerializer): model = VMDiskImageProduct fields = '__all__' +class VMProductSerializer(serializers.HyperlinkedModelSerializer): + # Custom field used at creation (= ordering) only. + recurring_period = serializers.ChoiceField( + choices=VMProduct.allowed_recurring_periods()) + + class Meta: + model = VMProduct + fields = ['uuid', 'order', 'owner', 'status', 'name', \ + '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/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 62edaa0..fd135a6 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 @@ -7,11 +8,11 @@ from rest_framework import viewsets, permissions from rest_framework.response import Response from rest_framework.exceptions import ValidationError - from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct from uncloud_pay.models import Order from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer, VMDiskImageProductSerializer, VMDiskProductSerializer +from uncloud_pay.helpers import ProductViewSet import datetime @@ -77,27 +78,37 @@ class VMDiskProductViewSet(viewsets.ModelViewSet): -class VMProductViewSet(viewsets.ModelViewSet): +class VMProductViewSet(ProductViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = VMProductSerializer 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): + # Extract serializer data. serializer = VMProductSerializer(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_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() - serializer.save(owner=request.user, order=order) + # Create VM. + 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) + return Response(serializer.data) 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/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/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..8f95973 --- /dev/null +++ b/uncloud/ungleich_service/models.py @@ -0,0 +1,32 @@ +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 + + 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 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 30 diff --git a/uncloud/ungleich_service/serializers.py b/uncloud/ungleich_service/serializers.py new file mode 100644 index 0000000..b4038b7 --- /dev/null +++ b/uncloud/ungleich_service/serializers.py @@ -0,0 +1,17 @@ +from rest_framework import serializers +from .models import MatrixServiceProduct +from uncloud_vm.serializers import ManagedVMProductSerializer +from uncloud_vm.models import VMProduct +from uncloud_pay.models import RecurringPeriod + +class MatrixServiceProductSerializer(serializers.ModelSerializer): + 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', 'recurring_period'] + read_only_fields = ['uuid', 'order', 'owner', 'status'] 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..d5191a2 --- /dev/null +++ b/uncloud/ungleich_service/views.py @@ -0,0 +1,61 @@ +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] + serializer_class = MatrixServiceProductSerializer + + def get_queryset(self): + return MatrixServiceProduct.objects.filter(owner=self.request.user) + + @transaction.atomic + def create(self, request): + # 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)