+
+
+
+
+
+ {% trans "Are you sure that you want to cancel this subscription?."%}
+
+ {% blocktrans %} The instance will be active till the end date of the last bill and will be deleted
+ after that. {% endblocktrans %}
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block js_extra %}
+
+{% endblock %}
diff --git a/nextcloud/templates/nextcloud/pricing.html b/nextcloud/templates/nextcloud/pricing.html
new file mode 100644
index 0000000..3dbc367
--- /dev/null
+++ b/nextcloud/templates/nextcloud/pricing.html
@@ -0,0 +1,7 @@
+{% extends "matrixhosting/base.html" %}
+
+{% load static i18n %}
+
+{% block main %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/nextcloud/templates/uncloud_pay/bills.html b/nextcloud/templates/uncloud_pay/bills.html
new file mode 100644
index 0000000..faae5f1
--- /dev/null
+++ b/nextcloud/templates/uncloud_pay/bills.html
@@ -0,0 +1,190 @@
+{% extends "nextcloud/base.html" %}
+
+{% load static i18n compress mathfilters %}
+
+{% block title %} Bills {% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Bill No."%}
+
{% trans "Creation Date"%}
+
{% trans "Amount"%}
+
{% trans "Due Date"%}
+
{% trans "Status"%}
+
+
+
+
+
+
+
+
+ {% for bill in object_list %}
+
+
+
+
+
Bill Lines:
+
+
+
+
+ | Order |
+ Description |
+ Start Date |
+ End Date |
+ Subtotal |
+ Total |
+
+
+
+ {% for record in bill.bill_records.all %}
+
+ | #{{record.order.id}} |
+ {{record.description}} |
+ {{record.starting_date|date:"Y-m-d"}} |
+ {{record.ending_date|date:"Y-m-d"}} |
+ {{record.subtotal}} |
+ {{record.sum}} |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
{% trans "Subtotal" %}
+
+
+
+
+
+
{{bill.billing_address.get_country_display}} VAT {{ bill.vat_rate|mul:100 }}%
+
+
+
+
+
+
+
+
+
+
+
+
{{bill.sum|floatformat:2}} CHF
+
+
+
+
+
+
+
+ {%endfor%}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+{% block js_extra %}
+
+
+{% endblock js_extra %}
\ No newline at end of file
diff --git a/nextcloud/templates/uncloud_pay/cards.html b/nextcloud/templates/uncloud_pay/cards.html
new file mode 100644
index 0000000..7963d06
--- /dev/null
+++ b/nextcloud/templates/uncloud_pay/cards.html
@@ -0,0 +1,210 @@
+{% extends "nextcloud/base.html" %}
+
+{% load static i18n compress %}
+
+{% block title %} Payment Methods {% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
{% trans "Credit or Debit Cards"%} ({% trans "for payments"%})
+
+ {% for card in object_list %}
+
+
+
XXXX-XXXX-XXXX-{{card.last4}}
+
Valid
+ thru
+ {{card.expiry_date|date:"m"}}/{{card.expiry_date|date:"y"}} {% if card.active %}{% trans "Primary"%}{%endif%}
+
{{card.card_name}}
+
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+{% block js_extra %}
+
+
+
+{% endblock js_extra %}
\ No newline at end of file
diff --git a/nextcloud/templates/uncloud_pay/order_success.html b/nextcloud/templates/uncloud_pay/order_success.html
new file mode 100644
index 0000000..474e5c6
--- /dev/null
+++ b/nextcloud/templates/uncloud_pay/order_success.html
@@ -0,0 +1,51 @@
+{% extends "nextcloud/base.html" %}
+
+{% load static i18n %}
+
+{% block title %} Request Details {% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
{%trans "Success!" %}
+
{%trans "Order has been successfully added. Your VM will be up and running in a few moments. " %}
+
+
{%trans "We will send you a confirmation email as soon as it is ready." %} {%trans "Go to your dashboard" %} {%trans "Billing" %}.
+
{%trans "Download Invoice" %}
+
+
+
+{% endblock %}
+
+{% block js_extra %}
+{% endblock js_extra %}
\ No newline at end of file
diff --git a/nextcloud/templates/uncloud_pay/payments.html b/nextcloud/templates/uncloud_pay/payments.html
new file mode 100644
index 0000000..c00faf5
--- /dev/null
+++ b/nextcloud/templates/uncloud_pay/payments.html
@@ -0,0 +1,124 @@
+{% extends "nextcloud/base.html" %}
+
+{% load static i18n compress %}
+
+{% block title %} Payments {% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% trans "Payment Moves"%}
+
+
+
+
{% trans "ID"%}
+
{% trans "Date"%}
+
{% trans "Description" %}
+
{% trans "Type"%}
+
{% trans "Amount"%}
+
+
+
+
+
+
+ {% for payment in object_list %}
+
+
+
#{{payment.id}}
+
{{payment.timestamp|date:"Y-m-d"}} {{payment.timestamp|date:"H:i"}}
+
{{payment.notes}}
+
{{payment.type}}
+
{% if payment.type == 'withdraw' %}- {%else%}+ {%endif%}{{payment.amount}} ({{payment.currency}})
+
+
+ {%endfor%}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block js_extra %}
+ {% compress js %}
+
+ {% endcompress %}
+{% endblock js_extra %}
\ No newline at end of file
diff --git a/nextcloud/tests.py b/nextcloud/tests.py
new file mode 100644
index 0000000..78cac27
--- /dev/null
+++ b/nextcloud/tests.py
@@ -0,0 +1,102 @@
+import datetime
+import json
+import logging
+from django import utils
+
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from django.utils import timezone
+
+from .models import VMInstance
+from .utils import *
+from uncloud_pay.models import Order, PricingPlan, BillingAddress, Product, RecurringPeriod, Bill, BillRecord
+
+import oca
+
+logger = logging.getLogger()
+
+vm_product_config = {
+ 'features': {
+ 'cores':
+ { 'min': 1,
+ 'max': 48
+ },
+ 'ram_gb':
+ { 'min': 2,
+ 'max': 200
+ },
+ },
+}
+
+class VMInstanceTestCase(TestCase):
+
+ def setUp(self):
+ RecurringPeriod.populate_db_defaults()
+ self.user = get_user_model().objects.create(
+ username='random_user',
+ email='jane.random@domain.tld')
+ self.config = json.dumps({
+ 'cores': 1,
+ 'memory': 2,
+ 'storage': 100,
+ 'homeserver_domain': '',
+ 'webclient_domain': '',
+ 'matrix_domain': '',
+ })
+ self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
+ ram_unit_price=4, storage_ssd_unit_price=0.35, storage_hd_unit_price=0.02)
+ self.ba = BillingAddress.objects.create(
+ owner=self.user,
+ organization = 'Test org',
+ street="unknown",
+ city="unknown",
+ postal_code="somewhere else",
+ active=True)
+
+ self.product = Product.objects.create(name="Testproduct",
+ description="Only for testing",
+ config=vm_product_config)
+ self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
+ self.product.recurring_periods.add(self.default_recurring_period,
+ through_defaults= { 'is_default': True })
+
+ def test_delete_matrix_vm_related_to_bill(self):
+ order = Order.objects.create(owner=self.user,
+ recurring_period=self.default_recurring_period,
+ billing_address=self.ba,
+ pricing_plan = self.pricing_plan,
+ product=self.product,
+ config=self.config)
+ VMInstance.create_instance(order)
+ instances = VMInstance.objects.filter(order=order)
+ self.assertEqual(len(instances), 1)
+ bill = Bill.create_next_bill_for_order(order, ending_date=order.next_cancel_or_downgrade_date(order.starting_date))
+ bill_records = BillRecord.objects.filter(bill=bill)
+ self.assertEqual(len(bill_records), 1)
+ self.assertEqual(bill_records[0].order, order)
+
+ VMInstance.delete_for_bill(bill)
+ instances = VMInstance.objects.filter(order=order)
+ self.assertEqual(len(instances), 0)
+
+
+ def test_create_matrix_vm(self):
+ order = Order.objects.create(owner=self.user,
+ recurring_period=self.default_recurring_period,
+ billing_address=self.ba,
+ pricing_plan = self.pricing_plan,
+ product=self.product,
+ config=self.config)
+ VMInstance.create_instance(order)
+ instances = VMInstance.objects.filter(order=order)
+ self.assertEqual(len(instances), 1)
+
+ def test_update_dns(self):
+ #add_to_dns_files("test12", "192.168.1.32", "2001:db8::44", settings.MATRIX_DNS_MAIN_DOMAIN)
+ remove_from_dns_files("test1", settings.MATRIX_DNS_MAIN_DOMAIN)
+
+
+
+
+
+
diff --git a/nextcloud/urls.py b/nextcloud/urls.py
new file mode 100644
index 0000000..1d8eb7d
--- /dev/null
+++ b/nextcloud/urls.py
@@ -0,0 +1,15 @@
+from django.urls import path, include
+from django.conf import settings
+from django.conf.urls.static import static
+from .views import *
+
+app_name = 'nextcloud'
+
+urlpatterns = [
+ path('order/new/', OrderPaymentView.as_view(), name='payment'),
+ path('order/confirm/', OrderConfirmationView.as_view(), name='order_confirmation'),
+ path('instances/', InstancesView.as_view(), name='instances'),
+ path('orders/', OrdersView.as_view(), name='orders'),
+ path('pricing/', PricingView.as_view(), name='pricing'),
+ path('', IndexView.as_view(), name='index'),
+]
diff --git a/nextcloud/utils.py b/nextcloud/utils.py
new file mode 100644
index 0000000..a2ea75d
--- /dev/null
+++ b/nextcloud/utils.py
@@ -0,0 +1,89 @@
+import json
+import datetime
+import gitlab
+import logging
+from uncloud_pay.models import Product, Order, ProductToRecurringPeriod, Bill, Payment
+from .models import VMMachine
+import dns.zone
+from dns.exception import DNSException
+from django.conf import settings
+
+log = logging.getLogger()
+
+def finalize_order(request, customer, billing_address,
+ one_time_price, pricing_plan,
+ specs):
+ product = Product.objects.first()
+ recurring_period_product = ProductToRecurringPeriod.objects.filter(product=product, is_default=True).first()
+ order = Order.objects.create(
+ owner=request.user,
+ customer=customer,
+ billing_address=billing_address,
+ one_time_price=one_time_price,
+ pricing_plan=pricing_plan,
+ recurring_period= recurring_period_product.recurring_period,
+ product = product,
+ config=json.dumps(specs)
+ )
+ if order:
+ end_date = order.starting_date + datetime.timedelta(seconds=order.recurring_period.duration_seconds)
+ bill = Bill.create_next_bill_for_order(order, ending_date=end_date)
+ payment= Payment.withdraw(owner=request.user, amount=one_time_price, notes=f"BILL #{bill.id}")
+ if payment:
+ #Close the bill as the payment has been added
+ VMMachine.create_instance(order)
+ bill.close(status="paid")
+
+ return order, bill
+
+def add_to_dns_files(sub_domain, ipv4, ipv6, domain):
+ gl = gitlab.Gitlab(settings.GITLAB_SERVER, oauth_token=settings.GITLAB_DNS_OAUTH_TOKEN)
+ project = gl.projects.get(settings.GITLAB_DNS_PROJECT_ID)
+ f_path = f'zones/{domain}'
+ file = project.files.get(file_path=f_path, ref='master')
+ if file:
+ try:
+ zone_file = file.decode()
+ zone = dns.zone.from_file(zone_file, domain)
+
+ log.info(f"Adding record on {zone.origin} of type A: {sub_domain}")
+
+ rdataset = zone.find_rdataset(sub_domain, rdtype=dns.rdatatype.A, create=True)
+ rdata = dns.rdtypes.IN.A.A(dns.rdataclass.IN, dns.rdatatype.A, address=ipv4)
+ rdataset.add(rdata, ttl=600)
+
+ log.info(f"Adding record on {zone.origin} of type AAAA: {sub_domain}")
+
+ rdataset_ipv6 = zone.find_rdataset(sub_domain, rdtype=dns.rdatatype.AAAA, create=True)
+ rdata_ipv6 = dns.rdtypes.IN.AAAA.AAAA(dns.rdataclass.IN, dns.rdatatype.AAAA, address=ipv6)
+ rdataset_ipv6.add(rdata_ipv6, ttl=600)
+
+ file.content = zone.to_text()
+
+ # Write it back to gitlab
+ file.save(branch='master', commit_message=f'Update ungleich-dns-zones {domain}')
+
+ except DNSException as e:
+ log.error(e.__class__, e)
+ raise e
+
+def remove_from_dns_files(sub_domain, domain):
+ gl = gitlab.Gitlab(settings.GITLAB_SERVER, oauth_token=settings.GITLAB_DNS_OAUTH_TOKEN)
+ project = gl.projects.get(settings.GITLAB_DNS_PROJECT_ID)
+ f_path = f'zones/{domain}'
+ file = project.files.get(file_path=f_path, ref='master')
+ if file:
+ try:
+ zone_file = file.decode()
+ zone = dns.zone.from_file(zone_file, domain)
+
+ log.info(f"Removing record on {zone.origin}: {sub_domain}")
+ zone.delete_node(sub_domain)
+
+ file.content = zone.to_text()
+ # Write it back to gitlab
+ file.save(branch='master', commit_message=f'Removing {sub_domain} from {domain}')
+ except DNSException as e:
+ log.error(e.__class__, e)
+ raise e
+
diff --git a/nextcloud/views.py b/nextcloud/views.py
new file mode 100644
index 0000000..47e2553
--- /dev/null
+++ b/nextcloud/views.py
@@ -0,0 +1,289 @@
+import logging
+from django.views.generic.base import TemplateView
+from stripe.error import CardError
+from django.shortcuts import redirect, render
+from django.contrib import messages
+from django.utils.translation import get_language, ugettext_lazy as _
+from django.contrib.auth.decorators import login_required
+from django.views.decorators.cache import cache_control
+from django.utils.decorators import method_decorator
+from django.views import View
+from django.views.generic import FormView, DetailView
+from django.views.generic.list import ListView
+from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.http import require_POST
+from .forms import InitialRequestForm, RequestDomainsNamesForm
+from uncloud_pay.forms import BillingAddressForm
+from django.urls import reverse
+from django.conf import settings
+from django.utils import timezone
+from django_q.models import Schedule
+from django_q.tasks import schedule
+from django.http import (
+ HttpResponseRedirect, JsonResponse, HttpResponse
+)
+from rest_framework import viewsets, permissions
+
+from uncloud_pay.models import PricingPlan
+from uncloud_pay.utils import get_order_total_with_vat
+from uncloud_pay.models import *
+from uncloud_pay.utils import validate_vat_number
+from uncloud_pay.selectors import get_billing_address_for_user, get_balance_for_user
+import uncloud_pay.stripe as uncloud_stripe
+from .models import VMMachine
+from .serializers import *
+from .utils import *
+from ldap.ldapobject import LDAPObject
+
+logger = logging.getLogger(__name__)
+
+
+class PricingView(TemplateView):
+ template_name = "nextcloud/pricing.html"
+
+class IndexView(FormView):
+ template_name = "nextcloud/index.html"
+ form_class = InitialRequestForm
+ success_url = "/nextcloud#requestform"
+ success_message = "Thank you, we will contact you as soon as possible"
+
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['vm_pricing'] = PricingPlan.get_default_pricing()
+ return context
+
+ def form_valid(self, form):
+ self.request.session['order'] = form.cleaned_data
+ pricing = get_order_total_with_vat(
+ form.cleaned_data['cores'],
+ form.cleaned_data['memory'],
+ form.cleaned_data['storage'],
+ form.cleaned_data['pricing_name'],
+ False
+ )
+ self.request.session['pricing'] = pricing
+ return HttpResponseRedirect(reverse('nextcloud:payment'))
+
+
+class OrderPaymentView(FormView):
+ template_name = 'nextcloud/order_details.html'
+ success_url = 'nextcloud:order_confirmation'
+ form_class = BillingAddressForm
+
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = super(OrderPaymentView, self).get_context_data(**kwargs)
+ default_pricing = PricingPlan.get_default_pricing()
+ if 'billing_address_data' in self.request.session:
+ billing_address_form = BillingAddressForm(
+ initial=self.request.session['billing_address_data']
+ )
+ else:
+ old_active = get_billing_address_for_user(self.request.user)
+ billing_address_form = BillingAddressForm(
+ instance=old_active
+ ) if old_active else BillingAddressForm(
+ initial={'active': True, 'owner': self.request.user.id}
+ )
+ if self.request.GET.get('product', False):
+ matched_prod = Product.objects.filter(name=self.request.GET.get('product')).first()
+ if matched_prod:
+ self.request.session['order'] = matched_prod.config
+
+ details_form = InitialRequestForm(
+ initial=self.request.session.get('order', {'pricing_name': default_pricing.name})
+ )
+ balance = get_balance_for_user(self.request.user)
+ customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
+ #TODO optimize this part for better performance
+ uncloud_stripe.sync_cards_for_user(self.request.user)
+ cards = uncloud_stripe.get_customer_cards(customer_id)
+ pricing = self.request.session.get('pricing', {'name': default_pricing.name, 'total': 0})
+ context.update({
+ 'vm_pricing': PricingPlan.get_by_name(pricing['name']),
+ 'billing_address_form': billing_address_form,
+ 'details_form': details_form,
+ 'balance': balance,
+ 'cards': cards,
+ 'stripe_key': settings.STRIPE_PUBLIC_KEY,
+ 'show_cards': True if balance < pricing['total'] else False,
+ })
+ return context
+
+ @cache_control(no_cache=True, must_revalidate=True, no_store=True)
+ def get(self, request, *args, **kwargs):
+ for k in ['vat_validation_status', 'token', 'id_payment_method', 'total']:
+ if request.session.get(k):
+ request.session.pop(k)
+ return self.render_to_response(self.get_context_data())
+
+ def post(self, request, *args, **kwargs):
+ details_form = InitialRequestForm(request.POST)
+ billing_address_form = BillingAddressForm(request.POST)
+ context = self.get_context_data()
+ if not details_form.is_valid() or not billing_address_form.is_valid():
+ context.update({'details_form': details_form,
+ 'billing_address_form': billing_address_form})
+ return self.render_to_response(context)
+ address = get_billing_address_for_user(self.request.user)
+ if address:
+ form = BillingAddressForm(self.request.POST, instance=address)
+ else:
+ form = BillingAddressForm(self.request.POST)
+ if form.is_valid:
+ billing_address_ins = form.save()
+ self.request.session["billing_address_id"] = billing_address_ins.id
+ self.request.session['billing_address_data'] = billing_address_form.cleaned_data
+ self.request.session['billing_address_data']['owner'] = self.request.user.id
+ id_payment_method = self.request.POST.get('id_payment_method', False)
+ selected_card = False
+ if id_payment_method and id_payment_method != 'undefined':
+ uncloud_stripe.attach_payment_method(id_payment_method, self.request.user)
+ selected_card = StripeCreditCard.objects.filter(card_id=id_payment_method).first()
+ selected_card.activate()
+ vat_number = billing_address_form.cleaned_data.get('vat_number').strip()
+ if vat_number:
+ customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
+ validate_result = validate_vat_number(
+ stripe_customer_id=customer_id,
+ billing_address_id=billing_address_ins.id
+ )
+ if 'error' in validate_result and validate_result['error']:
+ messages.add_message(
+ self.request, messages.ERROR, validate_result["error"],
+ extra_tags='error'
+ )
+ return HttpResponseRedirect(
+ reverse('nextcloud:payment')
+ )
+ self.request.session["vat_validation_status"] = validate_result["status"]
+ specs = details_form.cleaned_data
+ vat_rate = VATRate.get_vat_rate(billing_address_ins)
+ vat_validation_status = "verified" if billing_address_ins.vat_number_validated_on and billing_address_ins.vat_number_verified else False
+ pricing = get_order_total_with_vat(
+ specs['cores'], specs['memory'], specs['storage'], context['vm_pricing'].name,
+ vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status
+ )
+ self.request.session['pricing'] = pricing
+ self.request.session['order'] = specs
+ self.request.session['vat_validation_status'] = vat_validation_status
+ amount = get_balance_for_user(self.request.user) - pricing["total"]
+ if (amount < 0 and not selected_card):
+ messages.add_message(
+ self.request, messages.ERROR, "You haven't enough balance please select credit card to continue",
+ extra_tags='error'
+ )
+ return HttpResponseRedirect(
+ reverse('nextcloud:payment')
+ )
+ return HttpResponseRedirect(reverse('nextcloud:order_confirmation'))
+
+class OrderConfirmationView(DetailView):
+ template_name = "nextcloud/order_confirmation.html"
+ context_object_name = "order"
+ model = Order
+
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = {
+ 'order': self.request.session.get('order'),
+ 'pricing': self.request.session.get('pricing'),
+ 'balance': get_balance_for_user(self.request.user)
+ }
+ return context
+
+ @cache_control(no_cache=True, must_revalidate=True, no_store=True)
+ def get(self, request, *args, **kwargs):
+ context = self.get_context_data()
+ context['domains_form'] = RequestDomainsNamesForm(initial={})
+ if ('order' not in request.session):
+ return HttpResponseRedirect(reverse('nextcloud:index'))
+ elif 'pricing' not in self.request.session or 'vat_validation_status' not in self.request.session:
+ return HttpResponseRedirect(reverse('nextcloud:payment'))
+
+ total = self.request.session['pricing']['total']
+ amount = get_balance_for_user(self.request.user) - total
+ if (amount < 0):
+ context['stripe_deposit_amount'] = max(amount, settings.MIN_PER_TRANSACTION)
+ return render(request, self.template_name, context)
+
+ def post(self, request, *args, **kwargs):
+ domains_form = RequestDomainsNamesForm(self.request.POST)
+ if domains_form.is_valid():
+ customer = StripeCustomer.objects.get(owner=self.request.user)
+ billing_address = BillingAddress.objects.get(id=request.session.get('billing_address_id'))
+ total = self.request.session['pricing']['total']
+ self.request.session['order']['domain'] = domains_form.cleaned_data.get('subdomain') + "." + domains_form.cleaned_data.get('main_domain')
+ try:
+ amount = get_balance_for_user(self.request.user) - total
+ if (amount < 0):
+ Payment.deposit(request.user, max(abs(amount), settings.MIN_PER_TRANSACTION), source='stripe')
+ amount = get_balance_for_user(self.request.user) - total
+ if (amount < 0):
+ messages.add_message(
+ self.request, messages.ERROR, "Please make sure that you have enough balance in your wallet and try again later.",
+ extra_tags='error'
+ )
+ return HttpResponseRedirect(
+ reverse('nextcloud:order_confirmation')
+ )
+ order, bill = finalize_order(request, customer,
+ billing_address,
+ total,
+ PricingPlan.get_by_name(self.request.session['pricing']['name']),
+ request.session.get('order'))
+ if order and bill:
+ self.request.session['bill_id'] = bill.id
+ return HttpResponseRedirect(reverse('uncloud_pay:order_success'))
+ except CardError as e:
+ messages.add_message(
+ self.request, messages.ERROR, e.user_message,
+ extra_tags='error'
+ )
+ return HttpResponseRedirect(
+ reverse('nextcloud:order_confirmation')
+ )
+ context = self.get_context_data()
+ context['domains_form'] = domains_form
+ return self.render_to_response(context)
+
+class OrdersView(ListView):
+ template_name = "nextcloud/orders.html"
+ model = Order
+
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
+ def get_queryset(self):
+ return Order.objects.filter(owner=self.request.user).order_by('-creation_date')
+
+ def post(self, request, *args, **kwargs):
+ order = Order.objects.get(id=request.POST.get('order_id', 0))
+ order.cancel()
+ if hasattr(order, 'instance_id'):
+ last_bill_record = BillRecord.objects.filter(order=order).order_by('id').last()
+ schedule('nextcloud.tasks.delete_instance',
+ order.instance_id,
+ schedule_type=Schedule.ONCE,
+ next_run=last_bill_record.ending_date or (timezone.now() + datetime.timedelta(hours=1)))
+ return JsonResponse({'message': 'Successfully Cancelled'})
+
+class InstancesView(ListView):
+ template_name = "nextcloud/instances.html"
+ model = VMMachine
+
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
+ def get_queryset(self):
+ return VMMachine.objects.filter(owner=self.request.user).order_by('-creation_date')
+
diff --git a/uncloud/ungleich_service/migrations/__init__.py b/opennebula/__init__.py
similarity index 100%
rename from uncloud/ungleich_service/migrations/__init__.py
rename to opennebula/__init__.py
diff --git a/uncloud/opennebula/admin.py b/opennebula/admin.py
similarity index 100%
rename from uncloud/opennebula/admin.py
rename to opennebula/admin.py
diff --git a/uncloud/opennebula/apps.py b/opennebula/apps.py
similarity index 100%
rename from uncloud/opennebula/apps.py
rename to opennebula/apps.py
diff --git a/uncloud/opennebula/management/commands/opennebula-synchosts.py b/opennebula/management/commands/opennebula-synchosts.py
similarity index 100%
rename from uncloud/opennebula/management/commands/opennebula-synchosts.py
rename to opennebula/management/commands/opennebula-synchosts.py
diff --git a/uncloud/opennebula/management/commands/opennebula-syncvms.py b/opennebula/management/commands/opennebula-syncvms.py
similarity index 88%
rename from uncloud/opennebula/management/commands/opennebula-syncvms.py
rename to opennebula/management/commands/opennebula-syncvms.py
index 458528b..3c12fa9 100644
--- a/uncloud/opennebula/management/commands/opennebula-syncvms.py
+++ b/opennebula/management/commands/opennebula-syncvms.py
@@ -1,12 +1,9 @@
import json
-import uncloud.secrets as secrets
-
-
from xmlrpc.client import ServerProxy as RPCClient
-
from django_auth_ldap.backend import LDAPBackend
from django.core.management.base import BaseCommand
+from django.conf import settings
from xmltodict import parse
from opennebula.models import VM as VMModel
@@ -19,9 +16,9 @@ class Command(BaseCommand):
pass
def handle(self, *args, **options):
- with RPCClient(secrets.OPENNEBULA_URL) as rpc_client:
+ with RPCClient(settings.OPENNEBULA_URL) as rpc_client:
success, response, *_ = rpc_client.one.vmpool.infoextended(
- secrets.OPENNEBULA_USER_PASS, -2, -1, -1, -1
+ settings.OPENNEBULA_USER_PASS, -2, -1, -1, -1
)
if success:
vms = json.loads(json.dumps(parse(response)))['VM_POOL']['VM']
diff --git a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py b/opennebula/management/commands/opennebula-to-uncloud.py
similarity index 56%
rename from uncloud/opennebula/management/commands/opennebula-to-uncloud.py
rename to opennebula/management/commands/opennebula-to-uncloud.py
index dc7cb45..230159a 100644
--- a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py
+++ b/opennebula/management/commands/opennebula-to-uncloud.py
@@ -1,13 +1,18 @@
+import sys
from datetime import datetime
from django.core.management.base import BaseCommand
from django.utils import timezone
+from django.contrib.auth import get_user_model
from opennebula.models import VM as VMModel
from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct
from uncloud_pay.models import Order
+import logging
+
+log = logging.getLogger(__name__)
def convert_mac_to_int(mac_address: str):
# Remove octet connecting characters
@@ -41,24 +46,35 @@ def create_nics(one_vm, vm_product):
)
-def create_disk_and_image(one_vm, vm_product):
- for disk in one_vm.disks:
- owner = one_vm.owner
- name = disk.get('image')
+def sync_disk_and_image(one_vm, vm_product, disk_owner):
+ """
+ a) Check all opennebula disk if they are in the uncloud VM, if not add
+ b) Check all uncloud disks and remove them if they are not in the opennebula VM
+ """
- # TODO: Fix the following hard coded values
- is_os_image, is_public, status = True, True, 'active'
+ vmdisknum = 0
+
+ one_disks_extra_data = []
+
+ for disk in one_vm.disks:
+ vmowner = one_vm.owner
+ name = disk.get('image')
+ vmdisknum += 1
+
+ log.info("Checking disk {} for VM {}".format(name, one_vm))
+
+ is_os_image, is_public, status = True, False, 'active'
image_size_in_gb = disk.get('image_size_in_gb')
disk_size_in_gb = disk.get('size_in_gb')
- storage_class = disk.get('pool_name')
+ storage_class = disk.get('storage_class')
image_source = disk.get('source')
image_source_type = disk.get('source_type')
image, _ = VMDiskImageProduct.objects.update_or_create(
name=name,
defaults={
- 'owner': owner,
+ 'owner': disk_owner,
'is_os_image': is_os_image,
'is_public': is_public,
'size_in_gb': image_size_in_gb,
@@ -68,29 +84,59 @@ def create_disk_and_image(one_vm, vm_product):
'status': status
}
)
- VMDiskProduct.objects.update_or_create(
- owner=owner, vm=vm_product,
- defaults={
- 'image': image,
- 'size_in_gb': disk_size_in_gb
- }
- )
+ # identify vmdisk from opennebula - primary mapping key
+ extra_data = {
+ 'opennebula_vm': one_vm.vmid,
+ 'opennebula_size_in_gb': disk_size_in_gb,
+ 'opennebula_source': disk.get('opennebula_source'),
+ 'opennebula_disk_num': vmdisknum
+ }
+ # Save for comparing later
+ one_disks_extra_data.append(extra_data)
+
+ try:
+ vm_disk = VMDiskProduct.objects.get(extra_data=extra_data)
+ except VMDiskProduct.DoesNotExist:
+ vm_disk = VMDiskProduct.objects.create(
+ owner=vmowner,
+ vm=vm_product,
+ image=image,
+ size_in_gb=disk_size_in_gb,
+ extra_data=extra_data
+ )
+
+ # Now remove all disks that are not in above extra_data list
+ for disk in VMDiskProduct.objects.filter(vm=vm_product):
+ extra_data = disk.extra_data
+ if not extra_data in one_disks_extra_data:
+ log.info("Removing disk {} from VM {}".format(disk, vm_product))
+ disk.delete()
+
+ disks = [ disk.extra_data for disk in VMDiskProduct.objects.filter(vm=vm_product) ]
+ log.info("VM {} has disks: {}".format(vm_product, disks))
class Command(BaseCommand):
help = 'Migrate Opennebula VM to regular (uncloud) vm'
+ def add_arguments(self, parser):
+ parser.add_argument('--disk-owner', required=True, help="The user who owns the the opennebula disks")
+
def handle(self, *args, **options):
+ log.debug("{} {}".format(args, options))
+
+ disk_owner = get_user_model().objects.get(username=options['disk_owner'])
+
for one_vm in VMModel.objects.all():
if not one_vm.last_host:
- print("No VMHost for VM {} - VM might be on hold - skipping".format(one_vm.vmid))
+ log.warning("No VMHost for VM {} - VM might be on hold - skipping".format(one_vm.vmid))
continue
try:
vmhost = VMHost.objects.get(hostname=one_vm.last_host)
except VMHost.DoesNotExist:
- print("VMHost {} does not exist, aborting".format(one_vm.last_host))
+ log.error("VMHost {} does not exist, aborting".format(one_vm.last_host))
raise
cores = one_vm.cores
@@ -98,9 +144,6 @@ class Command(BaseCommand):
owner = one_vm.owner
status = 'active'
- # Total Amount of SSD Storage
- # TODO: What would happen if the attached storage is not SSD but HDD?
-
ssd_size = sum([ disk['size_in_gb'] for disk in one_vm.disks if disk['pool_name'] in ['ssd', 'one'] ])
hdd_size = sum([ disk['size_in_gb'] for disk in one_vm.disks if disk['pool_name'] in ['hdd'] ])
@@ -119,30 +162,32 @@ class Command(BaseCommand):
len(ipv4), len(ipv6))
try:
- vm_product = VMProduct.objects.get(name=one_vm.uncloud_name)
+ vm_product = VMProduct.objects.get(extra_data__opennebula_id=one_vm.vmid)
except VMProduct.DoesNotExist:
order = Order.objects.create(
owner=owner,
creation_date=creation_date,
starting_date=starting_date
-# one_time_price=one_time_price,
-# recurring_price=recurring_price,
-# recurring_period=recurring_period
)
- vm_product, _ = VMProduct.objects.update_or_create(
+ vm_product = VMProduct(
+ extra_data={ 'opennebula_id': one_vm.vmid },
name=one_vm.uncloud_name,
- defaults={
- 'cores': cores,
- 'ram_in_gb': ram_in_gb,
- 'owner': owner,
- 'vmhost': vmhost,
- 'order': order,
- 'status': status
- }
+ order=order
)
+ # we don't use update_or_create, as filtering by json AND setting json
+ # at the same time does not work
+
+ vm_product.vmhost = vmhost
+ vm_product.owner = owner
+ vm_product.cores = cores
+ vm_product.ram_in_gb = ram_in_gb
+ vm_product.status = status
+
+ vm_product.save()
+
# Create VMNetworkCards
create_nics(one_vm, vm_product)
# Create VMDiskImageProduct and VMDiskProduct
- create_disk_and_image(one_vm, vm_product)
+ sync_disk_and_image(one_vm, vm_product, disk_owner=disk_owner)
diff --git a/opennebula/migrations/0001_initial.py b/opennebula/migrations/0001_initial.py
new file mode 100644
index 0000000..9a135c6
--- /dev/null
+++ b/opennebula/migrations/0001_initial.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1 on 2020-12-13 10:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='VM',
+ fields=[
+ ('vmid', models.IntegerField(primary_key=True, serialize=False)),
+ ('data', models.JSONField()),
+ ],
+ ),
+ ]
diff --git a/opennebula/migrations/__init__.py b/opennebula/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud/opennebula/models.py b/opennebula/models.py
similarity index 88%
rename from uncloud/opennebula/models.py
rename to opennebula/models.py
index f5faeb5..f15b845 100644
--- a/uncloud/opennebula/models.py
+++ b/opennebula/models.py
@@ -1,13 +1,18 @@
import uuid
from django.db import models
from django.contrib.auth import get_user_model
-from django.contrib.postgres.fields import JSONField
+from uncloud_pay.models import Product
+# ungleich specific
+storage_class_mapping = {
+ 'one': 'ssd',
+ 'ssd': 'ssd',
+ 'hdd': 'hdd'
+}
class VM(models.Model):
vmid = models.IntegerField(primary_key=True)
- owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
- data = JSONField()
+ data = models.JSONField()
@property
def uncloud_name(self):
@@ -48,7 +53,8 @@ class VM(models.Model):
'pool_name': d['POOL_NAME'],
'image': d['IMAGE'],
'source': d['SOURCE'],
- 'source_type': d['TM_MAD']
+ 'source_type': d['TM_MAD'],
+ 'storage_class': storage_class_mapping[d['POOL_NAME']]
}
for d in disks
diff --git a/uncloud/opennebula/serializers.py b/opennebula/serializers.py
similarity index 55%
rename from uncloud/opennebula/serializers.py
rename to opennebula/serializers.py
index 8e0c513..cd00622 100644
--- a/uncloud/opennebula/serializers.py
+++ b/opennebula/serializers.py
@@ -5,4 +5,6 @@ from opennebula.models import VM
class OpenNebulaVMSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = VM
- fields = '__all__'
+ fields = [ 'vmid', 'owner', 'data',
+ 'uncloud_name', 'cores', 'ram_in_gb',
+ 'disks', 'nics', 'ips' ]
diff --git a/uncloud/opennebula/tests.py b/opennebula/tests.py
similarity index 100%
rename from uncloud/opennebula/tests.py
rename to opennebula/tests.py
diff --git a/opennebula/views.py b/opennebula/views.py
new file mode 100644
index 0000000..688f0b4
--- /dev/null
+++ b/opennebula/views.py
@@ -0,0 +1,16 @@
+from rest_framework import viewsets, permissions
+
+#from .models import VM
+# from .serializers import OpenNebulaVMSerializer
+
+# class VMViewSet(viewsets.ModelViewSet):
+# permission_classes = [permissions.IsAuthenticated]
+# serializer_class = OpenNebulaVMSerializer
+
+# def get_queryset(self):
+# if self.request.user.is_superuser:
+# obj = VM.objects.all()
+# else:
+# obj = VM.objects.filter(owner=self.request.user)
+
+# return obj
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..71b1305
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,45 @@
+# Django basics
+Django==4.0
+djangorestframework
+django-auth-ldap
+psycopg2
+
+django-bootstrap-v5
+
+#fontawesome-free
+#django-mathfilters
+
+
+ldap3
+django-allauth
+django-compressor
+xmltodict
+django-wkhtmltopdf
+parsedatetime
+# Follow are for creating graph models
+pyparsing
+pydot
+django-extensions
+django-notifications-hq
+django-environ
+
+# PDF creating
+#django-hardcopy
+
+# schema support
+pyyaml
+uritemplate
+tldextract
+# Payment & VAT
+vat-validator
+stripe
+
+#Jobs
+django-q
+redis
+
+jinja2
+#python-gitlab
+dnspython
+
+git+https://github.com/ungleich/python-oca.git#egg=oca
diff --git a/resources/ci/.lock b/resources/ci/.lock
new file mode 100644
index 0000000..e69de29
diff --git a/resources/ci/Dockerfile b/resources/ci/Dockerfile
new file mode 100644
index 0000000..020b66e
--- /dev/null
+++ b/resources/ci/Dockerfile
@@ -0,0 +1,3 @@
+FROM fedora:latest
+
+RUN dnf install -y python3-devel python3-pip python3-coverage libpq-devel openldap-devel gcc chromium
diff --git a/vat_rates.csv b/resources/vat-rates.csv
similarity index 100%
rename from vat_rates.csv
rename to resources/vat-rates.csv
diff --git a/uncloud/.dockerignore b/uncloud/.dockerignore
new file mode 100644
index 0000000..32eda49
--- /dev/null
+++ b/uncloud/.dockerignore
@@ -0,0 +1,3 @@
+local_settings.py
+ldap_max_uid_file
+.env
diff --git a/uncloud/.env-sample b/uncloud/.env-sample
new file mode 100644
index 0000000..974d307
--- /dev/null
+++ b/uncloud/.env-sample
@@ -0,0 +1,31 @@
+ACTIVE_APP=nextcloud
+ALLOWED_HOSTS='*'
+STRIPE_KEY=
+STRIPE_PUBLIC_KEY=
+DATABASE_NAME=uncloud
+DATABASE_HOST=localhost
+DATABASE_PORT=
+DATABASE_USER=
+DATABASE_PASSWORD=
+EMAIL_HOST=
+EMAIL_HOST_USER=
+EMAIL_HOST_PASSWORD=
+GITLAB_SERVER=
+GITLAB_YAML_DIR=
+GITLAB_PROJECT_ID=
+GITLAB_DNS_PROJECT_ID=
+MATRIX_DNS_MAIN_DOMAIN=
+GITLAB_DNS_OAUTH_TOKEN=
+GITLAB_OAUTH_TOKEN=
+GITLAB_AUTHOR_EMAIL=
+GITLAB_AUTHOR_NAME=
+WKHTMLTOPDF_CMD=/usr/bin/wkhtmltopdf
+LDAP_DEFAULT_START_UID=
+AUTH_LDAP_SERVER_HOST=
+AUTH_LDAP_SERVER_URI=
+AUTH_LDAP_BIND_DN=
+AUTH_LDAP_BIND_PASSWORD=
+LDAP_ADMIN_DN=
+LDAP_ADMIN_PASSWORD=
+LDAP_CUSTOMER_GROUP_ID=
+LDAP_CUSTOMER_DN=
diff --git a/uncloud/.gitignore b/uncloud/.gitignore
index 71202e1..32eda49 100644
--- a/uncloud/.gitignore
+++ b/uncloud/.gitignore
@@ -1,4 +1,3 @@
-db.sqlite3
-uncloud/secrets.py
-debug.log
-uncloud/local_settings.py
\ No newline at end of file
+local_settings.py
+ldap_max_uid_file
+.env
diff --git a/uncloud/README-how-to-create-a-product.md b/uncloud/README-how-to-create-a-product.md
deleted file mode 100644
index 6ddd1fa..0000000
--- a/uncloud/README-how-to-create-a-product.md
+++ /dev/null
@@ -1,9 +0,0 @@
-## Introduction
-
-This document describes how to create a product and use it.
-
-A product (like a VMSnapshotproduct) creates an order when ordered.
-The "order" is used to combine products together.
-
-Sub-products or related products link to the same order.
-Each product has one (?) orderrecord
diff --git a/uncloud/README-object-relations.md b/uncloud/README-object-relations.md
deleted file mode 100644
index 58f2413..0000000
--- a/uncloud/README-object-relations.md
+++ /dev/null
@@ -1,82 +0,0 @@
-## Introduction
-
-This article describes how models relate to each other and what the
-design ideas are. It is meant to prevent us from double implementing
-something or changing something that is already solved.
-
-
-## Products
-
-A product is something someone can order. We might have "low level"
-products that need to be composed (= higher degree of flexibility, but
-more amount of details necessary) and "composed products" that present
-some defaults or select other products automatically (f.i. a "dual
-stack VM" can be a VM + a disk + an IPv4 address).
-
-
-## Bills
-
-Bills represent active orders of a month. Bills can be shown during a
-month but only become definitive at the end of the month.
-
-## Orders
-
-When customer X order a (set) of product, it generates an order for billing
-purposes. The ordered products point to that order and register an Order Record
-at creation.
-
-Orders and Order Records are assumed immutable => they are used to generate
-bills and should not be mutated. If a product is updated (e.g. adding RAM to
-VM), a new order should be generated.
-
-The order MUST NOT be deleted when a product is deleted, as it is used for
-billing (including past bills).
-
-### Order record
-
-Used to store billing details of a product at creation: will stay there even if
-the product change (e.g. new pricing, updated) and act as some kind of archive.
-Used to generate bills.
-
-## Payment Methods
-
-Users/customers can register payment methods.
-
-## Sample flows / products
-
-### A VM snapshot
-
-A VM snapshot creates a snapshot of all disks attached to a VM to be
-able to rollback the VM to a previous state.
-
-Creating a VM snapshot (-product) creates a related order. Deleting a
-VMSnapshotproduct sets the order to deleted.
-
-### Object Storage
-
-(tbd by Balazs)
-
-### A "raw" VM
-
-(tbd by Ahmed)
-
-### An IPv6 only VM
-
-(tbd by Ahmed)
-
-### A dual stack VM
-
-(tbd by Ahmed)
-
-### A managed service (e.g. Matrix-as-a-Service)
-
-Customer orders service with:
- * Service-specific configuration: e.g. domain name for matrix
- * VM configuration:
- - CPU
- - Memory
- - Disk (soon)
-
-It creates a new Order with two products/records:
- * Service itself (= management)
- * Underlying VM
diff --git a/uncloud/README.md b/uncloud/README.md
deleted file mode 100644
index 390a3af..0000000
--- a/uncloud/README.md
+++ /dev/null
@@ -1,95 +0,0 @@
-## Install
-
-### OS package requirements
-
-Alpine:
-
-```
-apk add openldap-dev postgresql-dev
-```
-
-Debian/Devuan:
-
-```
-apt install postgresql-server-dev-all
-```
-
-
-### Python requirements
-
-If you prefer using a venv, use:
-
-```
-python -m venv venv
-. ./venv/bin/activate
-```
-
-Then install the requirements
-
-```
-pip install -r requirements.txt
-```
-
-### Database requirements
-
-Due to the use of the JSONField, postgresql is required.
-
-First create a role to be used:
-
-```
-postgres=# create role nico login;
-```
-
-Then create the database owner by the new role:
-
-```
-postgres=# create database uncloud owner nico;
-```
-
-Installing the postgresql service is os dependent, but some hints:
-
-* Alpine: `apk add postgresql-server && rc-update add postgresql && rc-service postgresql start`
-* Debian/Devuan: `apt install postgresql`
-
-After postresql is started, apply the migrations:
-
-```
-python manage.py migrate
-```
-
-### Secrets
-
-cp `uncloud/secrets_sample.py` to `uncloud/secrets.py` and replace the
-sample values with real values.
-
-
-## Flows / Orders
-
-### Creating a VMHost
-
-
-
-### Creating a VM
-
-* Create a VMHost
-* Create a VM on a VMHost
-
-
-### Creating a VM Snapshot
-
-
-## Working Beta APIs
-
-These APIs can be used for internal testing.
-
-### URL Overview
-
-```
-http -a nicoschottelius:$(pass ungleich.ch/nico.schottelius@ungleich.ch) http://localhost:8000
-```
-
-### Snapshotting
-
-```
-http -a nicoschottelius:$(pass ungleich.ch/nico.schottelius@ungleich.ch) http://localhost:8000/vm/snapshot/ vm_uuid=$(uuidgen)
-```
diff --git a/uncloud/__init__.py b/uncloud/__init__.py
new file mode 100644
index 0000000..3ce2d95
--- /dev/null
+++ b/uncloud/__init__.py
@@ -0,0 +1,253 @@
+from django.utils.translation import gettext_lazy as _
+import decimal
+
+# Define DecimalField properties, used to represent amounts of money.
+AMOUNT_MAX_DIGITS=10
+AMOUNT_DECIMALS=2
+
+decimal.getcontext().prec = AMOUNT_DECIMALS
+
+# http://xml.coverpages.org/country3166.html
+COUNTRIES = (
+ ('AD', _('Andorra')),
+ ('AE', _('United Arab Emirates')),
+ ('AF', _('Afghanistan')),
+ ('AG', _('Antigua & Barbuda')),
+ ('AI', _('Anguilla')),
+ ('AL', _('Albania')),
+ ('AM', _('Armenia')),
+ ('AN', _('Netherlands Antilles')),
+ ('AO', _('Angola')),
+ ('AQ', _('Antarctica')),
+ ('AR', _('Argentina')),
+ ('AS', _('American Samoa')),
+ ('AT', _('Austria')),
+ ('AU', _('Australia')),
+ ('AW', _('Aruba')),
+ ('AZ', _('Azerbaijan')),
+ ('BA', _('Bosnia and Herzegovina')),
+ ('BB', _('Barbados')),
+ ('BD', _('Bangladesh')),
+ ('BE', _('Belgium')),
+ ('BF', _('Burkina Faso')),
+ ('BG', _('Bulgaria')),
+ ('BH', _('Bahrain')),
+ ('BI', _('Burundi')),
+ ('BJ', _('Benin')),
+ ('BM', _('Bermuda')),
+ ('BN', _('Brunei Darussalam')),
+ ('BO', _('Bolivia')),
+ ('BR', _('Brazil')),
+ ('BS', _('Bahama')),
+ ('BT', _('Bhutan')),
+ ('BV', _('Bouvet Island')),
+ ('BW', _('Botswana')),
+ ('BY', _('Belarus')),
+ ('BZ', _('Belize')),
+ ('CA', _('Canada')),
+ ('CC', _('Cocos (Keeling) Islands')),
+ ('CF', _('Central African Republic')),
+ ('CG', _('Congo')),
+ ('CH', _('Switzerland')),
+ ('CI', _('Ivory Coast')),
+ ('CK', _('Cook Iislands')),
+ ('CL', _('Chile')),
+ ('CM', _('Cameroon')),
+ ('CN', _('China')),
+ ('CO', _('Colombia')),
+ ('CR', _('Costa Rica')),
+ ('CU', _('Cuba')),
+ ('CV', _('Cape Verde')),
+ ('CX', _('Christmas Island')),
+ ('CY', _('Cyprus')),
+ ('CZ', _('Czech Republic')),
+ ('DE', _('Germany')),
+ ('DJ', _('Djibouti')),
+ ('DK', _('Denmark')),
+ ('DM', _('Dominica')),
+ ('DO', _('Dominican Republic')),
+ ('DZ', _('Algeria')),
+ ('EC', _('Ecuador')),
+ ('EE', _('Estonia')),
+ ('EG', _('Egypt')),
+ ('EH', _('Western Sahara')),
+ ('ER', _('Eritrea')),
+ ('ES', _('Spain')),
+ ('ET', _('Ethiopia')),
+ ('FI', _('Finland')),
+ ('FJ', _('Fiji')),
+ ('FK', _('Falkland Islands (Malvinas)')),
+ ('FM', _('Micronesia')),
+ ('FO', _('Faroe Islands')),
+ ('FR', _('France')),
+ ('FX', _('France, Metropolitan')),
+ ('GA', _('Gabon')),
+ ('GB', _('United Kingdom (Great Britain)')),
+ ('GD', _('Grenada')),
+ ('GE', _('Georgia')),
+ ('GF', _('French Guiana')),
+ ('GH', _('Ghana')),
+ ('GI', _('Gibraltar')),
+ ('GL', _('Greenland')),
+ ('GM', _('Gambia')),
+ ('GN', _('Guinea')),
+ ('GP', _('Guadeloupe')),
+ ('GQ', _('Equatorial Guinea')),
+ ('GR', _('Greece')),
+ ('GS', _('South Georgia and the South Sandwich Islands')),
+ ('GT', _('Guatemala')),
+ ('GU', _('Guam')),
+ ('GW', _('Guinea-Bissau')),
+ ('GY', _('Guyana')),
+ ('HK', _('Hong Kong')),
+ ('HM', _('Heard & McDonald Islands')),
+ ('HN', _('Honduras')),
+ ('HR', _('Croatia')),
+ ('HT', _('Haiti')),
+ ('HU', _('Hungary')),
+ ('ID', _('Indonesia')),
+ ('IE', _('Ireland')),
+ ('IL', _('Israel')),
+ ('IN', _('India')),
+ ('IO', _('British Indian Ocean Territory')),
+ ('IQ', _('Iraq')),
+ ('IR', _('Islamic Republic of Iran')),
+ ('IS', _('Iceland')),
+ ('IT', _('Italy')),
+ ('JM', _('Jamaica')),
+ ('JO', _('Jordan')),
+ ('JP', _('Japan')),
+ ('KE', _('Kenya')),
+ ('KG', _('Kyrgyzstan')),
+ ('KH', _('Cambodia')),
+ ('KI', _('Kiribati')),
+ ('KM', _('Comoros')),
+ ('KN', _('St. Kitts and Nevis')),
+ ('KP', _('Korea, Democratic People\'s Republic of')),
+ ('KR', _('Korea, Republic of')),
+ ('KW', _('Kuwait')),
+ ('KY', _('Cayman Islands')),
+ ('KZ', _('Kazakhstan')),
+ ('LA', _('Lao People\'s Democratic Republic')),
+ ('LB', _('Lebanon')),
+ ('LC', _('Saint Lucia')),
+ ('LI', _('Liechtenstein')),
+ ('LK', _('Sri Lanka')),
+ ('LR', _('Liberia')),
+ ('LS', _('Lesotho')),
+ ('LT', _('Lithuania')),
+ ('LU', _('Luxembourg')),
+ ('LV', _('Latvia')),
+ ('LY', _('Libyan Arab Jamahiriya')),
+ ('MA', _('Morocco')),
+ ('MC', _('Monaco')),
+ ('MD', _('Moldova, Republic of')),
+ ('MG', _('Madagascar')),
+ ('MH', _('Marshall Islands')),
+ ('ML', _('Mali')),
+ ('MN', _('Mongolia')),
+ ('MM', _('Myanmar')),
+ ('MO', _('Macau')),
+ ('MP', _('Northern Mariana Islands')),
+ ('MQ', _('Martinique')),
+ ('MR', _('Mauritania')),
+ ('MS', _('Monserrat')),
+ ('MT', _('Malta')),
+ ('MU', _('Mauritius')),
+ ('MV', _('Maldives')),
+ ('MW', _('Malawi')),
+ ('MX', _('Mexico')),
+ ('MY', _('Malaysia')),
+ ('MZ', _('Mozambique')),
+ ('NA', _('Namibia')),
+ ('NC', _('New Caledonia')),
+ ('NE', _('Niger')),
+ ('NF', _('Norfolk Island')),
+ ('NG', _('Nigeria')),
+ ('NI', _('Nicaragua')),
+ ('NL', _('Netherlands')),
+ ('NO', _('Norway')),
+ ('NP', _('Nepal')),
+ ('NR', _('Nauru')),
+ ('NU', _('Niue')),
+ ('NZ', _('New Zealand')),
+ ('OM', _('Oman')),
+ ('PA', _('Panama')),
+ ('PE', _('Peru')),
+ ('PF', _('French Polynesia')),
+ ('PG', _('Papua New Guinea')),
+ ('PH', _('Philippines')),
+ ('PK', _('Pakistan')),
+ ('PL', _('Poland')),
+ ('PM', _('St. Pierre & Miquelon')),
+ ('PN', _('Pitcairn')),
+ ('PR', _('Puerto Rico')),
+ ('PT', _('Portugal')),
+ ('PW', _('Palau')),
+ ('PY', _('Paraguay')),
+ ('QA', _('Qatar')),
+ ('RE', _('Reunion')),
+ ('RO', _('Romania')),
+ ('RU', _('Russian Federation')),
+ ('RW', _('Rwanda')),
+ ('SA', _('Saudi Arabia')),
+ ('SB', _('Solomon Islands')),
+ ('SC', _('Seychelles')),
+ ('SD', _('Sudan')),
+ ('SE', _('Sweden')),
+ ('SG', _('Singapore')),
+ ('SH', _('St. Helena')),
+ ('SI', _('Slovenia')),
+ ('SJ', _('Svalbard & Jan Mayen Islands')),
+ ('SK', _('Slovakia')),
+ ('SL', _('Sierra Leone')),
+ ('SM', _('San Marino')),
+ ('SN', _('Senegal')),
+ ('SO', _('Somalia')),
+ ('SR', _('Suriname')),
+ ('ST', _('Sao Tome & Principe')),
+ ('SV', _('El Salvador')),
+ ('SY', _('Syrian Arab Republic')),
+ ('SZ', _('Swaziland')),
+ ('TC', _('Turks & Caicos Islands')),
+ ('TD', _('Chad')),
+ ('TF', _('French Southern Territories')),
+ ('TG', _('Togo')),
+ ('TH', _('Thailand')),
+ ('TJ', _('Tajikistan')),
+ ('TK', _('Tokelau')),
+ ('TM', _('Turkmenistan')),
+ ('TN', _('Tunisia')),
+ ('TO', _('Tonga')),
+ ('TP', _('East Timor')),
+ ('TR', _('Turkey')),
+ ('TT', _('Trinidad & Tobago')),
+ ('TV', _('Tuvalu')),
+ ('TW', _('Taiwan, Province of China')),
+ ('TZ', _('Tanzania, United Republic of')),
+ ('UA', _('Ukraine')),
+ ('UG', _('Uganda')),
+ ('UM', _('United States Minor Outlying Islands')),
+ ('US', _('United States of America')),
+ ('UY', _('Uruguay')),
+ ('UZ', _('Uzbekistan')),
+ ('VA', _('Vatican City State (Holy See)')),
+ ('VC', _('St. Vincent & the Grenadines')),
+ ('VE', _('Venezuela')),
+ ('VG', _('British Virgin Islands')),
+ ('VI', _('United States Virgin Islands')),
+ ('VN', _('Viet Nam')),
+ ('VU', _('Vanuatu')),
+ ('WF', _('Wallis & Futuna Islands')),
+ ('WS', _('Samoa')),
+ ('YE', _('Yemen')),
+ ('YT', _('Mayotte')),
+ ('YU', _('Yugoslavia')),
+ ('ZA', _('South Africa')),
+ ('ZM', _('Zambia')),
+ ('ZR', _('Zaire')),
+ ('ZW', _('Zimbabwe')),
+)
+
+
+__all__ = ()
diff --git a/uncloud/admin.py b/uncloud/admin.py
new file mode 100644
index 0000000..38f8cce
--- /dev/null
+++ b/uncloud/admin.py
@@ -0,0 +1,6 @@
+from django.contrib import admin
+
+from .models import *
+
+for m in [ UncloudProvider, UncloudNetwork ]:
+ admin.site.register(m)
diff --git a/uncloud/uncloud/asgi.py b/uncloud/asgi.py
similarity index 100%
rename from uncloud/uncloud/asgi.py
rename to uncloud/asgi.py
diff --git a/uncloud/forms.py b/uncloud/forms.py
new file mode 100644
index 0000000..da8c4ec
--- /dev/null
+++ b/uncloud/forms.py
@@ -0,0 +1,35 @@
+from django import forms
+from django.contrib.auth.models import User
+from django.forms import ModelForm
+from .validators import domain_name_validator
+
+
+class DomainNameField(forms.CharField):
+ description = 'Domain name form field'
+ default_validators = [domain_name_validator, ]
+
+ def __init__(self, *args, **kwargs):
+ super(DomainNameField, self).__init__(*args, **kwargs)
+
+class UserDeleteForm(forms.ModelForm):
+ class Meta:
+ model = User
+ fields = []
+
+class MainForm(forms.Form):
+ def __init__(self, *args, **kwargs):
+ super(MainForm, self).__init__(*args, **kwargs)
+ for visible in self.visible_fields():
+ if isinstance(visible.field.widget, forms.TextInput):
+ visible.field.widget.attrs['class'] = 'form-control'
+ elif isinstance(visible.field.widget, forms.Select):
+ visible.field.widget.attrs['class'] = 'form-select'
+ elif (isinstance(visible.field.widget, forms.CheckboxInput)):
+ visible.field.widget.attrs['class'] = 'custom-control-input'
+
+class MainModelForm(ModelForm):
+ def __init__(self, *args, **kwargs):
+ super(MainModelForm, self).__init__(*args, **kwargs)
+ for visible in self.visible_fields():
+ visible.field.widget.attrs['class'] = 'form-control'
+
diff --git a/uncloud/management/commands/db-add-defaults.py b/uncloud/management/commands/db-add-defaults.py
new file mode 100644
index 0000000..605c8f5
--- /dev/null
+++ b/uncloud/management/commands/db-add-defaults.py
@@ -0,0 +1,43 @@
+import random
+import string
+
+from django.core.management.base import BaseCommand
+from django.core.exceptions import ObjectDoesNotExist
+from django.contrib.auth import get_user_model
+from django.conf import settings
+
+from uncloud_pay.models import BillingAddress, RecurringPeriod, Product
+from uncloud.models import UncloudProvider, UncloudNetwork
+
+
+class Command(BaseCommand):
+ help = 'Add standard uncloud values'
+
+ def add_arguments(self, parser):
+ pass
+
+ def handle(self, *args, **options):
+ # Order matters, objects can be dependent on each other
+
+ admin_username="uncloud-admin"
+ pw_length = 32
+
+ # Only set password if the user did not exist before
+ try:
+ admin_user = get_user_model().objects.get(username=settings.UNCLOUD_ADMIN_NAME)
+ except ObjectDoesNotExist:
+ random_password = ''.join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(pw_length))
+
+ admin_user = get_user_model().objects.create_user(username=settings.UNCLOUD_ADMIN_NAME, password=random_password)
+ admin_user.is_superuser=True
+ admin_user.is_staff=True
+ admin_user.save()
+
+ print(f"Created admin user '{admin_username}' with password '{random_password}'")
+
+ BillingAddress.populate_db_defaults()
+ RecurringPeriod.populate_db_defaults()
+ Product.populate_db_defaults()
+
+ UncloudNetwork.populate_db_defaults()
+ UncloudProvider.populate_db_defaults()
diff --git a/uncloud/management/commands/uncloud.py b/uncloud/management/commands/uncloud.py
new file mode 100644
index 0000000..bd47c6b
--- /dev/null
+++ b/uncloud/management/commands/uncloud.py
@@ -0,0 +1,28 @@
+import sys
+from datetime import datetime
+
+from django.core.management.base import BaseCommand
+
+from django.contrib.auth import get_user_model
+
+from opennebula.models import VM as VMModel
+from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct, VMCluster
+
+import logging
+log = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = 'General uncloud commands'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--bootstrap', action='store_true', help='Bootstrap a typical uncloud installation')
+
+ def handle(self, *args, **options):
+
+ if options['bootstrap']:
+ self.bootstrap()
+
+ def bootstrap(self):
+ default_cluster = VMCluster.objects.get_or_create(name="default")
+# local_host =
diff --git a/uncloud/migrations/0001_initial.py b/uncloud/migrations/0001_initial.py
new file mode 100644
index 0000000..10d1144
--- /dev/null
+++ b/uncloud/migrations/0001_initial.py
@@ -0,0 +1,46 @@
+# Generated by Django 3.1 on 2020-12-13 10:38
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import uncloud.models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UncloudNetwork',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('network_address', models.GenericIPAddressField(unique=True)),
+ ('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
+ ('description', models.CharField(max_length=256)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='UncloudProvider',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('full_name', models.CharField(max_length=256)),
+ ('organization', models.CharField(blank=True, max_length=256, null=True)),
+ ('street', models.CharField(max_length=256)),
+ ('city', models.CharField(max_length=256)),
+ ('postal_code', models.CharField(max_length=64)),
+ ('country', uncloud.models.CountryField(blank=True, choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2)),
+ ('starting_date', models.DateField()),
+ ('ending_date', models.DateField(blank=True, null=True)),
+ ('billing_network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderbill', to='uncloud.uncloudnetwork')),
+ ('coupon_network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudprovidercoupon', to='uncloud.uncloudnetwork')),
+ ('referral_network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderreferral', to='uncloud.uncloudnetwork')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/uncloud/migrations/0002_uncloudtasks.py b/uncloud/migrations/0002_uncloudtasks.py
new file mode 100644
index 0000000..9c69606
--- /dev/null
+++ b/uncloud/migrations/0002_uncloudtasks.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1 on 2020-12-20 17:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UncloudTasks',
+ fields=[
+ ('task_id', models.UUIDField(primary_key=True, serialize=False)),
+ ],
+ ),
+ ]
diff --git a/uncloud/migrations/0003_auto_20201220_1728.py b/uncloud/migrations/0003_auto_20201220_1728.py
new file mode 100644
index 0000000..2ec0eec
--- /dev/null
+++ b/uncloud/migrations/0003_auto_20201220_1728.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-12-20 17:28
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud', '0002_uncloudtasks'),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name='UncloudTasks',
+ new_name='UncloudTask',
+ ),
+ ]
diff --git a/uncloud/migrations/0004_auto_20210101_1308.py b/uncloud/migrations/0004_auto_20210101_1308.py
new file mode 100644
index 0000000..8385b16
--- /dev/null
+++ b/uncloud/migrations/0004_auto_20210101_1308.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1 on 2021-01-01 13:08
+
+from django.db import migrations
+import uncloud.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud', '0003_auto_20201220_1728'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='uncloudprovider',
+ name='country',
+ field=uncloud.models.CountryField(choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2),
+ ),
+ ]
diff --git a/uncloud/migrations/0005_delete_uncloudtask.py b/uncloud/migrations/0005_delete_uncloudtask.py
new file mode 100644
index 0000000..6d9b095
--- /dev/null
+++ b/uncloud/migrations/0005_delete_uncloudtask.py
@@ -0,0 +1,16 @@
+# Generated by Django 3.2.4 on 2021-07-07 15:11
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud', '0004_auto_20210101_1308'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='UncloudTask',
+ ),
+ ]
diff --git a/uncloud/migrations/__init__.py b/uncloud/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud/models.py b/uncloud/models.py
new file mode 100644
index 0000000..c2b3cf9
--- /dev/null
+++ b/uncloud/models.py
@@ -0,0 +1,209 @@
+from django.db import models
+from django.db.models import JSONField, Q
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+from django.core.validators import MinValueValidator, MaxValueValidator
+from django.core.exceptions import FieldError
+
+from uncloud import COUNTRIES
+from .selectors import filter_for_when
+
+class UncloudModel(models.Model):
+ """
+ This class extends the standard model with an
+ extra_data field that can be used to include public,
+ but internal information.
+
+ For instance if you migrate from an existing virtualisation
+ framework to uncloud.
+
+ The extra_data attribute should be considered a hack and whenever
+ data is necessary for running uncloud, it should **not** be stored
+ in there.
+
+ """
+
+ extra_data = JSONField(editable=False, blank=True, null=True)
+
+ class Meta:
+ abstract = True
+
+# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
+class UncloudStatus(models.TextChoices):
+ PENDING = 'PENDING', _('Pending')
+ AWAITING_PAYMENT = 'AWAITING_PAYMENT', _('Awaiting payment')
+ BEING_CREATED = 'BEING_CREATED', _('Being created')
+ SCHEDULED = 'SCHEDULED', _('Scheduled') # resource selected, waiting for dispatching
+ ACTIVE = 'ACTIVE', _('Active')
+ MODIFYING = 'MODIFYING', _('Modifying') # Resource is being changed
+ DELETED = 'DELETED', _('Deleted') # Resource has been deleted
+ DISABLED = 'DISABLED', _('Disabled') # Is usable, but cannot be used for new things
+ UNUSABLE = 'UNUSABLE', _('Unusable'), # Has some kind of error
+
+
+
+###
+# General address handling
+class CountryField(models.CharField):
+ def __init__(self, *args, **kwargs):
+ kwargs.setdefault('choices', COUNTRIES)
+ kwargs.setdefault('default', 'CH')
+ kwargs.setdefault('max_length', 2)
+
+ super().__init__(*args, **kwargs)
+
+ def get_internal_type(self):
+ return "CharField"
+
+
+class UncloudAddress(models.Model):
+ full_name = models.CharField(max_length=256, null=False)
+ organization = models.CharField(max_length=256, blank=True, null=True)
+ street = models.CharField(max_length=256, null=False)
+ city = models.CharField(max_length=256, null=False)
+ postal_code = models.CharField(max_length=64)
+ country = CountryField(blank=False, null=False)
+
+ class Meta:
+ abstract = True
+
+
+class UncloudValidTimeFrame(models.Model):
+ """
+ A model that allows to limit validity of something to a certain
+ time frame. Used for versioning basically.
+
+ Logic:
+
+ """
+
+ class Meta:
+ abstract = True
+
+ constraints = [
+ models.UniqueConstraint(fields=['owner'],
+ condition=models.Q(active=True),
+ name='one_active_card_per_user')
+ ]
+
+
+ valid_from = models.DateTimeField(default=timezone.now, null=True, blank=True)
+ valid_to = models.DateTimeField(null=True, blank=True)
+
+ @classmethod
+ def get_current(cls, *args, **kwargs):
+ now = timezone.now()
+
+ # With both given
+ cls.objects.filter(valid_from__lte=now,
+ valid_to__gte=now)
+
+ # With to missing
+ cls.objects.filter(valid_from__lte=now,
+ valid_to__isnull=true)
+
+ # With from missing
+ cls.objects.filter(valid_from__isnull=true,
+ valid_to__gte=now)
+
+ # Both missing
+ cls.objects.filter(valid_from__isnull=true,
+ valid_to__gte=now)
+
+
+
+
+
+###
+# UncloudNetworks are used as identifiers - such they are a base of uncloud
+
+class UncloudNetwork(models.Model):
+ """
+ Storing IP networks
+ """
+
+ network_address = models.GenericIPAddressField(null=False, unique=True)
+ network_mask = models.IntegerField(null=False,
+ validators=[MinValueValidator(0),
+ MaxValueValidator(128)]
+ )
+
+ description = models.CharField(max_length=256)
+
+ @classmethod
+ def populate_db_defaults(cls):
+ for net, desc in [
+ ( "2a0a:e5c0:11::", "uncloud Billing" ),
+ ( "2a0a:e5c0:11:1::", "uncloud Referral" ),
+ ( "2a0a:e5c0:11:2::", "uncloud Coupon" )
+ ]:
+ obj, created = cls.objects.get_or_create(network_address=net,
+ defaults= {
+ 'network_mask': 64,
+ 'description': desc
+ }
+ )
+
+
+ def save(self, *args, **kwargs):
+ if not ':' in self.network_address and self.network_mask > 32:
+ raise FieldError("Mask cannot exceed 32 for IPv4")
+
+ super().save(*args, **kwargs)
+
+
+ def __str__(self):
+ return f"{self.network_address}/{self.network_mask} {self.description}"
+
+###
+# Who is running / providing this instance of uncloud?
+
+class UncloudProvider(UncloudAddress):
+ """
+ A class resembling who is running this uncloud instance.
+ This might change over time so we allow starting/ending dates
+
+ This also defines the taxation rules.
+
+ starting/ending date define from when to when this is valid. This way
+ we can model address changes and have it correct in the bills.
+ """
+
+ # Meta:
+ # FIXMe: only allow non overlapping time frames -- how to define this as a constraint?
+ starting_date = models.DateField()
+ ending_date = models.DateField(blank=True, null=True)
+
+ billing_network = models.ForeignKey(UncloudNetwork, related_name="uncloudproviderbill", on_delete=models.CASCADE)
+ referral_network = models.ForeignKey(UncloudNetwork, related_name="uncloudproviderreferral", on_delete=models.CASCADE)
+ coupon_network = models.ForeignKey(UncloudNetwork, related_name="uncloudprovidercoupon", on_delete=models.CASCADE)
+
+
+ @classmethod
+ def get_provider(cls, when=None):
+ """
+ Find active provide at a certain time - if there was any
+ """
+
+
+ return cls.objects.get(Q(starting_date__gte=when, ending_date__lte=when) |
+ Q(starting_date__gte=when, ending_date__isnull=True))
+
+
+ @classmethod
+ def populate_db_defaults(cls):
+ obj, created = cls.objects.get_or_create(full_name="ungleich glarus ag",
+ street="Bahnhofstrasse 1",
+ postal_code="8783",
+ city="Linthal",
+ country="CH",
+ starting_date=timezone.now(),
+ billing_network=UncloudNetwork.objects.get(description="uncloud Billing"),
+ referral_network=UncloudNetwork.objects.get(description="uncloud Referral"),
+ coupon_network=UncloudNetwork.objects.get(description="uncloud Coupon")
+ )
+
+
+ def __str__(self):
+ return f"{self.full_name} {self.country}"
+
diff --git a/uncloud/opennebula/migrations/0001_initial.py b/uncloud/opennebula/migrations/0001_initial.py
deleted file mode 100644
index 4c0527a..0000000
--- a/uncloud/opennebula/migrations/0001_initial.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# Generated by Django 3.0.3 on 2020-02-23 17:12
-
-from django.conf import settings
-import django.contrib.postgres.fields.jsonb
-from django.db import migrations, models
-import django.db.models.deletion
-import uuid
-
-
-class Migration(migrations.Migration):
-
- initial = True
-
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.CreateModel(
- name='VM',
- fields=[
- ('vmid', models.IntegerField(primary_key=True, serialize=False)),
- ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
- ('data', django.contrib.postgres.fields.jsonb.JSONField()),
- ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
- ],
- ),
- ]
diff --git a/uncloud/opennebula/migrations/0002_auto_20200225_1335.py b/uncloud/opennebula/migrations/0002_auto_20200225_1335.py
deleted file mode 100644
index 1554aa6..0000000
--- a/uncloud/opennebula/migrations/0002_auto_20200225_1335.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# Generated by Django 3.0.3 on 2020-02-25 13:35
-
-from django.db import migrations, models
-import uuid
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('opennebula', '0001_initial'),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name='vm',
- name='uuid',
- ),
- migrations.RemoveField(
- model_name='vm',
- name='vmid',
- ),
- migrations.AddField(
- model_name='vm',
- name='id',
- field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, unique=True),
- ),
- ]
diff --git a/uncloud/opennebula/migrations/0003_auto_20200225_1428.py b/uncloud/opennebula/migrations/0003_auto_20200225_1428.py
deleted file mode 100644
index 8bb3d8d..0000000
--- a/uncloud/opennebula/migrations/0003_auto_20200225_1428.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# Generated by Django 3.0.3 on 2020-02-25 14:28
-
-from django.db import migrations, models
-import uuid
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('opennebula', '0002_auto_20200225_1335'),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='vm',
- name='id',
- field=models.CharField(default=uuid.uuid4, max_length=64, primary_key=True, serialize=False, unique=True),
- ),
- ]
diff --git a/uncloud/opennebula/migrations/0004_auto_20200225_1816.py b/uncloud/opennebula/migrations/0004_auto_20200225_1816.py
deleted file mode 100644
index 5b39f26..0000000
--- a/uncloud/opennebula/migrations/0004_auto_20200225_1816.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# Generated by Django 3.0.3 on 2020-02-25 18:16
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('opennebula', '0003_auto_20200225_1428'),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name='vm',
- name='id',
- ),
- migrations.AddField(
- model_name='vm',
- name='vmid',
- field=models.IntegerField(default=42, primary_key=True, serialize=False),
- preserve_default=False,
- ),
- ]
diff --git a/uncloud/opennebula/views.py b/uncloud/opennebula/views.py
deleted file mode 100644
index 89b1a52..0000000
--- a/uncloud/opennebula/views.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from rest_framework import viewsets, permissions
-
-from .models import VM
-from .serializers import OpenNebulaVMSerializer
-
-class VMViewSet(viewsets.ModelViewSet):
- permission_classes = [permissions.IsAuthenticated]
- serializer_class = OpenNebulaVMSerializer
-
- def get_queryset(self):
- if self.request.user.is_superuser:
- obj = VM.objects.all()
- else:
- obj = VM.objects.filter(owner=self.request.user)
-
- return obj
diff --git a/uncloud/requirements.txt b/uncloud/requirements.txt
deleted file mode 100644
index c7ebc65..0000000
--- a/uncloud/requirements.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-django
-djangorestframework
-django-auth-ldap
-stripe
-xmltodict
-psycopg2
-
-parsedatetime
-
-# Follow are for creating graph models
-pyparsing
-pydot
-django-extensions
diff --git a/uncloud/selectors.py b/uncloud/selectors.py
new file mode 100644
index 0000000..52b8548
--- /dev/null
+++ b/uncloud/selectors.py
@@ -0,0 +1,23 @@
+from django.db.models import Q
+from django.utils import timezone
+
+def filter_for_when(queryset, when=None):
+ """
+ Return a filtered queryset which is valid for the given date
+
+ Logic:
+
+ Look for entries that have a starting date before when
+ and either
+ - No ending date
+ - Ending date after "when"
+
+ Returns a queryset, you'll neet to apply .first() or similar on it
+
+ """
+
+ if not when:
+ when = timezone.now()
+
+ return queryset.filter(starting_date__lte=when).filter(Q(ending_date__gte=when) |
+ Q(ending_date__isnull=True))
diff --git a/uncloud/settings.py b/uncloud/settings.py
new file mode 100644
index 0000000..f7a16ce
--- /dev/null
+++ b/uncloud/settings.py
@@ -0,0 +1,304 @@
+"""
+Django settings for uncloud project.
+
+Generated by 'django-admin startproject' using Django 3.0.3.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/3.0/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/3.0/ref/settings/
+"""
+
+import os
+import re
+import ldap
+import sys
+import environ
+
+from django.core.management.utils import get_random_secret_key
+from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
+
+LOGGING = {
+ 'version': 1,
+ 'disable_existing_loggers': False,
+ 'handlers': {
+ 'console': {
+ 'class': 'logging.StreamHandler',
+ },
+ },
+ 'root': {
+ 'handlers': ['console'],
+ 'level': 'DEBUG',
+ },
+}
+
+# Initialise environment variables
+env = environ.Env()
+environ.Env.read_env()
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+SITE_ID = 1
+
+# Application definition
+
+INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.sites',
+ 'django.contrib.staticfiles',
+ 'django_extensions',
+ 'bootstrap5',
+# 'mathfilters',
+# 'compressor',
+# 'wkhtmltopdf',
+ 'rest_framework',
+ 'django_q',
+# 'notifications',
+ 'uncloud',
+ 'uncloud_auth',
+ 'uncloud_net',
+ 'uncloud_storage',
+ 'uncloud_vm',
+ 'uncloud_service',
+ 'opennebula',
+# env('ACTIVE_APP'),
+ 'uncloud_pay', # should be after the active app to load the templates from the active app first
+# 'allauth',
+# 'allauth.account',
+# 'allauth.socialaccount',
+]
+
+DEFAULT_AUTO_FIELD='django.db.models.AutoField'
+
+MIDDLEWARE = [
+ 'django.middleware.security.SecurityMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'uncloud.urls'
+#WKHTMLTOPDF_CMD = env('WKHTMLTOPDF_CMD')
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.template.context_processors.debug',
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = 'uncloud.wsgi.application'
+DJANGO_NOTIFICATIONS_CONFIG = { 'USE_JSONFIELD': True}
+
+# Password validation
+# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+###############################################################################
+# Authall Settings
+ACCOUNT_AUTHENTICATION_METHOD = "username"
+ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 1
+ACCOUNT_EMAIL_REQUIRED = True
+ACCOUNT_UNIQUE_EMAIL = True
+MAX_EMAIL_ADDRESSES = 1
+################################################################################
+# AUTH/LDAP
+
+LDAP_ENABLED = True
+AUTH_LDAP_SERVER_HOST = env('AUTH_LDAP_SERVER_HOST')
+AUTH_LDAP_SERVER_URI = env('AUTH_LDAP_SERVER_URI')
+AUTH_LDAP_BIND_DN = env('AUTH_LDAP_BIND_DN')
+AUTH_LDAP_BIND_PASSWORD = env('AUTH_LDAP_BIND_PASSWORD')
+
+AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=customers,dc=ungleich,dc=ch"
+AUTH_LDAP_USER_SEARCH = LDAPSearch("ou=customers,dc=ungleich,dc=ch",
+ ldap.SCOPE_SUBTREE,
+ "(uid=%(user)s)")
+# BIND_AS_AUTHENTICATING_USER = True
+START_TLS = True
+LDAP_ADMIN_DN = env("LDAP_ADMIN_DN")
+LDAP_ADMIN_PASSWORD = env("LDAP_ADMIN_PASSWORD")
+LDAP_CUSTOMER_GROUP_ID = env("LDAP_CUSTOMER_GROUP_ID")
+LDAP_CUSTOMER_DN=env("LDAP_CUSTOMER_DN")
+
+#AUTH_LDAP_USER_QUERY_FIELD = "email"
+AUTH_LDAP_USER_ATTR_MAP = {
+ "first_name": "cn",
+ "last_name": "sn",
+ "email": "mail"
+}
+LDAP_DEFAULT_START_UID = int(env('LDAP_DEFAULT_START_UID'))
+
+LDAP_MAX_UID_FILE_PATH = os.environ.get('LDAP_MAX_UID_FILE_PATH',
+ os.path.join(os.path.abspath(os.path.dirname(__file__)), 'ldap_max_uid_file')
+)
+################################################################################
+# AUTH/Django
+AUTHENTICATION_BACKENDS = [
+ "django_auth_ldap.backend.LDAPBackend",
+ "django.contrib.auth.backends.ModelBackend",
+# 'allauth.account.auth_backends.AuthenticationBackend',
+]
+
+
+
+AUTH_USER_MODEL = 'uncloud_auth.User'
+ACCOUNT_FORMS = {
+ 'signup': 'uncloud_auth.forms.MySignupForm',
+ 'change_password': 'uncloud_auth.forms.MyChangePasswordForm',
+ 'set_password': 'uncloud_auth.forms.MySetPasswordForm',
+ 'reset_password_from_key': 'uncloud_auth.forms.MyResetPasswordKeyForm',
+ }
+
+################################################################################
+# AUTH/REST
+REST_FRAMEWORK = {
+ 'DEFAULT_AUTHENTICATION_CLASSES': [
+ 'rest_framework.authentication.BasicAuthentication',
+ 'rest_framework.authentication.SessionAuthentication',
+ ]
+}
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/3.0/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/3.0/howto/static-files/
+STATIC_URL = '/static/'
+STATIC_ROOT = os.path.join(BASE_DIR, "static")
+STATICFILES_FINDERS = [
+ 'django.contrib.staticfiles.finders.FileSystemFinder',
+ 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
+ 'compressor.finders.CompressorFinder',
+]
+COMPRESS_ENABLED = True
+
+#VM Deployment TEMPLATE
+GITLAB_SERVER = env('GITLAB_SERVER')
+GITLAB_OAUTH_TOKEN = env('GITLAB_OAUTH_TOKEN')
+GITLAB_PROJECT_ID = env('GITLAB_PROJECT_ID')
+GITLAB_AUTHOR_EMAIL = env('GITLAB_AUTHOR_EMAIL')
+GITLAB_AUTHOR_NAME = env('GITLAB_AUTHOR_NAME')
+GITLAB_YAML_DIR = env('GITLAB_YAML_DIR')
+GITLAB_DNS_PROJECT_ID = env('GITLAB_DNS_PROJECT_ID')
+MATRIX_DNS_MAIN_DOMAIN = env('MATRIX_DNS_MAIN_DOMAIN')
+GITLAB_DNS_OAUTH_TOKEN = env('GITLAB_DNS_OAUTH_TOKEN')
+
+# XML-RPC interface of opennebula
+OPENNEBULA_URL = 'https://opennebula.example.com:2634/RPC2'
+
+# user:pass for accessing opennebula
+OPENNEBULA_USER_PASS = 'user:password'
+
+# Stripe (Credit Card payments)
+STRIPE_KEY=env('STRIPE_KEY')
+STRIPE_PUBLIC_KEY=env('STRIPE_PUBLIC_KEY')
+BILL_PAYMENT_DELAY = 0
+MIN_PER_TRANSACTION = 5
+# The django secret key
+SECRET_KEY=get_random_secret_key()
+
+ALLOWED_HOSTS = ''
+
+# required for hardcopy / pdf rendering: https://github.com/loftylabs/django-hardcopy
+CHROME_PATH = '/usr/bin/chromium-browser'
+
+# Username that is created by default and owns the configuration objects
+UNCLOUD_ADMIN_NAME = "uncloud-admin"
+
+LOGIN_REDIRECT_URL = '/'
+LOGOUT_REDIRECT_URL = '/'
+
+EMAIL_USE_TLS = True
+EMAIL_HOST = env('EMAIL_HOST')
+
+EMAIL_PORT = 25
+EMAIL_HOST_USER = DEFAULT_FROM_EMAIL = env('EMAIL_HOST_USER')
+EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD')
+DEFAULT_FROM_EMAIL = 'support@ungleich.ch'
+RENEWAL_FROM_EMAIL = 'support@ungleich.ch'
+
+##############
+# Jobs
+Q_CLUSTER = {
+ 'name': 'uncloud',
+ 'workers': 1,
+ 'recycle': 500,
+ 'timeout': 60,
+ 'compress': True,
+ 'cpu_affinity': 1,
+ 'save_limit': 250,
+ 'queue_limit': 500,
+ 'label': 'Django Q',
+ 'redis': {
+ 'host': '127.0.0.1',
+ 'port': 6379,
+ 'db': 0, }
+}
+
+REPORT_FORMAT = {
+ 'page_height': 200,
+ 'page_width':175,
+ 'orientation': 'Portrait',
+ 'header_spacing': 65,
+ 'margin_bottom':25,
+ 'header_line': False,
+}
+
+
+# Overwrite settings with local settings, if existing
+try:
+ from uncloud.local_settings import *
+except (ModuleNotFoundError, ImportError):
+ pass
diff --git a/uncloud/static/uncloud/uncloud.css b/uncloud/static/uncloud/uncloud.css
new file mode 100644
index 0000000..51d93ef
--- /dev/null
+++ b/uncloud/static/uncloud/uncloud.css
@@ -0,0 +1,4 @@
+#content {
+ width: 400px;
+ margin: auto;
+}
diff --git a/uncloud/templates/uncloud/base.html b/uncloud/templates/uncloud/base.html
new file mode 100644
index 0000000..3f1632d
--- /dev/null
+++ b/uncloud/templates/uncloud/base.html
@@ -0,0 +1,61 @@
+{% load static i18n %}
+{% load bootstrap5 %}
+
+{# Display django.contrib.messages as Bootstrap alerts #}
+{% bootstrap_messages %}
+
+{% get_current_language as LANGUAGE_CODE %}
+
+
+
+
+
+
+
+
+
+
+ uncloud - {% block title %} made in Switzerland {% endblock %}
+
+
+ {% block css_extra %} {% endblock css_extra %}
+ {% block head_extra %} {% endblock head_extra %}
+
+ {# Load CSS and JavaScript #}
+ {% bootstrap_css %}
+ {% bootstrap_javascript %}
+
+
+
+
+
+
+ {% block content %} {% endblock %}
+
+
+ {% block js_extra %} {% endblock js_extra %}
+
+
diff --git a/uncloud/templates/uncloud/index.html b/uncloud/templates/uncloud/index.html
new file mode 100644
index 0000000..5aeb112
--- /dev/null
+++ b/uncloud/templates/uncloud/index.html
@@ -0,0 +1,168 @@
+{% extends 'uncloud/base.html' %}
+{% block title %}Welcome to uncloud [beta]{% endblock %}
+{% block content %}
+
+
+
+
+
Welcome to uncloud [beta]
+
+
+
+
+
+
Getting started
+
+
uncloud is designed to be as easy as possible to use. However,
+ there are some "real world" requirements that need to be met to
+ start using uncloud:
+
+
+ - First you need
+ to register an
+ account. If you already have one, you can
+ login.
+
- If you have forgotten your password or other issues with
+ logging in, you can contact the ungleich support
+ via support at ungleich.ch.
+
+
- Secondy you will need to
+ create a billing
+ address. This is required for determining the correct
+ tax.
+
- Next you will need to
+ register a credit card
+ from which payments can be made. Your credit card will not
+ be charged without your consent.
+
+
+
+
+
Introduction to uncloud concepts
+
+
We plan to offer many services on uncloud ranging from
+ for free, for a small amount or regular charges. As transfer
+ fees are a major challenge for our business, we based uncloud
+ on the pre-paid account model. Which means
+ that you can charge your account and then use your balance to
+ pay for product usage.
+
+
+
+
+
Credit cards
+
+
+ Credit cards are registered with stripe. We only save a the
+ last 4 digits and the expiry date of the card to make
+ identification for you easier.
+
+
+ - Register a credit card
+ (this is required to be done via Javascript so that we never see
+ your credit card, but it is sent directly to stripe)
+
- You can list your
+ credit cards
+ By default the first credit card is used for charging
+ ("active: true") and later added cards will not be
+ used. To change this, first disable the active flag and
+ then set it on another credit card.
+
+
+
+
Billing Address, Payments and Balance
+
+
Billing addresses behave similar to credit cards: you can
+ have many of them, but only one can be active. The active
+ billing address is taken for creating new orders.
+
+
In uncloud we use the pre-paid model: you can add money to
+ your account via payments. You can always check your
+ balance. The products you use will automatically be charged from
+ your existing balance.
+
+
+
In the future you will be able opt-in to automatically
+ recharging your account at a certain time frame or whenever it
+ is below a certain amount
+
+
+
+
+
+
+
+
Networking
+
+
+ With uncloud you can use a variety of network related
+ services.
+
+
+
+
+
+
+
+
Current limitations
+
+
+ - Payments are only possible in CHF.
+
+
+
+ {% if user.is_authenticated %}
+
+
Account Settings
+
+
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/uncloud/uncloud/.gitignore b/uncloud/uncloud/.gitignore
deleted file mode 100644
index ef418f5..0000000
--- a/uncloud/uncloud/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-secrets.py
diff --git a/uncloud/uncloud/__init__.py b/uncloud/uncloud/__init__.py
deleted file mode 100644
index 9e2545a..0000000
--- a/uncloud/uncloud/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# Define DecimalField properties, used to represent amounts of money.
-# Used in pay and auth
-AMOUNT_MAX_DIGITS=10
-AMOUNT_DECIMALS=2
diff --git a/uncloud/uncloud/secrets_sample.py b/uncloud/uncloud/secrets_sample.py
deleted file mode 100644
index 464662f..0000000
--- a/uncloud/uncloud/secrets_sample.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# Live/test key from stripe
-STRIPE_KEY = ''
-
-# XML-RPC interface of opennebula
-OPENNEBULA_URL = 'https://opennebula.ungleich.ch:2634/RPC2'
-
-# user:pass for accessing opennebula
-OPENNEBULA_USER_PASS = 'user:password'
-
-POSTGRESQL_DB_NAME="uncloud"
-
-# See https://django-auth-ldap.readthedocs.io/en/latest/authentication.html
-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
deleted file mode 100644
index 99cf7a1..0000000
--- a/uncloud/uncloud/settings.py
+++ /dev/null
@@ -1,175 +0,0 @@
-"""
-Django settings for uncloud project.
-
-Generated by 'django-admin startproject' using Django 3.0.3.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/3.0/topics/settings/
-
-For the full list of settings and their values, see
-https://docs.djangoproject.com/en/3.0/ref/settings/
-"""
-
-import os
-import ldap
-
-# Uncommitted file with secrets
-import uncloud.secrets
-
-from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
-
-# Uncommitted file with local settings i.e logging
-try:
- from uncloud.local_settings import LOGGING, DATABASES
-except ModuleNotFoundError:
- LOGGING = {}
- # https://docs.djangoproject.com/en/3.0/ref/settings/#databases
- DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.postgresql',
- 'NAME': uncloud.secrets.POSTGRESQL_DB_NAME,
- }
- }
-
-
-# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
-BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-
-
-# Quick-start development settings - unsuitable for production
-# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
-
-# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = uncloud.secrets.SECRET_KEY
-
-# SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = True
-
-ALLOWED_HOSTS = []
-
-
-# Application definition
-
-INSTALLED_APPS = [
- 'django.contrib.admin',
- 'django.contrib.auth',
- 'django.contrib.contenttypes',
- 'django.contrib.sessions',
- 'django.contrib.messages',
- 'django.contrib.staticfiles',
- 'django_extensions',
- 'rest_framework',
- 'uncloud_pay',
- 'uncloud_auth',
- 'uncloud_storage',
- 'uncloud_vm',
- 'ungleich_service',
- 'opennebula'
-]
-
-MIDDLEWARE = [
- 'django.middleware.security.SecurityMiddleware',
- 'django.contrib.sessions.middleware.SessionMiddleware',
- 'django.middleware.common.CommonMiddleware',
- 'django.middleware.csrf.CsrfViewMiddleware',
- 'django.contrib.auth.middleware.AuthenticationMiddleware',
- 'django.contrib.messages.middleware.MessageMiddleware',
- 'django.middleware.clickjacking.XFrameOptionsMiddleware',
-]
-
-ROOT_URLCONF = 'uncloud.urls'
-
-TEMPLATES = [
- {
- 'BACKEND': 'django.template.backends.django.DjangoTemplates',
- 'DIRS': [],
- 'APP_DIRS': True,
- 'OPTIONS': {
- 'context_processors': [
- 'django.template.context_processors.debug',
- 'django.template.context_processors.request',
- 'django.contrib.auth.context_processors.auth',
- 'django.contrib.messages.context_processors.messages',
- ],
- },
- },
-]
-
-WSGI_APPLICATION = 'uncloud.wsgi.application'
-
-
-# Password validation
-# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
-
-AUTH_PASSWORD_VALIDATORS = [
- {
- 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
- },
-]
-
-################################################################################
-# AUTH/LDAP
-
-AUTH_LDAP_SERVER_URI = uncloud.secrets.LDAP_SERVER_URI
-
-AUTH_LDAP_USER_ATTR_MAP = {
- "first_name": "givenName",
- "last_name": "sn",
- "email": "mail"
-}
-
-
-AUTH_LDAP_BIND_DN = uncloud.secrets.LDAP_ADMIN_DN
-AUTH_LDAP_BIND_PASSWORD = uncloud.secrets.LDAP_ADMIN_PASSWORD
-
-AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=%(user)s)")
-
-
-################################################################################
-# AUTH/Django
-AUTHENTICATION_BACKENDS = [
- "django_auth_ldap.backend.LDAPBackend",
- "django.contrib.auth.backends.ModelBackend"
-]
-
-AUTH_USER_MODEL = 'uncloud_auth.User'
-
-
-################################################################################
-# AUTH/REST
-REST_FRAMEWORK = {
- 'DEFAULT_AUTHENTICATION_CLASSES': [
- 'rest_framework.authentication.BasicAuthentication',
- 'rest_framework.authentication.SessionAuthentication',
- ]
-}
-
-
-
-# Internationalization
-# https://docs.djangoproject.com/en/3.0/topics/i18n/
-
-LANGUAGE_CODE = 'en-us'
-
-TIME_ZONE = 'UTC'
-
-USE_I18N = True
-
-USE_L10N = True
-
-USE_TZ = True
-
-
-# Static files (CSS, JavaScript, Images)
-# https://docs.djangoproject.com/en/3.0/howto/static-files/
-
-STATIC_URL = '/static/'
diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py
deleted file mode 100644
index 856e59c..0000000
--- a/uncloud/uncloud/urls.py
+++ /dev/null
@@ -1,83 +0,0 @@
-"""uncloud URL Configuration
-
-The `urlpatterns` list routes URLs to views. For more information please see:
- https://docs.djangoproject.com/en/3.0/topics/http/urls/
-Examples:
-Function views
- 1. Add an import: from my_app import views
- 2. Add a URL to urlpatterns: path('', views.home, name='home')
-Class-based views
- 1. Add an import: from other_app.views import Home
- 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
-Including another URLconf
- 1. Import the include() function: from django.urls import include, path
- 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
-"""
-from django.contrib import admin
-from django.urls import path, include
-
-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
-from uncloud_auth import views as authviews
-
-router = routers.DefaultRouter()
-
-# 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')
-router.register(r'vm/image/public', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct')
-
-# images the provider provides :-)
-# router.register(r'vm/image/official', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct')
-
-
-
-
-router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct')
-
-
-# TBD
-#router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct')
-
-# creates VM from os image
-#router.register(r'vm/ipv6onlyvm', vmviews.VMProductViewSet, basename='vmproduct')
-# ... AND adds IPv4 mapping
-#router.register(r'vm/dualstackvm', vmviews.VMProductViewSet, basename='vmproduct')
-
-# allow vm creation from own images
-
-
-# Services
-router.register(r'service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct')
-
-
-# Pay
-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')
-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')
-
-# User/Account
-router.register(r'user', authviews.UserViewSet, basename='user')
-
-
-urlpatterns = [
- path('', include(router.urls)),
- path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) # for login to REST API
-]
diff --git a/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py b/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py
deleted file mode 100644
index ad2654f..0000000
--- a/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# Generated by Django 3.0.3 on 2020-03-18 13:43
-
-import django.core.validators
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('uncloud_auth', '0001_initial'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='user',
- name='amount',
- field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]),
- ),
- migrations.AddField(
- model_name='user',
- name='maximum_credit',
- field=models.FloatField(default=0),
- preserve_default=False,
- ),
- ]
diff --git a/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py b/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py
deleted file mode 100644
index 31b1717..0000000
--- a/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# Generated by Django 3.0.3 on 2020-03-18 13:45
-
-import django.core.validators
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('uncloud_auth', '0002_auto_20200318_1343'),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name='user',
- name='amount',
- ),
- migrations.AlterField(
- model_name='user',
- name='maximum_credit',
- field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]),
- ),
- ]
diff --git a/uncloud/uncloud_auth/serializers.py b/uncloud/uncloud_auth/serializers.py
deleted file mode 100644
index 3627149..0000000
--- a/uncloud/uncloud_auth/serializers.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from django.contrib.auth import get_user_model
-from rest_framework import serializers
-
-from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
-
-class UserSerializer(serializers.ModelSerializer):
-
- class Meta:
- model = get_user_model()
- fields = ['username', 'email', 'balance', 'maximum_credit' ]
-
- balance = serializers.DecimalField(max_digits=AMOUNT_MAX_DIGITS,
- decimal_places=AMOUNT_DECIMALS)
diff --git a/uncloud/uncloud_auth/views.py b/uncloud/uncloud_auth/views.py
deleted file mode 100644
index 2f78e1f..0000000
--- a/uncloud/uncloud_auth/views.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from rest_framework import viewsets, permissions, status
-from .serializers import *
-
-class UserViewSet(viewsets.ReadOnlyModelViewSet):
- serializer_class = UserSerializer
- permission_classes = [permissions.IsAuthenticated]
-
- def get_queryset(self):
- if self.request.user.is_superuser:
- obj = get_user_model().objects.all()
- else:
- # This is a bit stupid: we have a user, we create a queryset by
- # matching on the username. But I don't know a "nicer" way.
- # Nico, 2020-03-18
- obj = get_user_model().objects.filter(username=self.request.user.username)
-
- return obj
diff --git a/uncloud/uncloud_net/models.py b/uncloud/uncloud_net/models.py
deleted file mode 100644
index 6d0c742..0000000
--- a/uncloud/uncloud_net/models.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from django.db import models
-
-class MACAdress(models.Model):
- prefix = 0x420000000000
diff --git a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py
deleted file mode 100644
index 24d53bf..0000000
--- a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from django.core.management.base import BaseCommand
-from uncloud_auth.models import User
-from uncloud_pay.models import Order, Bill, PaymentMethod, 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))
- payment_method = PaymentMethod.get_primary_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/migrations/0001_initial.py b/uncloud/uncloud_pay/migrations/0001_initial.py
deleted file mode 100644
index 89fa586..0000000
--- a/uncloud/uncloud_pay/migrations/0001_initial.py
+++ /dev/null
@@ -1,85 +0,0 @@
-# Generated by Django 3.0.3 on 2020-03-05 10:17
-
-from django.conf import settings
-import django.core.validators
-from django.db import migrations, models
-import django.db.models.deletion
-import uuid
-
-
-class Migration(migrations.Migration):
-
- initial = True
-
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ('uncloud_auth', '0001_initial'),
- ]
-
- operations = [
- migrations.CreateModel(
- name='Bill',
- fields=[
- ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
- ('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)),
- ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
- ],
- ),
- migrations.CreateModel(
- name='Order',
- fields=[
- ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
- ('creation_date', models.DateTimeField(auto_now_add=True)),
- ('starting_date', models.DateTimeField(auto_now_add=True)),
- ('ending_date', models.DateTimeField(blank=True, null=True)),
- ('recurring_period', 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)),
- ('bill', models.ManyToManyField(blank=True, editable=False, to='uncloud_pay.Bill')),
- ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
- ],
- ),
- migrations.CreateModel(
- name='StripeCustomer',
- fields=[
- ('owner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
- ('stripe_id', models.CharField(max_length=32)),
- ],
- ),
- migrations.CreateModel(
- name='Payment',
- fields=[
- ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
- ('amount', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
- ('source', models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256)),
- ('timestamp', models.DateTimeField(auto_now_add=True)),
- ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
- ],
- ),
- migrations.CreateModel(
- name='OrderRecord',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('one_time_price', 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')),
- ],
- ),
- 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()),
- ('primary', models.BooleanField(default=True)),
- ('stripe_card_id', models.CharField(blank=True, max_length=32, null=True)),
- ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
- ],
- options={
- 'unique_together': {('owner', 'primary')},
- },
- ),
- ]
diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py
deleted file mode 100644
index a11c3c1..0000000
--- a/uncloud/uncloud_pay/models.py
+++ /dev/null
@@ -1,473 +0,0 @@
-from django.db import models
-from django.db.models import Q
-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 django.dispatch import receiver
-from django.core.exceptions import ObjectDoesNotExist
-import django.db.models.signals as signals
-
-import uuid
-from functools import reduce
-from math import ceil
-from datetime import timedelta
-from calendar import monthrange
-
-from decimal import Decimal
-
-import uncloud_pay.stripe
-from uncloud_pay.helpers import beginning_of_month, end_of_month
-from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
-
-
-
-
-# Used to generate bill due dates.
-BILL_PAYMENT_DELAY=timedelta(days=10)
-
-# 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')
-
-# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
-class ProductStatus(models.TextChoices):
- PENDING = 'PENDING', _('Pending')
- AWAITING_PAYMENT = 'AWAITING_PAYMENT', _('Awaiting payment')
- BEING_CREATED = 'BEING_CREATED', _('Being created')
- ACTIVE = 'ACTIVE', _('Active')
- DELETED = 'DELETED', _('Deleted')
-
-
-def get_balance_for_user(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
-
-class StripeCustomer(models.Model):
- owner = models.OneToOneField( get_user_model(),
- primary_key=True,
- on_delete=models.CASCADE)
- stripe_id = models.CharField(max_length=32)
-
-###
-# 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)
-
- # WIP prepaid and service activation logic by fnux.
- ## We override save() in order to active products awaiting payment.
- #def save(self, *args, **kwargs):
- # # TODO: only run activation logic on creation, not on update.
- # unpaid_bills_before_payment = Bill.get_unpaid_for(self.owner)
- # super(Payment, self).save(*args, **kwargs) # Save payment in DB.
- # unpaid_bills_after_payment = Bill.get_unpaid_for(self.owner)
-
- # newly_paid_bills = list(
- # set(unpaid_bills_before_payment) - set(unpaid_bills_after_payment))
- # for bill in newly_paid_bills:
- # bill.activate_orders()
-
-
-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)
-
- @property
- def stripe_card_last4(self):
- if self.source == 'stripe':
- card_request = uncloud_pay.stripe.get_card(
- StripeCustomer.objects.get(owner=self.owner).stripe_id,
- self.stripe_card_id)
- if card_request['error'] == None:
- return card_request['response_object']['last4']
- else:
- return None
- else:
- return None
-
-
- def charge(self, amount):
- if amount > 0: # Make sure we don't charge negative amount by errors...
- if self.source == 'stripe':
- stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id
- charge_request = uncloud_pay.stripe.charge_customer(amount, stripe_customer, self.stripe_card_id)
- if charge_request['error'] == None:
- payment = Payment(owner=self.owner, source=self.source, amount=amount)
- payment.save() # TODO: Check return status
-
- return payment
- else:
- raise Exception('Stripe error: {}'.format(charge_request['error']))
- else:
- raise Exception('This payment method is unsupported/cannot be charged.')
- else:
- raise Exception('Cannot charge negative amount.')
-
-
- def get_primary_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
-
- 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 reference(self):
- return "{}-{}".format(
- self.owner.username,
- self.creation_date.strftime("%Y-%m-%d-%H%M"))
-
- @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()
-
- @staticmethod
- def generate_for(year, month, user):
- # /!\ 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() + BILL_PAYMENT_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.
- return None
-
- @staticmethod
- def get_unpaid_for(user):
- balance = get_balance_for(user)
- unpaid_bills = []
- # No unpaid bill if balance is positive.
- if balance >= 0:
- return []
- else:
- bills = Bill.objects.filter(
- owner=user,
- due_date__lt=timezone.now()
- ).order_by('-creation_date')
-
- # Amount to be paid by the customer.
- unpaid_balance = abs(balance)
- for bill in bills:
- if unpaid_balance < 0:
- break
-
- unpaid_balance -= bill.amount
- unpaid_bills.append(bill)
-
- return unpaid_bills
-
- @staticmethod
- def get_overdue_for(user):
- unpaid_bills = Bill.get_unpaid_for(user)
- return list(filter(lambda bill: bill.due_date > timezone.now(), unpaid_bills))
-
-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.recurring_price = order_record.recurring_price
- self.recurring_period = order_record.recurring_period
- self.description = order_record.description
-
- if self.order.starting_date > self.bill.starting_date:
- self.one_time_price = order_record.one_time_price
- else:
- self.one_time_price = 0
-
- @property
- def recurring_count(self):
- # Compute billing delta.
- billed_until = self.bill.ending_date
- if self.order.ending_date != None and self.order.ending_date < self.order.ending_date:
- 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)
- return Decimal(days / days_in_month)
- elif self.recurring_period == RecurringPeriod.PER_DAY:
- days = ceil(billed_delta / timedelta(days=1))
- return Decimal(days)
- elif self.recurring_period == RecurringPeriod.PER_HOUR:
- hours = ceil(billed_delta / timedelta(hours=1))
- return Decimal(hours)
- elif self.recurring_period == RecurringPeriod.PER_SECOND:
- seconds = ceil(billed_delta / timedelta(seconds=1))
- return Decimal(seconds)
- elif self.recurring_period == RecurringPeriod.ONE_TIME:
- return Decimal(0)
- else:
- raise Exception('Unsupported recurring period: {}.'.
- format(record.recurring_period))
-
- @property
- def amount(self):
- return self.recurring_price * self.recurring_count + self.one_time_price
-
-###
-# Orders.
-
-# Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
-# bills. Do **NOT** mutate then!
-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 one_time_price(self):
- return reduce(lambda acc, record: acc + record.one_time_price, self.records, 0)
-
- @property
- def recurring_price(self):
- return reduce(lambda acc, record: acc + record.recurring_price, self.records, 0)
-
- def add_record(self, one_time_price, recurring_price, description):
- OrderRecord.objects.create(order=self,
- one_time_price=one_time_price,
- recurring_price=recurring_price,
- description=description)
-
-
-class OrderRecord(models.Model):
- """
- Order records store billing informations for products: the actual product
- might be mutated and/or moved to another order but we do not want to loose
- the details of old orders.
-
- Used as source of trust to dynamically generate bill entries.
- """
-
- order = models.ForeignKey(Order, on_delete=models.CASCADE)
- one_time_price = 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(),
- on_delete=models.CASCADE,
- editable=False)
-
- description = ""
-
- status = models.CharField(max_length=32,
- choices=ProductStatus.choices,
- default=ProductStatus.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.
-
- @property
- def one_time_price(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
deleted file mode 100644
index a0a8635..0000000
--- a/uncloud/uncloud_pay/serializers.py
+++ /dev/null
@@ -1,73 +0,0 @@
-from django.contrib.auth import get_user_model
-from rest_framework import serializers
-from .models import *
-
-###
-# Payments and Payment Methods.
-
-class PaymentSerializer(serializers.ModelSerializer):
- class Meta:
- model = Payment
- fields = ['owner', 'amount', 'source', 'timestamp']
-
-class PaymentMethodSerializer(serializers.ModelSerializer):
- stripe_card_last4 = serializers.IntegerField()
-
- class Meta:
- model = PaymentMethod
- fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4']
-
-class ChargePaymentMethodSerializer(serializers.Serializer):
- amount = serializers.DecimalField(max_digits=10, decimal_places=2)
-
-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']
-
-###
-# Orders & Products.
-
-class OrderRecordSerializer(serializers.ModelSerializer):
- class Meta:
- model = OrderRecord
- fields = ['one_time_price', 'recurring_price', 'description']
-
-
-class OrderSerializer(serializers.ModelSerializer):
- records = OrderRecordSerializer(many=True, read_only=True)
- class Meta:
- model = Order
- fields = ['uuid', 'creation_date', 'starting_date', 'ending_date',
- 'bill', 'recurring_period', 'records', 'recurring_price', 'one_time_price']
-
-
-###
-# Bills
-
-# TODO: remove magic numbers for decimal fields
-class BillRecordSerializer(serializers.Serializer):
- order = serializers.HyperlinkedRelatedField(
- view_name='order-detail',
- read_only=True)
- description = serializers.CharField()
- recurring_period = serializers.CharField()
- recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2)
- recurring_count = serializers.DecimalField(max_digits=10, decimal_places=2)
- one_time_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 = ['reference', 'owner', 'total', 'due_date', 'creation_date',
- 'starting_date', 'ending_date', 'records', 'final']
diff --git a/uncloud/uncloud_pay/stripe.py b/uncloud/uncloud_pay/stripe.py
deleted file mode 100644
index 4f28d94..0000000
--- a/uncloud/uncloud_pay/stripe.py
+++ /dev/null
@@ -1,133 +0,0 @@
-import stripe
-import stripe.error
-import logging
-
-from django.core.exceptions import ObjectDoesNotExist
-import uncloud_pay.models
-
-import uncloud.secrets
-
-# Static stripe configuration used below.
-CURRENCY = 'chf'
-
-stripe.api_key = uncloud.secrets.STRIPE_KEY
-
-# Helper (decorator) used to catch errors raised by stripe logic.
-def handle_stripe_error(f):
- def handle_problems(*args, **kwargs):
- response = {
- 'paid': False,
- 'response_object': None,
- 'error': None
- }
-
- common_message = "Currently it is not possible to make payments."
- try:
- response_object = f(*args, **kwargs)
- response = {
- 'response_object': response_object,
- 'error': None
- }
- return response
- except stripe.error.CardError as e:
- # Since it's a decline, stripe.error.CardError will be caught
- body = e.json_body
- err = body['error']
- response.update({'error': err['message']})
- logging.error(str(e))
- return response
- except stripe.error.RateLimitError:
- response.update(
- {'error': "Too many requests made to the API too quickly"})
- return response
- except stripe.error.InvalidRequestError as e:
- logging.error(str(e))
- response.update({'error': "Invalid parameters"})
- return response
- except stripe.error.AuthenticationError as e:
- # Authentication with Stripe's API failed
- # (maybe you changed API keys recently)
- logging.error(str(e))
- response.update({'error': common_message})
- return response
- except stripe.error.APIConnectionError as e:
- logging.error(str(e))
- response.update({'error': common_message})
- return response
- except stripe.error.StripeError as e:
- # maybe send email
- logging.error(str(e))
- response.update({'error': common_message})
- return response
- except Exception as e:
- # maybe send email
- logging.error(str(e))
- response.update({'error': common_message})
- 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.
-
-def get_customer_id_for(user):
- try:
- # .get() raise if there is no matching entry.
- return uncloud_pay.models.StripeCustomer.objects.get(owner=user).stripe_id
- except ObjectDoesNotExist:
- # No entry yet - making a new one.
- customer_request = create_customer(user.username, user.email)
- if customer_request['error'] == None:
- mapping = uncloud_pay.models.StripeCustomer.objects.create(
- owner=user,
- stripe_id=customer_request['response_object']['id']
- )
- return mapping.stripe_id
- else:
- return None
-
-@handle_stripe_error
-def create_card(customer_id, credit_card):
- 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.Customer.retrieve_source(customer_id, card_id)
-
-@handle_stripe_error
-def charge_customer(amount, customer_id, card_id):
- # Amount is in CHF but stripes requires smallest possible unit.
- # See https://stripe.com/docs/api/charges/create
- adjusted_amount = int(amount * 100)
- return stripe.Charge.create(
- amount=adjusted_amount,
- currency=CURRENCY,
- customer=customer_id,
- source=card_id)
-
-@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
deleted file mode 100644
index e86a464..0000000
--- a/uncloud/uncloud_pay/views.py
+++ /dev/null
@@ -1,150 +0,0 @@
-from django.shortcuts import render
-from django.db import transaction
-from django.contrib.auth import get_user_model
-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
-import uncloud_pay.stripe as uncloud_stripe
-
-###
-# Standard user views:
-
-class BalanceViewSet(viewsets.ViewSet):
- # here we return a number
- # number = sum(payments) - sum(bills)
-
- #bills = Bill.objects.filter(owner=self.request.user)
- #payments = Payment.objects.filter(owner=self.request.user)
-
- # sum_paid = sum([ amount for amount payments..,. ]) # you get the picture
- # sum_to_be_paid = sum([ amount for amount bills..,. ]) # you get the picture
- pass
-
-
-class BillViewSet(viewsets.ReadOnlyModelViewSet):
- serializer_class = BillSerializer
- permission_classes = [permissions.IsAuthenticated]
-
- def get_queryset(self):
- return Bill.objects.filter(owner=self.request.user)
-
-class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
- serializer_class = PaymentSerializer
- permission_classes = [permissions.IsAuthenticated]
-
- def get_queryset(self):
- return Payment.objects.filter(owner=self.request.user)
-
-class OrderViewSet(viewsets.ReadOnlyModelViewSet):
- serializer_class = OrderSerializer
- permission_classes = [permissions.IsAuthenticated]
-
- def get_queryset(self):
- return Order.objects.filter(owner=self.request.user)
-
-
-class PaymentMethodViewSet(viewsets.ModelViewSet):
- permission_classes = [permissions.IsAuthenticated]
-
- def get_serializer_class(self):
- if self.action == 'create':
- return CreatePaymentMethodSerializer
- elif self.action == 'charge':
- return ChargePaymentMethodSerializer
- else:
- return PaymentMethodSerializer
-
-
- def get_queryset(self):
- return PaymentMethod.objects.filter(owner=self.request.user)
-
- # XXX: Handling of errors is far from great down there.
- @transaction.atomic
- def create(self, request):
- serializer = self.get_serializer(data=request.data)
- serializer.is_valid(raise_exception=True)
-
- # Retrieve Stripe customer ID for user.
- customer_id = uncloud_stripe.get_customer_id_for(request.user)
- if customer_id == None:
- return Response(
- {'error': 'Could not resolve customer stripe ID.'},
- status=status.HTTP_500_INTERNAL_SERVER_ERROR)
-
- # Register card under stripe customer.
- credit_card = uncloud_stripe.CreditCard(**serializer.validated_data.pop('credit_card'))
- card_request = uncloud_stripe.create_card(customer_id, credit_card)
- if card_request['error']:
- return Response({'stripe_error': card_request['error']}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
- card_id = card_request['response_object']['id']
-
- # Save payment method locally.
- serializer.validated_data['stripe_card_id'] = card_request['response_object']['id']
- payment_method = PaymentMethod.objects.create(owner=request.user, **serializer.validated_data)
-
- # We do not want to return the credit card details sent with the POST
- # request.
- output_serializer = PaymentMethodSerializer(payment_method)
- return Response(output_serializer.data)
-
- @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.validated_data['amount']
- try:
- payment = payment_method.charge(amount)
- output_serializer = PaymentSerializer(payment)
- return Response(output_serializer.data)
- except Exception as e:
- return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
-
-###
-# Admin views.
-
-class AdminPaymentViewSet(viewsets.ModelViewSet):
- serializer_class = PaymentSerializer
- permission_classes = [permissions.IsAuthenticated]
-
- def get_queryset(self):
- return Payment.objects.all()
-
- def create(self, request):
- serializer = self.get_serializer(data=request.data)
- serializer.is_valid(raise_exception=True)
- serializer.save(timestamp=datetime.now())
-
- headers = self.get_success_headers(serializer.data)
- return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
-
-class AdminBillViewSet(viewsets.ModelViewSet):
- serializer_class = BillSerializer
- permission_classes = [permissions.IsAuthenticated]
-
- def get_queryset(self):
- return Bill.objects.all()
-
- def unpaid(self, request):
- return Bill.objects.filter(owner=self.request.user, paid=False)
-
- def create(self, request):
- serializer = self.get_serializer(data=request.data)
- serializer.is_valid(raise_exception=True)
- serializer.save(creation_date=datetime.now())
-
- headers = self.get_success_headers(serializer.data)
- return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
-
-class AdminOrderViewSet(viewsets.ModelViewSet):
- serializer_class = OrderSerializer
- permission_classes = [permissions.IsAuthenticated]
-
- def get_queryset(self):
- return Order.objects.all()
diff --git a/uncloud/uncloud_storage/admin.py b/uncloud/uncloud_storage/admin.py
deleted file mode 100644
index 8c38f3f..0000000
--- a/uncloud/uncloud_storage/admin.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.contrib import admin
-
-# Register your models here.
diff --git a/uncloud/uncloud_vm/admin.py b/uncloud/uncloud_vm/admin.py
deleted file mode 100644
index 8c38f3f..0000000
--- a/uncloud/uncloud_vm/admin.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.contrib import admin
-
-# Register your models here.
diff --git a/uncloud/uncloud_vm/management/commands/vm.py b/uncloud/uncloud_vm/management/commands/vm.py
deleted file mode 100644
index c0e2783..0000000
--- a/uncloud/uncloud_vm/management/commands/vm.py
+++ /dev/null
@@ -1,85 +0,0 @@
-import json
-
-import uncloud.secrets as secrets
-
-from django.core.management.base import BaseCommand
-from django.contrib.auth import get_user_model
-
-from uncloud_vm.models import VMProduct, VMHost
-
-class Command(BaseCommand):
- help = 'Select VM Host for VMs'
-
- def add_arguments(self, parser):
- parser.add_argument('--schedule-vms', action='store_true')
- parser.add_argument('--start-vms-here', action='store_true')
- parser.add_argument('--check-health', action='store_true')
- parser.add_argument('--vmhostname')
- print(parser)
-
-
- def handle(self, *args, **options):
- print(args)
- print(options)
-
- if options['schedule_vms']:
- self.schedule_vms(args, option)
- if options['start_vms_here']:
- if not options['vmhostname']:
- raise Exception("Argument vmhostname is required to know which vmhost we are on")
- self.start_vms(args, options)
- if options['check_health']:
- self.check_health(args, option)
-
- def start_vms(self, *args, **options):
- vmhost = VMHost.objects.get(status='active',
- hostname=options['vmhostname'])
-
- if not vmhost:
- print("No active vmhost {} exists".format(options['vmhostname']))
- return
-
- vms_to_start = VMProduct.objects.filter(vmhost=vmhost,
- status='creating')
- for vm in vms_to_start:
-
- """ run qemu:
- check if VM is not already active / qemu running
- prepare / create the Qemu arguments
-
-
- """
-
- def schedule_vms(self, *args, **options)):
- pending_vms = VMProduct.objects.filter(vmhost__isnull=True)
- vmhosts = VMHost.objects.filter(status='active')
-
- for vm in pending_vms:
- print(vm)
-
- found_vmhost = False
- for vmhost in vmhosts:
- if vmhost.available_cores >= vm.cores and vmhost.available_ram_in_gb >= vm.ram_in_gb:
- vm.vmhost = vmhost
- vm.status = "creating"
- vm.save()
- found_vmhost = True
- print("Scheduled VM {} on VMHOST {}".format(vm, vmhost))
- break
-
- if not found_vmhost:
- print("Error: cannot schedule VM {}, no suitable host found".format(vm))
-
- def check_health(self, *args, **options):
- pending_vms = VMProduct.objects.filter(vmhost__isnull=True)
- vmhosts = VMHost.objects.filter(status='active')
-
- # 1. Check that all active hosts reported back N seconds ago
- # 2. Check that no VM is running on a dead host
- # 3. Migrate VMs if necessary
- # 4. Check that no VMs have been pending for longer than Y seconds
-
- # If VM snapshots exist without a VM -> notify user (?)
-
-
- print("Nothing is good, you should implement me")
diff --git a/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py b/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py
deleted file mode 100644
index 2711b33..0000000
--- a/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py
+++ /dev/null
@@ -1,23 +0,0 @@
-# Generated by Django 3.0.3 on 2020-03-05 13:21
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('uncloud_vm', '0001_initial'),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='vmdiskimageproduct',
- name='storage_class',
- field=models.CharField(choices=[('HDD', 'HDD'), ('SSD', 'SSD')], default='SSD', max_length=32),
- ),
- migrations.AlterField(
- model_name='vmproduct',
- name='name',
- field=models.CharField(blank=True, max_length=32, null=True),
- ),
- ]
diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py
deleted file mode 100644
index f759d01..0000000
--- a/uncloud/uncloud_vm/serializers.py
+++ /dev/null
@@ -1,94 +0,0 @@
-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
-
-GB_SSD_PER_DAY=0.012
-GB_HDD_PER_DAY=0.0006
-
-
-class VMHostSerializer(serializers.ModelSerializer):
- vms = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
-
- class Meta:
- model = VMHost
- fields = '__all__'
- read_only_fields = [ 'vms' ]
-
-
-class VMDiskProductSerializer(serializers.ModelSerializer):
- class Meta:
- model = VMDiskProduct
- fields = '__all__'
-
-class VMDiskImageProductSerializer(serializers.ModelSerializer):
- class Meta:
- model = VMDiskImageProduct
- fields = '__all__'
-
-class VMProductSerializer(serializers.ModelSerializer):
- class Meta:
- model = VMProduct
- fields = ['uuid', 'order', 'owner', 'status', 'name',
- 'cores', 'ram_in_gb', 'recurring_period',
- 'snapshots' ]
- read_only_fields = ['uuid', 'order', 'owner', 'status']
-
- # Custom field used at creation (= ordering) only.
- recurring_period = serializers.ChoiceField(
- choices=VMProduct.allowed_recurring_periods())
-
- snapshots = serializers.PrimaryKeyRelatedField(many=True,
- read_only=True)
-
-
-class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer):
- """
- Create an interface similar to standard DCL
- """
-
- # Custom field used at creation (= ordering) only.
- recurring_period = serializers.ChoiceField(
- choices=VMProduct.allowed_recurring_periods())
-
- os_disk_uuid = serializers.UUIDField()
- # os_disk_size =
-
- class Meta:
- model = VMProduct
-
-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
- fields = '__all__'
-
-
- # verify that vm.owner == user.request
- def validate_vm(self, value):
- if not value.owner == self.context['request'].user:
- raise serializers.ValidationError("VM {} not found for owner {}.".format(value,
- self.context['request'].user))
- disks = VMDiskProduct.objects.filter(vm=value)
-
- if len(disks) == 0:
- raise serializers.ValidationError("VM {} does not have any disks, cannot snapshot".format(value.uuid))
-
- return value
-
- pricing = {}
- pricing['per_gb_ssd'] = 0.012
- pricing['per_gb_hdd'] = 0.0006
- pricing['recurring_period'] = 'per_day'
diff --git a/uncloud/uncloud_vm/tests.py b/uncloud/uncloud_vm/tests.py
deleted file mode 100644
index 8d7994f..0000000
--- a/uncloud/uncloud_vm/tests.py
+++ /dev/null
@@ -1,112 +0,0 @@
-import datetime
-
-import parsedatetime
-
-from django.test import TestCase
-from django.contrib.auth import get_user_model
-from django.utils import timezone
-from django.core.exceptions import ValidationError
-
-from uncloud_vm.models import VMDiskImageProduct, VMDiskProduct, VMProduct, VMHost
-from uncloud_pay.models import Order
-
-User = get_user_model()
-cal = parsedatetime.Calendar()
-
-
-# If you want to check the test database using some GUI/cli tool
-# then use the following connecting parameters
-
-# host: localhost
-# database: test_uncloud
-# user: root
-# password:
-# port: 5432
-
-class VMTestCase(TestCase):
- @classmethod
- def setUpClass(cls):
- # Setup vm host
- cls.vm_host, created = VMHost.objects.get_or_create(
- hostname='serverx.placey.ungleich.ch', physical_cores=32, usable_cores=320,
- usable_ram_in_gb=512.0, status='active'
- )
- super().setUpClass()
-
- def setUp(self) -> None:
- # Setup two users as it is common to test with different user
- self.user = User.objects.create_user(
- username='testuser', email='test@test.com', first_name='Test', last_name='User'
- )
- self.user2 = User.objects.create_user(
- username='Meow', email='meow123@test.com', first_name='Meow', last_name='Cat'
- )
- super().setUp()
-
- def create_sample_vm(self, owner):
- one_month_later, parse_status = cal.parse("1 month later")
- return VMProduct.objects.create(
- vmhost=self.vm_host, cores=2, ram_in_gb=4, owner=owner,
- order=Order.objects.create(
- owner=owner,
- creation_date=datetime.datetime.now(tz=timezone.utc),
- starting_date=datetime.datetime.now(tz=timezone.utc),
- ending_date=datetime.datetime(*one_month_later[:6], tzinfo=timezone.utc),
- recurring_price=4.0, one_time_price=5.0, recurring_period='per_month'
- )
- )
-
- def test_disk_product(self):
- """Ensures that a VMDiskProduct can only be created from a VMDiskImageProduct
- that is in status 'active'"""
-
- vm = self.create_sample_vm(owner=self.user)
-
- pending_disk_image = VMDiskImageProduct.objects.create(
- owner=self.user, name='pending_disk_image', is_os_image=True, is_public=True, size_in_gb=10,
- status='pending'
- )
- try:
- vm_disk_product = VMDiskProduct.objects.create(
- owner=self.user, vm=vm, image=pending_disk_image, size_in_gb=10
- )
- except ValidationError:
- vm_disk_product = None
-
- self.assertIsNone(
- vm_disk_product,
- msg='VMDiskProduct created with disk image whose status is not active.'
- )
-
- def test_vm_disk_product_creation(self):
- """Ensure that a user can only create a VMDiskProduct for an existing VM"""
-
- disk_image = VMDiskImageProduct.objects.create(
- owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10,
- status='active'
- )
-
- with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for non-existing VM'):
- # Create VMProduct object but don't save it in database
- vm = VMProduct()
-
- vm_disk_product = VMDiskProduct.objects.create(
- owner=self.user, vm=vm, image=disk_image, size_in_gb=10
- )
-
- def test_vm_disk_product_creation_for_someone_else(self):
- """Ensure that a user can only create a VMDiskProduct for his/her own VM"""
-
- # Create a VM which is ownership of self.user2
- someone_else_vm = self.create_sample_vm(owner=self.user2)
-
- # 'self.user' would try to create a VMDiskProduct for 'user2's VM
- with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for someone else VM.'):
- vm_disk_product = VMDiskProduct.objects.create(
- owner=self.user, vm=someone_else_vm,
- size_in_gb=10,
- image=VMDiskImageProduct.objects.create(
- owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10,
- status='active'
- )
- )
diff --git a/uncloud/ungleich_service/admin.py b/uncloud/ungleich_service/admin.py
deleted file mode 100644
index 8c38f3f..0000000
--- a/uncloud/ungleich_service/admin.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.contrib import admin
-
-# Register your models here.
diff --git a/uncloud/ungleich_service/migrations/0001_initial.py b/uncloud/ungleich_service/migrations/0001_initial.py
deleted file mode 100644
index 5b843c8..0000000
--- a/uncloud/ungleich_service/migrations/0001_initial.py
+++ /dev/null
@@ -1,34 +0,0 @@
-# Generated by Django 3.0.3 on 2020-03-17 11:45
-
-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_vm', '0003_remove_vmhost_vms'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ('uncloud_pay', '0001_initial'),
- ]
-
- 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'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted')], default='PENDING', max_length=32)),
- ('domain', models.CharField(default='domain.tld', max_length=255)),
- ('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/models.py b/uncloud/ungleich_service/models.py
deleted file mode 100644
index 9d6a8ac..0000000
--- a/uncloud/ungleich_service/models.py
+++ /dev/null
@@ -1,32 +0,0 @@
-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 one_time_price(self):
- return 30
diff --git a/uncloud/ungleich_service/serializers.py b/uncloud/ungleich_service/serializers.py
deleted file mode 100644
index b4038b7..0000000
--- a/uncloud/ungleich_service/serializers.py
+++ /dev/null
@@ -1,17 +0,0 @@
-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/views.py b/uncloud/ungleich_service/views.py
deleted file mode 100644
index 47c15e2..0000000
--- a/uncloud/ungleich_service/views.py
+++ /dev/null
@@ -1,61 +0,0 @@
-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.one_time_price,
- 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.one_time_price,
- service.recurring_price(order.recurring_period),
- service.description)
-
- return Response(serializer.data)
diff --git a/uncloud/urls.py b/uncloud/urls.py
new file mode 100644
index 0000000..afb3a2e
--- /dev/null
+++ b/uncloud/urls.py
@@ -0,0 +1,72 @@
+"""uncloud URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/3.0/topics/http/urls/
+"""
+
+from django.contrib import admin
+from django.urls import path, include
+from django.conf import settings
+from django.conf.urls.static import static
+
+from rest_framework import routers
+from rest_framework.schemas import get_schema_view
+
+from uncloud_auth import views as authviews
+from uncloud_net import views as netviews
+from uncloud_pay import views as payviews
+from uncloud_vm import views as vmviews
+from . import views as uncloudviews
+#import notifications.urls
+
+router = routers.DefaultRouter()
+
+# Beta endpoints
+router.register(r'beta/vm', vmviews.NicoVMProductViewSet, basename='nicovmproduct')
+
+################################################################################
+# v2
+
+# Net
+router.register(r'v2/net/wireguardvpn', netviews.WireGuardVPNViewSet, basename='wireguardvpnnetwork')
+router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basename='wireguardvpnnetworksizes')
+
+# Payment related for a user
+router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='stripecreditcard')
+router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment')
+router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance')
+router.register(r'v2/payment/address', payviews.BillingAddressViewSet, basename='billingaddress')
+router.register(r'v2/orders', payviews.OrderViewSet, basename='orders')
+router.register(r'v2/bill', payviews.BillViewSet, basename='bills')
+
+# Generic helper views that are usually not needed
+router.register(r'v2/generic/vat-rate', payviews.VATRateViewSet, basename='vatrate')
+
+
+urlpatterns = [
+ path(r'', uncloudviews.UncloudIndex.as_view(), name='uncloudindex'),
+ path(r'^/login', authviews.LoginView.as_view(), name='login'),
+ path(r'^/logout', authviews.LogoutView.as_view(), name='logout'),
+
+ path(r'api/', include(router.urls), name='api'),
+
+ path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), # for login to REST API
+ path('openapi', get_schema_view(
+ title="uncloud",
+ description="uncloud API",
+ version="2.0.0"
+ ), name='openapi-schema'),
+
+ path('admin/', admin.site.urls),
+ #path('accounts/', include('allauth.urls')),
+ path('pricing/
/calculate/', payviews.PricingView.as_view(), name='pricing_calculator'),
+ path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"),
+# path('inbox/notifications/', include(notifications.urls, namespace='notifications')),
+ path('payments/', include('uncloud_pay.urls', namespace='payments')),
+ #
+]
+
+# if env('ACTIVE_APP') == 'nextcloud':
+# urlpatterns.append(path('', include('nextcloud.urls', namespace='nextcloud')))
+# elif env('ACTIVE_APP') == 'matrixhosting':
+# urlpatterns.append(path('', include('matrixhosting.urls', namespace='matrix')))
diff --git a/uncloud/validators.py b/uncloud/validators.py
new file mode 100644
index 0000000..08cc818
--- /dev/null
+++ b/uncloud/validators.py
@@ -0,0 +1,34 @@
+from django.core.validators import RegexValidator
+
+
+def _validator():
+
+ ul = '\u00a1-\uffff' # unicode letters range (must not be a raw string)
+
+ # IP patterns
+ ipv4_re = r'(?:25[0-5]|2[0-4]\d|[0-1]?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}'
+ ipv6_re = r'\[[0-9a-f:\.]+\]' # (simple regex, validated later)
+
+ # Host patterns
+ hostname_re = r'[a-z' + ul + \
+ r'0-9](?:[a-z' + ul + r'0-9-]{0,61}[a-z' + ul + r'0-9])?'
+ # Max length for domain name labels is 63 characters per RFC 1034 sec. 3.1
+ domain_re = r'(?:\.(?!-)[a-z' + ul + r'0-9-]{1,63}(?{% trans "E-mail Addresses" %}
+{% if user.emailaddress_set.all %}
+{% trans 'The following e-mail addresses are associated with your account:' %}
+
+
+
+{% else %}
+{% trans 'Warning:'%} {% trans "You currently do not have any e-mail address set up. You should really add an e-mail address so you can receive notifications, reset your password, etc." %}
+
+{% endif %}
+
+
+
+{% endblock %}
+
+
+{% block extra_body %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/uncloud_auth/templates/account/email_confirm.html b/uncloud_auth/templates/account/email_confirm.html
new file mode 100644
index 0000000..0ba7d45
--- /dev/null
+++ b/uncloud_auth/templates/account/email_confirm.html
@@ -0,0 +1,30 @@
+{% extends "account/base.html" %}
+
+{% load i18n %}
+{% load account %}
+
+{% block head_title %}{% trans "Confirm E-mail Address" %}{% endblock %}
+
+{% block content %}
+{% trans "Confirm E-mail Address" %}
+
+{% if confirmation %}
+
+{% user_display confirmation.email_address.user as user_display %}
+
+{% blocktrans with confirmation.email_address.email as email %}Please confirm that {{ email }} is an e-mail address for user {{ user_display }}.{% endblocktrans %}
+
+
+
+{% else %}
+
+{% url 'account_email' as email_url %}
+
+{% blocktrans %}This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request.{% endblocktrans %}
+
+{% endif %}
+
+{% endblock %}
diff --git a/uncloud_auth/templates/account/login.html b/uncloud_auth/templates/account/login.html
new file mode 100644
index 0000000..d8d92f7
--- /dev/null
+++ b/uncloud_auth/templates/account/login.html
@@ -0,0 +1,43 @@
+{% extends "account/base.html" %}
+
+{% load i18n %}
+{% load account socialaccount %}
+
+{% block head_title %}{% trans "Sign In" %}{% endblock %}
+
+{% block content %}
+ Log In
+
+ {% trans "Don't have an account?" %}{% trans "Sign Up" %}
+
+{% endblock %}
diff --git a/uncloud_auth/templates/account/logout.html b/uncloud_auth/templates/account/logout.html
new file mode 100644
index 0000000..8363f84
--- /dev/null
+++ b/uncloud_auth/templates/account/logout.html
@@ -0,0 +1,18 @@
+{% extends "account/base.html" %}
+
+{% load i18n %}
+
+{% block head_title %}{% trans "Sign Out" %}{% endblock %}
+
+{% block content %}
+ {% trans "Sign Out" %}
+ {% trans 'Are you sure you want to sign out?' %}
+
+{% endblock %}
+
diff --git a/uncloud_auth/templates/account/password_reset.html b/uncloud_auth/templates/account/password_reset.html
new file mode 100644
index 0000000..033f84e
--- /dev/null
+++ b/uncloud_auth/templates/account/password_reset.html
@@ -0,0 +1,29 @@
+{% extends "account/base.html" %}
+
+{% load i18n %}
+{% load account %}
+
+{% block head_title %}{% trans "Password Reset" %}{% endblock %}
+
+{% block content %}
+ {% if user.is_authenticated %}
+ {% include "account/snippets/already_logged_in.html" %}
+ {% endif %}
+ {% trans "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}
+
+{% endblock %}
diff --git a/uncloud_auth/templates/account/password_reset_from_key.html b/uncloud_auth/templates/account/password_reset_from_key.html
new file mode 100644
index 0000000..b98d174
--- /dev/null
+++ b/uncloud_auth/templates/account/password_reset_from_key.html
@@ -0,0 +1,37 @@
+{% extends "account/base.html" %}
+
+{% load i18n %}
+{% block head_title %}{% trans "Change Password" %}{% endblock %}
+
+{% block content %}
+ {% if token_fail %}{% trans "Bad Token" %}{% else %}{% trans "Change Password" %}{% endif %}
+
+ {% if token_fail %}
+ {% url 'account_reset_password' as passwd_reset_url %}
+ {% blocktrans %}The password reset link was invalid, possibly because it has already been used. Please request a new password reset.{% endblocktrans %}
+ {% else %}
+ {% if form %}
+
+ {% else %}
+ {% trans 'Your password is now changed.' %}
+ {% endif %}
+ {% endif %}
+{% endblock %}
diff --git a/uncloud_auth/templates/account/signup.html b/uncloud_auth/templates/account/signup.html
new file mode 100644
index 0000000..ea1d625
--- /dev/null
+++ b/uncloud_auth/templates/account/signup.html
@@ -0,0 +1,57 @@
+{% extends "account/base.html" %}
+
+{% load i18n %}
+{% load account socialaccount %}
+
+{% block head_title %}{% trans "Sign Up" %}{% endblock %}
+
+{% block content %}
+ {% trans "Sign Up" %}
+
+ {% trans "Already have an account?" %}{% trans "Login" %}
+{% endblock %}
diff --git a/uncloud_auth/templates/uncloud_auth/login.html b/uncloud_auth/templates/uncloud_auth/login.html
new file mode 100644
index 0000000..7956687
--- /dev/null
+++ b/uncloud_auth/templates/uncloud_auth/login.html
@@ -0,0 +1,21 @@
+{% extends 'uncloud/base.html' %}
+{% load bootstrap5 %}
+{% block title %}Welcome to uncloud [beta]{% endblock %}
+
+{% block content %}
+
+
+
+
+
Login to uncloud
+
+
+
+
+{% endblock %}
diff --git a/uncloud_auth/ungleich_ldap.py b/uncloud_auth/ungleich_ldap.py
new file mode 100644
index 0000000..4e260d1
--- /dev/null
+++ b/uncloud_auth/ungleich_ldap.py
@@ -0,0 +1,277 @@
+import base64
+import hashlib
+import logging
+import random
+import unicodedata
+
+import ldap3
+from ldap3 import ALL
+from django.conf import settings
+from django.contrib.auth.hashers import make_password
+
+logger = logging.getLogger(__name__)
+
+
+class LdapManager:
+ __instance = None
+ def __new__(cls):
+ if LdapManager.__instance is None:
+ LdapManager.__instance = object.__new__(cls)
+ return LdapManager.__instance
+
+ def __init__(self):
+ """
+ Initialize the LDAP subsystem.
+ """
+ self.rng = random.SystemRandom()
+ self.server = ldap3.Server(settings.AUTH_LDAP_SERVER_HOST, use_ssl=True, get_info=ALL)
+
+
+ def get_admin_conn(self):
+ """
+ Return a bound :class:`ldap3.Connection` instance which has write
+ permissions on the dn in which the user accounts reside.
+ """
+ conn = self.get_conn(user=settings.LDAP_ADMIN_DN,
+ password=settings.LDAP_ADMIN_PASSWORD,
+ raise_exceptions=True)
+ conn.start_tls()
+ conn.bind()
+ return conn
+
+
+ def get_conn(self, **kwargs):
+ """
+ Return an unbound :class:`ldap3.Connection` which talks to the configured
+ LDAP server.
+
+ The *kwargs* are passed to the constructor of :class:`ldap3.Connection` and
+ can be used to set *user*, *password* and other useful arguments.
+ """
+ return ldap3.Connection(self.server, **kwargs)
+
+
+ def _ssha_password(self, password):
+ """
+ Apply the SSHA password hashing scheme to the given *password*.
+ *password* must be a :class:`bytes` object, containing the utf-8
+ encoded password.
+
+ Return a :class:`bytes` object containing ``ascii``-compatible data
+ which can be used as LDAP value, e.g. after armoring it once more using
+ base64 or decoding it to unicode from ``ascii``.
+ """
+ SALT_BYTES = 15
+ sha1 = hashlib.sha1()
+ salt = self.rng.getrandbits(SALT_BYTES * 8).to_bytes(SALT_BYTES,
+ "little")
+ sha1.update(password)
+ sha1.update(salt)
+
+ digest = sha1.digest()
+ passwd = b"{SSHA}" + base64.b64encode(digest + salt)
+
+ return passwd
+
+
+ def create_user(self, username, password, firstname, lastname, email):
+ conn = self.get_admin_conn()
+ uidNumber = self._get_max_uid() + 1
+ logger.debug("uidNumber={uidNumber}".format(uidNumber=uidNumber))
+ user_exists = True
+ while user_exists:
+ user_exists, _ = self.check_user_exists(
+ "",
+ '(&(objectClass=inetOrgPerson)(objectClass=posixAccount)'
+ '(objectClass=top)(uidNumber={uidNumber}))'.format(
+ uidNumber=uidNumber
+ )
+ )
+ if user_exists:
+ logger.debug(
+ "{uid} exists. Trying next.".format(uid=uidNumber)
+ )
+ uidNumber += 1
+ logger.debug("{uid} does not exist. Using it".format(uid=uidNumber))
+ self._set_max_uid(uidNumber)
+ try:
+ uid = username
+ conn.add("uid={uid},{customer_dn}".format(
+ uid=uid, customer_dn=settings.LDAP_CUSTOMER_DN
+ ),
+ ["inetOrgPerson", "posixAccount", "ldapPublickey"],
+ {
+ "uid": [uid],
+ "sn": [lastname.encode("utf-8")],
+ "givenName": [firstname.encode("utf-8")],
+ "cn": [uid],
+ "displayName": ["{} {}".format(firstname, lastname).encode("utf-8")],
+ "uidNumber": [str(uidNumber)],
+ "gidNumber": [str(settings.LDAP_CUSTOMER_GROUP_ID)],
+ "loginShell": ["/bin/bash"],
+ "homeDirectory": ["/home/{}".format(unicodedata.normalize('NFKD', username).encode('ascii','ignore'))],
+ "mail": email.encode("utf-8"),
+ "userPassword": [self._ssha_password(password.encode("utf-8"))]
+ }
+ )
+ logger.debug('Created user %s %s' % (username.encode('utf-8'),
+ uidNumber))
+ except Exception as ex:
+ logger.debug('Could not create user %s' % username.encode('utf-8'))
+ logger.error("Exception: " + str(ex))
+ raise
+ finally:
+ conn.unbind()
+
+
+ def change_password(self, entry_dn, new_password):
+ """
+ Changes the password of the user identified by user_dn
+
+ :param entry_dn: str The dn that identifies the user
+ :param new_password: str The new password string
+ :return: True if password was changed successfully False otherwise
+ """
+ conn = self.get_admin_conn()
+ return_val = False
+ try:
+ return_val = conn.modify(
+ entry_dn,
+ {
+ "userpassword": (
+ ldap3.MODIFY_REPLACE,
+ [self._ssha_password(new_password.encode("utf-8"))]
+ )
+ }
+ )
+ except Exception as ex:
+ logger.error("Exception: " + str(ex))
+
+ conn.unbind()
+ return return_val
+
+ def change_user_details(self, uid, details):
+ """
+ Updates the user details as per given values in kwargs of the user
+ identified by user_dn.
+
+ Assumes that all attributes passed in kwargs are valid.
+
+ :param uid: str The uid that identifies the user
+ :param details: dict A dictionary containing the new values
+ :return: True if user details were updated successfully False otherwise
+ """
+ conn = self.get_admin_conn()
+
+ # Make sure the user exists first to change his/her details
+ user_exists, entries = self.check_user_exists(
+ uid=uid,
+ search_base=settings.LDAP_CUSTOMER_DN
+ )
+
+ return_val = False
+ if user_exists:
+ details_dict = {k: (ldap3.MODIFY_REPLACE, [v.encode("utf-8")]) for
+ k, v in details.items()}
+ try:
+ return_val = conn.modify(entries[0].entry_dn, details_dict)
+ msg = "success"
+ except Exception as ex:
+ msg = str(ex)
+ logger.error("Exception: " + msg)
+ finally:
+ conn.unbind()
+ else:
+ msg = "User {} not found".format(uid)
+ logger.error(msg)
+ conn.unbind()
+ return return_val, msg
+
+ def check_user_exists(self, uid, search_filter="", attributes=None,
+ search_base=settings.LDAP_CUSTOMER_DN):
+ """
+ Check if the user with the given uid exists in the customer group.
+
+ :param uid: str representing the user
+ :param search_filter: str representing the filter condition to find
+ users. If its empty, the search finds the user with
+ the given uid.
+ :param attributes: list A list of str representing all the attributes
+ to be obtained in the result entries
+ :param search_base: str
+ :return: tuple (bool, [ldap3.abstract.entry.Entry ..])
+ A bool indicating if the user exists
+ A list of all entries obtained in the search
+ """
+ conn = self.get_admin_conn()
+ entries = []
+ try:
+ result = conn.search(
+ search_base=search_base,
+ search_filter=search_filter if len(search_filter)> 0 else
+ '(uid={uid})'.format(uid=uid),
+ attributes=attributes
+ )
+ entries = conn.entries
+ finally:
+ conn.unbind()
+ return result, entries
+
+ def delete_user(self, uid):
+ """
+ Deletes the user with the given uid from ldap
+
+ :param uid: str representing the user
+ :return: True if the delete was successful False otherwise
+ """
+ conn = self.get_admin_conn()
+ try:
+ return_val = conn.delete(
+ ("uid={uid}," + settings.LDAP_CUSTOMER_DN).format(uid=uid),
+ )
+ msg = "success"
+ except Exception as ex:
+ msg = str(ex)
+ logger.error("Exception: " + msg)
+ return_val = False
+ finally:
+ conn.unbind()
+ return return_val, msg
+
+ def _set_max_uid(self, max_uid):
+ """
+ a utility function to save max_uid value to a file
+
+ :param max_uid: an integer representing the max uid
+ :return:
+ """
+ with open(settings.LDAP_MAX_UID_FILE_PATH, 'w+') as handler:
+ handler.write(str(max_uid))
+
+ def _get_max_uid(self):
+ """
+ A utility function to read the max uid value that was previously set
+
+ :return: An integer representing the max uid value that was previously
+ set
+ """
+ try:
+ with open(settings.LDAP_MAX_UID_FILE_PATH, 'r+') as handler:
+ try:
+ return_value = int(handler.read())
+ except ValueError as ve:
+ logger.error(
+ "Error reading int value from {}. {}"
+ "Returning default value {} instead".format(
+ settings.LDAP_MAX_UID_PATH,
+ str(ve),
+ settings.LDAP_DEFAULT_START_UID
+ )
+ )
+ return_value = settings.LDAP_DEFAULT_START_UID
+ return return_value
+ except FileNotFoundError as fnfe:
+ logger.error("File not found : " + str(fnfe))
+ return_value = settings.LDAP_DEFAULT_START_UID
+ logger.error("So, returning UID={}".format(return_value))
+ return return_value
diff --git a/uncloud_auth/views.py b/uncloud_auth/views.py
new file mode 100644
index 0000000..6430be9
--- /dev/null
+++ b/uncloud_auth/views.py
@@ -0,0 +1,75 @@
+from django.contrib.auth import views as auth_views
+from django.contrib.auth import logout
+
+from django_auth_ldap.backend import LDAPBackend
+from rest_framework import mixins, permissions, status, viewsets
+from rest_framework.decorators import action
+from rest_framework.response import Response
+
+from .serializers import *
+
+class LoginView(auth_views.LoginView):
+ template_name = 'uncloud_auth/login.html'
+
+class LogoutView(auth_views.LogoutView):
+ pass
+
+
+class UserViewSet(viewsets.GenericViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = UserSerializer
+
+ def get_queryset(self):
+ return self.request.user
+
+ def list(self, request, format=None):
+ # This is a bit stupid: we have a user, we create a queryset by
+ # matching on the username. But I don't know a "nicer" way.
+ # Nico, 2020-03-18
+ user = request.user
+ serializer = self.get_serializer(user, context = {'request': request})
+ return Response(serializer.data)
+
+ @action(detail=False, methods=['post'])
+ def change_email(self, request):
+ serializer = self.get_serializer(
+ request.user, data=request.data, context={'request': request}
+ )
+ serializer.is_valid(raise_exception=True)
+ serializer.save()
+ return Response(serializer.data)
+
+
+class AccountManagementViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
+ serializer_class = UserRegistrationSerializer
+
+ def create(self, request, *args, **kwargs):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ self.perform_create(serializer)
+ headers = self.get_success_headers(serializer.data)
+ return Response(
+ serializer.data, status=status.HTTP_201_CREATED, headers=headers
+ )
+
+
+class AdminUserViewSet(viewsets.ReadOnlyModelViewSet):
+ permission_classes = [permissions.IsAdminUser]
+
+ def get_serializer_class(self):
+ if self.action == 'import_from_ldap':
+ return ImportUserSerializer
+ else:
+ return UserSerializer
+
+ def get_queryset(self):
+ return get_user_model().objects.all()
+
+ @action(detail=False, methods=['post'], url_path='import_from_ldap')
+ def import_from_ldap(self, request, pk=None):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ ldap_username = serializer.validated_data.pop("username")
+ user = LDAPBackend().populate_user(ldap_username)
+
+ return Response(UserSerializer(user, context = {'request': request}).data)
diff --git a/uncloud_net/__init__.py b/uncloud_net/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_net/admin.py b/uncloud_net/admin.py
new file mode 100644
index 0000000..ca6aaa1
--- /dev/null
+++ b/uncloud_net/admin.py
@@ -0,0 +1,7 @@
+from django.contrib import admin
+
+from .models import *
+
+
+for m in [ ReverseDNSEntry, WireGuardVPNPool, WireGuardVPN ]:
+ admin.site.register(m)
diff --git a/uncloud/uncloud_net/apps.py b/uncloud_net/apps.py
similarity index 100%
rename from uncloud/uncloud_net/apps.py
rename to uncloud_net/apps.py
diff --git a/uncloud_net/forms.py b/uncloud_net/forms.py
new file mode 100644
index 0000000..ad4e013
--- /dev/null
+++ b/uncloud_net/forms.py
@@ -0,0 +1,11 @@
+from django import forms
+
+from .models import *
+from .selectors import *
+
+class WireGuardVPNForm(forms.ModelForm):
+ network_size = forms.ChoiceField(choices=allowed_vpn_network_reservation_size)
+
+ class Meta:
+ model = WireGuardVPN
+ fields = [ "wireguard_public_key" ]
diff --git a/uncloud_net/management/commands/vpn.py b/uncloud_net/management/commands/vpn.py
new file mode 100644
index 0000000..9fdc80d
--- /dev/null
+++ b/uncloud_net/management/commands/vpn.py
@@ -0,0 +1,44 @@
+import sys
+from datetime import datetime
+
+from django.core.management.base import BaseCommand
+
+from django.contrib.auth import get_user_model
+
+from opennebula.models import VM as VMModel
+from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct, VMCluster
+
+import logging
+log = logging.getLogger(__name__)
+
+
+
+peer_template="""
+# {username}
+[Peer]
+PublicKey = {public_key}
+AllowedIPs = {vpnnetwork}
+"""
+
+class Command(BaseCommand):
+ help = 'General uncloud commands'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--hostname',
+ action='store_true',
+ help='Name of this VPN Host',
+ required=True)
+
+ def handle(self, *args, **options):
+ if options['bootstrap']:
+ self.bootstrap()
+
+ self.create_vpn_config(options['hostname'])
+
+ def create_vpn_config(self, hostname):
+ configs = []
+
+ for pool in VPNPool.objects.filter(vpn_hostname=hostname):
+ configs.append(pool_config)
+
+ print(configs)
diff --git a/uncloud_net/migrations/0001_initial.py b/uncloud_net/migrations/0001_initial.py
new file mode 100644
index 0000000..6794156
--- /dev/null
+++ b/uncloud_net/migrations/0001_initial.py
@@ -0,0 +1,62 @@
+# Generated by Django 3.1 on 2020-12-13 13:42
+
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='MACAdress',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='WireGuardVPNPool',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('network', models.GenericIPAddressField(unique=True)),
+ ('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
+ ('subnetwork_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
+ ('vpn_server_hostname', models.CharField(max_length=256)),
+ ('wireguard_private_key', models.CharField(max_length=48)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='WireGuardVPNFreeLeases',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('pool_index', models.IntegerField(unique=True)),
+ ('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.wireguardvpnpool')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='WireGuardVPN',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('pool_index', models.IntegerField(unique=True)),
+ ('wireguard_public_key', models.CharField(max_length=48)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.wireguardvpnpool')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ReverseDNSEntry',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('ip_address', models.GenericIPAddressField(unique=True)),
+ ('name', models.CharField(max_length=253)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/uncloud_net/migrations/0002_wireguardvpnpool_wireguard_public_key.py b/uncloud_net/migrations/0002_wireguardvpnpool_wireguard_public_key.py
new file mode 100644
index 0000000..479aba1
--- /dev/null
+++ b/uncloud_net/migrations/0002_wireguardvpnpool_wireguard_public_key.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1 on 2020-12-13 17:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='wireguardvpnpool',
+ name='wireguard_public_key',
+ field=models.CharField(default='', max_length=48),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud_net/migrations/0003_wireguardvpnpool_wg_name.py b/uncloud_net/migrations/0003_wireguardvpnpool_wg_name.py
new file mode 100644
index 0000000..9ecf52c
--- /dev/null
+++ b/uncloud_net/migrations/0003_wireguardvpnpool_wg_name.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1 on 2020-12-13 17:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0002_wireguardvpnpool_wireguard_public_key'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='wireguardvpnpool',
+ name='wg_name',
+ field=models.CharField(default='wg0', max_length=15),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud_net/migrations/0004_auto_20201213_1734.py b/uncloud_net/migrations/0004_auto_20201213_1734.py
new file mode 100644
index 0000000..24e46e7
--- /dev/null
+++ b/uncloud_net/migrations/0004_auto_20201213_1734.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-12-13 17:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0003_wireguardvpnpool_wg_name'),
+ ]
+
+ operations = [
+ migrations.AddConstraint(
+ model_name='wireguardvpnpool',
+ constraint=models.UniqueConstraint(fields=('wg_name', 'vpn_server_hostname'), name='unique_interface_name_per_host'),
+ ),
+ ]
diff --git a/uncloud_net/migrations/0005_auto_20201220_1837.py b/uncloud_net/migrations/0005_auto_20201220_1837.py
new file mode 100644
index 0000000..1dbabe6
--- /dev/null
+++ b/uncloud_net/migrations/0005_auto_20201220_1837.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1 on 2020-12-20 18:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0004_auto_20201213_1734'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='wireguardvpn',
+ name='wireguard_public_key',
+ field=models.CharField(max_length=48, unique=True),
+ ),
+ ]
diff --git a/uncloud_net/migrations/0006_auto_20201224_1626.py b/uncloud_net/migrations/0006_auto_20201224_1626.py
new file mode 100644
index 0000000..c0dd2ef
--- /dev/null
+++ b/uncloud_net/migrations/0006_auto_20201224_1626.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-12-24 16:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0005_auto_20201220_1837'),
+ ]
+
+ operations = [
+ migrations.AddConstraint(
+ model_name='wireguardvpn',
+ constraint=models.UniqueConstraint(fields=('vpnpool', 'wireguard_public_key'), name='wg_key_unique_per_pool'),
+ ),
+ ]
diff --git a/uncloud_net/migrations/__init__.py b/uncloud_net/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_net/models.py b/uncloud_net/models.py
new file mode 100644
index 0000000..9865a08
--- /dev/null
+++ b/uncloud_net/models.py
@@ -0,0 +1,208 @@
+import uuid
+import ipaddress
+
+from django.db import models
+from django.contrib.auth import get_user_model
+from django.core.validators import MinValueValidator, MaxValueValidator
+from django.core.exceptions import FieldError, ValidationError
+
+from uncloud_pay.models import Order, Product
+
+class WireGuardVPNPool(models.Model):
+ """
+ Network address pools from which VPNs can be created
+ """
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['wg_name', 'vpn_server_hostname' ],
+ name='unique_interface_name_per_host')
+ ]
+
+
+ # Linux interface naming is restricing to max 15 characters
+ wg_name = models.CharField(max_length=15)
+
+ network = models.GenericIPAddressField(unique=True)
+ network_mask = models.IntegerField(validators=[MinValueValidator(0),
+ MaxValueValidator(128)])
+
+ subnetwork_mask = models.IntegerField(validators=[
+ MinValueValidator(0),
+ MaxValueValidator(128)
+ ])
+
+ vpn_server_hostname = models.CharField(max_length=256)
+ wireguard_private_key = models.CharField(max_length=48)
+ wireguard_public_key = models.CharField(max_length=48)
+
+ @property
+ def max_pool_index(self):
+ """
+ Return the highest possible network / last network id
+ """
+
+ bits = self.subnetwork_mask - self.network_mask
+
+ return (2**bits)-1
+
+ @property
+ def ip_network(self):
+ """
+ Return the IP network based on our address and mask
+ """
+ return ipaddress.ip_network(f"{self.network}/{self.network_mask}")
+
+ def __str__(self):
+ return f"{self.ip_network} (subnets: /{self.subnetwork_mask})"
+
+ @property
+ def wireguard_config(self):
+ wireguard_config = [ f"[Interface]\nListenPort = 51820\nPrivateKey = {self.wireguard_private_key}\n" ]
+
+ peers = []
+
+ for vpn in self.wireguardvpn_set.all():
+ public_key = vpn.wireguard_public_key
+ peer_network = f"{vpn.address}/{self.subnetwork_mask}"
+ owner = vpn.owner
+
+ peers.append(f"# Owner: {owner}\n[Peer]\nPublicKey = {public_key}\nAllowedIPs = {peer_network}\n\n")
+
+ wireguard_config.extend(peers)
+
+ return "\n".join(wireguard_config)
+
+
+class WireGuardVPN(models.Model):
+ """
+ Created VPNNetworks
+ """
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE)
+ vpnpool = models.ForeignKey(WireGuardVPNPool,
+ on_delete=models.CASCADE)
+
+ pool_index = models.IntegerField(unique=True)
+
+ wireguard_public_key = models.CharField(max_length=48, unique=True)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['vpnpool', 'wireguard_public_key'],
+ name='wg_key_unique_per_pool')
+ ]
+
+
+ @property
+ def network_mask(self):
+ return self.vpnpool.subnetwork_mask
+
+ @property
+ def vpn_server(self):
+ return self.vpnpool.vpn_server_hostname
+
+ @property
+ def vpn_server_public_key(self):
+ return self.vpnpool.wireguard_public_key
+
+ @property
+ def address(self):
+ """
+ Locate the correct subnet in the supernet
+
+ First get the network itself
+
+ """
+
+ net = self.vpnpool.ip_network
+ subnet = net[(2**(128-self.vpnpool.subnetwork_mask)) * self.pool_index]
+
+ return str(subnet)
+
+ def __str__(self):
+ return f"{self.address} ({self.pool_index})"
+
+ def create_product(self):
+ """
+ Ensure we have a product for the WireguardVPN
+ """
+
+ pass
+
+ # Product.objects.get_or_create(
+ # name="WireGuardVPN",
+ # description="Wireguard VPN",
+ # currency=Currency.CHF,
+ # config=
+
+
+class WireGuardVPNFreeLeases(models.Model):
+ """
+ Previously used VPNNetworks
+ """
+ vpnpool = models.ForeignKey(WireGuardVPNPool,
+ on_delete=models.CASCADE)
+
+ pool_index = models.IntegerField(unique=True)
+
+################################################################################
+
+class MACAdress(models.Model):
+ default_prefix = 0x420000000000
+
+
+class ReverseDNSEntry(models.Model):
+ """
+ A reverse DNS entry
+ """
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE)
+
+ ip_address = models.GenericIPAddressField(null=False, unique=True)
+
+ name = models.CharField(max_length=253, null=False)
+
+ @property
+ def reverse_pointer(self):
+ return ipaddress.ip_address(self.ip_address).reverse_pointer
+
+ def implement(self):
+ """
+ The implement function implements the change
+ """
+
+ # Get all DNS entries (?) / update this DNS entry
+ # convert to DNS name
+ #
+ pass
+
+
+ def save(self, *args, **kwargs):
+ # Product.objects.filter(config__parameters__contains='reverse_dns_network')
+ # FIXME: check if order is still active / not replaced
+
+ allowed = False
+ product = None
+
+ for order in Order.objects.filter(config__parameters__reverse_dns_network__isnull=False,
+ owner=self.owner):
+ network = order.config['parameters']['reverse_dns_network']
+
+ net = ipaddress.ip_network(network)
+ addr = ipaddress.ip_address(self.ip_address)
+
+ if addr in net:
+ allowed = True
+ product = order.product
+ break
+
+
+ if not allowed:
+ raise ValidationError(f"User {self.owner} does not have the right to create reverse DNS entry for {self.ip_address}")
+
+ super().save(*args, **kwargs)
+
+
+ def __str__(self):
+ return f"{self.ip_address} - {self.name}"
diff --git a/uncloud_net/selectors.py b/uncloud_net/selectors.py
new file mode 100644
index 0000000..6e12e8b
--- /dev/null
+++ b/uncloud_net/selectors.py
@@ -0,0 +1,43 @@
+from django.db import transaction
+from django.db.models import Count, F
+from .models import *
+
+def get_suitable_pools(subnetwork_mask):
+ """
+ Find suitable pools for a certain network size.
+
+ First, filter for all pools that offer the requested subnetwork_size.
+
+ Then find those pools that are not fully exhausted:
+
+ The number of available networks in a pool is 2^(subnetwork_size-network_size.
+
+ The number of available networks in a pool is given by the number of VPNNetworkreservations.
+
+ """
+
+ return WireGuardVPNPool.objects.annotate(
+ num_reservations=Count('wireguardvpn'),
+ max_reservations=2**(F('subnetwork_mask')-F('network_mask'))).filter(
+ num_reservations__lt=F('max_reservations'),
+ subnetwork_mask=subnetwork_mask)
+
+
+def allowed_vpn_network_reservation_size():
+ """
+ Find all possible sizes of subnetworks that are available.
+
+ Select all pools with free networks.
+
+ Get their subnetwork sizes, reduce to a set
+
+ """
+
+ pools = WireGuardVPNPool.objects.annotate(num_reservations=Count('wireguardvpn'),
+ max_reservations=2**(F('subnetwork_mask')-F('network_mask'))).filter(
+ num_reservations__lt=F('max_reservations'))
+
+ # Need to return set of tuples, see
+ # https://docs.djangoproject.com/en/3.1/ref/models/fields/#field-choices
+# return set([ (pool.subnetwork_mask, pool.subnetwork_mask) for pool in pools ])
+ return set([pool.subnetwork_mask for pool in pools ])
diff --git a/uncloud_net/serializers.py b/uncloud_net/serializers.py
new file mode 100644
index 0000000..09baa59
--- /dev/null
+++ b/uncloud_net/serializers.py
@@ -0,0 +1,57 @@
+import base64
+
+from django.contrib.auth import get_user_model
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from .models import *
+from .services import *
+from .selectors import *
+
+
+class WireGuardVPNSerializer(serializers.ModelSerializer):
+ address = serializers.CharField(read_only=True)
+ vpn_server = serializers.CharField(read_only=True)
+ vpn_server_public_key = serializers.CharField(read_only=True)
+ network_mask = serializers.IntegerField()
+
+ class Meta:
+ model = WireGuardVPN
+ fields = [ 'wireguard_public_key', 'address', 'network_mask', 'vpn_server',
+ 'vpn_server_public_key' ]
+
+ extra_kwargs = {
+ 'network_mask': {'write_only': True }
+ }
+
+
+ def validate_network_mask(self, value):
+ msg = _(f"No pool for network size {value}")
+ sizes = allowed_vpn_network_reservation_size()
+
+ if not value in sizes:
+ raise serializers.ValidationError(msg)
+
+ return value
+
+ def validate_wireguard_public_key(self, value):
+ msg = _("Supplied key is not a valid wireguard public key")
+
+ """
+ Verify wireguard key.
+ See https://lists.zx2c4.com/pipermail/wireguard/2020-December/006221.html
+ """
+
+ try:
+ decoded_key = base64.standard_b64decode(value)
+ except Exception as e:
+ raise serializers.ValidationError(msg)
+
+ if not len(decoded_key) == 32:
+ raise serializers.ValidationError(msg)
+
+ return value
+
+
+class WireGuardVPNSizesSerializer(serializers.Serializer):
+ size = serializers.IntegerField(min_value=0, max_value=128)
diff --git a/uncloud_net/services.py b/uncloud_net/services.py
new file mode 100644
index 0000000..8449394
--- /dev/null
+++ b/uncloud_net/services.py
@@ -0,0 +1,65 @@
+from django.db import transaction
+
+from .models import *
+from .selectors import *
+from .tasks import *
+from django_q.tasks import async_task, result
+
+@transaction.atomic
+def create_wireguard_vpn(owner, public_key, network_mask):
+ # Check if the user has a membership.
+ #------------------------------------
+ # If yes, user is eligible for API access and 2 VPNs
+ # If user already has 2 VPNs, we deduct from the credit
+ # If deduction is higher than the allowed credit, we fail
+
+
+ #
+ # Check if the user has suitable balance
+ # Create order
+ #
+ return create_wireguard_vpn_tech(owner, public_key, network_mask)
+
+@transaction.atomic
+def create_wireguard_vpn_tech(owner, public_key, network_mask):
+ pool = get_suitable_pools(network_mask)[0]
+ count = pool.wireguardvpn_set.count()
+
+ # Try re-using previously used networks first
+ try:
+ free_lease = WireGuardVPNFreeLeases.objects.get(vpnpool=pool)
+
+ vpn = WireGuardVPN.objects.create(owner=owner,
+ vpnpool=pool,
+ pool_index=free_lease.pool_index,
+ wireguard_public_key=public_key)
+
+ free_lease.delete()
+
+ except WireGuardVPNFreeLeases.DoesNotExist:
+ # First object
+ if count == 0:
+ vpn = WireGuardVPN.objects.create(owner=owner,
+ vpnpool=pool,
+ pool_index=0,
+ wireguard_public_key=public_key)
+
+ else: # Select last network and try +1 it
+ last_net = WireGuardVPN.objects.filter(vpnpool=pool).order_by('pool_index').last()
+
+ next_index = last_net.pool_index + 1
+
+ if next_index <= pool.max_pool_index:
+ vpn = WireGuardVPN.objects.create(owner=owner,
+ vpnpool=pool,
+ pool_index=next_index,
+ wireguard_public_key=public_key)
+
+
+ config = pool.wireguard_config
+ server = pool.vpn_server_hostname
+ wg_name = pool.wg_name
+
+ async_task(configure_wireguard_server_on_host, (wg_name, config), queue=server)
+
+ return vpn
diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py
new file mode 100644
index 0000000..5684871
--- /dev/null
+++ b/uncloud_net/tasks.py
@@ -0,0 +1,74 @@
+from .models import *
+
+import os
+import subprocess
+import logging
+import uuid
+from django_q.tasks import async_task, result
+
+log = logging.getLogger(__name__)
+
+
+def configure_wireguard_server_on_host(wg_name, config):
+ """
+ - Create wireguard config (DB query -> string)
+ - Submit config to cdist worker
+ - Change config locally on worker / commit / shared
+ """
+
+ # Write config
+ fname = f"/etc/wireguard/{wg_name}.conf"
+ with open(fname, "w") as fd:
+ fd.write(config)
+
+ # Ensure the device exists
+ subprocess.run(f"ip link show {wg_name} >/dev/null || sudo ip link add {{wg_name}} type wireguard",
+ shell=True, check=True)
+
+ # Ensure the config is correct
+ subprocess.run(f"sudo wg setconf {wg_name} {fname}",
+ shell=True, check=True)
+
+
+
+def configure_wireguard_server_via_cdist(wireguardvpnpool):
+ """
+ - Create wireguard config (DB query -> string)
+ - Submit config to cdist worker
+ - Change config locally on worker / commit / shared
+
+ """
+
+ config = wireguardvpnpool.wireguard_config
+ server = wireguardvpnpool.vpn_server_hostname
+
+ log.info(f"Configuring VPN server {server} (async)")
+
+ async_task(cdist_configure_wireguard_server,config, server).id
+
+
+def cdist_configure_wireguard_server(config, server):
+ """
+ Create config and configure server.
+
+ To be executed on the cdist worker.
+ """
+
+ dirname= "/home/app/.cdist/type/__ungleich_wireguard/files/"
+ fname = os.path.join(dirname,server)
+
+ log.info(f"Configuring VPN server {server} (on cdist host)")
+ with open(fname, "w") as fd:
+ fd.write(config)
+
+ log.debug("git committing wireguard changes")
+ subprocess.run(f"cd {dirname} && git pull && git add {server} && git commit -m 'Updating config for {server}' && git push",
+ shell=True, check=True)
+
+ log.debug(f"Configuring VPN server {server} with cdist")
+ subprocess.run(f"cdist config {server}", shell=True, check=True)
+
+ # FIXME:
+ # ensure logs are on the server
+ # ensure exit codes are known
+ return True
diff --git a/uncloud_net/templates/uncloud_net/wireguardvpn_form.html b/uncloud_net/templates/uncloud_net/wireguardvpn_form.html
new file mode 100644
index 0000000..1463f41
--- /dev/null
+++ b/uncloud_net/templates/uncloud_net/wireguardvpn_form.html
@@ -0,0 +1,25 @@
+{% extends 'uncloud/base.html' %}
+
+{% block body %}
+
+
+
+
+ Create a VPN Network
+
+ Create a new wireguard based VPN network.
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/uncloud_net/tests.py b/uncloud_net/tests.py
new file mode 100644
index 0000000..75bdafa
--- /dev/null
+++ b/uncloud_net/tests.py
@@ -0,0 +1,53 @@
+from django.test import TestCase
+from rest_framework.test import APIRequestFactory, force_authenticate
+
+from rest_framework.reverse import reverse
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError, FieldError
+
+from .views import *
+from .models import *
+
+from uncloud_pay.models import BillingAddress, Order
+from uncloud.models import UncloudNetwork
+
+class UncloudNetworkTests(TestCase):
+ def test_invalid_IPv4_network(self):
+ with self.assertRaises(FieldError):
+ UncloudNetwork.objects.create(network_address="192.168.1.0",
+ network_mask=33)
+
+class VPNTests(TestCase):
+ def setUp(self):
+ self.user = get_user_model().objects.create_user('django-test-user', 'noreply@ungleich.ch')
+ self.admin_user = get_user_model().objects.create_user('django-test-adminuser',
+ 'noreply-admin@ungleich.ch')
+
+
+
+ self.admin_user.is_staff = True
+ self.admin_user.save()
+
+ self.pool_network = '2001:db8::'
+ self.pool_network2 = '2001:db8:1::'
+ self.pool_network_size = '48'
+ self.pool_subnetwork_size = '64'
+ self.pool_vpn_hostname = 'vpn.example.org'
+ self.pool_wireguard_private_key = 'MOz8kk0m4jhNtAXlge0qzexZh1MipIhu4HJwtdvZ2EY='
+
+ self.vpn_wireguard_public_key = 'B2b78eWBIXPMM1x4DDjkCDZepS0qDgcLN3T3PjcgXkY='
+
+ self.vpnpool = WireGuardVPNPool.objects.get_or_create(network=self.pool_network,
+ network_size=self.pool_network_size,
+ subnetwork_size=self.pool_subnetwork_size,
+ vpn_hostname=self.pool_vpn_hostname,
+ wireguard_private_key=self.pool_wireguard_private_key
+ )
+
+ self.factory = APIRequestFactory()
+
+
+
+ def tearDown(self):
+ self.user.delete()
+ self.admin_user.delete()
diff --git a/uncloud_net/views.py b/uncloud_net/views.py
new file mode 100644
index 0000000..8e7e81b
--- /dev/null
+++ b/uncloud_net/views.py
@@ -0,0 +1,63 @@
+from django.views.generic.edit import CreateView
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.messages.views import SuccessMessageMixin
+from rest_framework.response import Response
+
+from django.shortcuts import render
+
+from rest_framework import viewsets, permissions
+
+from .models import *
+from .serializers import *
+from .selectors import *
+from .services import *
+from .forms import *
+from .tasks import *
+
+class WireGuardVPNViewSet(viewsets.ModelViewSet):
+ serializer_class = WireGuardVPNSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = WireGuardVPN.objects.all()
+ else:
+ obj = WireGuardVPN.objects.filter(owner=self.request.user)
+
+ return obj
+
+ def create(self, request):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ vpn = create_wireguard_vpn(
+ owner=self.request.user,
+ public_key=serializer.validated_data['wireguard_public_key'],
+ network_mask=serializer.validated_data['network_mask']
+ )
+
+ return Response(WireGuardVPNSerializer(vpn).data)
+
+
+class WireGuardVPNCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
+ model = WireGuardVPN
+
+ login_url = '/login/'
+ success_url = '/'
+ success_message = "%(network) was created successfully"
+
+ form_class = WireGuardVPNForm
+
+ def get_success_message(self, cleaned_data):
+ return self.success_message % dict(cleaned_data,
+ the_prefix = self.object.prefix)
+
+class WireGuardVPNSizes(viewsets.ViewSet):
+ def list(self, request):
+ sizes = allowed_vpn_network_reservation_size()
+ print(sizes)
+
+ sizes = [ { 'size': size } for size in sizes ]
+ print(sizes)
+
+ return Response(WireGuardVPNSizesSerializer(sizes, many=True).data)
diff --git a/uncloud_pay/__init__.py b/uncloud_pay/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/uncloud_pay/__init__.py
@@ -0,0 +1 @@
+
diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py
new file mode 100644
index 0000000..2b07400
--- /dev/null
+++ b/uncloud_pay/admin.py
@@ -0,0 +1,102 @@
+from django.contrib import admin
+from django.template.response import TemplateResponse
+from django.urls import path
+from django.shortcuts import render
+
+from hardcopy import bytestring_to_pdf
+from django.core.files.temp import NamedTemporaryFile
+from django.http import FileResponse
+from django.template.loader import render_to_string
+
+from uncloud_pay.models import *
+
+class BillRecordInline(admin.TabularInline):
+ model = BillRecord
+
+class RecurringPeriodInline(admin.TabularInline):
+ model = ProductToRecurringPeriod
+
+class ProductAdmin(admin.ModelAdmin):
+ inlines = [ RecurringPeriodInline ]
+
+# class BillAdmin(admin.ModelAdmin):
+# inlines = [ BillRecordInline ]
+
+# def get_urls(self):
+# """
+# Create URLs for PDF view
+# """
+
+# info = "%s_%s" % (self.model._meta.app_label, self.model._meta.model_name)
+# pat = lambda regex, fn: url(regex, self.admin_site.admin_view(fn), name='%s_%s' % (info, fn.__name__))
+
+# url_patterns = [
+# pat(r'^([0-9]+)/as_pdf/$', self.as_pdf),
+# pat(r'^([0-9]+)/as_html/$', self.as_html),
+# ] + super().get_urls()
+
+# return url_patterns
+
+# def as_pdf(self, request, object_id):
+# bill = self.get_object(request, object_id=object_id)
+# print(bill)
+
+# if bill is None:
+# raise self._get_404_exception(object_id)
+
+# output_file = NamedTemporaryFile()
+# bill_html = render_to_string(
+# "uncloud_pay/bill.html.j2",
+# {
+# 'bill': bill,
+# 'bill_records': bill.bill_records.all()
+# }
+# )
+
+# bytestring_to_pdf(bill_html.encode('utf-8'), output_file)
+# response = FileResponse(output_file, content_type="application/pdf")
+# response['Content-Disposition'] = f'filename="bill_{bill}.pdf"'
+
+# return response
+
+# def as_html(self, request, object_id):
+# bill = self.get_object(request, object_id=object_id)
+
+# if bill is None:
+# raise self._get_404_exception(object_id)
+
+# return render(request, 'uncloud_pay/bill.html.j2',
+# {'bill': bill,
+# 'bill_records': bill.bill_records.all()
+# })
+
+
+# bill_html = render_to_string("bill.html.j2", {'bill': bill,
+# 'bill_records': bill.bill_records.all()
+# })
+
+# bytestring_to_pdf(bill_html.encode('utf-8'), output_file)
+# response = FileResponse(output_file, content_type="application/pdf")
+
+# response['Content-Disposition'] = f'filename="bill_{bill}.pdf"'
+
+# return HttpResponse(template.render(context, request))
+# return response
+
+
+#admin.site.register(Bill, BillAdmin)
+admin.site.register(Product, ProductAdmin)
+
+for m in [
+ BillingAddress,
+ Order,
+ BillRecord,
+ Payment,
+ ProductToRecurringPeriod,
+ RecurringPeriod,
+ StripeCreditCard,
+ StripeCustomer,
+ PricingPlan,
+ VATRate
+]:
+ admin.site.register(m)
diff --git a/uncloud/uncloud_pay/apps.py b/uncloud_pay/apps.py
similarity index 100%
rename from uncloud/uncloud_pay/apps.py
rename to uncloud_pay/apps.py
diff --git a/uncloud_pay/forms.py b/uncloud_pay/forms.py
new file mode 100644
index 0000000..a98c00f
--- /dev/null
+++ b/uncloud_pay/forms.py
@@ -0,0 +1,8 @@
+from uncloud.forms import MainModelForm
+from .models import BillingAddress
+
+class BillingAddressForm(MainModelForm):
+ class Meta:
+ model = BillingAddress
+ fields = ['full_name', 'street',
+ 'city', 'postal_code', 'country', 'vat_number', 'active', 'owner']
\ No newline at end of file
diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud_pay/helpers.py
similarity index 100%
rename from uncloud/uncloud_pay/helpers.py
rename to uncloud_pay/helpers.py
diff --git a/uncloud_pay/management/commands/.gitignore b/uncloud_pay/management/commands/.gitignore
new file mode 100644
index 0000000..cf5c7fa
--- /dev/null
+++ b/uncloud_pay/management/commands/.gitignore
@@ -0,0 +1,2 @@
+# Customer tests
+customer-*.py
diff --git a/uncloud_pay/management/commands/add-opennebula-vm-orders.py b/uncloud_pay/management/commands/add-opennebula-vm-orders.py
new file mode 100644
index 0000000..1d66790
--- /dev/null
+++ b/uncloud_pay/management/commands/add-opennebula-vm-orders.py
@@ -0,0 +1,140 @@
+from django.core.management.base import BaseCommand
+from django.contrib.auth import get_user_model
+
+from django.utils import timezone
+from datetime import datetime, timedelta
+
+from uncloud_pay.models import *
+from uncloud_vm.models import *
+
+import sys
+
+def vm_price_2020(cpu=1, ram=2, v6only=False):
+ if v6only:
+ discount = 9
+ else:
+ discount = 0
+
+ return cpu*3 + ram*4 - discount
+
+def disk_price_2020(size_in_gb, disk_type):
+ if disk_type == VMDiskType.CEPH_SSD:
+ price = 3.5/10
+ elif disk_type == VMDiskType.CEPH_HDD:
+ price = 1.5/100
+ else:
+ raise Exception("not yet defined price")
+
+ return size_in_gb * price
+
+class Command(BaseCommand):
+ help = 'Adding VMs / creating orders for user'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--username', type=str, required=True)
+
+ def handle(self, *args, **options):
+ user = get_user_model().objects.get(username=options['username'])
+
+ addr, created = BillingAddress.objects.get_or_create(
+ owner=user,
+ active=True,
+ defaults={'organization': 'Undefined organisation',
+ 'name': 'Undefined name',
+ 'street': 'Undefined Street',
+ 'city': 'Undefined city',
+ 'postal_code': '8750',
+ 'country': 'CH',
+ 'active': True
+ }
+ )
+
+ # 25206 + SSD
+ vm25206 = VMProduct.objects.create(name="one-25206", cores=1, ram_in_gb=4, owner=user)
+ vm25206.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3)))
+
+ # vm25206_ssd = VMDiskProduct.objects.create(vm=vm25206, owner=user, size_in_gb=30)
+ # vm25206_ssd.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3)))
+
+ # change 1
+ vm25206.cores = 2
+ vm25206.ram_in_gb = 8
+ vm25206.save()
+ vm25206.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,4,17)))
+
+ sys.exit(0)
+
+ # change 2
+ # vm25206_ssd.size_in_gb = 50
+ # vm25206_ssd.save()
+ # vm25206_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5)))
+
+ # 25206 done.
+
+ # 25615
+ vm25615 = VMProduct.objects.create(name="one-25615", cores=1, ram_in_gb=4, owner=user)
+ vm25615.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3)))
+
+ # Change 2020-04-17
+ vm25615.cores = 2
+ vm25615.ram_in_gb = 8
+ vm25615.save()
+ vm25615.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,4,17)))
+
+ # vm25615_ssd = VMDiskProduct(vm=vm25615, owner=user, size_in_gb=30)
+ # vm25615_ssd.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3)))
+ # vm25615_ssd.save()
+
+ vm25208 = VMProduct.objects.create(name="one-25208", cores=1, ram_in_gb=4, owner=user)
+ vm25208.create_order_at(timezone.make_aware(datetime.datetime(2020,3,5)))
+
+ vm25208.cores = 2
+ vm25208.ram_in_gb = 8
+ vm25208.save()
+ vm25208.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,4,17)))
+
+ Bill.create_next_bills_for_user(user, ending_date=end_of_month(timezone.make_aware(datetime.datetime(2020,7,31))))
+
+ sys.exit(0)
+
+
+ vm25615_ssd.size_in_gb = 50
+ vm25615_ssd.save()
+ vm25615_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5)))
+
+
+
+ vm25208_ssd = VMDiskProduct.objects.create(vm=vm25208,
+ owner=user,
+ size_in_gb=30)
+
+
+
+ vm25208_ssd.size_in_gb = 50
+ vm25208_ssd.save()
+ vm25208_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5)))
+
+
+ # 25207
+ vm25207 = VMProduct.objects.create(name="OpenNebula 25207",
+ cores=1,
+ ram_in_gb=4,
+ owner=user)
+
+ vm25207_ssd = VMDiskProduct.objects.create(vm=vm25207,
+ owner=user,
+ size_in_gb=30)
+
+ vm25207_ssd.size_in_gb = 50
+ vm25207_ssd.save()
+ vm25207_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5)))
+
+
+ vm25207.cores = 2
+ vm25207.ram_in_gb = 8
+ vm25207.save()
+ vm25207.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,6,19)))
+
+
+ # FIXES: check starting times (they are slightly different)
+ # add vm 25236
diff --git a/uncloud_pay/management/commands/bootstrap-user.py b/uncloud_pay/management/commands/bootstrap-user.py
new file mode 100644
index 0000000..b78e80c
--- /dev/null
+++ b/uncloud_pay/management/commands/bootstrap-user.py
@@ -0,0 +1,40 @@
+from django.core.management.base import BaseCommand
+from django.contrib.auth import get_user_model
+import datetime
+
+from uncloud_pay.models import *
+
+class Command(BaseCommand):
+ help = 'Bootstrap user (for testing)'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--username', type=str, required=True)
+
+ def handle(self, *args, **options):
+ user = get_user_model().objects.get(username=options['username'])
+
+ addr = BillingAddress.objects.get_or_create(
+ owner=user,
+ active=True,
+ defaults={'organization': 'ungleich',
+ 'name': 'Nico Schottelius',
+ 'street': 'Hauptstrasse 14',
+ 'city': 'Luchsingen',
+ 'postal_code': '8775',
+ 'country': 'CH' }
+ )
+
+
+ bills = Bill.objects.filter(owner=user)
+
+ # not even one bill? create!
+ if bills:
+ bill = bills[0]
+ else:
+ bill = Bill.objects.create(owner=user)
+
+ # find any order that is associated to this bill
+ orders = Order.objects.filter(owner=user)
+
+ print(f"Addr: {addr}")
+ print(f"Bill: {bill}")
diff --git a/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud_pay/management/commands/charge-negative-balance.py
new file mode 100644
index 0000000..8405bd3
--- /dev/null
+++ b/uncloud_pay/management/commands/charge-negative-balance.py
@@ -0,0 +1,28 @@
+from django.core.management.base import BaseCommand
+from uncloud_auth.models import User
+from uncloud_pay.models import Order, Bill, get_balance_for_user
+import uncloud_pay.stripe as uncloud_stripe
+
+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(user)
+ if balance < 0:
+ print("User {} has negative balance ({}), charging.".format(user.username, balance))
+ amount_to_be_charged = abs(balance)
+ result = uncloud_stripe.charge_customer(user, amount_to_be_charged)
+ if result.status != 'succeeded':
+ print("ERR: charging {} with method {} failed"
+ .format(user.username, result)
+ )
+ print("=> Done.")
diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud_pay/management/commands/generate-bills.py
similarity index 100%
rename from uncloud/uncloud_pay/management/commands/generate-bills.py
rename to uncloud_pay/management/commands/generate-bills.py
diff --git a/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py b/uncloud_pay/management/commands/handle-overdue-bills.py
similarity index 100%
rename from uncloud/uncloud_pay/management/commands/handle-overdue-bills.py
rename to uncloud_pay/management/commands/handle-overdue-bills.py
diff --git a/uncloud_pay/management/commands/import-vat-rates.py b/uncloud_pay/management/commands/import-vat-rates.py
new file mode 100644
index 0000000..a741740
--- /dev/null
+++ b/uncloud_pay/management/commands/import-vat-rates.py
@@ -0,0 +1,50 @@
+from django.core.management.base import BaseCommand
+from uncloud_pay.models import VATRate
+
+import logging
+import urllib
+import csv
+import sys
+import io
+
+logger = logging.getLogger(__name__)
+
+class Command(BaseCommand):
+ help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv'''
+ vat_url = "https://raw.githubusercontent.com/ungleich/vat-rates/main/vat_rates.csv"
+
+
+ def add_arguments(self, parser):
+ parser.add_argument('--vat-url', default=self.vat_url)
+
+ def handle(self, *args, **options):
+ vat_url = options['vat_url']
+ url_open = urllib.request.urlopen(vat_url)
+
+ # map to fileio using stringIO
+ csv_file = io.StringIO(url_open.read().decode('utf-8'))
+ reader = csv.DictReader(csv_file)
+
+ for row in reader:
+ if row["territory_codes"] and len(row["territory_codes"].splitlines()) > 1:
+ for code in row["territory_codes"].splitlines():
+ VATRate.objects.get_or_create(
+ starting_date=row["start_date"],
+ ending_date=row["stop_date"] if row["stop_date"] != "" else None,
+ territory_codes=code,
+ currency_code=row["currency_code"],
+ rate=row["rate"],
+ rate_type=row["rate_type"],
+ description=row["description"]
+ )
+ else:
+ VATRate.objects.get_or_create(
+ starting_date=row["start_date"],
+ ending_date=row["stop_date"] if row["stop_date"] != "" else None,
+ territory_codes=row["territory_codes"],
+ currency_code=row["currency_code"],
+ rate=row["rate"],
+ rate_type=row["rate_type"],
+ description=row["description"]
+ )
+ logger.info('All VAT Rates have been added!')
diff --git a/uncloud_pay/migrations/0001_initial.py b/uncloud_pay/migrations/0001_initial.py
new file mode 100644
index 0000000..e65f3dd
--- /dev/null
+++ b/uncloud_pay/migrations/0001_initial.py
@@ -0,0 +1,202 @@
+# Generated by Django 3.1 on 2020-12-28 22:19
+
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import uncloud.models
+import uncloud_pay.models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('uncloud_auth', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Bill',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('creation_date', models.DateTimeField(auto_now_add=True)),
+ ('starting_date', models.DateTimeField(default=uncloud_pay.models.start_of_this_month)),
+ ('ending_date', models.DateTimeField()),
+ ('due_date', models.DateField(default=uncloud_pay.models.default_payment_delay)),
+ ('is_final', models.BooleanField(default=False)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='BillingAddress',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('full_name', models.CharField(max_length=256)),
+ ('organization', models.CharField(blank=True, max_length=256, null=True)),
+ ('street', models.CharField(max_length=256)),
+ ('city', models.CharField(max_length=256)),
+ ('postal_code', models.CharField(max_length=64)),
+ ('country', uncloud.models.CountryField(blank=True, choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2)),
+ ('vat_number', models.CharField(blank=True, default='', max_length=100)),
+ ('active', models.BooleanField(default=False)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Product',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=256, unique=True)),
+ ('description', models.CharField(max_length=1024)),
+ ('config', models.JSONField()),
+ ('currency', models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='RecurringPeriod',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('duration_seconds', models.IntegerField(unique=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='StripeCustomer',
+ fields=[
+ ('owner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='uncloud_auth.user')),
+ ('stripe_id', models.CharField(max_length=32)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VATRate',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('starting_date', models.DateField(blank=True, null=True)),
+ ('ending_date', models.DateField(blank=True, null=True)),
+ ('territory_codes', models.TextField(blank=True, default='')),
+ ('currency_code', models.CharField(max_length=10)),
+ ('rate', models.FloatField()),
+ ('rate_type', models.TextField(blank=True, default='')),
+ ('description', models.TextField(blank=True, default='')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='StripeCreditCard',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('card_name', models.CharField(default='My credit card', max_length=128)),
+ ('card_id', models.CharField(max_length=32)),
+ ('last4', models.CharField(max_length=4)),
+ ('brand', models.CharField(max_length=64)),
+ ('expiry_date', models.DateField()),
+ ('owner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ProductToRecurringPeriod',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('is_default', models.BooleanField(default=False)),
+ ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product')),
+ ('recurring_period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod')),
+ ],
+ ),
+ migrations.AddField(
+ model_name='product',
+ name='recurring_periods',
+ field=models.ManyToManyField(through='uncloud_pay.ProductToRecurringPeriod', to='uncloud_pay.RecurringPeriod'),
+ ),
+ migrations.CreateModel(
+ name='PaymentMethod',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('source', models.CharField(choices=[('stripe', 'Stripe'), ('unknown', 'Unknown')], default='stripe', max_length=256)),
+ ('description', models.TextField()),
+ ('primary', models.BooleanField(default=False, editable=False)),
+ ('stripe_payment_method_id', models.CharField(blank=True, max_length=32, null=True)),
+ ('stripe_setup_intent_id', models.CharField(blank=True, max_length=32, null=True)),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Payment',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('amount', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
+ ('source', models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256)),
+ ('timestamp', models.DateTimeField(auto_now_add=True)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Order',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('description', models.TextField()),
+ ('config', models.JSONField()),
+ ('creation_date', models.DateTimeField(auto_now_add=True)),
+ ('starting_date', models.DateTimeField(default=django.utils.timezone.now)),
+ ('ending_date', models.DateTimeField(blank=True, null=True)),
+ ('one_time_price', 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)])),
+ ('currency', models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32)),
+ ('should_be_billed', models.BooleanField(default=True)),
+ ('billing_address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress')),
+ ('depends_on', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_of', to='uncloud_pay.order')),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product')),
+ ('recurring_period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod')),
+ ('replaces', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replaced_by', to='uncloud_pay.order')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Membership',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('starting_date', models.DateField(blank=True, null=True)),
+ ('ending_date', models.DateField(blank=True, null=True)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='BillRecord',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('creation_date', models.DateTimeField(auto_now_add=True)),
+ ('starting_date', models.DateTimeField()),
+ ('ending_date', models.DateTimeField()),
+ ('is_recurring_record', models.BooleanField()),
+ ('bill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.bill')),
+ ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.order')),
+ ],
+ ),
+ migrations.AddField(
+ model_name='bill',
+ name='billing_address',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress'),
+ ),
+ migrations.AddField(
+ model_name='bill',
+ name='owner',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddConstraint(
+ model_name='producttorecurringperiod',
+ constraint=models.UniqueConstraint(condition=models.Q(is_default=True), fields=('product',), name='one_default_recurring_period_per_product'),
+ ),
+ migrations.AddConstraint(
+ model_name='producttorecurringperiod',
+ constraint=models.UniqueConstraint(fields=('product', 'recurring_period'), name='recurring_period_once_per_product'),
+ ),
+ migrations.AddConstraint(
+ model_name='billingaddress',
+ constraint=models.UniqueConstraint(condition=models.Q(active=True), fields=('owner',), name='one_active_billing_address_per_user'),
+ ),
+ migrations.AddConstraint(
+ model_name='bill',
+ constraint=models.UniqueConstraint(fields=('owner', 'starting_date', 'ending_date'), name='one_bill_per_month_per_user'),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0002_auto_20201228_2244.py b/uncloud_pay/migrations/0002_auto_20201228_2244.py
new file mode 100644
index 0000000..4665553
--- /dev/null
+++ b/uncloud_pay/migrations/0002_auto_20201228_2244.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.1 on 2020-12-28 22:44
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='payment',
+ name='currency',
+ field=models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32),
+ ),
+ migrations.AlterField(
+ model_name='payment',
+ name='amount',
+ field=models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0003_auto_20201228_2256.py b/uncloud_pay/migrations/0003_auto_20201228_2256.py
new file mode 100644
index 0000000..b516bd5
--- /dev/null
+++ b/uncloud_pay/migrations/0003_auto_20201228_2256.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.1 on 2020-12-28 22:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0002_auto_20201228_2244'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='order',
+ name='currency',
+ field=models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32),
+ ),
+ migrations.AlterField(
+ model_name='payment',
+ name='currency',
+ field=models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32),
+ ),
+ migrations.AlterField(
+ model_name='product',
+ name='currency',
+ field=models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0004_stripecreditcard_active.py b/uncloud_pay/migrations/0004_stripecreditcard_active.py
new file mode 100644
index 0000000..3fb8015
--- /dev/null
+++ b/uncloud_pay/migrations/0004_stripecreditcard_active.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1 on 2020-12-28 23:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0003_auto_20201228_2256'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='stripecreditcard',
+ name='active',
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0005_auto_20201228_2335.py b/uncloud_pay/migrations/0005_auto_20201228_2335.py
new file mode 100644
index 0000000..814752e
--- /dev/null
+++ b/uncloud_pay/migrations/0005_auto_20201228_2335.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1 on 2020-12-28 23:35
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0004_stripecreditcard_active'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='stripecreditcard',
+ name='active',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0006_auto_20201228_2337.py b/uncloud_pay/migrations/0006_auto_20201228_2337.py
new file mode 100644
index 0000000..a164767
--- /dev/null
+++ b/uncloud_pay/migrations/0006_auto_20201228_2337.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1 on 2020-12-28 23:37
+
+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', '0005_auto_20201228_2335'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='stripecreditcard',
+ name='owner',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0007_auto_20201228_2338.py b/uncloud_pay/migrations/0007_auto_20201228_2338.py
new file mode 100644
index 0000000..315a74b
--- /dev/null
+++ b/uncloud_pay/migrations/0007_auto_20201228_2338.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-12-28 23:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0006_auto_20201228_2337'),
+ ]
+
+ operations = [
+ migrations.AddConstraint(
+ model_name='stripecreditcard',
+ constraint=models.UniqueConstraint(condition=models.Q(active=True), fields=('owner',), name='one_active_card_per_user'),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0008_payment_external_reference.py b/uncloud_pay/migrations/0008_payment_external_reference.py
new file mode 100644
index 0000000..0de20b6
--- /dev/null
+++ b/uncloud_pay/migrations/0008_payment_external_reference.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1 on 2020-12-29 00:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0007_auto_20201228_2338'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='payment',
+ name='external_reference',
+ field=models.CharField(default='', max_length=256),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0009_auto_20201229_0037.py b/uncloud_pay/migrations/0009_auto_20201229_0037.py
new file mode 100644
index 0000000..fc195e4
--- /dev/null
+++ b/uncloud_pay/migrations/0009_auto_20201229_0037.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1 on 2020-12-29 00:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0008_payment_external_reference'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='payment',
+ name='external_reference',
+ field=models.CharField(blank=True, default='', max_length=256, null=True),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0010_auto_20201229_0042.py b/uncloud_pay/migrations/0010_auto_20201229_0042.py
new file mode 100644
index 0000000..6dd6a60
--- /dev/null
+++ b/uncloud_pay/migrations/0010_auto_20201229_0042.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1 on 2020-12-29 00:42
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0009_auto_20201229_0037'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='payment',
+ name='timestamp',
+ field=models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0011_auto_20210101_1308.py b/uncloud_pay/migrations/0011_auto_20210101_1308.py
new file mode 100644
index 0000000..942f430
--- /dev/null
+++ b/uncloud_pay/migrations/0011_auto_20210101_1308.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1 on 2021-01-01 13:08
+
+from django.db import migrations
+import uncloud.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0010_auto_20201229_0042'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='billingaddress',
+ name='country',
+ field=uncloud.models.CountryField(choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0012_auto_20210630_0742.py b/uncloud_pay/migrations/0012_auto_20210630_0742.py
new file mode 100644
index 0000000..45e3dfe
--- /dev/null
+++ b/uncloud_pay/migrations/0012_auto_20210630_0742.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.4 on 2021-06-30 07:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0011_auto_20210101_1308'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='billingaddress',
+ name='vat_number_verified',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AlterField(
+ model_name='payment',
+ name='source',
+ field=models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral')], max_length=256),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0013_alter_billingaddress_owner.py b/uncloud_pay/migrations/0013_alter_billingaddress_owner.py
new file mode 100644
index 0000000..7597129
--- /dev/null
+++ b/uncloud_pay/migrations/0013_alter_billingaddress_owner.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.2.4 on 2021-07-03 15:23
+
+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', '0012_auto_20210630_0742'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='billingaddress',
+ name='owner',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='billing_addresses', to=settings.AUTH_USER_MODEL),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0014_auto_20210703_1747.py b/uncloud_pay/migrations/0014_auto_20210703_1747.py
new file mode 100644
index 0000000..1c004d0
--- /dev/null
+++ b/uncloud_pay/migrations/0014_auto_20210703_1747.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.4 on 2021-07-03 17:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0013_alter_billingaddress_owner'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='billingaddress',
+ name='stripe_tax_id',
+ field=models.CharField(blank=True, default='', max_length=100),
+ ),
+ migrations.AddField(
+ model_name='billingaddress',
+ name='vat_number_validated_on',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0015_auto_20210705_0849.py b/uncloud_pay/migrations/0015_auto_20210705_0849.py
new file mode 100644
index 0000000..dfb6d80
--- /dev/null
+++ b/uncloud_pay/migrations/0015_auto_20210705_0849.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.2.4 on 2021-07-05 08:49
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0014_auto_20210703_1747'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='order',
+ name='customer',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.stripecustomer'),
+ ),
+ migrations.AddField(
+ model_name='order',
+ name='status',
+ field=models.CharField(choices=[('draft', 'Draft'), ('declined', 'Declined'), ('approved', 'Approved')], default='draft', max_length=100),
+ ),
+ migrations.AddField(
+ model_name='order',
+ name='stripe_charge_id',
+ field=models.CharField(max_length=100, null=True),
+ ),
+ migrations.AddField(
+ model_name='order',
+ name='vm_id',
+ field=models.IntegerField(default=0),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0016_pricingplan.py b/uncloud_pay/migrations/0016_pricingplan.py
new file mode 100644
index 0000000..505c141
--- /dev/null
+++ b/uncloud_pay/migrations/0016_pricingplan.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.4 on 2021-07-06 13:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0015_auto_20210705_0849'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='PricingPlan',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=255, unique=True)),
+ ('vat_inclusive', models.BooleanField(default=True)),
+ ('vat_percentage', models.DecimalField(blank=True, decimal_places=5, default=0, max_digits=7)),
+ ('set_up_fees', models.DecimalField(decimal_places=2, default=0, max_digits=7)),
+ ('cores_unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=7)),
+ ('ram_unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=7)),
+ ('storage_unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=7)),
+ ('discount_name', models.CharField(blank=True, max_length=255, null=True)),
+ ('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=6)),
+ ('stripe_coupon_id', models.CharField(blank=True, max_length=255, null=True)),
+ ],
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0017_auto_20210706_1728.py b/uncloud_pay/migrations/0017_auto_20210706_1728.py
new file mode 100644
index 0000000..1571b10
--- /dev/null
+++ b/uncloud_pay/migrations/0017_auto_20210706_1728.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.4 on 2021-07-06 17:28
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0016_pricingplan'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='paymentmethod',
+ name='owner',
+ ),
+ migrations.DeleteModel(
+ name='Payment',
+ ),
+ migrations.DeleteModel(
+ name='PaymentMethod',
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0018_payment.py b/uncloud_pay/migrations/0018_payment.py
new file mode 100644
index 0000000..47d6e3a
--- /dev/null
+++ b/uncloud_pay/migrations/0018_payment.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.2.4 on 2021-07-06 17:47
+
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('uncloud_pay', '0017_auto_20210706_1728'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Payment',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
+ ('source', models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral')], max_length=256)),
+ ('timestamp', models.DateTimeField(default=django.utils.timezone.now)),
+ ('currency', models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32)),
+ ('external_reference', models.CharField(blank=True, default='', max_length=256, null=True)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0019_order_pricing_plan.py b/uncloud_pay/migrations/0019_order_pricing_plan.py
new file mode 100644
index 0000000..5392ce6
--- /dev/null
+++ b/uncloud_pay/migrations/0019_order_pricing_plan.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.4 on 2021-07-06 19:18
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0018_payment'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='order',
+ name='pricing_plan',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.pricingplan'),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0020_rename_is_final_bill_is_closed.py b/uncloud_pay/migrations/0020_rename_is_final_bill_is_closed.py
new file mode 100644
index 0000000..f3419eb
--- /dev/null
+++ b/uncloud_pay/migrations/0020_rename_is_final_bill_is_closed.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.4 on 2021-07-07 20:18
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0019_order_pricing_plan'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='bill',
+ old_name='is_final',
+ new_name='is_closed',
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0021_auto_20210709_0914.py b/uncloud_pay/migrations/0021_auto_20210709_0914.py
new file mode 100644
index 0000000..66e3dcb
--- /dev/null
+++ b/uncloud_pay/migrations/0021_auto_20210709_0914.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.2.4 on 2021-07-09 09:14
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0020_rename_is_final_bill_is_closed'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='order',
+ name='stripe_charge_id',
+ ),
+ migrations.RemoveField(
+ model_name='order',
+ name='vm_id',
+ ),
+ ]
diff --git a/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py b/uncloud_pay/migrations/0022_remove_order_status.py
similarity index 51%
rename from uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py
rename to uncloud_pay/migrations/0022_remove_order_status.py
index 5f44b57..2b51be8 100644
--- a/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py
+++ b/uncloud_pay/migrations/0022_remove_order_status.py
@@ -1,4 +1,4 @@
-# Generated by Django 3.0.3 on 2020-03-17 14:40
+# Generated by Django 3.2.4 on 2021-07-11 08:32
from django.db import migrations
@@ -6,12 +6,12 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('uncloud_vm', '0003_remove_vmhost_vms'),
+ ('uncloud_pay', '0021_auto_20210709_0914'),
]
operations = [
migrations.RemoveField(
- model_name='vmproduct',
- name='vmid',
+ model_name='order',
+ name='status',
),
]
diff --git a/uncloud_pay/migrations/0023_auto_20210730_1342.py b/uncloud_pay/migrations/0023_auto_20210730_1342.py
new file mode 100644
index 0000000..e2e4c6f
--- /dev/null
+++ b/uncloud_pay/migrations/0023_auto_20210730_1342.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.4 on 2021-07-30 13:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0022_remove_order_status'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='payment',
+ name='notes',
+ field=models.TextField(blank=True, default='', null=True),
+ ),
+ migrations.AddField(
+ model_name='payment',
+ name='type',
+ field=models.CharField(choices=[('send', 'Send Money'), ('receive', 'Receive Money')], default='send', max_length=256),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0024_auto_20210730_1441.py b/uncloud_pay/migrations/0024_auto_20210730_1441.py
new file mode 100644
index 0000000..97baebc
--- /dev/null
+++ b/uncloud_pay/migrations/0024_auto_20210730_1441.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.4 on 2021-07-30 14:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0023_auto_20210730_1342'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='payment',
+ name='source',
+ field=models.CharField(blank=True, choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral')], max_length=256, null=True),
+ ),
+ migrations.AlterField(
+ model_name='payment',
+ name='type',
+ field=models.CharField(choices=[('withdraw', 'Withdraw Money'), ('deposit', 'Deposit Money')], default='send', max_length=256),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0025_auto_20210803_2118.py b/uncloud_pay/migrations/0025_auto_20210803_2118.py
new file mode 100644
index 0000000..5be92a5
--- /dev/null
+++ b/uncloud_pay/migrations/0025_auto_20210803_2118.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.4 on 2021-08-03 21:18
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0024_auto_20210730_1441'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='billrecord',
+ name='bill',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bill_records', to='uncloud_pay.bill'),
+ ),
+ migrations.AlterField(
+ model_name='payment',
+ name='type',
+ field=models.CharField(choices=[('withdraw', 'Withdraw Money'), ('deposit', 'Deposit Money')], default='deposit', max_length=256),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0026_remove_order_description.py b/uncloud_pay/migrations/0026_remove_order_description.py
new file mode 100644
index 0000000..c81cdfe
--- /dev/null
+++ b/uncloud_pay/migrations/0026_remove_order_description.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.4 on 2021-08-03 21:40
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0025_auto_20210803_2118'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='order',
+ name='description',
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0027_alter_stripecreditcard_card_name.py b/uncloud_pay/migrations/0027_alter_stripecreditcard_card_name.py
new file mode 100644
index 0000000..b468ed7
--- /dev/null
+++ b/uncloud_pay/migrations/0027_alter_stripecreditcard_card_name.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.4 on 2021-08-12 16:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0026_remove_order_description'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='stripecreditcard',
+ name='card_name',
+ field=models.CharField(default='', max_length=128),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0028_bill_currency.py b/uncloud_pay/migrations/0028_bill_currency.py
new file mode 100644
index 0000000..27e3dc7
--- /dev/null
+++ b/uncloud_pay/migrations/0028_bill_currency.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.4 on 2021-08-12 16:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0027_alter_stripecreditcard_card_name'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='bill',
+ name='currency',
+ field=models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0029_auto_20210817_1129.py b/uncloud_pay/migrations/0029_auto_20210817_1129.py
new file mode 100644
index 0000000..6d8bc9d
--- /dev/null
+++ b/uncloud_pay/migrations/0029_auto_20210817_1129.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.4 on 2021-08-17 11:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0028_bill_currency'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='pricingplan',
+ old_name='storage_unit_price',
+ new_name='storage_hd_unit_price',
+ ),
+ migrations.AddField(
+ model_name='pricingplan',
+ name='storage_ssd_unit_price',
+ field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0030_pricingplan_monthly_maintenance_fees.py b/uncloud_pay/migrations/0030_pricingplan_monthly_maintenance_fees.py
new file mode 100644
index 0000000..2638b67
--- /dev/null
+++ b/uncloud_pay/migrations/0030_pricingplan_monthly_maintenance_fees.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.4 on 2021-08-17 16:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0029_auto_20210817_1129'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='pricingplan',
+ name='monthly_maintenance_fees',
+ field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0031_auto_20210819_1304.py b/uncloud_pay/migrations/0031_auto_20210819_1304.py
new file mode 100644
index 0000000..a3d75db
--- /dev/null
+++ b/uncloud_pay/migrations/0031_auto_20210819_1304.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.4 on 2021-08-19 13:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0030_pricingplan_monthly_maintenance_fees'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='bill',
+ name='is_closed',
+ ),
+ migrations.AddField(
+ model_name='bill',
+ name='status',
+ field=models.CharField(choices=[('new', 'New'), ('cancelled', 'Cancelled'), ('paid', 'Paid')], default='new', max_length=32),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/__init__.py b/uncloud_pay/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py
new file mode 100644
index 0000000..51de3ad
--- /dev/null
+++ b/uncloud_pay/models.py
@@ -0,0 +1,1256 @@
+import logging
+import datetime
+import json
+
+from math import ceil
+from calendar import monthrange
+from decimal import Decimal
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.validators import MinValueValidator
+from django.db import models
+from django.db.models import Q
+from django.utils.translation import gettext_lazy as _
+from django.utils import timezone
+# Verify whether or not to use them here
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
+
+import uncloud_pay
+from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
+from uncloud.models import UncloudAddress, UncloudProvider
+from uncloud.selectors import filter_for_when
+from .services import *
+
+# Used to generate bill due dates.
+BILL_PAYMENT_DELAY=datetime.timedelta(days=settings.BILL_PAYMENT_DELAY)
+
+EU_COUNTRIES = ['at', 'be', 'bg', 'ch', 'cy', 'cz', 'hr', 'dk',
+ 'ee', 'fi', 'fr', 'mc', 'de', 'gr', 'hu', 'ie', 'it',
+ 'lv', 'lu', 'mt', 'nl', 'po', 'pt', 'ro','sk', 'si', 'es',
+ 'se', 'gb']
+
+
+# Initialize logger.
+logger = logging.getLogger(__name__)
+
+def default_payment_delay():
+ return timezone.now() + BILL_PAYMENT_DELAY
+
+class Currency(models.TextChoices):
+ """
+ Possible currencies to be billed
+ """
+ CHF = 'CHF', _('Swiss Franc')
+# EUR = 'EUR', _('Euro')
+# USD = 'USD', _('US Dollar')
+
+
+###
+# Stripe
+
+class StripeCustomer(models.Model):
+ owner = models.OneToOneField( get_user_model(),
+ primary_key=True,
+ on_delete=models.CASCADE)
+ stripe_id = models.CharField(max_length=32)
+
+ def __str__(self):
+ return self.owner.username
+
+
+class StripeCreditCard(models.Model):
+ owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
+
+ card_name = models.CharField(null=False, max_length=128, default="")
+ card_id = models.CharField(null=False, max_length=32)
+ last4 = models.CharField(null=False, max_length=4)
+ brand = models.CharField(null=False, max_length=64)
+ expiry_date = models.DateField(null=False)
+ active = models.BooleanField(default=False)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['owner'],
+ condition=models.Q(active=True),
+ name='one_active_card_per_user')
+ ]
+
+
+ def __str__(self):
+ return f"{self.card_name}: {self.brand} {self.last4} ({self.expiry_date})"
+
+ def delete(self, **kwargs):
+ uncloud_pay.stripe.delete_card(self.card_id)
+ super().delete(**kwargs)
+
+
+ def activate(self):
+ StripeCreditCard.objects.filter(owner=self.owner, active=True).update(active=False)
+ self.active = True
+ self.save()
+
+class Payment(models.Model):
+ owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
+ type = models.CharField(max_length=256,
+ choices = (
+ ('withdraw', 'Withdraw Money'),
+ ('deposit', 'Deposit Money')
+ ), null=False, blank=False, default="deposit")
+
+ notes = models.TextField(default="", null=True, blank=True)
+
+ amount = models.DecimalField(
+ 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'),
+ ), null=True, blank=True,)
+
+ timestamp = models.DateTimeField(default=timezone.now)
+
+ currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF)
+
+ external_reference = models.CharField(max_length=256, default="", null=True, blank=True)
+
+ def __str__(self):
+ return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}"
+
+ @classmethod
+ def deposit(cls, owner, amount, source, currency='CHF', notes=''):
+ if source == 'stripe':
+ try:
+ payment_intent = uncloud_pay.stripe.charge_customer(owner, amount, currency)
+ if not payment_intent.status or payment_intent.status != 'succeeded':
+ raise Exception("The payment has been failed, please try to activate another card")
+ return cls.objects.create(owner=owner, type="deposit", amount=amount, external_reference=payment_intent["id"],
+ currency=currency, source=source, notes=notes)
+ except Exception as e:
+ raise e
+
+ @classmethod
+ def withdraw(cls, owner, amount, currency='CHF', notes=''):
+ return cls.objects.create(owner=owner, type="withdraw", amount=amount,
+ currency=currency, notes=notes)
+
+
+
+# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
+class RecurringPeriodDefaultChoices(models.IntegerChoices):
+ """
+ This is an old class and being superseeded by the database model below
+ """
+ PER_365D = 365*24*3600, _('Per 365 days')
+ PER_30D = 30*24*3600, _('Per 30 days')
+ PER_WEEK = 7*24*3600, _('Per Week')
+ PER_DAY = 24*3600, _('Per Day')
+ PER_HOUR = 3600, _('Per Hour')
+ PER_MINUTE = 60, _('Per Minute')
+ PER_SECOND = 1, _('Per Second')
+ ONE_TIME = 0, _('Onetime')
+
+# RecurringPeriods
+class RecurringPeriod(models.Model):
+ """
+ Available recurring periods.
+ By default seeded from RecurringPeriodChoices
+ """
+
+ name = models.CharField(max_length=100, unique=True)
+ duration_seconds = models.IntegerField(unique=True)
+
+ @classmethod
+ def populate_db_defaults(cls):
+ for (seconds, name) in RecurringPeriodDefaultChoices.choices:
+ obj, created = cls.objects.get_or_create(name=name,
+ defaults={ 'duration_seconds': seconds })
+
+ @staticmethod
+ def secs_to_name(secs):
+ name = ""
+ days = 0
+ hours = 0
+
+ if secs > 24*3600:
+ days = secs // (24*3600)
+ secs -= (days*24*3600)
+
+ if secs > 3600:
+ hours = secs // 3600
+ secs -= hours*3600
+
+ return f"{days} days {hours} hours {secs} seconds"
+
+ def __str__(self):
+ duration = self.secs_to_name(self.duration_seconds)
+
+ return f"{self.name} ({duration})"
+
+
+###
+# Bills.
+
+class BillingAddress(UncloudAddress):
+ owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='billing_addresses')
+ vat_number = models.CharField(max_length=100, default="", blank=True)
+ vat_number_verified = models.BooleanField(default=False)
+ vat_number_validated_on = models.DateTimeField(blank=True, null=True)
+ stripe_tax_id = models.CharField(max_length=100, default="", blank=True)
+ active = models.BooleanField(default=False)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['owner'],
+ condition=models.Q(active=True),
+ name='one_active_billing_address_per_user')
+ ]
+
+ @classmethod
+ def populate_db_defaults(cls):
+ """
+ Ensure we have at least one billing address that is associated with the uncloud-admin.
+
+ This way we are sure that an UncloudProvider can be created.
+
+ Cannot use get_or_create as that looks for exactly one.
+
+ """
+
+ owner = get_user_model().objects.get(username=settings.UNCLOUD_ADMIN_NAME)
+ billing_address = cls.objects.filter(owner=owner).first()
+
+ if not billing_address:
+ billing_address = cls.objects.create(owner=owner,
+ organization="uncloud admins",
+ full_name="Uncloud Admin",
+ street="Uncloudstreet. 42",
+ city="Luchsingen",
+ postal_code="8775",
+ country="CH",
+ active=True)
+
+ def __str__(self):
+ return "{} - {}, {}, {} {}, {}".format(
+ self.owner,
+ self.full_name, self.street, self.postal_code, self.city,
+ self.country)
+
+ @staticmethod
+ def get_address_for(user):
+ return BillingAddress.objects.get(owner=user)
+
+###
+# VAT
+
+class VATRate(models.Model):
+ starting_date = models.DateField(blank=True, null=True)
+ ending_date = models.DateField(blank=True, null=True)
+ territory_codes = models.TextField(blank=True, default='')
+ currency_code = models.CharField(max_length=10)
+ rate = models.FloatField()
+ rate_type = models.TextField(blank=True, default='')
+ description = models.TextField(blank=True, default='')
+
+ @staticmethod
+ def get_vat_rate_for_country(country, when=None):
+ """
+ Returns the VAT rate for business to customer.
+
+ B2B is always 0% with the exception of trading within the own country
+ """
+ vatrate = filter_for_when(VATRate.objects.filter(territory_codes=country), when).first()
+
+ # By default we charge VAT. This affects:
+ # - Same country sales (VAT applied)
+ # - B2C to EU (VAT applied)
+ rate = vatrate.rate if vatrate else 0
+ if not country.lower().strip() in EU_COUNTRIES:
+ rate = 0
+
+ return rate
+
+ @staticmethod
+ def get_vat_rate(billing_address, when=None):
+ rate = VATRate.get_vat_rate_for_country(billing_address.country, when)
+
+ # Exception: if...
+ # - the billing_address is in EU,
+ # - the vat_number has been set
+ # - the vat_number has been verified
+ # Then we do not charge VAT
+
+ if rate != 0 and billing_address.vat_number and billing_address.vat_number_verified:
+ rate = 0
+ return rate
+
+
+ def __str__(self):
+ return f"{self.territory_codes}: {self.starting_date} - {self.ending_date or ''}: {self.rate_type}"
+
+###
+# Products
+
+class Product(models.Model):
+ """
+ A product is something a user can order. To record the pricing, we
+ create order that define a state in time.
+
+ A product can have *one* one_time_order and/or *one*
+ recurring_order.
+
+ If either of them needs to be updated, a new order of the same
+ type will be created and links to the previous order.
+
+ """
+
+ name = models.CharField(max_length=256, unique=True)
+ description = models.CharField(max_length=1024)
+ config = models.JSONField()
+ recurring_periods = models.ManyToManyField(RecurringPeriod, through='ProductToRecurringPeriod')
+ currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF)
+
+ @property
+ def default_recurring_period(self):
+ """
+ Return the default recurring Period
+ """
+ return self.recurring_periods.get(producttorecurringperiod__is_default=True)
+
+ @classmethod
+ def populate_db_defaults(cls):
+ recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
+
+ obj, created = cls.objects.get_or_create(name="Dual Stack Virtual Machine v1",
+ description="A standard virtual machine",
+ currency=Currency.CHF,
+ config={
+ 'features': {
+ 'cores':
+ { 'min': 1,
+ 'max': 48
+ },
+ 'ram_gb':
+ { 'min': 1,
+ 'max': 256
+ },
+ 'ssd_gb':
+ { 'min': 10
+ },
+ 'hdd_gb':
+ { 'min': 0,
+ },
+ 'additional_ipv4_address':
+ { 'min': 0,
+ },
+ }
+ }
+ )
+
+ obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True })
+
+ obj, created = cls.objects.get_or_create(name="Dual Stack Virtual Machine v2",
+ description="A standard virtual machine",
+ currency=Currency.CHF,
+ config={
+ 'features': {
+ 'base':
+ { 'min': 1,
+ 'max': 1,
+ },
+ 'cores':
+ { 'min': 1,
+ 'max': 48,
+ },
+ 'ram_gb':
+ { 'min': 1,
+ 'max': 256,
+ },
+ 'ssd_gb':
+ { 'min': 10
+ },
+ 'hdd_gb':
+ { 'min': 0
+ },
+ 'additional_ipv4_address':
+ { 'min': 0,},
+ }
+ }
+ )
+
+ obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True })
+
+ obj, created = cls.objects.get_or_create(name="reverse DNS",
+ description="Reverse DNS network",
+ currency=Currency.CHF,
+ config={
+ 'parameters': [
+ 'network'
+ ]
+ })
+ obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True })
+
+
+ def __str__(self):
+ return f"{self.name}"
+
+ @property
+ def recurring_orders(self):
+ return self.orders.order_by('id').exclude(recurring_price=0)
+
+ @property
+ def last_recurring_order(self):
+ return self.recurring_orders.last()
+
+ @property
+ def one_time_orders(self):
+ return self.orders.order_by('id').filter(recurring_price=0)
+
+ @property
+ def last_one_time_order(self):
+ return self.one_time_orders.last()
+
+ # FIXME: this could/should be part of Order (?)
+ def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None):
+ if not self.recurring_price:
+ return
+
+ if not recurring_period:
+ recurring_period = self.default_recurring_period
+
+ if not when_to_start:
+ when_to_start = timezone.now()
+
+ if self.last_recurring_order:
+ if self.recurring_price < self.last_recurring_order.price:
+
+ if when_to_start < self.last_recurring_order.next_cancel_or_downgrade_date:
+ when_to_start = start_after(self.last_recurring_order.next_cancel_or_downgrade_date)
+
+ when_to_end = end_before(when_to_start)
+
+ new_order = Order.objects.create(owner=self.owner,
+ billing_address=self.last_recurring_order.billing_address,
+ starting_date=when_to_start,
+ price=self.recurring_price,
+ recurring_period=recurring_period,
+ description=str(self),
+ replaces=self.last_recurring_order)
+
+ self.last_recurring_order.replace_with(new_order)
+ self.orders.add(new_order)
+ else:
+ self.create_order(when_to_start, recurring_period)
+
+ @property
+ def is_recurring(self):
+ return self.recurring_price > 0
+
+ @property
+ def billing_address(self):
+ return self.order.billing_address
+
+ def discounted_price_by_period(self, requested_period):
+ """
+ Each product has a standard recurring period for which
+ we define a pricing. I.e. VPN is usually year, VM is usually monthly.
+
+ The user can opt-in to use a different period, which influences the price:
+ The longer a user commits, the higher the discount.
+
+ Products can also be limited in the available periods. For instance
+ a VPN only makes sense to be bought for at least one day.
+
+ Rules are as follows:
+
+ given a standard recurring period of ..., changing to ... modifies price ...
+
+
+ # One month for free if buying / year, compared to a month: about 8.33% discount
+ per_year -> per_month -> /11
+ per_month -> per_year -> *11
+
+ # Month has 30.42 days on average. About 7.9% discount to go monthly
+ per_month -> per_day -> /28
+ per_day -> per_month -> *28
+
+ # Day has 24h, give one for free
+ per_day -> per_hour -> /23
+ per_hour -> per_day -> /23
+
+
+ Examples
+
+ VPN @ 120CHF/y becomes
+ - 10.91 CHF/month (130.91 CHF/year)
+ - 0.39 CHF/day (142.21 CHF/year)
+
+ VM @ 15 CHF/month becomes
+ - 165 CHF/month (13.75 CHF/month)
+ - 0.54 CHF/day (16.30 CHF/month)
+
+ """
+
+ # FIXME: This logic needs to be phased out / replaced by product specific (?)
+ # proportions. Maybe using the RecurringPeriod table to link the possible discounts/add ups
+
+ if self.default_recurring_period == RecurringPeriod.PER_365D:
+ if requested_period == RecurringPeriod.PER_365D:
+ return self.recurring_price
+ if requested_period == RecurringPeriod.PER_30D:
+ return self.recurring_price/11.
+ if requested_period == RecurringPeriod.PER_DAY:
+ return self.recurring_price/11./28.
+
+ elif self.default_recurring_period == RecurringPeriod.PER_30D:
+ if requested_period == RecurringPeriod.PER_365D:
+ return self.recurring_price*11
+ if requested_period == RecurringPeriod.PER_30D:
+ return self.recurring_price
+ if requested_period == RecurringPeriod.PER_DAY:
+ return self.recurring_price/28.
+
+ elif self.default_recurring_period == RecurringPeriod.PER_DAY:
+ if requested_period == RecurringPeriod.PER_365D:
+ return self.recurring_price*11*28
+ if requested_period == RecurringPeriod.PER_30D:
+ return self.recurring_price*28
+ if requested_period == RecurringPeriod.PER_DAY:
+ return self.recurring_price
+ else:
+ # FIXME: use the right type of exception here!
+ raise Exception("Did not implement the discounter for this case")
+
+
+ def save(self, *args, **kwargs):
+ # try:
+ # ba = BillingAddress.get_address_for(self.owner)
+ # except BillingAddress.DoesNotExist:
+ # raise ValidationError("User does not have a billing address")
+
+ # if not ba.active:
+ # raise ValidationError("User does not have an active billing address")
+
+
+ # Verify the required JSON fields
+
+ super().save(*args, **kwargs)
+
+
+###
+# Pricing
+######
+import logging
+
+from django.db import models
+
+logger = logging.getLogger(__name__)
+
+class PricingPlan(models.Model):
+ name = models.CharField(max_length=255, unique=True)
+ vat_inclusive = models.BooleanField(default=True)
+ vat_percentage = models.DecimalField(
+ max_digits=7, decimal_places=5, blank=True, default=0
+ )
+ set_up_fees = models.DecimalField(
+ max_digits=7, decimal_places=2, default=0
+ )
+ monthly_maintenance_fees = models.DecimalField(
+ max_digits=7, decimal_places=2, default=0
+ )
+ cores_unit_price = models.DecimalField(
+ max_digits=7, decimal_places=2, default=0
+ )
+ ram_unit_price = models.DecimalField(
+ max_digits=7, decimal_places=2, default=0
+ )
+ storage_hd_unit_price = models.DecimalField(
+ max_digits=7, decimal_places=2, default=0
+ )
+ storage_ssd_unit_price = models.DecimalField(
+ max_digits=7, decimal_places=2, default=0
+ )
+ discount_name = models.CharField(max_length=255, null=True, blank=True)
+ discount_amount = models.DecimalField(
+ max_digits=6, decimal_places=2, default=0
+ )
+ stripe_coupon_id = models.CharField(max_length=255, null=True, blank=True)
+
+ def __str__(self):
+ display_str = self.name + ' => ' + ' - '.join([
+ '{} Setup'.format(self.set_up_fees.normalize()),
+ '{}/Core'.format(self.cores_unit_price.normalize()),
+ '{}/GB RAM'.format(self.ram_unit_price.normalize()),
+ '{}/GB SSD'.format(self.storage_ssd_unit_price.normalize()),
+ '{}/GB HD'.format(self.storage_hd_unit_price.normalize()),
+ '{}% VAT'.format(self.vat_percentage.normalize())
+ if not self.vat_inclusive else 'VAT-Incl',
+ ])
+ if self.discount_amount:
+ display_str = ' - '.join([
+ display_str,
+ '{} {}'.format(
+ self.discount_amount,
+ self.discount_name if self.discount_name else 'Discount'
+ )
+ ])
+ return display_str
+
+ @classmethod
+ def get_by_name(cls, name):
+ try:
+ pricing = PricingPlan.objects.get(name=name)
+ except Exception as e:
+ logger.error(
+ "Error getting VMPricing with name {name}. "
+ "Details: {details}. Attempting to return default"
+ "pricing.".format(name=name, details=str(e))
+ )
+ pricing = PricingPlan.get_default_pricing()
+ return pricing
+
+ @classmethod
+ def get_default_pricing(cls):
+ """ Returns the default pricing or None """
+ try:
+ default_pricing = PricingPlan.objects.get(name='default')
+ except Exception as e:
+ logger.error(str(e))
+ default_pricing = None
+ return default_pricing
+
+###
+# Orders.
+class Order(models.Model):
+ """
+ Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
+ bills. Do **NOT** mutate then!
+
+ An one time order is "closed" (does not need to be billed anymore)
+ if it has one bill record. Having more than one is a programming
+ error.
+
+ A recurring order is closed if it has been replaced
+ (replaces__isnull=False) AND the ending_date is set AND it was
+ billed the last time it needed to be billed (how to check the last
+ item?)
+
+ BOTH are closed, if they are ended/closed AND have been fully
+ charged.
+
+ Fully charged == fully billed: sum_of_order_usage == sum_of_bill_records
+
+ """
+
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE,
+ editable=True)
+
+ billing_address = models.ForeignKey(BillingAddress,
+ on_delete=models.CASCADE)
+
+ # Let's forget about this one
+ customer = models.ForeignKey(StripeCustomer, on_delete=models.CASCADE, null=True)
+
+ product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE)
+ config = models.JSONField()
+
+ creation_date = models.DateTimeField(auto_now_add=True)
+ starting_date = models.DateTimeField(default=timezone.now)
+ ending_date = models.DateTimeField(blank=True, null=True)
+
+ recurring_period = models.ForeignKey(RecurringPeriod,
+ on_delete=models.CASCADE,
+ editable=True)
+
+ one_time_price = models.DecimalField(default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ 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)])
+
+ currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF)
+
+ replaces = models.ForeignKey('self',
+ related_name='replaced_by',
+ on_delete=models.CASCADE,
+ blank=True,
+ null=True)
+
+ depends_on = models.ForeignKey('self',
+ related_name='parent_of',
+ on_delete=models.CASCADE,
+ blank=True,
+ null=True)
+ pricing_plan = models.ForeignKey(PricingPlan, blank=False, null=True, on_delete=models.CASCADE)
+
+ should_be_billed = models.BooleanField(default=True)
+
+ @property
+ def earliest_ending_date(self):
+ """
+ Recurring orders cannot end before finishing at least one recurring period.
+
+ One time orders have a recurring period of 0, so this work universally
+ """
+
+ return self.starting_date + datetime.timedelta(seconds=self.recurring_period.duration_seconds)
+
+
+ def next_cancel_or_downgrade_date(self, until_when=None):
+ """
+ Return the next proper ending date after n times the
+ recurring_period, where n is an integer that applies for downgrading
+ or cancelling.
+ """
+
+ if not until_when:
+ until_when = timezone.now()
+
+ if until_when < self.starting_date:
+ raise ValidationError("Cannot end before start of start of order")
+
+ if self.recurring_period.duration_seconds > 0:
+ delta = until_when - self.starting_date
+
+ num_times = ceil(delta.total_seconds() / self.recurring_period.duration_seconds)
+
+ next_date = self.starting_date + datetime.timedelta(seconds=num_times * self.recurring_period.duration_seconds)
+ else:
+ next_date = self.starting_date
+
+ return next_date
+
+ def get_ending_date_for_bill(self, bill):
+ """
+ Determine the ending date given a specific bill
+ """
+
+ # If the order is quit, charge the final amount / finish (????)
+ # Probably not a good idea -- FIXME :continue until usual
+ if self.ending_date:
+ this_ending_date = self.ending_date
+ else:
+ if self.next_cancel_or_downgrade_date(bill.ending_date) > bill.ending_date:
+ this_ending_date = self.next_cancel_or_downgrade_date(bill.ending_date)
+ else:
+ this_ending_date = bill.ending_date
+
+ return this_ending_date
+
+
+ @property
+ def count_billed(self):
+ """
+ How many times this order was billed so far.
+ This logic is mainly thought to be for recurring bills, but also works for one time bills
+ """
+
+ return sum([ br.quantity for br in self.bill_records.all() ])
+
+ def cancel(self):
+ self.ending_date = timezone.now()
+ self.should_be_billed = False
+ self.save()
+
+ def count_used(self, when=None):
+ """
+ How many times this order was billed so far.
+ This logic is mainly thought to be for recurring bills, but also works for one time bills
+ """
+
+ if self.is_one_time:
+ return 1
+
+ if not when:
+ when = timezone.now()
+
+ # Cannot be used after it ended
+ if self.ending_date and when > self.ending_date:
+ when = self.ending_date
+
+ return (when - self.starting_date) / self.default_recurring_period
+
+ @property
+ def all_usage_billed(self, when=None):
+ """
+ Returns true if this order does not need any further billing
+ ever. In other words: is this order "closed"?
+ """
+
+ if self.count_billed == self.count_used(when):
+ return True
+ else:
+ return False
+
+ @property
+ def is_recurring(self):
+ return self.recurring_price > 0
+
+ @property
+ def is_one_time(self):
+ return not self.is_recurring
+
+ @property
+ def description(self):
+ desc = self.product.description + "( "
+ config = json.loads(self.config)
+ if config and config["cores"]:
+ desc = f"{desc} {config['cores']} Cores,"
+ if config and config["memory"]:
+ desc = f"{desc} {config['memory']} RAM,"
+ if config and config["storage"]:
+ desc = f"{desc} {config['storage']} GB SSD,"
+ desc += " )"
+ return desc
+
+ def replace_with(self, new_order):
+ new_order.replaces = self
+ self.ending_date = end_before(new_order.starting_date)
+ self.save()
+
+ def update_order(self, config, starting_date=None):
+ """
+ Updating an order means creating a new order and reference the previous order
+ """
+
+ if not starting_date:
+ starting_date = timezone.now()
+
+ new_order = self.__class__(owner=self.owner,
+ billing_address=self.billing_address,
+ product=self.product,
+ config=config,
+ pricing_plan=self.pricing_plan,
+ starting_date=starting_date,
+ currency=self.currency
+ )
+
+ new_order.recurring_price = new_order.calculate_recurring_price()
+ new_order.replaces = self
+ new_order.save()
+
+ self.ending_date = end_before(new_order.starting_date)
+ self.save()
+
+ return new_order
+
+
+ def create_bill_record(self, bill):
+ br = None
+ if self.recurring_price != 0:
+ records = BillRecord.objects.filter(order=self).all()
+ if not records:
+ if self.one_time_price:
+ br = BillRecord.objects.create(bill=bill,
+ order=self,
+ starting_date=self.starting_date,
+ ending_date=bill.ending_date,
+ is_recurring_record=False)
+ else:
+ br = self.create_new_bill_record_for_recurring_order(bill)
+ else:
+ opened_recurring_record = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first()
+ if opened_recurring_record:
+ br = opened_recurring_record
+ self.update_bill_record_for_recurring_order(br, bill)
+ else:
+ br = self.create_new_bill_record_for_recurring_order(bill)
+ return br
+
+ def update_bill_record_for_recurring_order(self,
+ bill_record,
+ bill):
+ """
+ Possibly update a bill record according to the information in the bill
+ """
+
+ # If the order has an ending date set, we might need to adjust the bill_record
+ if self.ending_date:
+ if bill_record.ending_date != self.ending_date:
+ bill_record.ending_date = self.ending_date
+
+ else:
+ # recurring, not terminated, should go until at least end of bill
+ if bill_record.ending_date < bill.ending_date:
+ bill_record.ending_date = bill.ending_date
+
+ bill_record.save()
+
+ def create_new_bill_record_for_recurring_order(self, bill):
+ """
+ Create a new bill record
+ """
+ last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last()
+
+ starting_date=self.starting_date
+
+ if last_bill_record:
+ # We already charged beyond the end of this bill's period
+ if last_bill_record.ending_date >= bill.ending_date:
+ return
+
+ # This order is terminated or replaced
+ if self.ending_date:
+ # And the last bill record already covered us -> nothing to be done anymore
+ if last_bill_record.ending_date == self.ending_date:
+ return
+
+ starting_date = start_after(last_bill_record.ending_date)
+ ending_date = self.get_ending_date_for_bill(bill)
+
+ return BillRecord.objects.create(bill=bill,
+ order=self,
+ starting_date=starting_date,
+ ending_date=ending_date,
+ is_recurring_record=True)
+
+ def calculate_recurring_price(self):
+ try:
+ config = json.loads(self.config)
+ recurring_price = self.pricing_plan.monthly_maintenance_fees
+ if 'cores' in config:
+ recurring_price += self.pricing_plan.cores_unit_price * int(config['cores'])
+ if 'memory' in config:
+ recurring_price += self.pricing_plan.ram_unit_price * int(config['memory'])
+ if 'storage' in config:
+ #TODO Fix the ssd static value
+ recurring_price += (10 * self.pricing_plan.storage_ssd_unit_price) + (self.pricing_plan.storage_hd_unit_price * int(config['storage']))
+
+ vat_rate = VATRate.get_vat_rate(self.billing_address)
+ vat_validation_status = "verified" if self.billing_address.vat_number_validated_on and self.billing_address.vat_number_verified else False
+ subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, vat_amount, discount = uncloud_pay.utils.apply_vat_discount(
+ recurring_price, self.pricing_plan,
+ vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status
+ )
+ return price_after_discount_with_vat
+ except Exception as e:
+ logger.error("An error occurred while parsing the config obj", e)
+ return 0
+
+ def check_parameters(self):
+ if 'parameters' in self.product.config:
+ for parameter in self.product.config['parameters']:
+ if not parameter in self.config['parameters']:
+ raise ValidationError(f"Required parameter '{parameter}' is missing.")
+
+
+ def save(self, *args, **kwargs):
+ # Calculate the price of the order when we create it
+ # IMMUTABLE fields -- need to create new order to modify them
+ # However this is not enforced here...
+ if self._state.adding:
+ self.recurring_price = self.calculate_recurring_price()
+
+ if self.recurring_period_id is None:
+ self.recurring_period = self.product.default_recurring_period
+
+ try:
+ prod_period = self.product.recurring_periods.get(producttorecurringperiod__recurring_period=self.recurring_period)
+ except ObjectDoesNotExist:
+ raise ValidationError(f"Recurring Period {self.recurring_period} not allowed for product {self.product}")
+
+ self.check_parameters()
+
+ if self.ending_date and self.ending_date < self.starting_date:
+ raise ValidationError("End date cannot be before starting date")
+
+
+ super().save(*args, **kwargs)
+
+
+ def __str__(self):
+ return f"Order {self.id}: {self.description}"
+
+class Bill(models.Model):
+ """
+ A bill is a representation of usage at a specific time
+ """
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE)
+
+ creation_date = models.DateTimeField(auto_now_add=True)
+ starting_date = models.DateTimeField(default=start_of_this_month)
+ ending_date = models.DateTimeField()
+ due_date = models.DateField(default=default_payment_delay)
+
+
+ billing_address = models.ForeignKey(BillingAddress,
+ on_delete=models.CASCADE,
+ editable=True,
+ null=False)
+
+ currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF)
+
+ # FIXME: editable=True -> is in the admin, but also editable in DRF
+ # Maybe filter fields in the serializer?
+
+ status = models.CharField(max_length=32, choices= (
+ ('new', 'New'),
+ ('cancelled', 'Cancelled'),
+ ('paid', 'Paid')
+ ), null=False, blank=False, default="new")
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['owner',
+ 'starting_date',
+ 'ending_date' ],
+ name='one_bill_per_month_per_user')
+ ]
+
+ def close(self, status):
+ """
+ Close/finish a bill
+ """
+ self.status = status
+ if not self.ending_date:
+ self.ending_date = timezone.now()
+ self.save()
+
+ @property
+ def sum(self):
+ bill_records = BillRecord.objects.filter(bill=self)
+ return sum([ br.sum for br in bill_records ])
+
+ @property
+ def subtotal(self):
+ bill_records = BillRecord.objects.filter(bill=self)
+ return sum([ br.subtotal for br in bill_records ])
+
+ @property
+ def vat_amount(self):
+ return round(self.vat_rate * self.subtotal, 2)
+
+ @property
+ def vat_rate(self):
+ return VATRate.get_vat_rate(self.billing_address, when=self.ending_date)
+
+
+ @classmethod
+ def create_bills_for_all_users(cls):
+ """
+ Create next bill for each user
+ """
+
+ for owner in get_user_model().objects.all():
+ cls.create_next_bills_for_user(owner)
+
+ @classmethod
+ def create_next_bills_for_user(cls, owner, ending_date=None):
+ """
+ Create one bill per billing address, as the VAT rates might be different
+ for each address
+ """
+
+ bills = []
+ for billing_address in BillingAddress.objects.filter(owner=owner):
+ bill = cls.create_next_bill_for_user_address(billing_address, ending_date)
+ if bill:
+ bills.append(bill)
+
+ return bills
+
+ @classmethod
+ def create_next_bill_for_user_address(cls, billing_address, ending_date=None):
+ """
+ Create the next bill for a specific billing address of a user
+ """
+
+ owner = billing_address.owner
+
+ all_orders = Order.objects.filter(Q(owner__id=owner.id), Q(should_be_billed=True),
+ Q(billing_address__id=billing_address.id)
+ ).order_by('id')
+
+ if len(all_orders) > 0:
+ bill = cls.get_or_create_bill(billing_address, ending_date=ending_date)
+ for order in all_orders:
+ order.create_bill_record(bill)
+ return bill
+ else:
+ # This Customer Hasn't any active orders
+ return False
+
+ @classmethod
+ def create_next_bill_for_order(cls, order, ending_date=None):
+ """
+ Create the next bill for a specific order of a user
+ """
+ bill = cls.get_or_create_bill(order.billing_address, ending_date=ending_date)
+ order.create_bill_record(bill)
+ return bill
+
+
+ @classmethod
+ def get_or_create_bill(cls, billing_address, ending_date=None):
+ """
+ Get / reuse last bill if it is not yet closed
+
+ Create bill, if there is no bill or if bill is closed.
+ """
+
+ last_bill = cls.objects.filter(billing_address=billing_address).order_by('id').last()
+
+ all_orders = Order.objects.filter(billing_address=billing_address).order_by('id')
+ first_order = all_orders.first()
+
+ bill = None
+
+ # Get date & bill from previous bill, if it exists
+ if last_bill:
+ if last_bill.status == 'new':
+ bill = last_bill
+ starting_date = last_bill.starting_date
+ ending_date = bill.ending_date
+ else:
+ starting_date = last_bill.ending_date + datetime.timedelta(seconds=1)
+ else:
+ # Might be an idea to make this the start of the month, too
+ if first_order:
+ starting_date = first_order.starting_date
+ else:
+ starting_date = timezone.now()
+
+ if not ending_date:
+ ending_date = end_of_month(starting_date)
+
+ if not bill:
+ bill = cls.objects.create(
+ owner=billing_address.owner,
+ starting_date=starting_date,
+ ending_date=ending_date,
+ billing_address=billing_address)
+
+
+ return bill
+
+ def __str__(self):
+ return f"{self.owner}-{self.id}"
+
+
+class BillRecord(models.Model):
+ """
+ Entry of a bill, dynamically generated from an order.
+ """
+
+ bill = models.ForeignKey(Bill, on_delete=models.CASCADE, related_name='bill_records')
+ order = models.ForeignKey(Order, on_delete=models.CASCADE)
+
+ creation_date = models.DateTimeField(auto_now_add=True)
+ starting_date = models.DateTimeField()
+ ending_date = models.DateTimeField()
+
+ is_recurring_record = models.BooleanField(blank=False, null=False)
+
+ @property
+ def quantity(self):
+ """ Determine the quantity by the duration"""
+ if not self.is_recurring_record:
+ return 1
+
+ record_delta = self.ending_date.date() - self.starting_date.date()
+ if self.order.recurring_period and self.order.recurring_period.duration_seconds > 0:
+ return int(record_delta.total_seconds() / self.order.recurring_period.duration_seconds)
+ else:
+ return 1
+
+ @property
+ def sum(self):
+ if self.is_recurring_record:
+ return round(float(self.order.recurring_price) * self.quantity, 2)
+ else:
+ return self.order.one_time_price
+
+ @property
+ def description(self):
+ if self.order:
+ return self.order.description
+ return ''
+
+ @property
+ def price(self):
+ if self.is_recurring_record:
+ return self.order.recurring_price
+ else:
+ return self.order.one_time_price
+
+ @property
+ def subtotal(self):
+ billing_address_ins = self.order.billing_address
+ vat_rate = VATRate.get_vat_rate(billing_address_ins)
+ vat_validation_status = "verified" if billing_address_ins.vat_number_validated_on and billing_address_ins.vat_number_verified else False
+ config = json.loads(self.order.config)
+ pricing = uncloud_pay.utils.get_order_total_with_vat(
+ config["cores"], config["memory"], config["storage"], self.order.pricing_plan.name,
+ vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status
+ )
+ return pricing['subtotal_after_discount']
+
+ def __str__(self):
+ if self.is_recurring_record:
+ bill_line = f"{self.starting_date} - {self.ending_date}: {self.quantity} x {self.order}"
+ else:
+ bill_line = f"{self.starting_date}: {self.order}"
+
+ return bill_line
+
+ def save(self, *args, **kwargs):
+ if self.ending_date < self.starting_date:
+ raise ValidationError("End date cannot be before starting date")
+
+ super().save(*args, **kwargs)
+
+
+class ProductToRecurringPeriod(models.Model):
+ """
+ Intermediate manytomany mapping class that allows storing the default recurring period
+ for a product
+ """
+
+ recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE)
+ product = models.ForeignKey(Product, on_delete=models.CASCADE)
+
+ is_default = models.BooleanField(default=False)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['product'],
+ condition=models.Q(is_default=True),
+ name='one_default_recurring_period_per_product'),
+ models.UniqueConstraint(fields=['product', 'recurring_period'],
+ name='recurring_period_once_per_product')
+ ]
+
+ def __str__(self):
+ return f"{self.product} - {self.recurring_period} (default: {self.is_default})"
+
+
+class Membership(models.Model):
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE)
+
+ starting_date = models.DateField(blank=True, null=True)
+ ending_date = models.DateField(blank=True, null=True)
+
+
+ @classmethod
+ def user_has_membership(user, when):
+ """
+ Return true if user has membership at a point of time,
+ return false if that is not the case
+ """
+
+ pass
+
+ # cls.objects.filter(owner=user,
+ # starting_date)
diff --git a/uncloud_pay/selectors.py b/uncloud_pay/selectors.py
new file mode 100644
index 0000000..098e90b
--- /dev/null
+++ b/uncloud_pay/selectors.py
@@ -0,0 +1,25 @@
+from django.utils import timezone
+from django.db import transaction
+from .models import *
+
+def get_deposit_payments_for_user(user):
+ payments = [ payment.amount for payment in Payment.objects.filter(owner=user, type='deposit')]
+ return sum(payments)
+
+def get_spendings_for_user(user):
+ spendings = [payment.amount for payment in Payment.objects.filter(owner=user, type='withdraw')]
+ return sum(spendings)
+
+@transaction.atomic
+def get_balance_for_user(user):
+ return float(get_deposit_payments_for_user(user) - get_spendings_for_user(user))
+
+@transaction.atomic
+def has_enough_balance(user, due_amount):
+ balance = get_balance_for_user(user)
+ if balance >= due_amount:
+ return True
+ return False
+
+def get_billing_address_for_user(user):
+ return BillingAddress.objects.filter(owner=user, active=True).first()
diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py
new file mode 100644
index 0000000..9f52574
--- /dev/null
+++ b/uncloud_pay/serializers.py
@@ -0,0 +1,132 @@
+from django.contrib.auth import get_user_model
+from rest_framework import serializers
+from uncloud_auth.serializers import UserSerializer
+from django.utils.translation import gettext_lazy as _
+from stripe.error import CardError
+
+from .models import *
+import uncloud_pay.stripe as uncloud_stripe
+from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
+
+###
+# 2020-12 Checked code
+
+class StripeCreditCardSerializer(serializers.HyperlinkedModelSerializer):
+ class Meta:
+ model = StripeCreditCard
+ exclude = [ "card_id", "owner" ]
+ read_only_fields = [ "last4", "brand", "expiry_date" ]
+
+class PaymentSerializer(serializers.ModelSerializer):
+ owner = serializers.HiddenField(default=serializers.CurrentUserDefault())
+
+ class Meta:
+ model = Payment
+ fields = '__all__'
+ read_only_fields = [ "external_reference", "source", "timestamp" ]
+
+ def validate(self, data):
+ data["source"] = "stripe"
+ return data
+
+ def create(self, validated_data):
+ try:
+ if validated_data['type'] == 'deposit':
+ return Payment.deposit(validated_data['owner'], validated_data['amount'], validated_data['source'], currency=validated_data['currency'], notes=validated_data['notes'])
+ else:
+ return Payment.objects.create(**validated_data)
+ except CardError as err:
+ raise serializers.ValidationError(err.user_message)
+
+class BalanceSerializer(serializers.Serializer):
+ balance = serializers.DecimalField(max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS)
+
+class BillingAddressSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = BillingAddress
+ exclude = [ "owner" ]
+
+
+class VATRateSerializer(serializers.ModelSerializer):
+
+ class Meta:
+ model = VATRate
+ fields = '__all__'
+
+
+################################################################################
+# Unchecked code
+
+
+###
+# Orders & Products.
+
+class OrderSerializer(serializers.ModelSerializer):
+ owner = serializers.PrimaryKeyRelatedField(queryset=get_user_model().objects.all())
+
+ def __init__(self, *args, **kwargs):
+ # Don't pass the 'fields' arg up to the superclass
+ admin = kwargs.pop('admin', None)
+
+ # Instantiate the superclass normally
+ super(OrderSerializer, self).__init__(*args, **kwargs)
+
+ # Only allows owner in admin mode.
+ if not admin:
+ self.fields.pop('owner')
+
+ def create(self, validated_data):
+ billing_address = BillingAddress.get_preferred_address_for(validated_data["owner"])
+ instance = Order(billing_address=billing_address, **validated_data)
+ instance.save()
+
+ return instance
+
+ def validate_owner(self, value):
+ if BillingAddress.get_preferred_address_for(value) == None:
+ raise serializers.ValidationError("Owner does not have a valid billing address.")
+
+ return value
+
+ class Meta:
+ model = Order
+ read_only_fields = ['replaced_by', 'depends_on']
+ fields = ['owner', 'description', 'creation_date', 'starting_date', 'ending_date',
+ 'recurring_period', 'recurring_price', 'one_time_price',
+ 'config', 'pricing_plan', 'should_be_billed'] + read_only_fields
+
+
+###
+# Bills
+
+# TODO: remove magic numbers for decimal fields
+class BillRecordSerializer(serializers.Serializer):
+ order = serializers.HyperlinkedRelatedField(
+ view_name='order-detail',
+ read_only=True)
+ description = serializers.CharField()
+ one_time_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+ recurring_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+# recurring_period = serializers.ChoiceField()
+ recurring_count = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+ vat_rate = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+ vat_amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+ amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+ total = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+
+class BillSerializer(serializers.ModelSerializer):
+ billing_address = BillingAddressSerializer(read_only=True)
+ records = BillRecordSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = Bill
+ fields = ['owner', 'sum', 'vat_rate',
+ 'due_date', 'creation_date', 'starting_date', 'ending_date',
+ 'records', 'status', 'billing_address']
+
+# We do not want users to mutate the country / VAT number of an address, as it
+# will change VAT on existing bills.
+class UpdateBillingAddressSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = BillingAddress
+ fields = ['street', 'city', 'postal_code']
diff --git a/uncloud_pay/services.py b/uncloud_pay/services.py
new file mode 100644
index 0000000..84a7c8d
--- /dev/null
+++ b/uncloud_pay/services.py
@@ -0,0 +1,34 @@
+import datetime
+from calendar import monthrange
+from django.utils import timezone
+
+
+def start_of_month(a_day):
+ """ Returns first of the month of a given datetime object"""
+ return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0)
+
+def end_of_month(a_day):
+ """ Returns first of the month of a given datetime object"""
+
+ _, last_day = monthrange(a_day.year, a_day.month)
+ return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0)
+
+def start_of_this_month():
+ """ Returns first of this month"""
+ a_day = timezone.now()
+ return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0)
+
+def end_of_this_month():
+ """ Returns first of this month"""
+ a_day = timezone.now()
+
+ _, last_day = monthrange(a_day.year, a_day.month)
+ return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0)
+
+def end_before(a_date):
+ """ Return suitable datetimefield for ending just before a_date """
+ return a_date - datetime.timedelta(seconds=1)
+
+def start_after(a_date):
+ """ Return suitable datetimefield for starting just after a_date """
+ return a_date + datetime.timedelta(seconds=1)
diff --git a/uncloud_pay/static/uncloud_pay/css/invoice.css b/uncloud_pay/static/uncloud_pay/css/invoice.css
new file mode 100644
index 0000000..3d8d04f
--- /dev/null
+++ b/uncloud_pay/static/uncloud_pay/css/invoice.css
@@ -0,0 +1,114 @@
+body {
+ font-family: Avenir;
+ background: white;
+ font-weight: 500;
+ line-height: 1.1em;
+ font-size: 16px;
+ margin: auto;
+}
+p {
+ display: block;
+ -webkit-margin-before: 14px;
+ -webkit-margin-after: 14px;
+ -webkit-margin-start: 0px;
+ -webkit-margin-end: 0px;
+}
+.bold {
+ font-weight: bold;
+}
+.d1 {
+ line-height:1.1em;
+ width: 60%;
+ float: left;
+}
+.d2 {
+ line-height:1.5em;
+ padding-top: 15px;
+ font-style: normal;
+ width: 40%;
+ float: left;
+}
+.d4 {
+ line-height:1.5em;
+ width:40%;
+ float: left;
+}
+.b1 {
+ width: 45%;
+ float: left;
+}
+.b2 {
+ width: 55%;
+ float: left;
+ text-align: right;
+ left: 0;
+}
+.d5 {
+ width: 100%;
+}
+.d6 {
+ width: 68%;
+ float: left;
+ font-size: 13px;
+}
+.d7 {
+ width: 32%;
+ float: left;
+}
+.wf {
+ width: 100%;
+}
+hr {
+ border: 0;
+ clear:both;
+ display: inline-block;
+ width: 100%;
+ background-color:gray;
+ height: 1px;
+ }
+ .tl {
+ text-align: left;
+ margin-left: 5px;
+ }
+
+ .tr {
+ text-align: right;
+ margin-right: 5px;
+ float: right;
+ }
+ .tc {
+ text-align: center;
+ }
+ .pc p {
+ display: block;
+ -webkit-margin-before: 3px;
+ -webkit-margin-after: 5px;
+ -webkit-margin-start: 0px;
+ -webkit-margin-end: 0px;
+}
+ .th {
+ border-top: 1px solid gray;
+ border-bottom: 1px solid gray;
+
+ }
+ .ts {
+ font-size: 14px;
+ }
+ .icon {
+ width: 16px;
+ height: 14px;
+ vertical-align: middle;
+ margin-right: 2px;
+ }
+ .footer {
+ margin-top: 70px;
+ font-size: 14px;
+ }
+
+ .footer p {
+ display: block;
+ -webkit-margin-before: 5px;
+ -webkit-margin-after: 5px;
+ -webkit-margin-start: 0px;
+ -webkit-margin-end: 0px;
+}
\ No newline at end of file
diff --git a/uncloud_pay/static/uncloud_pay/images/call.png b/uncloud_pay/static/uncloud_pay/images/call.png
new file mode 100644
index 0000000..e774362
Binary files /dev/null and b/uncloud_pay/static/uncloud_pay/images/call.png differ
diff --git a/uncloud_pay/static/uncloud_pay/images/company-small.jpg b/uncloud_pay/static/uncloud_pay/images/company-small.jpg
new file mode 100644
index 0000000..a7b575f
Binary files /dev/null and b/uncloud_pay/static/uncloud_pay/images/company-small.jpg differ
diff --git a/uncloud_pay/static/uncloud_pay/images/home.png b/uncloud_pay/static/uncloud_pay/images/home.png
new file mode 100644
index 0000000..24428e7
Binary files /dev/null and b/uncloud_pay/static/uncloud_pay/images/home.png differ
diff --git a/uncloud_pay/static/uncloud_pay/images/msg.png b/uncloud_pay/static/uncloud_pay/images/msg.png
new file mode 100644
index 0000000..3b7b0c7
Binary files /dev/null and b/uncloud_pay/static/uncloud_pay/images/msg.png differ
diff --git a/uncloud_pay/static/uncloud_pay/images/twitter.png b/uncloud_pay/static/uncloud_pay/images/twitter.png
new file mode 100644
index 0000000..4db6da0
Binary files /dev/null and b/uncloud_pay/static/uncloud_pay/images/twitter.png differ
diff --git a/uncloud_pay/static/uncloud_pay/webfonts/Avenir-Book.ttf b/uncloud_pay/static/uncloud_pay/webfonts/Avenir-Book.ttf
new file mode 100644
index 0000000..84ae914
Binary files /dev/null and b/uncloud_pay/static/uncloud_pay/webfonts/Avenir-Book.ttf differ
diff --git a/uncloud_pay/stripe.py b/uncloud_pay/stripe.py
new file mode 100644
index 0000000..7d8d757
--- /dev/null
+++ b/uncloud_pay/stripe.py
@@ -0,0 +1,255 @@
+import stripe
+import stripe.error
+import logging
+import datetime
+
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.conf import settings
+from django.contrib.auth import get_user_model
+
+from .models import StripeCustomer, StripeCreditCard
+
+logger = logging.getLogger(__name__)
+
+CURRENCY = 'chf'
+
+stripe.api_key = settings.STRIPE_KEY
+
+def handle_stripe_error(f):
+ def handle_problems(*args, **kwargs):
+ response = {
+ 'paid': False,
+ 'response_object': None,
+ 'error': None
+ }
+
+ common_message = "Currently it is not possible to make payments. Please try agin later."
+ try:
+ response_object = f(*args, **kwargs)
+ return response_object
+ except stripe.error.CardError as e:
+ # Since it's a decline, stripe.error.CardError will be caught
+ body = e.json_body
+ logging.error(str(e))
+
+ raise e # For error handling.
+ except stripe.error.RateLimitError:
+ logging.error("Too many requests made to the API too quickly.")
+ raise Exception(common_message)
+ except stripe.error.InvalidRequestError as e:
+ logging.error(str(e))
+ raise Exception('Invalid parameters.')
+ except stripe.error.AuthenticationError as e:
+ # Authentication with Stripe's API failed
+ # (maybe you changed API keys recently)
+ logging.error(str(e))
+ raise Exception(common_message)
+ except stripe.error.APIConnectionError as e:
+ logging.error(str(e))
+ raise Exception(common_message)
+ except stripe.error.StripeError as e:
+ # XXX: maybe send email
+ logging.error(str(e))
+ raise Exception(common_message)
+
+ return handle_problems
+
+def public_api_key():
+ return settings.STRIPE_PUBLIC_KEY
+
+def get_customer_id_for(user):
+ try:
+ # .get() raise if there is no matching entry.
+ return StripeCustomer.objects.get(owner=user).stripe_id
+ except ObjectDoesNotExist:
+ # No entry yet - making a new one.
+ try:
+ customer = create_customer(user.username, user.email)
+ uncloud_stripe_mapping = StripeCustomer.objects.create(
+ owner=user, stripe_id=customer.id)
+ return uncloud_stripe_mapping.stripe_id
+ except Exception as e:
+ return None
+
+@handle_stripe_error
+def create_setup_intent(customer_id):
+ return stripe.SetupIntent.create(customer=customer_id)
+
+@handle_stripe_error
+def get_setup_intent(setup_intent_id):
+ return stripe.SetupIntent.retrieve(setup_intent_id)
+
+@handle_stripe_error
+def get_payment_method(payment_method_id):
+ return stripe.PaymentMethod.retrieve(payment_method_id)
+
+@handle_stripe_error
+def get_card_from_payment(user, payment_method_id):
+ payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
+ if payment_method:
+ if 'card' in payment_method:
+ sync_cards_for_user(user)
+ return payment_method['card']
+ return False
+
+
+@handle_stripe_error
+def attach_payment_method(payment_method_id, user):
+ customer_id = get_customer_id_for(user)
+ ret = stripe.PaymentMethod.attach(payment_method_id, customer=customer_id)
+ sync_cards_for_user(user)
+ return ret
+
+@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)
+
+@handle_stripe_error
+def get_customer_cards(customer_id):
+
+ cards = []
+ stripe_cards = stripe.PaymentMethod.list(
+ customer=customer_id,
+ type="card",
+ )
+
+ for stripe_card in stripe_cards["data"]:
+ card = {}
+ card['brand'] = stripe_card["card"]["brand"]
+ card['last4'] = stripe_card["card"]["last4"]
+ card['month'] = stripe_card["card"]["exp_month"]
+ card['year'] = stripe_card["card"]["exp_year"]
+ card['id'] = stripe_card["id"]
+
+ cards.append(card)
+
+ return cards
+
+@handle_stripe_error
+def delete_card(card_id):
+ return stripe.PaymentMethod.detach(card_id)
+
+def sync_cards_for_user(user):
+ customer_id = get_customer_id_for(user)
+ cards = get_customer_cards(customer_id)
+
+ active_cards = StripeCreditCard.objects.filter(owner=user,
+ active=True)
+
+ if len(active_cards) > 0:
+ has_active_card = True
+ else:
+ has_active_card = False
+
+ for card in cards:
+ active = False
+
+ if not has_active_card:
+ active = True
+ has_active_card = True
+
+ StripeCreditCard.objects.get_or_create(card_id=card['id'],
+ owner = user,
+ defaults = {
+ 'last4': card['last4'],
+ 'brand': card['brand'],
+ 'expiry_date': datetime.date(card['year'],
+ card['month'],
+ 1),
+ 'active': active
+ }
+ )
+
+@handle_stripe_error
+def charge_customer(user, amount, currency='CHF', card=False):
+ # Amount is in CHF but stripes requires smallest possible unit.
+ # https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount
+ # FIXME: might need to be adjusted for other currencies
+
+ if currency == 'CHF':
+ adjusted_amount = int(amount * 100)
+ else:
+ return Exception("Programming error: unsupported currency")
+
+ try:
+ card = card or StripeCreditCard.objects.get(owner=user,
+ active=True)
+
+ except StripeCreditCard.DoesNotExist:
+ raise ValidationError("No active credit card - cannot create payment")
+
+ customer_id = get_customer_id_for(user)
+
+ return stripe.PaymentIntent.create(
+ amount=adjusted_amount,
+ currency=currency,
+ customer=customer_id,
+ payment_method=card.card_id,
+ off_session=True,
+ confirm=True,
+ )
+
+@handle_stripe_error
+def get_payment_intent(user, amount, currency='CHF', card=False):
+ # Amount is in CHF but stripes requires smallest possible unit.
+ # https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount
+ # FIXME: might need to be adjusted for other currencies
+
+ if currency == 'CHF':
+ adjusted_amount = int(amount * 100)
+ else:
+ return Exception("Programming error: unsupported currency")
+
+ try:
+ card = card or StripeCreditCard.objects.get(owner=user,
+ active=True)
+
+ except StripeCreditCard.DoesNotExist:
+ raise ValidationError("No active credit card - cannot create payment")
+
+ customer_id = get_customer_id_for(user)
+
+ return stripe.PaymentIntent.create(
+ amount=adjusted_amount,
+ currency=currency,
+ customer=customer_id,
+ payment_method=card.card_id,
+ setup_future_usage='off_session',
+ confirm=False,
+ )
+
+@handle_stripe_error
+def get_or_create_tax_id_for_user(stripe_customer_id, vat_number,
+ type="eu_vat", country=""):
+ def compare_vat_numbers(vat1, vat2):
+ _vat1 = vat1.replace(" ", "").replace(".", "").replace("-","")
+ _vat2 = vat2.replace(" ", "").replace(".", "").replace("-","")
+ return True if _vat1 == _vat2 else False
+
+ tax_ids_list = stripe.Customer.list_tax_ids(
+ stripe_customer_id,
+ limit=100,
+ )
+ for tax_id_obj in tax_ids_list.data:
+ if compare_vat_numbers(tax_id_obj.value, vat_number):
+ return tax_id_obj
+ else:
+ logger.debug(
+ "{val1} is not equal to {val2} or {con1} not same as "
+ "{con2}".format(val1=tax_id_obj.value, val2=vat_number,
+ con1=tax_id_obj.country.lower(),
+ con2=country.lower().strip()))
+ logger.debug(
+ "tax id obj does not exist for {val}. Creating a new one".format(
+ val=vat_number
+ ))
+ tax_id_obj = stripe.Customer.create_tax_id(
+ stripe_customer_id,
+ type=type,
+ value=vat_number,
+ )
+ return tax_id_obj
diff --git a/uncloud_pay/templates/uncloud_pay/bill.html.j2 b/uncloud_pay/templates/uncloud_pay/bill.html.j2
new file mode 100644
index 0000000..7cf10f8
--- /dev/null
+++ b/uncloud_pay/templates/uncloud_pay/bill.html.j2
@@ -0,0 +1,1059 @@
+{% load static %}
+
+
+
+
+
+
+
+
+ {{ bill }}
+
+
+
+
+
+
+
+
+

+
+
+
+
+ ungleich glarus ag
+
Bahnhofstrasse 1
+
8783 Linthal
+
Switzerland
+
+
+
+ {{ bill.billing_address.organization }}
+ {{ bill.billing_address.name }}
+ {{ bill.owner.email }}
+ {{ bill.billing_address.street }}
+ {{ bill.billing_address.country }} {{ bill.billing_address.postal_code }} {{ bill.billing_address.city }}
+
+
+
+
+ Bill id: {{ bill }}
+
{{ bill.starting_date|date:"Ymd" }} -
+ {{ bill.ending_date|date:"Ymd" }}
+
+
+
+
+
Invoice
+
+
+
+
+ | Detail |
+ Price/Unit |
+ Units |
+ Total price |
+
+
+
+ {% for record in bill_records %}
+
+ | {{ record.starting_date|date:"Ymd-H:i:s" }}
+ - {{ record.ending_date|date:"Ymd-H:i:s" }}
+ {{ record.order }}
+ |
+ {{ record.price|floatformat:2 }} |
+ {{ record.quantity|floatformat:2 }} |
+ {{ record.sum|floatformat:2 }} |
+
+ {% endfor %}
+
+
+
+
+ Total (excl. VAT)
+ {{ bill.amount }}
+
+
+ VAT 7.7%
+ {{ bill.vat_amount|floatformat:2 }}
+
+
+
+
+ Total amount to be paid
+ {{ bill.sum|floatformat:2 }}
+
+
+
+
+
+
diff --git a/uncloud_pay/templates/uncloud_pay/includes/_card.html b/uncloud_pay/templates/uncloud_pay/includes/_card.html
new file mode 100644
index 0000000..9cb9462
--- /dev/null
+++ b/uncloud_pay/templates/uncloud_pay/includes/_card.html
@@ -0,0 +1,33 @@
+{% load i18n %}
+
diff --git a/uncloud_pay/templates/uncloud_pay/includes/invoice_footer.html b/uncloud_pay/templates/uncloud_pay/includes/invoice_footer.html
new file mode 100644
index 0000000..aebdab5
--- /dev/null
+++ b/uncloud_pay/templates/uncloud_pay/includes/invoice_footer.html
@@ -0,0 +1,78 @@
+{% load static i18n %}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/uncloud_pay/templates/uncloud_pay/invoice.html b/uncloud_pay/templates/uncloud_pay/invoice.html
new file mode 100644
index 0000000..e8a3cfa
--- /dev/null
+++ b/uncloud_pay/templates/uncloud_pay/invoice.html
@@ -0,0 +1,129 @@
+{% load static i18n %}
+
+
+
+
+
+
+
+ {%trans "Invoice: " %} {{bill.id}}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/uncloud_pay/templates/uncloud_pay/register_stripe.html b/uncloud_pay/templates/uncloud_pay/register_stripe.html
new file mode 100644
index 0000000..6d192a4
--- /dev/null
+++ b/uncloud_pay/templates/uncloud_pay/register_stripe.html
@@ -0,0 +1,80 @@
+{% extends 'uncloud/base.html' %}
+
+{% block head_extra %}
+
+
+{% endblock %}
+
+{% block content %}
+
+ {% csrf_token %}
+
+
Register Credit Card with Stripe
+
+ By submitting I authorise to send instructions to
+ the financial institution that issued my card to take
+ payments from my card account in accordance with the
+ terms of my agreement with you.
+
+
+
+
+
+
+
+
The card will be registered with stripe.
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py
new file mode 100644
index 0000000..4f2aded
--- /dev/null
+++ b/uncloud_pay/tests.py
@@ -0,0 +1,546 @@
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from datetime import datetime, date, timedelta
+from django.utils import timezone
+
+from .models import *
+from uncloud_service.models import GenericServiceProduct
+from uncloud.models import UncloudProvider, UncloudNetwork
+
+import json
+
+vm_product_config = {
+ 'features': {
+ 'cores':
+ { 'min': 1,
+ 'max': 48
+ },
+ 'ram_gb':
+ { 'min': 1,
+ 'max': 256
+ },
+ },
+}
+
+vm_order_config = json.dumps({
+ 'cores': 1,
+ 'memory': 2,
+ 'storage': 100
+})
+
+vm_order_downgrade_config = {
+ 'features': {
+ 'cores': 1,
+ 'ram_gb': 1
+ }
+}
+
+vm_order_upgrade_config = {
+ 'features': {
+ 'cores': 4,
+ 'ram_gb': 4
+ }
+}
+
+
+class ProductTestCase(TestCase):
+ """
+ Test products and products <-> order interaction
+ """
+
+ def setUp(self):
+ self.user = get_user_model().objects.create(
+ username='random_user',
+ email='jane.random@domain.tld')
+
+ self.ba = BillingAddress.objects.create(
+ owner=self.user,
+ organization = 'Test org',
+ street="unknown",
+ city="unknown",
+ postal_code="somewhere else",
+ active=True)
+
+ RecurringPeriod.populate_db_defaults()
+ self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
+
+ def test_create_product(self):
+ """
+ Create a sample product
+ """
+ p = Product.objects.create(name="Testproduct",
+ description="Only for testing",
+ config=vm_product_config)
+
+ p.recurring_periods.add(self.default_recurring_period,
+ through_defaults= { 'is_default': True })
+
+
+class OrderTestCase(TestCase):
+ """
+ The heart of ordering products
+ """
+
+ def setUp(self):
+ self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
+ ram_unit_price=4, storage_ssd_unit_price=0.35, storage_hd_unit_price=0.02)
+ self.user = get_user_model().objects.create(
+ username='random_user',
+ email='jane.random@domain.tld')
+
+ self.ba = BillingAddress.objects.create(
+ owner=self.user,
+ organization = 'Test org',
+ street="unknown",
+ city="unknown",
+ postal_code="somewhere else",
+ active=True)
+
+ self.product = Product.objects.create(name="Testproduct",
+ description="Only for testing",
+ config=vm_product_config)
+
+ RecurringPeriod.populate_db_defaults()
+ self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
+
+ self.product.recurring_periods.add(self.default_recurring_period,
+ through_defaults= { 'is_default': True })
+
+
+ def test_order_invalid_recurring_period(self):
+ """
+ Order a products with a recurringperiod that is not added to the product
+ """
+
+ order_config = json.dumps({
+ 'cores': 1,
+ 'memory':2,
+ 'storage': 100
+ })
+ o = Order.objects.create(owner=self.user,
+ billing_address=self.ba,
+ pricing_plan = self.pricing_plan,
+ product=self.product,
+ config=order_config)
+
+
+ def test_order_product(self):
+ """
+ Order a product, ensure the order has correct price setup
+ """
+ order_config = json.dumps({
+ 'cores': 1,
+ 'memory':2,
+ 'storage': 100
+ })
+ o = Order.objects.create(owner=self.user,
+ billing_address=self.ba,
+ pricing_plan = self.pricing_plan,
+ product=self.product,
+ config=order_config)
+
+ self.assertEqual(o.one_time_price, 0)
+ self.assertEqual(o.recurring_price, 16.5)
+
+ def test_change_order(self):
+ """
+ Change an order and ensure that
+ - a new order is created
+ - the price is correct in the new order
+ """
+ order_config = json.dumps({
+ 'cores': 2,
+ 'memory':4,
+ 'storage': 200,
+ })
+ order1 = Order.objects.create(owner=self.user,
+ billing_address=self.ba,
+ pricing_plan = self.pricing_plan,
+ product=self.product,
+ config=order_config)
+
+ self.assertEqual(order1.one_time_price, 0)
+ self.assertEqual(order1.recurring_price, 29.5)
+
+
+class ModifyOrderTestCase(TestCase):
+ """
+ Test typical order flows like
+ - cancelling
+ - downgrading
+ - upgrading
+ """
+
+ def setUp(self):
+ self.user = get_user_model().objects.create(
+ username='random_user',
+ email='jane.random@domain.tld')
+ self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
+ ram_unit_price=4, storage_ssd_unit_price=0.35, storage_hd_unit_price=0.02)
+ self.order1_config = json.dumps({
+ 'cores': 2,
+ 'memory':4,
+ 'storage': 200
+ })
+ self.order2_config = json.dumps({
+ 'cores': 1,
+ 'memory':2,
+ 'storage': 100
+ })
+ self.ba = BillingAddress.objects.create(
+ owner=self.user,
+ organization = 'Test org',
+ street="unknown",
+ city="unknown",
+ postal_code="somewhere else",
+ active=True)
+
+ self.product = Product.objects.create(name="Testproduct",
+ description="Only for testing",
+ config=vm_product_config)
+
+ RecurringPeriod.populate_db_defaults()
+ self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
+
+ self.product.recurring_periods.add(self.default_recurring_period,
+ through_defaults= { 'is_default': True })
+
+
+ def test_change_order(self):
+ """
+ Test changing an order
+
+ Expected result:
+
+ - Old order should be closed before new order starts
+ - New order should start at starting data
+ """
+
+ user = self.user
+
+ starting_price = 16
+ downgrade_price = 8
+
+ starting_date = timezone.make_aware(datetime.datetime(2019,3,3))
+ ending1_date = starting_date + datetime.timedelta(days=15)
+ change1_date = start_after(ending1_date)
+
+ bill_ending_date = change1_date + datetime.timedelta(days=1)
+
+
+ order1 = Order.objects.create(owner=self.user,
+ billing_address=BillingAddress.get_address_for(self.user),
+ product=self.product,
+ config=self.order1_config,
+ pricing_plan=self.pricing_plan,
+ starting_date=starting_date)
+
+ order1.update_order(self.order2_config, starting_date=change1_date)
+
+ bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
+
+ bill = bills[0]
+ bill_records = BillRecord.objects.filter(bill=bill)
+
+ self.assertEqual(len(bill_records), 2)
+
+ self.assertEqual(bill_records[0].starting_date, starting_date)
+ self.assertEqual(bill_records[0].ending_date, ending1_date)
+
+ self.assertEqual(bill_records[1].starting_date, change1_date)
+
+
+
+ def test_downgrade_product(self):
+ """
+ Test downgrading behaviour:
+
+ We create a recurring product (recurring time: 30 days) and downgrade after 15 days.
+
+ We create the bill right AFTER the end of the first order.
+
+ Expected result:
+
+ - First bill record for 30 days
+ - Second bill record starting after 30 days
+ - Bill contains two bill records
+
+ """
+
+ user = self.user
+
+ starting_price = 16
+ downgrade_price = 8
+
+ starting_date = timezone.make_aware(datetime.datetime(2019,3,3))
+ first_order_should_end_at = starting_date + datetime.timedelta(days=30)
+ change1_date = start_after(starting_date + datetime.timedelta(days=15))
+ bill_ending_date = change1_date + datetime.timedelta(days=1)
+ order1 = Order.objects.create(owner=self.user,
+ billing_address=BillingAddress.get_address_for(self.user),
+ product=self.product,
+ pricing_plan=self.pricing_plan,
+ config=self.order1_config,
+ starting_date=starting_date)
+
+ bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
+
+ bill = bills[0]
+ bill_records = BillRecord.objects.filter(bill=bill)
+
+ self.assertEqual(len(bill_records), 1)
+ self.assertEqual(bill_records[0].starting_date, starting_date)
+
+ order1.update_order(self.order2_config, starting_date=change1_date)
+ bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
+ bill_records = BillRecord.objects.filter(bill=bill)
+ self.assertEqual(len(bill_records), 2)
+ self.assertEqual(bill_records[0].order.ending_date.date(), change1_date.date())
+
+
+class BillTestCase(TestCase):
+ """
+ Test aspects of billing / creating a bill
+ """
+
+ def setUp(self):
+ RecurringPeriod.populate_db_defaults()
+
+ self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
+ ram_unit_price=4, storage_ssd_unit_price=0.35, storage_hd_unit_price=0.02)
+
+ self.user_without_address = get_user_model().objects.create(
+ username='no_home_person',
+ email='far.away@domain.tld')
+
+ self.user = get_user_model().objects.create(
+ username='jdoe',
+ email='john.doe@domain.tld')
+
+ self.recurring_user = get_user_model().objects.create(
+ username='recurrent_product_user',
+ email='jane.doe@domain.tld')
+
+ self.user_addr = BillingAddress.objects.create(
+ owner=self.user,
+ organization = 'Test org',
+ street="unknown",
+ city="unknown",
+ postal_code="unknown",
+ active=True)
+
+ self.recurring_user_addr = BillingAddress.objects.create(
+ owner=self.recurring_user,
+ organization = 'Test org',
+ street="Somewhere",
+ city="Else",
+ postal_code="unknown",
+ active=True)
+
+ self.order_meta = {}
+ self.order_meta[1] = {
+ 'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)),
+ 'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)),
+ 'price': 15,
+ 'description': ''
+ }
+
+ self.product = Product.objects.create(name="Product Sample",
+ description="Not only for testing, but for joy",
+ config=vm_product_config)
+
+
+ self.vm = Product.objects.create(name="Super Fast VM",
+ description="Zooooom",
+ config=vm_product_config)
+
+
+ RecurringPeriod.populate_db_defaults()
+ self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
+
+ self.onetime_recurring_period = RecurringPeriod.objects.get(name="Onetime")
+
+ self.product.recurring_periods.add(self.onetime_recurring_period,
+ through_defaults= { 'is_default': True })
+
+ self.vm.recurring_periods.add(self.default_recurring_period,
+ through_defaults= { 'is_default': True })
+
+
+ # used for generating multiple bills
+ self.bill_dates = [
+ timezone.make_aware(datetime.datetime(2020,3,31)),
+ timezone.make_aware(datetime.datetime(2020,4,30)),
+ timezone.make_aware(datetime.datetime(2020,5,31)),
+ ]
+
+
+ def order_product(self):
+ return Order.objects.create(
+ owner=self.user,
+ recurring_period=RecurringPeriod.objects.get(name="Onetime"),
+ product=self.product,
+ billing_address=BillingAddress.get_address_for(self.user),
+ starting_date=self.order_meta[1]['starting_date'],
+ ending_date=self.order_meta[1]['ending_date'],
+ pricing_plan=self.pricing_plan,
+ config=vm_order_config)
+
+ def order_vm(self, owner=None):
+
+ if not owner:
+ owner = self.recurring_user
+
+ return Order.objects.create(
+ owner=owner,
+ product=self.vm,
+ config=vm_order_config,
+ pricing_plan=self.pricing_plan,
+ billing_address=BillingAddress.get_address_for(self.recurring_user),
+ starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
+ )
+
+ def test_bill_one_time_with_recurring(self):
+ """
+ Validate that if the order contains one_time_price and recurring_pricing
+ One Bill records should be created
+ """
+
+ order = Order.objects.create(
+ owner=self.user,
+ product=self.vm,
+ config=vm_order_config,
+ pricing_plan=self.pricing_plan,
+ one_time_price = 35,
+ billing_address=BillingAddress.get_address_for(self.user),
+ starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
+ )
+
+ bill = Bill.create_next_bill_for_user_address(self.user_addr)
+
+ self.assertEqual(order.billrecord_set.count(), 1)
+ record = order.billrecord_set.first()
+ self.assertEqual(record.is_recurring_record, False)
+ self.assertEqual(record.price, 35)
+ self.assertEqual(record.quantity, 1)
+ self.assertEqual(record.sum, 35)
+ #close the bill as it has been paid
+ bill.close(status="paid")
+ bill2 = Bill.create_next_bill_for_user_address(self.user_addr)
+ self.assertNotEqual(bill.id, bill2.id)
+ self.assertEqual(order.billrecord_set.count(), 2)
+ record = BillRecord.objects.filter(bill=bill2, order=order).first()
+ self.assertEqual(record.is_recurring_record, True)
+ self.assertEqual(record.price, 16.5)
+ self.assertEqual(record.quantity, 1)
+ self.assertEqual(record.sum, 16.5)
+
+ def test_bill_one_time_one_bill_record(self):
+ """
+ Ensure there is only 1 bill record per order
+ """
+
+ order = self.order_product()
+
+ bill = Bill.create_next_bill_for_user_address(self.user_addr)
+
+ self.assertEqual(order.billrecord_set.count(), 1)
+
+ def test_bill_sum_onetime(self):
+ """
+ Check the bill sum for a single one time order
+ """
+
+ order = self.order_product()
+ self.assertEqual(order.recurring_price, 16.5)
+ bill = Bill.create_next_bill_for_user_address(self.user_addr)
+ self.assertEqual(order.billrecord_set.count(), 1)
+ record = order.billrecord_set.first()
+ self.assertEqual(record.price, 16.5)
+ self.assertEqual(record.quantity, 1)
+ self.assertEqual(bill.sum, 16.5)
+
+
+ def test_bill_creates_record_for_recurring_order(self):
+ """
+ Ensure there is only 1 bill record per order
+ """
+
+ order = self.order_vm()
+ bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr)
+
+ self.assertEqual(order.billrecord_set.count(), 1)
+ self.assertEqual(bill.bill_records.count(), 1)
+
+
+ def test_new_bill_after_closing(self):
+ """
+ After closing a bill and the user has a recurring product,
+ the next bill run should create e new bill
+ """
+
+ order = self.order_vm()
+
+ for ending_date in self.bill_dates:
+ b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date)
+ b.close(status="paid")
+
+ bill_count = Bill.objects.filter(owner=self.recurring_user).count()
+
+ self.assertEqual(len(self.bill_dates), bill_count)
+
+
+
+class BillingAddressTestCase(TestCase):
+ def setUp(self):
+ self.user = get_user_model().objects.create(
+ username='random_user',
+ email='jane.random@domain.tld')
+
+
+ def test_user_no_address(self):
+ """
+ Raise an error, when there is no address
+ """
+
+ self.assertRaises(BillingAddress.DoesNotExist,
+ BillingAddress.get_address_for,
+ self.user)
+
+class VATRatesTestCase(TestCase):
+ def setUp(self):
+ self.user = get_user_model().objects.create(
+ username='random_user',
+ email='jane.random@domain.tld')
+
+ self.user_addr = BillingAddress.objects.create(
+ owner=self.user,
+ organization = 'Test org',
+ street="unknown",
+ city="unknown",
+ postal_code="unknown",
+ country="CH",
+ active=True)
+
+ UncloudNetwork.populate_db_defaults()
+ UncloudProvider.populate_db_defaults()
+
+ VATRate.objects.create(territory_codes="CH", currency_code="CHF", rate=7.7,
+ starting_date=timezone.make_aware(datetime.datetime(2000,1,1)))
+
+
+
+ def test_get_rate_for_user(self):
+ """
+ Raise an error, when there is no address
+ """
+ rate = VATRate.get_vat_rate(self.user_addr)
+ self.assertEqual(rate, 7.7)
+ self.user_addr.vat_number_verified = True
+ self.user_addr.vat_number = "11111"
+ rate1 = VATRate.get_vat_rate(self.user_addr)
+ self.assertEqual(rate1, 0)
+ rate2 = VATRate.get_vat_rate_for_country('CH')
+ self.assertEqual(rate, 7.7)
+ rate2 = VATRate.get_vat_rate_for_country('EG')
+ self.assertEqual(rate2, 0)
diff --git a/uncloud_pay/urls.py b/uncloud_pay/urls.py
new file mode 100644
index 0000000..f30becb
--- /dev/null
+++ b/uncloud_pay/urls.py
@@ -0,0 +1,14 @@
+from django.urls import path, include
+from django.conf import settings
+from .views import *
+
+app_name = 'uncloud_pay'
+
+urlpatterns = [
+ path('cards/activate', CardActivateView.as_view(), name='card_activate'),
+ path('billing/', PaymentsView.as_view(), name='billing'),
+ path('billing/cards', CardsView.as_view(), name='cards'),
+ path('billing/bills', BillsView.as_view(), name='bills'),
+ path('order/success/', OrderSuccessView.as_view(), name='order_success'),
+ path('order/invoice/
/download/', InvoiceDownloadView.as_view(), name='invoice_download'),
+]
diff --git a/uncloud_pay/utils.py b/uncloud_pay/utils.py
new file mode 100644
index 0000000..08db9d0
--- /dev/null
+++ b/uncloud_pay/utils.py
@@ -0,0 +1,166 @@
+import logging
+import decimal
+import datetime
+
+from . import stripe as uncloud_stripe
+import stripe
+from .models import PricingPlan, BillingAddress
+
+logger = logging.getLogger(__name__)
+
+eu_countries = ['at', 'be', 'bg', 'ch', 'cy', 'cz', 'hr', 'dk',
+ 'ee', 'fi', 'fr', 'mc', 'de', 'gr', 'hu', 'ie', 'it',
+ 'lv', 'lu', 'mt', 'nl', 'po', 'pt', 'ro','sk', 'si', 'es',
+ 'se', 'gb']
+
+def validate_vat_number(stripe_customer_id, billing_address_id):
+ try:
+ billing_address = BillingAddress.objects.get(id=billing_address_id)
+ except BillingAddress.DoesNotExist as dne:
+ billing_address = None
+ except BillingAddress.MultipleObjectsReturned as mor:
+ billing_address = BillingAddress.objects.filter(id=billing_address_id).order_by('-id').first()
+ if billing_address is not None:
+ logger.debug("BillingAddress found: %s %s" % (
+ billing_address_id, str(billing_address)))
+ if billing_address.country.lower().strip() not in eu_countries:
+ return {
+ "validated_on": "",
+ "status": "not_needed"
+ }
+ if billing_address.vat_number_validated_on and billing_address.vat_number_verified:
+ return {
+ "validated_on": billing_address.vat_number_validated_on,
+ "status": "verified"
+ }
+ else:
+ if billing_address.stripe_tax_id:
+ logger.debug("We have a tax id %s" % billing_address.stripe_tax_id)
+ tax_id_obj = stripe.Customer.retrieve_tax_id(
+ stripe_customer_id,
+ billing_address.stripe_tax_id,
+ )
+ if tax_id_obj.verification.status == "verified":
+ logger.debug("Latest status on Stripe=%s. Updating" %
+ tax_id_obj.verification.status)
+ # update billing address
+ billing_address.vat_number_validated_on = datetime.datetime.now()
+ billing_address.vat_number_verified = True
+ billing_address.save()
+ return {
+ "status": "verified",
+ "validated_on": billing_address.vat_number_validated_on
+ }
+ else:
+ billing_address.vat_number_validated_on = datetime.datetime.now()
+ billing_address.vat_number_verified = False
+ billing_address.save()
+ else:
+ logger.debug("Creating a tax id")
+ tax_id_obj = create_tax_id(
+ stripe_customer_id, billing_address_id,
+ "ch_vat" if billing_address.country.lower() == "ch" else "eu_vat",
+ )
+ else:
+ logger.debug("invalid billing address")
+ return {
+ "status": "invalid billing address",
+ "validated_on": ""
+ }
+ return {
+ "status": tax_id_obj.verification.status if 'verification' in tax_id_obj else "unknown",
+ "validated_on": datetime.datetime.now() if tax_id_obj.verification.status == "verified" else ""
+ }
+
+def create_tax_id(stripe_customer_id, billing_address_id, type):
+ try:
+ billing_address = BillingAddress.objects.get(id=billing_address_id)
+ except BillingAddress.DoesNotExist as dne:
+ billing_address = None
+ logger.debug("BillingAddress does not exist for %s" % billing_address_id)
+ except BillingAddress.MultipleObjectsReturned as mor:
+ logger.debug("Multiple BillingAddress exist for %s" % billing_address_id)
+ billing_address = BillingAddress.objects.filter(billing_address_id).order_by('-id').first()
+
+ tax_id_obj = None
+ if billing_address:
+ try:
+ tax_id_obj = uncloud_stripe.get_or_create_tax_id_for_user(
+ stripe_customer_id,
+ vat_number=billing_address.vat_number,
+ type=type,
+ country=billing_address.country
+ )
+ billing_address.stripe_tax_id = tax_id_obj.id
+ billing_address.vat_number_verified = True if tax_id_obj.verification.status == "verified" else False
+ billing_address.save()
+ return tax_id_obj
+ except Exception as e:
+ logger.debug("Received none in tax_id_obj")
+ return {
+ 'verification': None,
+ 'error': str(e)
+ }
+
+def apply_vat_discount(subtotal, pricing_plan, vat_rate=False, vat_validation_status=False):
+ vat_percent = vat_rate
+ if pricing_plan.vat_inclusive or (vat_validation_status and vat_validation_status in ["verified", "not_needed"]):
+ vat_percent = decimal.Decimal(0)
+ vat = decimal.Decimal(0)
+ else:
+ vat = subtotal * decimal.Decimal(vat_rate) * decimal.Decimal(0.01)
+ discount_amount = 0
+ discount_amount_with_vat = 0
+ if pricing_plan.discount_amount:
+ discount_amount = round(float(pricing_plan.discount_amount), 2)
+ discount_amount_with_vat = decimal.Decimal(discount_amount) * (1 + decimal.Decimal(vat_rate) * decimal.Decimal(0.01))
+ discount_amount_with_vat = discount_amount_with_vat
+
+ subtotal = round(float(subtotal), 2)
+ vat_percent = round(float(vat_percent), 2)
+ discount = {
+ 'name': pricing_plan.discount_name or 'Discount',
+ 'amount': discount_amount,
+ 'amount_with_vat': round(float(discount_amount_with_vat), 2)
+ }
+ subtotal_after_discount = subtotal - discount["amount"]
+ vat_amount = round(vat_percent * 0.01 * subtotal_after_discount, 2)
+ price_after_discount_with_vat = round((subtotal - discount['amount']) * (1 + vat_percent * 0.01), 2)
+
+ return (subtotal, round(float(subtotal_after_discount), 2), price_after_discount_with_vat,
+ round(float(vat), 2), vat_percent, vat_amount, discount)
+
+
+def get_order_total_with_vat(cores, memory, storage,
+ pricing_name='default', vat_rate=False, vat_validation_status=False):
+ try:
+ pricing = PricingPlan.objects.get(name=pricing_name)
+ except Exception as ex:
+ logger.error(
+ "Error getting PricingPlan object for {pricing_name}."
+ "Details: {details}".format(
+ pricing_name=pricing_name, details=str(ex)
+ )
+ )
+ return None
+ recurring_price = pricing.monthly_maintenance_fees + (decimal.Decimal(cores) * pricing.cores_unit_price) + \
+ (decimal.Decimal(memory) * pricing.ram_unit_price) + \
+ (decimal.Decimal(10) * (pricing.storage_ssd_unit_price)) + \
+ (decimal.Decimal(storage) * (pricing.storage_hd_unit_price))
+ subtotal = pricing.set_up_fees + recurring_price
+ subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, vat_amount, discount = \
+ apply_vat_discount(subtotal, pricing, vat_rate, vat_validation_status)
+ return {
+ "name": pricing.name,
+ 'recurring_price': round(float(recurring_price), 2),
+ "subtotal": subtotal,
+ "discount": discount,
+ "vat": vat, "vat_percent": vat_percent,
+ 'vat_amount': vat_amount,
+ "vat_validation_status": vat_validation_status,
+ "subtotal_after_discount": subtotal_after_discount,
+ "total": price_after_discount_with_vat
+ }
+
+
+
diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py
new file mode 100644
index 0000000..d880857
--- /dev/null
+++ b/uncloud_pay/views.py
@@ -0,0 +1,460 @@
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.views.generic.base import TemplateView, View
+from django.views.generic import DetailView
+from django.shortcuts import render
+from django.views.decorators.cache import cache_control
+from django.db import transaction
+from django.contrib.auth import get_user_model
+from rest_framework import viewsets, mixins, permissions, status, views
+from rest_framework.renderers import TemplateHTMLRenderer
+from rest_framework.response import Response
+from rest_framework.decorators import action
+from rest_framework.reverse import reverse
+from rest_framework.decorators import renderer_classes
+from vat_validator import validate_vat, vies
+from vat_validator.countries import EU_COUNTRY_CODES
+from hardcopy import bytestring_to_pdf
+from django.core.files.temp import NamedTemporaryFile
+from django.views.generic.list import ListView
+from django.http import FileResponse, HttpResponseRedirect
+from django.template.loader import render_to_string
+#from wkhtmltopdf.views import PDFTemplateResponse
+from copy import deepcopy
+
+import json
+import logging
+
+from .models import *
+from .serializers import *
+from .selectors import *
+from .utils import get_order_total_with_vat
+
+from datetime import datetime
+from vat_validator import sanitize_vat
+import uncloud_pay.stripe as uncloud_stripe
+from django.contrib.auth.decorators import login_required
+from django.utils.decorators import method_decorator
+from django.http import JsonResponse
+import stripe
+
+logger = logging.getLogger(__name__)
+
+
+class PricingView(View):
+ def get(self, request, **args):
+ vat_rate = False
+ vat_validation_status = False
+ address = False
+ selected_country = request.GET.get('country', False)
+ if self.request.user and self.request.user.is_authenticated:
+ address = get_billing_address_for_user(self.request.user)
+ if address and (address.country == selected_country or not selected_country):
+ vat_rate = VATRate.get_vat_rate(address)
+ vat_validation_status = "verified" if address.vat_number_validated_on and address.vat_number_verified else False
+ elif selected_country:
+ vat_rate = VATRate.get_vat_rate_for_country(selected_country)
+ vat_validation_status = False
+
+ pricing = get_order_total_with_vat(
+ request.GET.get('cores'),
+ request.GET.get('memory'),
+ request.GET.get('storage'),
+ pricing_name = args['name'],
+ vat_rate = vat_rate * 100,
+ vat_validation_status = vat_validation_status
+ )
+ return JsonResponse(pricing)
+
+class CardActivateView(View):
+ def post(self, request, **args):
+ card_id = request.POST.get('card_id')
+ if card_id:
+ matched_card = StripeCreditCard.objects.filter(owner=self.request.user, id=card_id).first()
+ matched_card.activate()
+ return JsonResponse({'success': 1})
+ else:
+ return JsonResponse({'error': "Please select a card"})
+
+class RegisterCard(TemplateView):
+ template_name = "uncloud_pay/register_stripe.html"
+
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
+
+ def get_context_data(self, **kwargs):
+ customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
+ setup_intent = uncloud_stripe.create_setup_intent(customer_id)
+ context = super().get_context_data(**kwargs)
+ context['client_secret'] = setup_intent.client_secret
+ context['username'] = self.request.user.username
+ context['stripe_pk'] = uncloud_stripe.public_api_key
+ return context
+
+class OrderSuccessView(DetailView):
+ template_name = "uncloud_pay/order_success.html"
+ context_object_name = "order"
+ model = Order
+
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
+ @cache_control(no_cache=True, must_revalidate=True, no_store=True)
+ def get(self, request, *args, **kwargs):
+ if ('order' not in request.session or ('bill_id' not in request.session)):
+ return HttpResponseRedirect(reverse('nextcloud:index'))
+ context = {
+ 'order': self.request.session.get('order'),
+ 'bill_id': self.request.session['bill_id'],
+ 'balance': get_balance_for_user(self.request.user)
+ }
+ return render(request, self.template_name, context)
+
+class InvoiceDownloadView(View):
+ template = 'uncloud_pay/invoice.html'
+
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = {'base_url': f'{self.request.scheme}://{self.request.get_host()}'}
+ return context
+
+ def get(self, request, bill_id):
+ cmd_options = settings.REPORT_FORMAT
+ context = self.get_context_data()
+ bill = Bill.objects.get(owner=self.request.user, id=bill_id)
+ if bill:
+ context['bill'] = bill
+ context['vat_rate'] = str(round(bill.vat_rate * 100, 2))
+ context['tax_amount'] = round(bill.vat_rate * bill.subtotal, 2)
+ return PDFTemplateResponse(request=request,
+ template=self.template,
+ filename = f"bill-{bill_id}.pdf",
+ cmd_options= cmd_options,
+ footer_template= 'uncloud_pay/includes/invoice_footer.html',
+ context= context)
+
+class PaymentsView(ListView):
+ template_name = "uncloud_pay/payments.html"
+ model = Payment
+
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = super(PaymentsView, self).get_context_data(**kwargs)
+ context.update({
+ 'balance': get_balance_for_user(self.request.user),
+ 'type': self.request.GET.get('type')
+ })
+ return context
+
+ def get_queryset(self):
+ if self.request.GET.get('type'):
+ return Payment.objects.filter(owner=self.request.user, type=self.request.GET.get('type')).order_by('-timestamp')
+ return Payment.objects.filter(owner=self.request.user).order_by('-timestamp')
+
+class CardsView(ListView):
+ template_name = "uncloud_pay/cards.html"
+ model = StripeCreditCard
+
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = super(CardsView, self).get_context_data(**kwargs)
+ customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
+ setup_intent = uncloud_stripe.create_setup_intent(customer_id)
+
+ context = super().get_context_data(**kwargs)
+ context.update({
+ 'balance': get_balance_for_user(self.request.user),
+ 'client_secret': setup_intent.client_secret,
+ 'username': self.request.user.username,
+ 'stripe_pk':uncloud_stripe.public_api_key,
+ 'min_amount': settings.MIN_PER_TRANSACTION
+ })
+ return context
+
+ def get_queryset(self):
+ uncloud_stripe.sync_cards_for_user(self.request.user)
+ return StripeCreditCard.objects.filter(owner=self.request.user).order_by('-active')
+
+class BillsView(ListView):
+ template_name = "uncloud_pay/bills.html"
+ model = Bill
+
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = super(BillsView, self).get_context_data(**kwargs)
+
+ context = super().get_context_data(**kwargs)
+ context.update({
+ 'balance': get_balance_for_user(self.request.user),
+ })
+ return context
+
+ def get_queryset(self):
+ return Bill.objects.filter(owner=self.request.user).order_by('-creation_date')
+
+class CreditCardViewSet(mixins.RetrieveModelMixin,
+ mixins.UpdateModelMixin,
+ mixins.DestroyModelMixin,
+ mixins.ListModelMixin,
+ viewsets.GenericViewSet):
+
+ serializer_class = StripeCreditCardSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def list(self, request):
+ uncloud_stripe.sync_cards_for_user(self.request.user)
+ return super().list(request)
+
+ def get_queryset(self):
+ return StripeCreditCard.objects.filter(owner=self.request.user)
+
+class PaymentViewSet(viewsets.ModelViewSet):
+ serializer_class = PaymentSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return Payment.objects.filter(owner=self.request.user)
+
+class BalanceViewSet(viewsets.ViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def list(self, request):
+ serializer = BalanceSerializer(data={
+ 'balance': get_balance_for_user(self.request.user)
+ })
+ serializer.is_valid()
+ return Response(serializer.data)
+
+
+class ListCards(TemplateView):
+ template_name = "uncloud_pay/list_stripe.html"
+
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
+ cards = uncloud_stripe.get_customer_cards(customer_id)
+
+ context = super().get_context_data(**kwargs)
+ context['cards'] = cards
+ context['username'] = self.request.user
+
+ return context
+
+###
+# Bills and Orders.
+
+class BillViewSet(viewsets.ReadOnlyModelViewSet):
+ serializer_class = BillSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return Bill.objects.filter(owner=self.request.user)
+
+
+ @action(detail=False, methods=['get'])
+ def unpaid(self, request):
+ serializer = self.get_serializer(
+ Bill.get_unpaid_for(self.request.user),
+ many=True)
+ return Response(serializer.data)
+
+ @action(detail=True, methods=['get'])
+ def download(self, *args, **kwargs):
+ """
+ Allow to download
+ """
+ bill = self.get_object()
+ provider = UncloudProvider.get_provider()
+ output_file = NamedTemporaryFile()
+ bill_html = render_to_string("bill.html.j2", {'bill': bill})
+
+ bytestring_to_pdf(bill_html.encode('utf-8'), output_file)
+ response = FileResponse(output_file, content_type="application/pdf")
+ response['Content-Disposition'] = 'filename="{}_{}.pdf"'.format(
+ bill.reference, bill.uuid
+ )
+
+ return response
+
+
+class OrderViewSet(viewsets.ReadOnlyModelViewSet):
+ serializer_class = OrderSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return Order.objects.filter(owner=self.request.user)
+
+class VATRateViewSet(viewsets.ReadOnlyModelViewSet):
+ serializer_class = VATRateSerializer
+ permission_classes = [permissions.IsAuthenticated]
+ queryset = VATRate.objects.all()
+
+class BillingAddressViewSet(mixins.CreateModelMixin,
+ mixins.RetrieveModelMixin,
+ mixins.UpdateModelMixin,
+ mixins.ListModelMixin,
+ viewsets.GenericViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_serializer_class(self):
+ if self.action == 'update':
+ return UpdateBillingAddressSerializer
+ else:
+ return BillingAddressSerializer
+
+ def get_queryset(self):
+ return self.request.user.billing_addresses.all()
+
+ def create(self, request):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ # Validate VAT numbers.
+ country = serializer.validated_data["country"]
+
+ # We ignore empty VAT numbers.
+ if 'vat_number' in serializer.validated_data and serializer.validated_data["vat_number"] != "":
+ vat_number = serializer.validated_data["vat_number"]
+
+ if not validate_vat(country, vat_number):
+ return Response(
+ {'error': 'Malformed VAT number.'},
+ status=status.HTTP_400_BAD_REQUEST)
+ elif country in EU_COUNTRY_CODES:
+ # XXX: make a synchroneous call to a third patry API here might not be a good idea..
+ try:
+ vies_state = vies.check_vat(country, vat_number)
+ if not vies_state.valid:
+ return Response(
+ {'error': 'European VAT number does not exist in VIES.'},
+ status=status.HTTP_400_BAD_REQUEST)
+ except Exception as e:
+ logger.warning(e)
+ return Response(
+ {'error': 'Could not validate EU VAT number against VIES. Try again later..'},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+
+ serializer.save(owner=request.user)
+ return Response(serializer.data)
+
+###
+# Admin stuff.
+
+class AdminPaymentViewSet(viewsets.ModelViewSet):
+ serializer_class = PaymentSerializer
+ permission_classes = [permissions.IsAdminUser]
+
+ def get_queryset(self):
+ return Payment.objects.all()
+
+ def create(self, request):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ serializer.save(timestamp=datetime.now())
+
+ headers = self.get_success_headers(serializer.data)
+ return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+
+# Bills are generated from orders and should not be created or updated by hand.
+class AdminBillViewSet(BillViewSet):
+ serializer_class = BillSerializer
+ permission_classes = [permissions.IsAdminUser]
+
+ def get_queryset(self):
+ return Bill.objects.all()
+
+ @action(detail=False, methods=['get'])
+ def unpaid(self, request):
+ unpaid_bills = []
+ # XXX: works but we can do better than number of users + 1 SQL requests...
+ for user in get_user_model().objects.all():
+ unpaid_bills = unpaid_bills + Bill.get_unpaid_for(self.request.user)
+
+ serializer = self.get_serializer(unpaid_bills, many=True)
+ return Response(serializer.data)
+
+ @action(detail=False, methods=['post'])
+ def generate(self, request):
+ users = get_user_model().objects.all()
+
+ generated_bills = []
+ for user in users:
+ now = timezone.now()
+ generated_bills = generated_bills + Bill.generate_for(
+ year=now.year,
+ month=now.month,
+ user=user)
+
+ return Response(
+ map(lambda b: b.reference, generated_bills),
+ status=status.HTTP_200_OK)
+
+class AdminOrderViewSet(mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+ mixins.CreateModelMixin,
+ mixins.UpdateModelMixin,
+ viewsets.GenericViewSet):
+ serializer_class = OrderSerializer
+ permission_classes = [permissions.IsAdminUser]
+
+ def get_serializer(self, *args, **kwargs):
+ return self.serializer_class(*args, **kwargs, admin=True)
+
+ def get_queryset(self):
+ return Order.objects.all()
+
+ # Updates create a new order and terminate the 'old' one.
+ @transaction.atomic
+ def update(self, request, *args, **kwargs):
+ order = self.get_object()
+ partial = kwargs.pop('partial', False)
+ serializer = self.get_serializer(order, data=request.data, partial=partial)
+ serializer.is_valid(raise_exception=True)
+
+ # Clone existing order for replacement.
+ replacing_order = deepcopy(order)
+
+ # Yes, that's how you make a new entry in DB:
+ # https://docs.djangoproject.com/en/3.0/topics/db/queries/#copying-model-instances
+ replacing_order.pk = None
+
+ for attr, value in serializer.validated_data.items():
+ setattr(replacing_order, attr, value)
+
+ # Save replacing order and terminate 'previous' one.
+ replacing_order.save()
+ order.replaced_by = replacing_order
+ order.save()
+ order.terminate()
+
+ return Response(replacing_order)
+
+ @action(detail=True, methods=['post'])
+ def terminate(self, request, pk):
+ order = self.get_object()
+ if order.is_terminated:
+ return Response(
+ {'error': 'Order is already terminated.'},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+ else:
+ order.terminate()
+ return Response({}, status=status.HTTP_200_OK)
diff --git a/uncloud_service/__init__.py b/uncloud_service/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud/uncloud_net/admin.py b/uncloud_service/admin.py
similarity index 100%
rename from uncloud/uncloud_net/admin.py
rename to uncloud_service/admin.py
diff --git a/uncloud/ungleich_service/apps.py b/uncloud_service/apps.py
similarity index 71%
rename from uncloud/ungleich_service/apps.py
rename to uncloud_service/apps.py
index 184e181..190bd35 100644
--- a/uncloud/ungleich_service/apps.py
+++ b/uncloud_service/apps.py
@@ -2,4 +2,4 @@ from django.apps import AppConfig
class UngleichServiceConfig(AppConfig):
- name = 'ungleich_service'
+ name = 'uncloud_service'
diff --git a/uncloud_service/models.py b/uncloud_service/models.py
new file mode 100644
index 0000000..a37e42b
--- /dev/null
+++ b/uncloud_service/models.py
@@ -0,0 +1,63 @@
+from django.db import models
+from uncloud_pay.models import Product, RecurringPeriod, AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS
+from uncloud_vm.models import VMProduct, VMDiskImageProduct
+from django.core.validators import MinValueValidator
+
+class MatrixServiceProduct(models.Model):
+ 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')
+
+ # Default recurring price is PER_MONT, see Product class.
+ # def recurring_price(self, recurring_period=RecurringPeriod.PER_30D):
+ # return self.monthly_managment_fee
+
+ @staticmethod
+ def base_image():
+ # TODO: find a way to safely reference debian 10 image.
+#e return VMDiskImageProduct.objects.get(uuid="93e564c5-adb3-4741-941f-718f76075f02")
+ return False
+
+ # @staticmethod
+ # def allowed_recurring_periods():
+ # return list(filter(
+ # lambda pair: pair[0] in [RecurringPeriod.PER_30D],
+ # RecurringPeriod.choices))
+
+ @property
+ def one_time_price(self):
+ return 30
+
+class GenericServiceProduct(models.Model):
+ custom_description = models.TextField()
+ custom_recurring_price = models.DecimalField(default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS,
+ validators=[MinValueValidator(0)])
+ custom_one_time_price = models.DecimalField(default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS,
+ validators=[MinValueValidator(0)])
+
+ @property
+ def recurring_price(self):
+ # FIXME: handle recurring_period somehow.
+ return self.custom_recurring_price
+
+ @property
+ def description(self):
+ return self.custom_description
+
+ @property
+ def one_time_price(self):
+ return self.custom_one_time_price
+
+ @staticmethod
+ def allowed_recurring_periods():
+ return RecurringPeriod.choices
diff --git a/uncloud_service/serializers.py b/uncloud_service/serializers.py
new file mode 100644
index 0000000..bc6d753
--- /dev/null
+++ b/uncloud_service/serializers.py
@@ -0,0 +1,60 @@
+from rest_framework import serializers
+from .models import *
+from uncloud_vm.serializers import ManagedVMProductSerializer
+from uncloud_vm.models import VMProduct
+from uncloud_pay.models import RecurringPeriod, BillingAddress
+
+# XXX: the OrderSomethingSomthingProductSerializer classes add a lot of
+# boilerplate: can we reduce it somehow?
+
+class MatrixServiceProductSerializer(serializers.ModelSerializer):
+ vm = ManagedVMProductSerializer()
+
+ class Meta:
+ model = MatrixServiceProduct
+ fields = ['order', 'owner', 'status', 'vm', 'domain',
+ 'recurring_period']
+ read_only_fields = ['order', 'owner', 'status']
+
+class OrderMatrixServiceProductSerializer(MatrixServiceProductSerializer):
+ # recurring_period = serializers.ChoiceField(
+ # choices=MatrixServiceProduct.allowed_recurring_periods())
+
+ def __init__(self, *args, **kwargs):
+ super(OrderMatrixServiceProductSerializer, self).__init__(*args, **kwargs)
+ self.fields['billing_address'] = serializers.ChoiceField(
+ choices=BillingAddress.get_addresses_for(
+ self.context['request'].user)
+ )
+
+ class Meta:
+ model = MatrixServiceProductSerializer.Meta.model
+ fields = MatrixServiceProductSerializer.Meta.fields + [
+ 'recurring_period', 'billing_address'
+ ]
+ read_only_fields = MatrixServiceProductSerializer.Meta.read_only_fields
+
+class GenericServiceProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = GenericServiceProduct
+ fields = ['order', 'owner', 'status', 'custom_recurring_price',
+ 'custom_description', 'custom_one_time_price']
+ read_only_fields = [ 'owner', 'status']
+
+class OrderGenericServiceProductSerializer(GenericServiceProductSerializer):
+ # recurring_period = serializers.ChoiceField(
+ # choices=GenericServiceProduct.allowed_recurring_periods())
+
+ def __init__(self, *args, **kwargs):
+ super(OrderGenericServiceProductSerializer, self).__init__(*args, **kwargs)
+ self.fields['billing_address'] = serializers.ChoiceField(
+ choices=BillingAddress.get_addresses_for(
+ self.context['request'].user)
+ )
+
+ class Meta:
+ model = GenericServiceProductSerializer.Meta.model
+ fields = GenericServiceProductSerializer.Meta.fields + [
+ 'recurring_period', 'billing_address'
+ ]
+ read_only_fields = GenericServiceProductSerializer.Meta.read_only_fields
diff --git a/uncloud/uncloud_net/tests.py b/uncloud_service/tests.py
similarity index 100%
rename from uncloud/uncloud_net/tests.py
rename to uncloud_service/tests.py
diff --git a/uncloud_service/views.py b/uncloud_service/views.py
new file mode 100644
index 0000000..abd4a05
--- /dev/null
+++ b/uncloud_service/views.py
@@ -0,0 +1,128 @@
+from rest_framework import viewsets, permissions
+from rest_framework.response import Response
+from django.db import transaction
+from django.utils import timezone
+
+from .models import *
+from .serializers import *
+
+from uncloud_pay.helpers import ProductViewSet
+from uncloud_pay.models import Order
+from uncloud_vm.models import VMProduct, VMDiskProduct
+
+def create_managed_vm(cores, ram, disk_size, image, order):
+ # Create VM
+ disk = VMDiskProduct(
+ owner=order.owner,
+ order=order,
+ size_in_gb=disk_size,
+ image=image)
+ vm = VMProduct(
+ name="Managed Service Host",
+ owner=order.owner,
+ cores=cores,
+ ram_in_gb=ram,
+ primary_disk=disk)
+ disk.vm = vm
+
+ vm.save()
+ disk.save()
+
+ return vm
+
+
+class MatrixServiceProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = MatrixServiceProductSerializer
+
+ def get_queryset(self):
+ return MatrixServiceProduct.objects.filter(owner=self.request.user)
+
+ def get_serializer_class(self):
+ if self.action == 'create':
+ return OrderMatrixServiceProductSerializer
+ else:
+ return MatrixServiceProductSerializer
+
+ @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")
+ order_billing_address = serializer.validated_data.pop("billing_address")
+
+ # Create base order.)
+ order = Order.objects.create(
+ recurring_period=order_recurring_period,
+ owner=request.user,
+ billing_address=order_billing_address,
+ starting_date=timezone.now()
+ )
+ order.save()
+
+ # Create unerderlying VM.
+ data = serializer.validated_data.pop('vm')
+ vm = create_managed_vm(
+ order=order,
+ cores=data['cores'],
+ ram=data['ram_in_gb'],
+ disk_size=data['primary_disk']['size_in_gb'],
+ image=MatrixServiceProduct.base_image())
+
+ # Create service.
+ service = serializer.save(
+ order=order,
+ owner=request.user,
+ vm=vm)
+
+ return Response(serializer.data)
+
+class GenericServiceProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return GenericServiceProduct.objects.filter(owner=self.request.user)
+
+ def get_serializer_class(self):
+ if self.action == 'create':
+ return OrderGenericServiceProductSerializer
+ else:
+ return GenericServiceProductSerializer
+
+ @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")
+ order_billing_address = serializer.validated_data.pop("billing_address")
+
+ # Create base order.
+ order = Order.objects.create(
+ recurring_period=order_recurring_period,
+ owner=request.user,
+ billing_address=order_billing_address,
+ starting_date=timezone.now()
+ )
+ order.save()
+
+ # Create service.
+ print(serializer.validated_data)
+ service = serializer.save(order=order, owner=request.user)
+
+ # XXX: Move this to some kind of on_create hook in parent
+ # Product class?
+ order.add_record(
+ service.one_time_price,
+ service.recurring_price,
+ service.description)
+
+ # XXX: Move this to some kind of on_create hook in parent
+ # Product class?
+ order.add_record(
+ service.one_time_price,
+ service.recurring_price,
+ service.description)
+
+ return Response(serializer.data)
diff --git a/uncloud_storage/__init__.py b/uncloud_storage/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud/uncloud_pay/admin.py b/uncloud_storage/admin.py
similarity index 100%
rename from uncloud/uncloud_pay/admin.py
rename to uncloud_storage/admin.py
diff --git a/uncloud/uncloud_storage/apps.py b/uncloud_storage/apps.py
similarity index 100%
rename from uncloud/uncloud_storage/apps.py
rename to uncloud_storage/apps.py
diff --git a/uncloud/uncloud_storage/models.py b/uncloud_storage/models.py
similarity index 100%
rename from uncloud/uncloud_storage/models.py
rename to uncloud_storage/models.py
diff --git a/uncloud/uncloud_pay/tests.py b/uncloud_storage/tests.py
similarity index 100%
rename from uncloud/uncloud_pay/tests.py
rename to uncloud_storage/tests.py
diff --git a/uncloud/uncloud_net/views.py b/uncloud_storage/views.py
similarity index 100%
rename from uncloud/uncloud_net/views.py
rename to uncloud_storage/views.py
diff --git a/uncloud_v3/.gitignore b/uncloud_v3/.gitignore
new file mode 100644
index 0000000..0330071
--- /dev/null
+++ b/uncloud_v3/.gitignore
@@ -0,0 +1,2 @@
+venv
+.env
diff --git a/uncloud_v3/Dockerfile b/uncloud_v3/Dockerfile
new file mode 100644
index 0000000..c953d76
--- /dev/null
+++ b/uncloud_v3/Dockerfile
@@ -0,0 +1,30 @@
+# Currently not working due do ldap:
+#
+# /usr/lib/gcc/x86_64-alpine-linux-musl/10.3.1/../../../../x86_64-alpine-linux-musl/bin/ld: cannot find -lldap_r
+#
+# While trying to install python-ldap
+
+FROM python:3.10.0-alpine3.15
+
+WORKDIR /usr/src/app
+
+# FIX https://github.com/python-ldap/python-ldap/issues/432
+RUN echo 'INPUT ( libldap.so )' > /usr/lib/libldap_r.so
+
+COPY requirements.txt ./
+# OS requirements for building wheel and for operating uncloud
+RUN apk update && apk add \
+ gcc \
+ git \
+ libxml2-dev \
+ libxslt-dev \
+ libffi-dev \
+ openldap-dev \
+ postgresql-dev \
+ python3-dev \
+ musl-dev \
+ nginx \
+ wireguard-tools-wg
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
diff --git a/uncloud_v3/Makefile b/uncloud_v3/Makefile
new file mode 100644
index 0000000..cadb844
--- /dev/null
+++ b/uncloud_v3/Makefile
@@ -0,0 +1,10 @@
+all: requirements
+
+run: requirements
+ . ./env && python manage.py runserver
+
+requirements: venv
+ . ./venv/bin/activate && pip install -r requirements.txt
+
+venv:
+ python3 -m venv venv
diff --git a/uncloud_v3/README.md b/uncloud_v3/README.md
new file mode 100644
index 0000000..1d53093
--- /dev/null
+++ b/uncloud_v3/README.md
@@ -0,0 +1,71 @@
+## Kubernetes integration
+
+### Development / Minikube
+
+To setup a development environment, start minikube on your local
+machine. Use `kubectl get nodes` to verify minikube is up and running.
+
+## Settings
+
+### Environment variables / Settings / Environment
+
+* `SECRET_KEY`
+* `DEBUG`
+* `DATABASE`
+
+## Versions
+
+#### Future (unplanned)
+
+* When/where to add timeframe constraints
+* Timeframe slug-or-id
+ * Maybe slug and backlink to avail products
+* Timeframe in product
+ * Should a product define list of time frames AND resources?
+ * Then can do autoselect on and only show
+ complete ones
+ * resources are also timeframe bound
+ * name != unique (?)
+ * how do we link?
+ * stays towards resource
+ * we only show resources which have price_per_time with one of
+ our timeframes
+ * And we filter out timeframes that don't have all resources
+ * Can we filter drop down in admin?
+ * yes: ModelAdmin.formfield_for_manytomany(db_field, request, **kwargs)¶
+* resources should have a slug
+ * can be used as an identifier and non unique names
+
+#### 3.1 (validation release, planned)
+
+* Ensure that one resource cannot have multiple price_per_timeframe of
+ the same timeframe
+
+
+#### 3.0.2 (planned)
+
+* Add basic validation to ordering
+
+#### 3.0.1 (planned)
+
+* Show products [done]
+* Link to ProductOrderForm [done]
+* Find suitable timeframes for a product [done]
+* Continue to resources / add resources
+ * Need to list resources [done]
+ * Need to create manytomany relations for each resource resoluting
+ in ResourceOrders1
+ * Need to pass in the price for the selected timeframe [done]
+ * On submit
+ * Create ProductOrder
+ * Create ResourceOrder(s)
+
+#### 3.0.0 (2022-01-14)
+
+* Introduce ProductOrderView
+
+
+## Pre-Production requirements
+
+* Products need to ensure *all* resources are consistent for different timeframes
+* Products cannot have same resource linked twice in same timeframe
diff --git a/uncloud_v3/app/__init__.py b/uncloud_v3/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_v3/app/admin.py b/uncloud_v3/app/admin.py
new file mode 100644
index 0000000..27ac5b2
--- /dev/null
+++ b/uncloud_v3/app/admin.py
@@ -0,0 +1,17 @@
+from django.contrib import admin
+
+from .models import *
+
+
+for m in [
+ Currency,
+ Order,
+ OneTimePrice,
+ PricePerTime,
+ Product,
+ ProductOrder,
+ Resource,
+ ResourceOrder,
+ TimeFrame,
+]:
+ admin.site.register(m)
diff --git a/uncloud_v3/app/apps.py b/uncloud_v3/app/apps.py
new file mode 100644
index 0000000..ed327d2
--- /dev/null
+++ b/uncloud_v3/app/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class AppConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'app'
diff --git a/uncloud_v3/app/forms.py b/uncloud_v3/app/forms.py
new file mode 100644
index 0000000..af42715
--- /dev/null
+++ b/uncloud_v3/app/forms.py
@@ -0,0 +1,27 @@
+from django import forms
+
+class ProductOneTimeOrderForm(forms.Form):
+ """
+ For products that only contain onetimeresoures
+ """
+
+ product = forms.SlugField(required=True, disabled=True)
+
+ def __init__(self, resources, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ for res in resources:
+ print(res)
+ field_name = f"{res.slug}"
+ self.fields[field_name] = forms.FloatField(required=True, label=res.name)
+
+ def clean(self):
+ cleaned_data = super().clean()
+ print("Cleaning form myself ...")
+
+
+class ProductOrderForm(ProductOneTimeOrderForm):
+ """
+ For recurring products (might also have OneTime items
+ """
+
+ timeframe = forms.SlugField(required=False, disabled=True)
diff --git a/uncloud_v3/app/management/commands/test-data.py b/uncloud_v3/app/management/commands/test-data.py
new file mode 100644
index 0000000..7e01b28
--- /dev/null
+++ b/uncloud_v3/app/management/commands/test-data.py
@@ -0,0 +1,75 @@
+from django.core.management.base import BaseCommand
+from app.models import *
+
+
+class Command(BaseCommand):
+ help = 'Add test data'
+
+# def add_arguments(self, parser):
+ #parser.add_argument('--username', type=str, required=True)
+
+ def handle(self, *args, **options):
+ currency, created = Currency.objects.get_or_create(defaults=
+ {
+ "slug": "CHF",
+ "name": "Swiss Franc",
+ "short_name": "CHF"
+ })
+ for timeframe in [ (3600, "1 hour", "1-hour"),
+ (86400, "1 day", "1-day"),
+ (7*86400, "7 days", "7-days"),
+ (30*86400, "30 days", "30-days"),
+ (365*86400, "365 days", "365 days") ]:
+ TimeFrame.objects.get_or_create(slug=timeframe[2],
+ defaults=
+ {
+ "name": timeframe[1],
+ "seconds": timeframe[0]
+ })
+
+ for ppt in [
+ ("1-day", 1, currency),
+ ("1-day", 2, currency),
+ ("30-days", 10, currency), # Nextcloud
+ ("30-days", 15, currency), # Gitea
+ ("30-days", 35, currency), # Matrix
+ ("30-days", 29, currency),
+ ("30-days", 55, currency) ]:
+ tf = TimeFrame.objects.get(slug=ppt[0])
+
+ PricePerTime.objects.get_or_create(timeframe=tf,
+ value=ppt[1],
+ defaults=
+ {
+ "currency": currency
+ })
+
+ for res in [
+ ("cpu-1", "CPU", "Core(s)", None, None),
+ ("cpu-min-max", "CPU", "Core(s)", 1, 20),
+ ("ram-1", "RAM", "GB", None, None),
+ ("ram-min-max", "RAM", "GB", 1, 200),
+ ("matrix-maintenance", "Matrix Maintenance Fee", "", 1, 1),
+ ("nextcloud-maintenance", "Nextcloud Maintenance Fee", "", 1, 1),
+ ("gitea-maintenance", "Gitea Maintenance Fee", "", 1, 1),
+
+ ]:
+ Resource.objects.get_or_create(slug=res[0],
+ defaults=
+ {
+ "name": res[1],
+ "unit": res[2],
+ "minimum_units": res[3],
+ "maximum_units": res[4]
+ })
+ # Link to PPT -- later
+ # for ppt_res in res[5]:
+ # ppt = PricePerTime.objects.get(
+
+
+ for product in [
+ ("matrix", "Matrix"),
+ ("nextcloud", "Nextcloud"),
+ ("gitea", "Gitea") ]:
+ Product.objects.get_or_create(slug=product[0],
+ defaults = { "name": product[1] })
diff --git a/uncloud_v3/app/migrations/0001_initial.py b/uncloud_v3/app/migrations/0001_initial.py
new file mode 100644
index 0000000..3302d90
--- /dev/null
+++ b/uncloud_v3/app/migrations/0001_initial.py
@@ -0,0 +1,115 @@
+# Generated by Django 4.0 on 2022-01-16 16:44
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('uauth', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Currency',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('slug', models.SlugField(null=True, unique=True)),
+ ('name', models.CharField(max_length=128, unique=True)),
+ ('short_name', models.CharField(max_length=3, unique=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='PricePerTime',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('value', models.FloatField()),
+ ('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.currency')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Product',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('slug', models.SlugField(null=True, unique=True)),
+ ('name', models.CharField(max_length=128, unique=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Resource',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('slug', models.SlugField(null=True, unique=True)),
+ ('name', models.CharField(max_length=128)),
+ ('unit', models.CharField(max_length=128)),
+ ('minimum_units', models.FloatField(blank=True, null=True)),
+ ('maximum_units', models.FloatField(blank=True, null=True)),
+ ('step_size', models.FloatField(default=1)),
+ ('price_per_time', models.ManyToManyField(blank=True, to='app.PricePerTime')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='TimeFrame',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('slug', models.SlugField(null=True, unique=True)),
+ ('name', models.CharField(max_length=128, unique=True)),
+ ('seconds', models.IntegerField(blank=True, null=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ResourceOrder',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('value', models.FloatField()),
+ ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.resource')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ProductOrder',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.product')),
+ ('resources', models.ManyToManyField(to='app.ResourceOrder')),
+ ('timeframe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.timeframe')),
+ ],
+ ),
+ migrations.AddField(
+ model_name='product',
+ name='resources',
+ field=models.ManyToManyField(blank=True, to='app.Resource'),
+ ),
+ migrations.AddField(
+ model_name='product',
+ name='timeframes',
+ field=models.ManyToManyField(blank=True, to='app.TimeFrame'),
+ ),
+ migrations.AddField(
+ model_name='pricepertime',
+ name='timeframe',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.timeframe'),
+ ),
+ migrations.CreateModel(
+ name='Order',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('creation_date', models.DateTimeField(auto_now_add=True)),
+ ('starting_date', models.DateTimeField(default=django.utils.timezone.now)),
+ ('ending_date', models.DateTimeField(blank=True, null=True)),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='uauth.user')),
+ ('product', models.ManyToManyField(blank=True, to='app.ProductOrder')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='OneTimePrice',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('value', models.FloatField()),
+ ('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.currency')),
+ ],
+ ),
+ ]
diff --git a/uncloud_v3/app/migrations/0002_resource_onetime_price.py b/uncloud_v3/app/migrations/0002_resource_onetime_price.py
new file mode 100644
index 0000000..5350a89
--- /dev/null
+++ b/uncloud_v3/app/migrations/0002_resource_onetime_price.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.0 on 2022-01-30 09:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('app', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='resource',
+ name='onetime_price',
+ field=models.ManyToManyField(blank=True, to='app.OneTimePrice'),
+ ),
+ ]
diff --git a/uncloud_v3/app/migrations/0003_remove_resource_onetime_price_resource_onetime_price.py b/uncloud_v3/app/migrations/0003_remove_resource_onetime_price_resource_onetime_price.py
new file mode 100644
index 0000000..ccdca59
--- /dev/null
+++ b/uncloud_v3/app/migrations/0003_remove_resource_onetime_price_resource_onetime_price.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.0 on 2022-01-30 10:06
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('app', '0002_resource_onetime_price'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='resource',
+ name='onetime_price',
+ ),
+ migrations.AddField(
+ model_name='resource',
+ name='onetime_price',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='app.onetimeprice'),
+ ),
+ ]
diff --git a/uncloud_v3/app/migrations/0004_alter_onetimeprice_options_and_more.py b/uncloud_v3/app/migrations/0004_alter_onetimeprice_options_and_more.py
new file mode 100644
index 0000000..7efe960
--- /dev/null
+++ b/uncloud_v3/app/migrations/0004_alter_onetimeprice_options_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.0 on 2022-01-30 10:39
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('app', '0003_remove_resource_onetime_price_resource_onetime_price'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='onetimeprice',
+ options={'ordering': ('value',)},
+ ),
+ migrations.AlterField(
+ model_name='productorder',
+ name='timeframe',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='app.timeframe'),
+ ),
+ ]
diff --git a/uncloud_v3/app/migrations/__init__.py b/uncloud_v3/app/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_v3/app/models.py b/uncloud_v3/app/models.py
new file mode 100644
index 0000000..3ae8a56
--- /dev/null
+++ b/uncloud_v3/app/models.py
@@ -0,0 +1,173 @@
+from django.db import models
+from django.contrib.auth import get_user_model
+from django.utils import timezone
+from django.urls import reverse
+from django.db.models import Q
+
+class Currency(models.Model):
+ slug = models.SlugField(null=True, unique=True)
+ name = models.CharField(max_length=128, unique=True)
+ short_name = models.CharField(max_length=3, unique=True)
+
+ def __str__(self):
+ return f"{self.name} ({self.short_name})"
+
+class TimeFrame(models.Model):
+ slug = models.SlugField(null=True, unique=True)
+ name = models.CharField(max_length=128, unique=True)
+ seconds = models.IntegerField(null=True, blank=True)
+
+ @staticmethod
+ def secs_to_name(secs):
+ name = ""
+ days = 0
+ hours = 0
+
+ if secs >= 24*3600:
+ days = secs // (24*3600)
+ secs -= (days*24*3600)
+
+ if secs >= 3600:
+ hours = secs // 3600
+ secs -= hours*3600
+
+ return f"{days} days {hours} hours {secs} seconds"
+
+ def __str__(self):
+ #return "{} ({})".format(self.name, self.secs_to_name(self.seconds))
+ return f"{self.name}"
+
+class OneTimePrice(models.Model):
+ value = models.FloatField()
+ currency = models.ForeignKey(Currency, on_delete=models.CASCADE)
+
+ class Meta:
+ ordering = ('value',)
+
+ def __str__(self):
+ return f"{self.value} {self.currency.short_name}"
+
+class PricePerTime(models.Model):
+ timeframe = models.ForeignKey(TimeFrame, on_delete=models.CASCADE)
+ value = models.FloatField()
+ currency = models.ForeignKey(Currency, on_delete=models.CASCADE)
+
+ def __str__(self):
+ return f"{self.value}{self.currency.short_name}/{self.timeframe}"
+
+class Resource(models.Model):
+ slug = models.SlugField(null=True, unique=True) # primary identifier
+ name = models.CharField(max_length=128, unique=False) # CPU, RAM
+ unit = models.CharField(max_length=128) # Count, GB
+ minimum_units = models.FloatField(null=True, blank=True) # might have min
+ maximum_units = models.FloatField(null=True, blank=True) # might have max
+ step_size = models.FloatField(default=1) # step size
+
+ price_per_time = models.ManyToManyField(PricePerTime, blank=True)
+ #onetime_price = models.ManyToManyField(OneTimePrice, blank=True)
+ onetime_price = models.ForeignKey(OneTimePrice, null=True, on_delete=models.CASCADE)
+
+ def __str__(self):
+ if self.minimum_units:
+ minimum = self.minimum_units
+ else:
+ minimum = "No minimum"
+ if self.maximum_units:
+ maximum = self.maximum_units
+ else:
+ maximum = "No maximum"
+
+ pricing = ", ".join([str(x) for x in self.price_per_time.all()])
+
+ #return f"{self.slug}: {minimum}-{maximum} (+/-){self.step_size} {self.unit} ({pricing})"
+ return f"{self.name} ({self.slug})"
+
+
+class Product(models.Model):
+ """
+ Describes a product a user can buy
+ """
+
+ slug = models.SlugField(null=True, unique=True)
+ name = models.CharField(max_length=128, unique=True)
+
+ resources = models.ManyToManyField(Resource, blank=True) # List of REQUIRED resources
+ timeframes = models.ManyToManyField(TimeFrame, blank=True) # List of POSSIBLE timeframes
+
+ def has_one_time_price(self):
+ has_otp = False
+
+ for res in self.resources.all():
+ if res.onetime_price:
+ has_otp = True
+ break
+
+ return has_otp
+
+ def valid_timeframes(self):
+ """
+ Return all timeframes that have all resources configured
+ """
+
+ valid_tf = []
+
+ num_res = self.resources.all().count()
+
+ for tf in self.timeframes.all():
+ # Get all distinct source for this timeframe
+ res = self.resources.filter(price_per_time__timeframe=tf).distinct().count()
+
+ if res == num_res:
+ valid_tf.append(tf)
+
+ return valid_tf
+
+ def get_absolute_url(self):
+ return reverse('product-detail', kwargs={'slug' : self.slug})
+
+ def __str__(self):
+ return self.name
+
+class ResourceOrder(models.Model):
+ """
+ Resources that have been ordered
+
+ We need to record the selected value *and* potentially the
+ calculated price
+
+ """
+ value = models.FloatField()
+ resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
+
+ def __str__(self):
+ return f"{self.value} x {self.resource}"
+
+
+class ProductOrder(models.Model):
+ """
+ Describes a product a user bought
+ """
+ product = models.ForeignKey(Product, on_delete=models.CASCADE)
+ timeframe = models.ForeignKey(TimeFrame, null=True, blank=True, on_delete=models.CASCADE)
+ resources = models.ManyToManyField(ResourceOrder)
+
+ def __str__(self):
+ if self.timeframe:
+ txt = f"Order {self.id}: {self.product} for {self.timeframe}"
+ else:
+ txt = f"Order {self.id}: {self.product}"
+
+ return txt
+
+
+
+class Order(models.Model):
+ owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False)
+
+ creation_date = models.DateTimeField(auto_now_add=True)
+ starting_date = models.DateTimeField(default=timezone.now)
+ ending_date = models.DateTimeField(blank=True, null=True)
+
+ product = models.ManyToManyField(ProductOrder, blank=True)
+
+ #textconfigs = models.ManyToManyField(ResourceConfig)
diff --git a/uncloud_v3/app/services.py b/uncloud_v3/app/services.py
new file mode 100644
index 0000000..ce98c66
--- /dev/null
+++ b/uncloud_v3/app/services.py
@@ -0,0 +1,29 @@
+from django.shortcuts import get_object_or_404
+
+from .models import *
+
+def order_product(product, timeframe, formdata):
+ """
+ Order a product with given parameters
+ """
+
+ print(formdata)
+ po = ProductOrder(product=product, timeframe=timeframe)
+ po.save()
+
+ for res, value in formdata.items():
+ print(f"{res}={value}")
+
+ # skip fixed fields
+ if res == 'product' or res == 'timeframe':
+ continue
+
+ resource = get_object_or_404(Resource, slug=res)
+ ro = ResourceOrder.objects.create(value=value, resource=resource)
+ po.resources.add(ro)
+
+ # Ordering without a timeframe
+ # if not timeframe:
+ # product = models.ForeignKey(Product, on_delete=models.CASCADE)
+ # timeframe = models.ForeignKey(TimeFrame, null=True, on_delete=models.CASCADE)
+ # resources = models.ManyToManyField(ResourceOrder)
diff --git a/uncloud_v3/app/templates/app/index.html b/uncloud_v3/app/templates/app/index.html
new file mode 100644
index 0000000..2ada352
--- /dev/null
+++ b/uncloud_v3/app/templates/app/index.html
@@ -0,0 +1,12 @@
+Welcome to uncloud
+
+What is uncloud?
+
+At ungleich we have developed uncloud as an order system that allows
+you to manage all your resources.
+
+What can I do with uncloud?
+
+
diff --git a/uncloud_v3/app/templates/app/product_detail.html b/uncloud_v3/app/templates/app/product_detail.html
new file mode 100644
index 0000000..5fd672b
--- /dev/null
+++ b/uncloud_v3/app/templates/app/product_detail.html
@@ -0,0 +1,20 @@
+{{ object.name }}
+
+(description to be added here)
+
+
diff --git a/uncloud_v3/app/templates/app/product_list.html b/uncloud_v3/app/templates/app/product_list.html
new file mode 100644
index 0000000..b622c7f
--- /dev/null
+++ b/uncloud_v3/app/templates/app/product_list.html
@@ -0,0 +1,13 @@
+Select Product
+
+Product List
+
+ {% for product in object_list %}
+ {% if product.slug %}
+ -
+ {{ product.name }}
+
+ {% endif %}
+
+ {% endfor %}
+
diff --git a/uncloud_v3/app/templates/app/productorder_form.html b/uncloud_v3/app/templates/app/productorder_form.html
new file mode 100644
index 0000000..0235c78
--- /dev/null
+++ b/uncloud_v3/app/templates/app/productorder_form.html
@@ -0,0 +1,13 @@
+Order {{ product }}
+
+{% if timeframe %}
+Timeframe: {{ timeframe }}
+{% endif %}
+
+
diff --git a/uncloud/uncloud_storage/tests.py b/uncloud_v3/app/tests.py
similarity index 100%
rename from uncloud/uncloud_storage/tests.py
rename to uncloud_v3/app/tests.py
diff --git a/uncloud_v3/app/views.py b/uncloud_v3/app/views.py
new file mode 100644
index 0000000..0478578
--- /dev/null
+++ b/uncloud_v3/app/views.py
@@ -0,0 +1,98 @@
+from django.shortcuts import render, get_object_or_404
+from django.urls import reverse
+
+from django.views.generic.edit import CreateView, FormView
+from django.views.generic.base import TemplateView
+from django.views.generic.list import ListView
+from django.views.generic.detail import DetailView
+
+from .models import *
+from .forms import *
+from .services import *
+
+class ProductOneTimeOrderView(FormView):
+ form_class = ProductOneTimeOrderForm
+ template_name = 'app/productorder_form.html'
+
+ def get_success_url(self):
+ return "/"
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+
+ # Set the product so the form can retrieve the resources
+ product = get_object_or_404(Product, slug=self.kwargs['product'])
+ kwargs['resources'] = product.resources.all()
+ return kwargs
+
+ def get_initial(self):
+ """
+ Initial values for the form
+ """
+
+ initial = super().get_initial()
+
+ initial['product'] = self.kwargs['product']
+
+ if 'timeframe' in self.kwargs:
+ initial['timeframe'] = self.kwargs['timeframe']
+ return initial
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['product'] = get_object_or_404(Product, slug=self.kwargs['product'])
+ if 'timeframe' in context:
+ context['timeframe'] = get_object_or_404(TimeFrame, slug=self.kwargs['timeframe'])
+ return context
+
+ def form_valid(self, form):
+ product = get_object_or_404(Product, slug=form.cleaned_data['product'])
+
+ print("We got a valid form, let's create the order, listing fields:\n------")
+ for f in form.fields:
+ print(f)
+ print(form.cleaned_data)
+
+ if 'timeframe' in form.cleaned_data:
+ timeframe = get_object_or_404(TimeFrame, slug=form.cleaned_data['timeframe'])
+ else:
+ timeframe = None
+
+ order_product(product, timeframe, form.cleaned_data)
+
+ return super().form_valid(form)
+
+class ProductOrderView(ProductOneTimeOrderView):
+ form_class = ProductOrderForm
+
+class ProductDetailView(DetailView):
+ model = Product
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['productorder'] = reverse('product-order', kwargs={'product': self.object.slug })
+ context['timeframes'] = context['product'].valid_timeframes()
+ context['has_one_time_price'] = context['product'].has_one_time_price()
+ print(context)
+ return context
+
+
+class ProductListView(ListView):
+ model = Product
+
+
+class ProductSelectView(CreateView):
+ model = ProductOrder
+ fields = ['product' ]
+
+class IndexView(TemplateView):
+ template_name = "app/index.html"
+
+class Yearly(TemplateView):
+ template_name = "app/config_product.html"
+
+ def get_context_data(self, **kwargs):
+ context = super(Yearly, self).get_context_data(**kwargs)
+ context['current_year'] = self.current_year
+ context['current_month'] = self.current_month
+ return context
diff --git a/uncloud_v3/build.sh b/uncloud_v3/build.sh
new file mode 100755
index 0000000..1e7f492
--- /dev/null
+++ b/uncloud_v3/build.sh
@@ -0,0 +1,33 @@
+#!/bin/sh
+
+set -x
+
+name=uncloud:$(git describe)
+docker build -t ${name} .
+
+# check for args
+if [ $# -ge 1 ]; then
+ target=$1; shift
+else
+ target=""
+fi
+
+case "$target" in
+ dev)
+ url=harbor.default.svc.c2.k8s.ooo/ungleich-public
+ docker tag $name ${url}/${name}
+ docker push ${url}/${name}
+ ;;
+ prod)
+ url=harbor.ungleich.svc.p10.k8s.ooo/ungleich-public
+ docker tag $name ${url}/${name}
+ docker push ${url}/${name}
+ ;;
+ "")
+ echo "Not pushing anywhere"
+ ;;
+ *)
+ echo "Unknown argument $target, ignoring"
+ exit 1
+ ;;
+esac
diff --git a/uncloud_v3/helm/.helmignore b/uncloud_v3/helm/.helmignore
new file mode 100644
index 0000000..0e8a0eb
--- /dev/null
+++ b/uncloud_v3/helm/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/uncloud_v3/helm/Chart.yaml b/uncloud_v3/helm/Chart.yaml
new file mode 100644
index 0000000..a17805d
--- /dev/null
+++ b/uncloud_v3/helm/Chart.yaml
@@ -0,0 +1,24 @@
+apiVersion: v2
+name: django
+description: A Helm chart for Kubernetes
+
+# A chart can be either an 'application' or a 'library' chart.
+#
+# Application charts are a collection of templates that can be packaged into versioned archives
+# to be deployed.
+#
+# Library charts provide useful utilities or functions for the chart developer. They're included as
+# a dependency of application charts to inject those utilities and functions into the rendering
+# pipeline. Library charts do not define any templates and therefore cannot be deployed.
+type: application
+
+# This is the chart version. This version number should be incremented each time you make changes
+# to the chart and its templates, including the app version.
+# Versions are expected to follow Semantic Versioning (https://semver.org/)
+version: 0.1.0
+
+# This is the version number of the application being deployed. This version number should be
+# incremented each time you make changes to the application. Versions are not expected to
+# follow Semantic Versioning. They should reflect the version the application is using.
+# It is recommended to use it with quotes.
+appVersion: "1.16.0"
diff --git a/uncloud_v3/helm/templates/NOTES.txt b/uncloud_v3/helm/templates/NOTES.txt
new file mode 100644
index 0000000..140bcc6
--- /dev/null
+++ b/uncloud_v3/helm/templates/NOTES.txt
@@ -0,0 +1,22 @@
+1. Get the application URL by running these commands:
+{{- if .Values.ingress.enabled }}
+{{- range $host := .Values.ingress.hosts }}
+ {{- range .paths }}
+ http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
+ {{- end }}
+{{- end }}
+{{- else if contains "NodePort" .Values.service.type }}
+ export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "django.fullname" . }})
+ export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
+ echo http://$NODE_IP:$NODE_PORT
+{{- else if contains "LoadBalancer" .Values.service.type }}
+ NOTE: It may take a few minutes for the LoadBalancer IP to be available.
+ You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "django.fullname" . }}'
+ export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "django.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
+ echo http://$SERVICE_IP:{{ .Values.service.port }}
+{{- else if contains "ClusterIP" .Values.service.type }}
+ export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "django.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
+ export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
+ echo "Visit http://127.0.0.1:8080 to use your application"
+ kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
+{{- end }}
diff --git a/uncloud_v3/helm/templates/_helpers.tpl b/uncloud_v3/helm/templates/_helpers.tpl
new file mode 100644
index 0000000..bbc3dda
--- /dev/null
+++ b/uncloud_v3/helm/templates/_helpers.tpl
@@ -0,0 +1,62 @@
+{{/*
+Expand the name of the chart.
+*/}}
+{{- define "django.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
+If release name contains chart name it will be used as a full name.
+*/}}
+{{- define "django.fullname" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- $name := default .Chart.Name .Values.nameOverride }}
+{{- if contains $name .Release.Name }}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+{{- end }}
+
+{{/*
+Create chart name and version as used by the chart label.
+*/}}
+{{- define "django.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Common labels
+*/}}
+{{- define "django.labels" -}}
+helm.sh/chart: {{ include "django.chart" . }}
+{{ include "django.selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{/*
+Selector labels
+*/}}
+{{- define "django.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "django.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{/*
+Create the name of the service account to use
+*/}}
+{{- define "django.serviceAccountName" -}}
+{{- if .Values.serviceAccount.create }}
+{{- default (include "django.fullname" .) .Values.serviceAccount.name }}
+{{- else }}
+{{- default "default" .Values.serviceAccount.name }}
+{{- end }}
+{{- end }}
diff --git a/uncloud_v3/helm/templates/deployment.yaml b/uncloud_v3/helm/templates/deployment.yaml
new file mode 100644
index 0000000..951bc0d
--- /dev/null
+++ b/uncloud_v3/helm/templates/deployment.yaml
@@ -0,0 +1,61 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "django.fullname" . }}
+ labels:
+ {{- include "django.labels" . | nindent 4 }}
+spec:
+ {{- if not .Values.autoscaling.enabled }}
+ replicas: {{ .Values.replicaCount }}
+ {{- end }}
+ selector:
+ matchLabels:
+ {{- include "django.selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ {{- with .Values.podAnnotations }}
+ annotations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ labels:
+ {{- include "django.selectorLabels" . | nindent 8 }}
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ serviceAccountName: {{ include "django.serviceAccountName" . }}
+ securityContext:
+ {{- toYaml .Values.podSecurityContext | nindent 8 }}
+ containers:
+ - name: {{ .Chart.Name }}
+ securityContext:
+ {{- toYaml .Values.securityContext | nindent 12 }}
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ ports:
+ - name: http
+ containerPort: 80
+ protocol: TCP
+ livenessProbe:
+ httpGet:
+ path: /
+ port: http
+ readinessProbe:
+ httpGet:
+ path: /
+ port: http
+ resources:
+ {{- toYaml .Values.resources | nindent 12 }}
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
diff --git a/uncloud_v3/helm/templates/hpa.yaml b/uncloud_v3/helm/templates/hpa.yaml
new file mode 100644
index 0000000..7be290c
--- /dev/null
+++ b/uncloud_v3/helm/templates/hpa.yaml
@@ -0,0 +1,28 @@
+{{- if .Values.autoscaling.enabled }}
+apiVersion: autoscaling/v2beta1
+kind: HorizontalPodAutoscaler
+metadata:
+ name: {{ include "django.fullname" . }}
+ labels:
+ {{- include "django.labels" . | nindent 4 }}
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: {{ include "django.fullname" . }}
+ minReplicas: {{ .Values.autoscaling.minReplicas }}
+ maxReplicas: {{ .Values.autoscaling.maxReplicas }}
+ metrics:
+ {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
+ - type: Resource
+ resource:
+ name: cpu
+ targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
+ {{- end }}
+ {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
+ - type: Resource
+ resource:
+ name: memory
+ targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
+ {{- end }}
+{{- end }}
diff --git a/uncloud_v3/helm/templates/ingress.yaml b/uncloud_v3/helm/templates/ingress.yaml
new file mode 100644
index 0000000..5115351
--- /dev/null
+++ b/uncloud_v3/helm/templates/ingress.yaml
@@ -0,0 +1,61 @@
+{{- if .Values.ingress.enabled -}}
+{{- $fullName := include "django.fullname" . -}}
+{{- $svcPort := .Values.service.port -}}
+{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
+ {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
+ {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
+ {{- end }}
+{{- end }}
+{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
+apiVersion: networking.k8s.io/v1
+{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
+apiVersion: networking.k8s.io/v1beta1
+{{- else -}}
+apiVersion: extensions/v1beta1
+{{- end }}
+kind: Ingress
+metadata:
+ name: {{ $fullName }}
+ labels:
+ {{- include "django.labels" . | nindent 4 }}
+ {{- with .Values.ingress.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
+ ingressClassName: {{ .Values.ingress.className }}
+ {{- end }}
+ {{- if .Values.ingress.tls }}
+ tls:
+ {{- range .Values.ingress.tls }}
+ - hosts:
+ {{- range .hosts }}
+ - {{ . | quote }}
+ {{- end }}
+ secretName: {{ .secretName }}
+ {{- end }}
+ {{- end }}
+ rules:
+ {{- range .Values.ingress.hosts }}
+ - host: {{ .host | quote }}
+ http:
+ paths:
+ {{- range .paths }}
+ - path: {{ .path }}
+ {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
+ pathType: {{ .pathType }}
+ {{- end }}
+ backend:
+ {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
+ service:
+ name: {{ $fullName }}
+ port:
+ number: {{ $svcPort }}
+ {{- else }}
+ serviceName: {{ $fullName }}
+ servicePort: {{ $svcPort }}
+ {{- end }}
+ {{- end }}
+ {{- end }}
+{{- end }}
diff --git a/uncloud_v3/helm/templates/service.yaml b/uncloud_v3/helm/templates/service.yaml
new file mode 100644
index 0000000..4d94ef7
--- /dev/null
+++ b/uncloud_v3/helm/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "django.fullname" . }}
+ labels:
+ {{- include "django.labels" . | nindent 4 }}
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
+ selector:
+ {{- include "django.selectorLabels" . | nindent 4 }}
diff --git a/uncloud_v3/helm/templates/serviceaccount.yaml b/uncloud_v3/helm/templates/serviceaccount.yaml
new file mode 100644
index 0000000..c686c91
--- /dev/null
+++ b/uncloud_v3/helm/templates/serviceaccount.yaml
@@ -0,0 +1,12 @@
+{{- if .Values.serviceAccount.create -}}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ include "django.serviceAccountName" . }}
+ labels:
+ {{- include "django.labels" . | nindent 4 }}
+ {{- with .Values.serviceAccount.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+{{- end }}
diff --git a/uncloud_v3/helm/templates/tests/test-connection.yaml b/uncloud_v3/helm/templates/tests/test-connection.yaml
new file mode 100644
index 0000000..d3e8da4
--- /dev/null
+++ b/uncloud_v3/helm/templates/tests/test-connection.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: "{{ include "django.fullname" . }}-test-connection"
+ labels:
+ {{- include "django.labels" . | nindent 4 }}
+ annotations:
+ "helm.sh/hook": test
+spec:
+ containers:
+ - name: wget
+ image: busybox
+ command: ['wget']
+ args: ['{{ include "django.fullname" . }}:{{ .Values.service.port }}']
+ restartPolicy: Never
diff --git a/uncloud_v3/helm/values.yaml b/uncloud_v3/helm/values.yaml
new file mode 100644
index 0000000..52f442d
--- /dev/null
+++ b/uncloud_v3/helm/values.yaml
@@ -0,0 +1,82 @@
+# Default values for django.
+# This is a YAML-formatted file.
+# Declare variables to be passed into your templates.
+
+replicaCount: 1
+
+image:
+ repository: nginx
+ pullPolicy: IfNotPresent
+ # Overrides the image tag whose default is the chart appVersion.
+ tag: ""
+
+imagePullSecrets: []
+nameOverride: ""
+fullnameOverride: ""
+
+serviceAccount:
+ # Specifies whether a service account should be created
+ create: true
+ # Annotations to add to the service account
+ annotations: {}
+ # The name of the service account to use.
+ # If not set and create is true, a name is generated using the fullname template
+ name: ""
+
+podAnnotations: {}
+
+podSecurityContext: {}
+ # fsGroup: 2000
+
+securityContext: {}
+ # capabilities:
+ # drop:
+ # - ALL
+ # readOnlyRootFilesystem: true
+ # runAsNonRoot: true
+ # runAsUser: 1000
+
+service:
+ type: ClusterIP
+ port: 80
+
+ingress:
+ enabled: false
+ className: ""
+ annotations: {}
+ # kubernetes.io/ingress.class: nginx
+ # kubernetes.io/tls-acme: "true"
+ hosts:
+ - host: chart-example.local
+ paths:
+ - path: /
+ pathType: ImplementationSpecific
+ tls: []
+ # - secretName: chart-example-tls
+ # hosts:
+ # - chart-example.local
+
+resources: {}
+ # We usually recommend not to specify default resources and to leave this as a conscious
+ # choice for the user. This also increases chances charts run on environments with little
+ # resources, such as Minikube. If you do want to specify resources, uncomment the following
+ # lines, adjust them as necessary, and remove the curly braces after 'resources:'.
+ # limits:
+ # cpu: 100m
+ # memory: 128Mi
+ # requests:
+ # cpu: 100m
+ # memory: 128Mi
+
+autoscaling:
+ enabled: false
+ minReplicas: 1
+ maxReplicas: 100
+ targetCPUUtilizationPercentage: 80
+ # targetMemoryUtilizationPercentage: 80
+
+nodeSelector: {}
+
+tolerations: []
+
+affinity: {}
diff --git a/uncloud_v3/manage.py b/uncloud_v3/manage.py
new file mode 100755
index 0000000..8d5f066
--- /dev/null
+++ b/uncloud_v3/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings')
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/uncloud_v3/requirements.txt b/uncloud_v3/requirements.txt
new file mode 100644
index 0000000..673b4a9
--- /dev/null
+++ b/uncloud_v3/requirements.txt
@@ -0,0 +1,4 @@
+# Django basics
+Django==4.0
+djangorestframework
+django-auth-ldap
diff --git a/uncloud_v3/uauth/__init__.py b/uncloud_v3/uauth/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_v3/uauth/admin.py b/uncloud_v3/uauth/admin.py
new file mode 100644
index 0000000..222a7a2
--- /dev/null
+++ b/uncloud_v3/uauth/admin.py
@@ -0,0 +1,6 @@
+from django.contrib import admin
+
+from django.contrib.auth.admin import UserAdmin
+from .models import User
+
+admin.site.register(User, UserAdmin)
diff --git a/uncloud_v3/uauth/apps.py b/uncloud_v3/uauth/apps.py
new file mode 100644
index 0000000..ff1cccd
--- /dev/null
+++ b/uncloud_v3/uauth/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class UauthConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'uauth'
diff --git a/uncloud/uncloud_auth/migrations/0001_initial.py b/uncloud_v3/uauth/migrations/0001_initial.py
similarity index 90%
rename from uncloud/uncloud_auth/migrations/0001_initial.py
rename to uncloud_v3/uauth/migrations/0001_initial.py
index a1f8d00..8b25595 100644
--- a/uncloud/uncloud_auth/migrations/0001_initial.py
+++ b/uncloud_v3/uauth/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 3.0.3 on 2020-03-03 16:49
+# Generated by Django 4.0 on 2022-01-02 18:03
import django.contrib.auth.models
import django.contrib.auth.validators
@@ -11,19 +11,19 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
- ('auth', '0011_update_proxy_permissions'),
+ ('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
- ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
diff --git a/uncloud_v3/uauth/migrations/__init__.py b/uncloud_v3/uauth/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_v3/uauth/models.py b/uncloud_v3/uauth/models.py
new file mode 100644
index 0000000..10ade16
--- /dev/null
+++ b/uncloud_v3/uauth/models.py
@@ -0,0 +1,7 @@
+from django.db import models
+from django.contrib.auth.models import AbstractUser
+
+class User(AbstractUser):
+ """
+ Custom class base
+ """
diff --git a/uncloud/ungleich_service/tests.py b/uncloud_v3/uauth/tests.py
similarity index 100%
rename from uncloud/ungleich_service/tests.py
rename to uncloud_v3/uauth/tests.py
diff --git a/uncloud/uncloud_storage/views.py b/uncloud_v3/uauth/views.py
similarity index 100%
rename from uncloud/uncloud_storage/views.py
rename to uncloud_v3/uauth/views.py
diff --git a/uncloud_v3/uncloud/__init__.py b/uncloud_v3/uncloud/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_v3/uncloud/asgi.py b/uncloud_v3/uncloud/asgi.py
new file mode 100644
index 0000000..ced64a0
--- /dev/null
+++ b/uncloud_v3/uncloud/asgi.py
@@ -0,0 +1,16 @@
+"""
+ASGI config for uncloud project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings')
+
+application = get_asgi_application()
diff --git a/uncloud_v3/uncloud/settings.py b/uncloud_v3/uncloud/settings.py
new file mode 100644
index 0000000..bc77e62
--- /dev/null
+++ b/uncloud_v3/uncloud/settings.py
@@ -0,0 +1,127 @@
+"""
+Django settings for uncloud project.
+
+Generated by 'django-admin startproject' using Django 4.0.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.0/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/4.0/ref/settings/
+"""
+
+from pathlib import Path
+import os
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = os.environ['SECRET_KEY'] if 'SECRET_KEY' in os.environ else 'a bad secret key'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = ['*']
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ 'app',
+ 'uauth'
+]
+
+MIDDLEWARE = [
+ 'django.middleware.security.SecurityMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'uncloud.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.template.context_processors.debug',
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = 'uncloud.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': BASE_DIR / 'db.sqlite3',
+ }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+
+AUTH_USER_MODEL = 'uauth.User'
+
+# Internationalization
+# https://docs.djangoproject.com/en/4.0/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/4.0/howto/static-files/
+
+STATIC_URL = 'static/'
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
diff --git a/uncloud_v3/uncloud/urls.py b/uncloud_v3/uncloud/urls.py
new file mode 100644
index 0000000..33b46b8
--- /dev/null
+++ b/uncloud_v3/uncloud/urls.py
@@ -0,0 +1,30 @@
+"""uncloud URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/4.0/topics/http/urls/
+Examples:
+Function views
+ 1. Add an import: from my_app import views
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
+Class-based views
+ 1. Add an import: from other_app.views import Home
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
+Including another URLconf
+ 1. Import the include() function: from django.urls import include, path
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
+"""
+from django.contrib import admin
+from django.urls import path
+from app import views as appviews
+
+urlpatterns = [
+ path('admin/', admin.site.urls),
+ path('order', appviews.ProductSelectView.as_view()),
+ path('order//', appviews.ProductOrderView.as_view(), name='product-order'),
+ path('order/recurring///', appviews.ProductOrderView.as_view(), name='product-order-tf'),
+ path('order/onetime//', appviews.ProductOneTimeOrderView.as_view(), name='product-order-onetime'),
+ path('product/', appviews.ProductListView.as_view(), name='products'),
+ path('product//', appviews.ProductDetailView.as_view(), name='product-detail'),
+ path('', appviews.IndexView.as_view(), name='index'),
+
+]
diff --git a/uncloud_v3/uncloud/wsgi.py b/uncloud_v3/uncloud/wsgi.py
new file mode 100644
index 0000000..c443b14
--- /dev/null
+++ b/uncloud_v3/uncloud/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for uncloud project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings')
+
+application = get_wsgi_application()
diff --git a/uncloud_v4/__init__.py b/uncloud_v4/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_v4/admin.py b/uncloud_v4/admin.py
new file mode 100644
index 0000000..6f3bc50
--- /dev/null
+++ b/uncloud_v4/admin.py
@@ -0,0 +1,19 @@
+from django.contrib import admin
+
+# Register your models here.
+from uncloud_vm.models import *
+from uncloud_pay.models import Order
+
+class VMDiskInline(admin.TabularInline):
+ model = VMDiskProduct
+
+class OrderInline(admin.TabularInline):
+ model = Order
+
+class VMProductAdmin(admin.ModelAdmin):
+ inlines = [
+ VMDiskInline
+ ]
+
+admin.site.register(VMProduct, VMProductAdmin)
+admin.site.register(VMDiskProduct)
diff --git a/uncloud/uncloud_vm/apps.py b/uncloud_v4/apps.py
similarity index 100%
rename from uncloud/uncloud_vm/apps.py
rename to uncloud_v4/apps.py
diff --git a/uncloud_v4/management/commands/vm.py b/uncloud_v4/management/commands/vm.py
new file mode 100644
index 0000000..667c5ad
--- /dev/null
+++ b/uncloud_v4/management/commands/vm.py
@@ -0,0 +1,119 @@
+import json
+
+import uncloud.secrets as secrets
+
+from django.core.management.base import BaseCommand
+from django.contrib.auth import get_user_model
+
+from uncloud_vm.models import VMSnapshotProduct, VMProduct, VMHost
+from datetime import datetime
+
+class Command(BaseCommand):
+ help = 'Select VM Host for VMs'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--this-hostname', required=True)
+ parser.add_argument('--this-cluster', required=True)
+
+ parser.add_argument('--create-vm-snapshots', action='store_true')
+ parser.add_argument('--schedule-vms', action='store_true')
+ parser.add_argument('--start-vms', action='store_true')
+
+
+ def handle(self, *args, **options):
+ for cmd in [ 'create_vm_snapshots', 'schedule_vms', 'start_vms' ]:
+ if options[cmd]:
+ f = getattr(self, cmd)
+ f(args, options)
+
+ def schedule_vms(self, *args, **options):
+ for pending_vm in VMProduct.objects.filter(status='PENDING'):
+ cores_needed = pending_vm.cores
+ ram_needed = pending_vm.ram_in_gb
+
+ # Database filtering
+ possible_vmhosts = VMHost.objects.filter(physical_cores__gte=cores_needed)
+
+ # Logical filtering
+ possible_vmhosts = [ vmhost for vmhost in possible_vmhosts
+ if vmhost.available_cores >=cores_needed
+ and vmhost.available_ram_in_gb >= ram_needed ]
+
+ if not possible_vmhosts:
+ log.error("No suitable Host found - cannot schedule VM {}".format(pending_vm))
+ continue
+
+ vmhost = possible_vmhosts[0]
+ pending_vm.vmhost = vmhost
+ pending_vm.status = 'SCHEDULED'
+ pending_vm.save()
+
+ print("Scheduled VM {} on VMHOST {}".format(pending_vm, pending_vm.vmhost))
+
+ print(self)
+
+ def start_vms(self, *args, **options):
+ vmhost = VMHost.objects.get(hostname=options['this_hostname'])
+
+ if not vmhost:
+ raise Exception("No vmhost {} exists".format(options['vmhostname']))
+
+ # not active? done here
+ if not vmhost.status = 'ACTIVE':
+ return
+
+ vms_to_start = VMProduct.objects.filter(vmhost=vmhost,
+ status='SCHEDULED')
+ for vm in vms_to_start:
+ """ run qemu:
+ check if VM is not already active / qemu running
+ prepare / create the Qemu arguments
+ """
+ print("Starting VM {}".format(VM))
+
+ def check_vms(self, *args, **options):
+ """
+ Check if all VMs that are supposed to run are running
+ """
+
+ def modify_vms(self, *args, **options):
+ """
+ Check all VMs that are requested to be modified and restart them
+ """
+
+ def create_vm_snapshots(self, *args, **options):
+ this_cluster = VMCluster(option['this_cluster'])
+
+ for snapshot in VMSnapshotProduct.objects.filter(status='PENDING',
+ cluster=this_cluster):
+ if not snapshot.extra_data:
+ snapshot.extra_data = {}
+
+ # TODO: implement locking here
+ if 'creating_hostname' in snapshot.extra_data:
+ pass
+
+ snapshot.extra_data['creating_hostname'] = options['this_hostname']
+ snapshot.extra_data['creating_start'] = str(datetime.now())
+ snapshot.save()
+
+ # something on the line of:
+ # for disk im vm.disks:
+ # rbd snap create pool/image-name@snapshot name
+ # snapshot.extra_data['snapshots']
+ # register the snapshot names in extra_data (?)
+
+ print(snapshot)
+
+ def check_health(self, *args, **options):
+ pending_vms = VMProduct.objects.filter(status='PENDING')
+ vmhosts = VMHost.objects.filter(status='active')
+
+ # 1. Check that all active hosts reported back N seconds ago
+ # 2. Check that no VM is running on a dead host
+ # 3. Migrate VMs if necessary
+ # 4. Check that no VMs have been pending for longer than Y seconds
+
+ # If VM snapshots exist without a VM -> notify user (?)
+
+ print("Nothing is good, you should implement me")
diff --git a/uncloud/uncloud_vm/migrations/0001_initial.py b/uncloud_v4/migrations/0001_initial.py
similarity index 53%
rename from uncloud/uncloud_vm/migrations/0001_initial.py
rename to uncloud_v4/migrations/0001_initial.py
index f9f40d8..4ec089a 100644
--- a/uncloud/uncloud_vm/migrations/0001_initial.py
+++ b/uncloud_v4/migrations/0001_initial.py
@@ -1,9 +1,8 @@
-# Generated by Django 3.0.3 on 2020-03-05 10:34
+# Generated by Django 3.1 on 2020-12-13 10:38
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
-import uuid
class Migration(migrations.Migration):
@@ -11,80 +10,76 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
- ('uncloud_pay', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
+ migrations.CreateModel(
+ name='VMCluster',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('extra_data', models.JSONField(blank=True, editable=False, null=True)),
+ ('name', models.CharField(max_length=128, unique=True)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
migrations.CreateModel(
name='VMDiskImageProduct',
fields=[
- ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('extra_data', models.JSONField(blank=True, editable=False, null=True)),
('name', models.CharField(max_length=256)),
('is_os_image', models.BooleanField(default=False)),
- ('is_public', models.BooleanField(default=False)),
+ ('is_public', models.BooleanField(default=False, editable=False)),
('size_in_gb', models.FloatField(blank=True, null=True)),
('import_url', models.URLField(blank=True, null=True)),
('image_source', models.CharField(max_length=128, null=True)),
('image_source_type', models.CharField(max_length=128, null=True)),
- ('storage_class', models.CharField(choices=[('hdd', 'HDD'), ('ssd', 'SSD')], default='ssd', max_length=32)),
- ('status', models.CharField(choices=[('pending', 'Pending'), ('creating', 'Creating'), ('active', 'Active'), ('disabled', 'Disabled'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32)),
+ ('storage_class', models.CharField(choices=[('HDD', 'HDD'), ('SSD', 'SSD')], default='SSD', max_length=32)),
+ ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32)),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
+ options={
+ 'abstract': False,
+ },
),
migrations.CreateModel(
name='VMHost',
fields=[
- ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('extra_data', models.JSONField(blank=True, editable=False, null=True)),
('hostname', models.CharField(max_length=253, unique=True)),
('physical_cores', models.IntegerField(default=0)),
('usable_cores', models.IntegerField(default=0)),
('usable_ram_in_gb', models.FloatField(default=0)),
- ('status', models.CharField(choices=[('pending', 'Pending'), ('creating', 'Creating'), ('active', 'Active'), ('disabled', 'Disabled'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32)),
- ('vms', models.TextField(default='')),
+ ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32)),
+ ('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmcluster')),
],
+ options={
+ 'abstract': False,
+ },
),
migrations.CreateModel(
name='VMProduct',
fields=[
- ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
- ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted')], default='PENDING', max_length=32)),
- ('name', models.CharField(max_length=32)),
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(blank=True, max_length=32, null=True)),
('cores', models.IntegerField()),
('ram_in_gb', models.FloatField()),
- ('vmid', models.IntegerField(null=True)),
- ('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)),
- ('vmhost', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMHost')),
+ ('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmcluster')),
+ ('vmhost', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmhost')),
],
- options={
- 'abstract': False,
- },
- ),
- migrations.CreateModel(
- name='VMWithOSProduct',
- fields=[
- ('vmproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.VMProduct')),
- ],
- options={
- 'abstract': False,
- },
- bases=('uncloud_vm.vmproduct',),
),
migrations.CreateModel(
name='VMSnapshotProduct',
fields=[
- ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
- ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted')], default='PENDING', max_length=32)),
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('gb_ssd', models.FloatField(editable=False)),
('gb_hdd', models.FloatField(editable=False)),
- ('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')),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='uncloud_vm.vmproduct')),
],
- options={
- 'abstract': False,
- },
),
migrations.CreateModel(
name='VMNetworkCard',
@@ -92,17 +87,25 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mac_address', models.BigIntegerField()),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
- ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmproduct')),
],
),
migrations.CreateModel(
name='VMDiskProduct',
fields=[
- ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('size_in_gb', models.FloatField(blank=True)),
- ('image', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMDiskImageProduct')),
- ('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')),
+ ('disk_type', models.CharField(choices=[('ceph/ssd', 'Ceph Ssd'), ('ceph/hdd', 'Ceph Hdd'), ('local/ssd', 'Local Ssd'), ('local/hdd', 'Local Hdd')], default='ceph/ssd', max_length=20)),
+ ('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmdiskimageproduct')),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmproduct')),
],
),
+ migrations.CreateModel(
+ name='VMWithOSProduct',
+ fields=[
+ ('vmproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.vmproduct')),
+ ('primary_disk', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmdiskproduct')),
+ ],
+ bases=('uncloud_vm.vmproduct',),
+ ),
]
diff --git a/uncloud_v4/migrations/__init__.py b/uncloud_v4/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_v4/models.py b/uncloud_v4/models.py
new file mode 100644
index 0000000..b23e276
--- /dev/null
+++ b/uncloud_v4/models.py
@@ -0,0 +1,72 @@
+from django.db import models
+from django.contrib.auth import get_user_model
+
+# 1st mixin: owner alike?
+# 2nd: time frames
+
+
+class Nextcloudv1(models.Model):
+ name = models.CharField(max_length=128, unique=True)
+
+ owner = models.ForeignKey(
+ get_user_model(), on_delete=models.CASCADE, editable=False
+ )
+
+ domain = models.CharField(max_length=253)
+
+ cpu_cores = models.FloatField(null=True, blank=True)
+ ram_in_gb = models.FloatField(null=True, blank=True)
+ storage_in_gb = models.FloatField(null=True, blank=True)
+
+
+ def __str__(self):
+ return f"Nextcloud {domain}: {cores}C {ram_in_gb}GB RAM {storage} GB storage from {owner}"
+
+
+class ResourceConfig(models.Model):
+ """
+ Describes an actual setting of a resource
+ """
+ value = models.FloatField()
+ resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
+
+
+class ProductTextConfiguration(models.Model):
+ name = models.CharField(max_length=128, unique=True) # Domain, etc.
+
+class ProductTextFieldConfiguration(models.Model):
+ name = models.CharField(max_length=128, unique=True) # homeserver.Yaml, etc.
+
+class ProductTextConfig(models.Model):
+ """
+ Describes an actual setting of a resource
+ """
+ value = models.CharField(max_length=256)
+ config = models.ForeignKey(ProductTextConfiguration, on_delete=models.CASCADE)
+
+class ProductTextFieldConfig(models.Model):
+ """
+ Describes an actual setting of a resource
+ """
+ value = models.TextField()
+ config = models.ForeignKey(ProductTextFieldConfiguration, on_delete=models.CASCADE)
+
+
+ # 3600
+ # 86400
+ # 30*86400
+
+# class Nextcloudv2(Product):
+# domain = models.CharField(max_length=253)
+
+# def __str__(self):
+# return f"Nextcloud {domain} {owner}"
+
+# class ProductPricing(models.Model):
+# """
+# Defines the allowed time frames and the available resources
+# """
+
+# name = models.CharField(max_length=128, unique=True) # cpu 2022, ipv4 2022
+# timeframes = models.ManyToManyField(TimeFrame)
+# resources = models.ManyToManyField(Resource)
diff --git a/uncloud_v4/serializers.py b/uncloud_v4/serializers.py
new file mode 100644
index 0000000..a60d10b
--- /dev/null
+++ b/uncloud_v4/serializers.py
@@ -0,0 +1,143 @@
+from django.contrib.auth import get_user_model
+
+from rest_framework import serializers
+
+from .models import *
+from uncloud_pay.models import RecurringPeriod, BillingAddress
+
+# XXX: does not seem to be used?
+
+GB_SSD_PER_DAY=0.012
+GB_HDD_PER_DAY=0.0006
+
+GB_SSD_PER_DAY=0.012
+GB_HDD_PER_DAY=0.0006
+
+###
+# Admin views.
+
+class VMHostSerializer(serializers.HyperlinkedModelSerializer):
+ vms = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
+
+ class Meta:
+ model = VMHost
+ fields = '__all__'
+ read_only_fields = [ 'vms' ]
+
+class VMClusterSerializer(serializers.HyperlinkedModelSerializer):
+ class Meta:
+ model = VMCluster
+ fields = '__all__'
+
+
+###
+# Disks.
+
+class VMDiskProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskProduct
+ fields = '__all__'
+
+class CreateVMDiskProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskProduct
+ fields = ['size_in_gb', 'image']
+
+class CreateManagedVMDiskProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskProduct
+ fields = ['size_in_gb']
+
+class VMDiskImageProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskImageProduct
+ fields = '__all__'
+
+class VMSnapshotProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMSnapshotProduct
+ fields = '__all__'
+
+
+ # verify that vm.owner == user.request
+ def validate_vm(self, value):
+ if not value.owner == self.context['request'].user:
+ raise serializers.ValidationError("VM {} not found for owner {}.".format(value,
+ self.context['request'].user))
+ disks = VMDiskProduct.objects.filter(vm=value)
+
+ if len(disks) == 0:
+ raise serializers.ValidationError("VM {} does not have any disks, cannot snapshot".format(value.id))
+
+ return value
+
+ pricing = {}
+ pricing['per_gb_ssd'] = 0.012
+ pricing['per_gb_hdd'] = 0.0006
+ pricing['recurring_period'] = 'per_day'
+
+###
+# VMs
+
+# Helper used in uncloud_service for services allocating VM.
+class ManagedVMProductSerializer(serializers.ModelSerializer):
+ """
+ Managed VM serializer used in ungleich_service app.
+ """
+ primary_disk = CreateManagedVMDiskProductSerializer()
+ class Meta:
+ model = VMWithOSProduct
+ fields = [ 'cores', 'ram_in_gb', 'primary_disk']
+
+class VMProductSerializer(serializers.ModelSerializer):
+ primary_disk = CreateVMDiskProductSerializer()
+ snapshots = VMSnapshotProductSerializer(many=True, read_only=True)
+ disks = VMDiskProductSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = VMWithOSProduct
+ fields = ['order', 'owner', 'status', 'name', 'cores',
+ 'ram_in_gb', 'primary_disk', 'snapshots', 'disks', 'extra_data']
+ read_only_fields = ['order', 'owner', 'status']
+
+class OrderVMProductSerializer(VMProductSerializer):
+ # recurring_period = serializers.ChoiceField(
+ # choices=VMWithOSProduct.allowed_recurring_periods())
+
+ def __init__(self, *args, **kwargs):
+ super(VMProductSerializer, self).__init__(*args, **kwargs)
+
+ class Meta:
+ model = VMProductSerializer.Meta.model
+ fields = VMProductSerializer.Meta.fields + [ 'recurring_period' ]
+ read_only_fields = VMProductSerializer.Meta.read_only_fields
+
+# Nico's playground.
+class NicoVMProductSerializer(serializers.ModelSerializer):
+ snapshots = VMSnapshotProductSerializer(many=True, read_only=True)
+ order = serializers.StringRelatedField()
+
+ class Meta:
+ model = VMProduct
+ read_only_fields = ['order', 'owner', 'status',
+ 'vmhost', 'vmcluster', 'snapshots',
+ 'extra_data' ]
+ fields = read_only_fields + [ 'name',
+ 'cores',
+ 'ram_in_gb'
+ ]
+
+class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer):
+ """
+ Create an interface similar to standard DCL
+ """
+
+ # Custom field used at creation (= ordering) only.
+ # recurring_period = serializers.ChoiceField(
+ # choices=VMProduct.allowed_recurring_periods())
+
+ os_disk_uuid = serializers.UUIDField()
+ # os_disk_size =
+
+ class Meta:
+ model = VMProduct
diff --git a/uncloud_v4/tests.py b/uncloud_v4/tests.py
new file mode 100644
index 0000000..e5d403f
--- /dev/null
+++ b/uncloud_v4/tests.py
@@ -0,0 +1,98 @@
+import datetime
+
+import parsedatetime
+
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from django.utils import timezone
+from django.core.exceptions import ValidationError
+
+from uncloud_vm.models import VMDiskImageProduct, VMDiskProduct, VMProduct, VMHost
+from uncloud_pay.models import Order, RecurringPeriod
+
+User = get_user_model()
+cal = parsedatetime.Calendar()
+
+
+# If you want to check the test database using some GUI/cli tool
+# then use the following connecting parameters
+
+# host: localhost
+# database: test_uncloud
+# user: root
+# password:
+# port: 5432
+
+class VMTestCase(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ # Setup vm host
+ cls.vm_host, created = VMHost.objects.get_or_create(
+ hostname='serverx.placey.ungleich.ch', physical_cores=32, usable_cores=320,
+ usable_ram_in_gb=512.0, status='active'
+ )
+ super().setUpClass()
+
+ def setUp(self) -> None:
+ # Setup two users as it is common to test with different user
+ self.user = User.objects.create_user(
+ username='testuser', email='test@test.com', first_name='Test', last_name='User'
+ )
+ self.user2 = User.objects.create_user(
+ username='Meow', email='meow123@test.com', first_name='Meow', last_name='Cat'
+ )
+ super().setUp()
+
+ def create_sample_vm(self, owner):
+ one_month_later, parse_status = cal.parse("1 month later")
+ return VMProduct.objects.create(
+ vmhost=self.vm_host, cores=2, ram_in_gb=4, owner=owner,
+ order=Order.objects.create(
+ owner=owner,
+ creation_date=datetime.datetime.now(tz=timezone.utc),
+ starting_date=datetime.datetime.now(tz=timezone.utc),
+ ending_date=datetime.datetime(*one_month_later[:6], tzinfo=timezone.utc),
+ recurring_period=RecurringPeriod.PER_MONTH
+ )
+ )
+
+# TODO: the logic tested by this test is not implemented yet.
+# def test_disk_product(self):
+# """Ensures that a VMDiskProduct can only be created from a VMDiskImageProduct
+# that is in status 'active'"""
+#
+# vm = self.create_sample_vm(owner=self.user)
+#
+# pending_disk_image = VMDiskImageProduct.objects.create(
+# owner=self.user, name='pending_disk_image', is_os_image=True, is_public=True, size_in_gb=10,
+# status='pending'
+# )
+# try:
+# vm_disk_product = VMDiskProduct.objects.create(
+# owner=self.user, vm=vm, image=pending_disk_image, size_in_gb=10
+# )
+# except ValidationError:
+# vm_disk_product = None
+#
+# self.assertIsNone(
+# vm_disk_product,
+# msg='VMDiskProduct created with disk image whose status is not active.'
+# )
+
+# TODO: the logic tested by this test is not implemented yet.
+# def test_vm_disk_product_creation_for_someone_else(self):
+# """Ensure that a user can only create a VMDiskProduct for his/her own VM"""
+#
+# # Create a VM which is ownership of self.user2
+# someone_else_vm = self.create_sample_vm(owner=self.user2)
+#
+# # 'self.user' would try to create a VMDiskProduct for 'user2's VM
+# with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for someone else VM.'):
+# vm_disk_product = VMDiskProduct.objects.create(
+# owner=self.user, vm=someone_else_vm,
+# size_in_gb=10,
+# image=VMDiskImageProduct.objects.create(
+# owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10,
+# status='active'
+# )
+# )
diff --git a/uncloud/uncloud_vm/views.py b/uncloud_v4/views.py
similarity index 68%
rename from uncloud/uncloud_vm/views.py
rename to uncloud_v4/views.py
index 7b5fa4f..67f8656 100644
--- a/uncloud/uncloud_vm/views.py
+++ b/uncloud_v4/views.py
@@ -1,5 +1,6 @@
from django.db import transaction
from django.shortcuts import render
+from django.utils import timezone
from django.contrib.auth.models import User
from django.shortcuts import get_object_or_404
@@ -8,23 +9,18 @@ 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 .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct, VMCluster
+from uncloud_pay.models import Order, BillingAddress
-from .serializers import (VMHostSerializer, VMProductSerializer,
- VMSnapshotProductSerializer, VMDiskImageProductSerializer,
- VMDiskProductSerializer, DCLVMProductSerializer)
+from .serializers import *
from uncloud_pay.helpers import ProductViewSet
-
import datetime
-class VMHostViewSet(viewsets.ModelViewSet):
- serializer_class = VMHostSerializer
- queryset = VMHost.objects.all()
- permission_classes = [permissions.IsAdminUser]
+###
+# Generic disk image views. Do not require orders / billing.
-class VMDiskImageProductMineViewSet(viewsets.ModelViewSet):
+class VMDiskImageProductViewSet(ProductViewSet):
permission_classes = [permissions.IsAuthenticated]
serializer_class = VMDiskImageProductSerializer
@@ -32,7 +28,7 @@ class VMDiskImageProductMineViewSet(viewsets.ModelViewSet):
if self.request.user.is_superuser:
obj = VMDiskImageProduct.objects.all()
else:
- obj = VMDiskImageProduct.objects.filter(owner=self.request.user)
+ obj = VMDiskImageProduct.objects.filter(owner=self.request.user) | VMDiskImageProduct.objects.filter(is_public=True)
return obj
@@ -49,7 +45,6 @@ class VMDiskImageProductMineViewSet(viewsets.ModelViewSet):
serializer.save(owner=request.user)
return Response(serializer.data)
-
class VMDiskImageProductPublicViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [permissions.IsAuthenticated]
serializer_class = VMDiskImageProductSerializer
@@ -57,6 +52,9 @@ class VMDiskImageProductPublicViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
return VMDiskImageProduct.objects.filter(is_public=True)
+###
+# User VM disk and snapshots.
+
class VMDiskProductViewSet(viewsets.ModelViewSet):
"""
Let a user modify their own VMDisks
@@ -65,7 +63,12 @@ class VMDiskProductViewSet(viewsets.ModelViewSet):
serializer_class = VMDiskProductSerializer
def get_queryset(self):
- return VMDiskProduct.objects.filter(owner=self.request.user)
+ if self.request.user.is_superuser:
+ obj = VMDiskProduct.objects.all()
+ else:
+ obj = VMDiskProduct.objects.filter(owner=self.request.user)
+
+ return obj
def create(self, request):
serializer = VMDiskProductSerializer(data=request.data, context={'request': request})
@@ -80,57 +83,20 @@ class VMDiskProductViewSet(viewsets.ModelViewSet):
if size_in_gb < serializer.validated_data['image'].size_in_gb:
raise ValidationError(detail={ 'error_mesage': 'Size is smaller than original image' })
-
serializer.save(owner=request.user, size_in_gb=size_in_gb)
return Response(serializer.data)
-
-
-class VMProductViewSet(ProductViewSet):
- permission_classes = [permissions.IsAuthenticated]
- serializer_class = VMProductSerializer
-
- def get_queryset(self):
- if self.request.user.is_superuser:
- obj = VMProduct.objects.all()
- else:
- obj = VMProduct.objects.filter(owner=self.request.user)
-
- return obj
-
- # 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)
- 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 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.one_time_price,
- vm.recurring_price(order.recurring_period), vm.description)
-
- 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)
+ if self.request.user.is_superuser:
+ obj = VMSnapshotProduct.objects.all()
+ else:
+ obj = VMSnapshotProduct.objects.filter(owner=self.request.user)
+
+ return obj
def create(self, request):
serializer = VMSnapshotProductSerializer(data=request.data, context={'request': request})
@@ -162,7 +128,99 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
+###
+# User VMs.
+class VMProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMWithOSProduct.objects.all()
+ else:
+ obj = VMWithOSProduct.objects.filter(owner=self.request.user)
+
+ return obj
+
+ def get_serializer_class(self):
+ if self.action == 'create':
+ return OrderVMProductSerializer
+ else:
+ return VMProductSerializer
+
+ # 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 = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ order_recurring_period = serializer.validated_data.pop("recurring_period")
+
+ # Create disk image.
+ disk = VMDiskProduct(owner=request.user,
+ **serializer.validated_data.pop("primary_disk"))
+ vm = VMWithOSProduct(owner=request.user, primary_disk=disk,
+ **serializer.validated_data)
+ disk.vm = vm # XXX: Is this really needed?
+
+ # Create VM and Disk orders.
+ vm_order = Order.from_product(
+ vm,
+ recurring_period=order_recurring_period,
+ starting_date=timezone.now()
+ )
+
+ disk_order = Order.from_product(
+ disk,
+ recurring_period=order_recurring_period,
+ starting_date=timezone.now(),
+ depends_on=vm_order
+ )
+
+
+ # Commit to DB.
+ vm.order = vm_order
+ vm.save()
+ vm_order.save()
+
+ disk.order = disk_order
+ disk_order.save()
+ disk.save()
+
+ return Response(VMProductSerializer(vm, context={'request': request}).data)
+
+class NicoVMProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = NicoVMProductSerializer
+
+ def get_queryset(self):
+ obj = VMProduct.objects.filter(owner=self.request.user)
+ return obj
+
+ def create(self, request):
+ serializer = self.serializer_class(data=request.data, context={'request': request})
+ serializer.is_valid(raise_exception=True)
+ vm = serializer.save(owner=request.user)
+
+ return Response(serializer.data)
+
+
+###
+# Admin stuff.
+
+class VMHostViewSet(viewsets.ModelViewSet):
+ serializer_class = VMHostSerializer
+ queryset = VMHost.objects.all()
+ permission_classes = [permissions.IsAdminUser]
+
+class VMClusterViewSet(viewsets.ModelViewSet):
+ serializer_class = VMClusterSerializer
+ queryset = VMCluster.objects.all()
+ permission_classes = [permissions.IsAdminUser]
+
+##
+# Nico's playground.
# Also create:
# - /dcl/available_os
@@ -200,9 +258,4 @@ class DCLCreateVMProductViewSet(ProductViewSet):
# 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.one_time_price,
- vm.recurring_price(order.recurring_period), vm.description)
-
return Response(serializer.data)
diff --git a/uncloud_vm/__init__.py b/uncloud_vm/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_vm/admin.py b/uncloud_vm/admin.py
new file mode 100644
index 0000000..6f3bc50
--- /dev/null
+++ b/uncloud_vm/admin.py
@@ -0,0 +1,19 @@
+from django.contrib import admin
+
+# Register your models here.
+from uncloud_vm.models import *
+from uncloud_pay.models import Order
+
+class VMDiskInline(admin.TabularInline):
+ model = VMDiskProduct
+
+class OrderInline(admin.TabularInline):
+ model = Order
+
+class VMProductAdmin(admin.ModelAdmin):
+ inlines = [
+ VMDiskInline
+ ]
+
+admin.site.register(VMProduct, VMProductAdmin)
+admin.site.register(VMDiskProduct)
diff --git a/uncloud_vm/apps.py b/uncloud_vm/apps.py
new file mode 100644
index 0000000..c5e94a5
--- /dev/null
+++ b/uncloud_vm/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class UncloudVmConfig(AppConfig):
+ name = 'uncloud_vm'
diff --git a/uncloud_vm/management/commands/vm.py b/uncloud_vm/management/commands/vm.py
new file mode 100644
index 0000000..667c5ad
--- /dev/null
+++ b/uncloud_vm/management/commands/vm.py
@@ -0,0 +1,119 @@
+import json
+
+import uncloud.secrets as secrets
+
+from django.core.management.base import BaseCommand
+from django.contrib.auth import get_user_model
+
+from uncloud_vm.models import VMSnapshotProduct, VMProduct, VMHost
+from datetime import datetime
+
+class Command(BaseCommand):
+ help = 'Select VM Host for VMs'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--this-hostname', required=True)
+ parser.add_argument('--this-cluster', required=True)
+
+ parser.add_argument('--create-vm-snapshots', action='store_true')
+ parser.add_argument('--schedule-vms', action='store_true')
+ parser.add_argument('--start-vms', action='store_true')
+
+
+ def handle(self, *args, **options):
+ for cmd in [ 'create_vm_snapshots', 'schedule_vms', 'start_vms' ]:
+ if options[cmd]:
+ f = getattr(self, cmd)
+ f(args, options)
+
+ def schedule_vms(self, *args, **options):
+ for pending_vm in VMProduct.objects.filter(status='PENDING'):
+ cores_needed = pending_vm.cores
+ ram_needed = pending_vm.ram_in_gb
+
+ # Database filtering
+ possible_vmhosts = VMHost.objects.filter(physical_cores__gte=cores_needed)
+
+ # Logical filtering
+ possible_vmhosts = [ vmhost for vmhost in possible_vmhosts
+ if vmhost.available_cores >=cores_needed
+ and vmhost.available_ram_in_gb >= ram_needed ]
+
+ if not possible_vmhosts:
+ log.error("No suitable Host found - cannot schedule VM {}".format(pending_vm))
+ continue
+
+ vmhost = possible_vmhosts[0]
+ pending_vm.vmhost = vmhost
+ pending_vm.status = 'SCHEDULED'
+ pending_vm.save()
+
+ print("Scheduled VM {} on VMHOST {}".format(pending_vm, pending_vm.vmhost))
+
+ print(self)
+
+ def start_vms(self, *args, **options):
+ vmhost = VMHost.objects.get(hostname=options['this_hostname'])
+
+ if not vmhost:
+ raise Exception("No vmhost {} exists".format(options['vmhostname']))
+
+ # not active? done here
+ if not vmhost.status = 'ACTIVE':
+ return
+
+ vms_to_start = VMProduct.objects.filter(vmhost=vmhost,
+ status='SCHEDULED')
+ for vm in vms_to_start:
+ """ run qemu:
+ check if VM is not already active / qemu running
+ prepare / create the Qemu arguments
+ """
+ print("Starting VM {}".format(VM))
+
+ def check_vms(self, *args, **options):
+ """
+ Check if all VMs that are supposed to run are running
+ """
+
+ def modify_vms(self, *args, **options):
+ """
+ Check all VMs that are requested to be modified and restart them
+ """
+
+ def create_vm_snapshots(self, *args, **options):
+ this_cluster = VMCluster(option['this_cluster'])
+
+ for snapshot in VMSnapshotProduct.objects.filter(status='PENDING',
+ cluster=this_cluster):
+ if not snapshot.extra_data:
+ snapshot.extra_data = {}
+
+ # TODO: implement locking here
+ if 'creating_hostname' in snapshot.extra_data:
+ pass
+
+ snapshot.extra_data['creating_hostname'] = options['this_hostname']
+ snapshot.extra_data['creating_start'] = str(datetime.now())
+ snapshot.save()
+
+ # something on the line of:
+ # for disk im vm.disks:
+ # rbd snap create pool/image-name@snapshot name
+ # snapshot.extra_data['snapshots']
+ # register the snapshot names in extra_data (?)
+
+ print(snapshot)
+
+ def check_health(self, *args, **options):
+ pending_vms = VMProduct.objects.filter(status='PENDING')
+ vmhosts = VMHost.objects.filter(status='active')
+
+ # 1. Check that all active hosts reported back N seconds ago
+ # 2. Check that no VM is running on a dead host
+ # 3. Migrate VMs if necessary
+ # 4. Check that no VMs have been pending for longer than Y seconds
+
+ # If VM snapshots exist without a VM -> notify user (?)
+
+ print("Nothing is good, you should implement me")
diff --git a/uncloud_vm/migrations/0001_initial.py b/uncloud_vm/migrations/0001_initial.py
new file mode 100644
index 0000000..4ec089a
--- /dev/null
+++ b/uncloud_vm/migrations/0001_initial.py
@@ -0,0 +1,111 @@
+# Generated by Django 3.1 on 2020-12-13 10:38
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='VMCluster',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('extra_data', models.JSONField(blank=True, editable=False, null=True)),
+ ('name', models.CharField(max_length=128, unique=True)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='VMDiskImageProduct',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('extra_data', models.JSONField(blank=True, editable=False, null=True)),
+ ('name', models.CharField(max_length=256)),
+ ('is_os_image', models.BooleanField(default=False)),
+ ('is_public', models.BooleanField(default=False, editable=False)),
+ ('size_in_gb', models.FloatField(blank=True, null=True)),
+ ('import_url', models.URLField(blank=True, null=True)),
+ ('image_source', models.CharField(max_length=128, null=True)),
+ ('image_source_type', models.CharField(max_length=128, null=True)),
+ ('storage_class', models.CharField(choices=[('HDD', 'HDD'), ('SSD', 'SSD')], default='SSD', max_length=32)),
+ ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32)),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='VMHost',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('extra_data', models.JSONField(blank=True, editable=False, null=True)),
+ ('hostname', models.CharField(max_length=253, unique=True)),
+ ('physical_cores', models.IntegerField(default=0)),
+ ('usable_cores', models.IntegerField(default=0)),
+ ('usable_ram_in_gb', models.FloatField(default=0)),
+ ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32)),
+ ('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmcluster')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='VMProduct',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(blank=True, max_length=32, null=True)),
+ ('cores', models.IntegerField()),
+ ('ram_in_gb', models.FloatField()),
+ ('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmcluster')),
+ ('vmhost', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmhost')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VMSnapshotProduct',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('gb_ssd', models.FloatField(editable=False)),
+ ('gb_hdd', models.FloatField(editable=False)),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='uncloud_vm.vmproduct')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VMNetworkCard',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('mac_address', models.BigIntegerField()),
+ ('ip_address', models.GenericIPAddressField(blank=True, null=True)),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmproduct')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VMDiskProduct',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('size_in_gb', models.FloatField(blank=True)),
+ ('disk_type', models.CharField(choices=[('ceph/ssd', 'Ceph Ssd'), ('ceph/hdd', 'Ceph Hdd'), ('local/ssd', 'Local Ssd'), ('local/hdd', 'Local Hdd')], default='ceph/ssd', max_length=20)),
+ ('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmdiskimageproduct')),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmproduct')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VMWithOSProduct',
+ fields=[
+ ('vmproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.vmproduct')),
+ ('primary_disk', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmdiskproduct')),
+ ],
+ bases=('uncloud_vm.vmproduct',),
+ ),
+ ]
diff --git a/uncloud_vm/migrations/__init__.py b/uncloud_vm/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud/uncloud_vm/models.py b/uncloud_vm/models.py
similarity index 53%
rename from uncloud/uncloud_vm/models.py
rename to uncloud_vm/models.py
index 57b54cf..c605779 100644
--- a/uncloud/uncloud_vm/models.py
+++ b/uncloud_vm/models.py
@@ -1,33 +1,23 @@
-import uuid
-
from django.db import models
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, RecurringPeriod
+from uncloud.models import UncloudModel, UncloudStatus
+
import uncloud_pay.models as pay_models
import uncloud_storage.models
-STATUS_CHOICES = (
- ('pending', 'Pending'), # Initial state
- ('creating', 'Creating'), # Creating VM/image/etc.
- ('active', 'Active'), # Is usable / active
- ('disabled', 'Disabled'), # Is usable, but cannot be used for new things
- ('unusable', 'Unusable'), # Has some kind of error
- ('deleted', 'Deleted'), # Does not exist anymore, only DB entry as a log
-)
-
-STATUS_DEFAULT = 'pending'
-
-
-class VMHost(models.Model):
- uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+class VMCluster(UncloudModel):
+ name = models.CharField(max_length=128, unique=True)
+class VMHost(UncloudModel):
# 253 is the maximum DNS name length
hostname = models.CharField(max_length=253, unique=True)
+ vmcluster = models.ForeignKey(
+ VMCluster, on_delete=models.CASCADE, editable=False, blank=True, null=True
+ )
+
# indirectly gives a maximum number of cores / VM - f.i. 32
physical_cores = models.IntegerField(default=0)
@@ -38,7 +28,7 @@ class VMHost(models.Model):
usable_ram_in_gb = models.FloatField(default=0)
status = models.CharField(
- max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT
+ max_length=32, choices=UncloudStatus.choices, default=UncloudStatus.PENDING
)
@property
@@ -51,55 +41,53 @@ class VMHost(models.Model):
@property
def available_ram_in_gb(self):
- return self.usable_ram_in_gb - sum([vm.ram_in_gb for vm in self.vms ])
+ return self.usable_ram_in_gb - self.used_ram_in_gb
@property
def available_cores(self):
return self.usable_cores - sum([vm.cores for vm in self.vms ])
-class VMProduct(Product):
+
+class VMProduct(models.Model):
vmhost = models.ForeignKey(
VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True
)
- # VM-specific. The name is only intended for customers: it's a pain to
- # remember IDs (speaking from experience as ungleich customer)!
+ vmcluster = models.ForeignKey(
+ VMCluster, on_delete=models.CASCADE, editable=False, blank=True, null=True
+ )
+
name = models.CharField(max_length=32, blank=True, null=True)
cores = models.IntegerField()
ram_in_gb = models.FloatField()
+ @property
+ def recurring_price(self):
+ return self.cores * 3 + self.ram_in_gb * 4
- 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 * 4
- elif recurring_period == RecurringPeriod.PER_HOUR:
- return self.cores * 4.0/(30 * 24) + self.ram_in_gb * 4.5/(30* 24)
- else:
- raise Exception('Invalid recurring period for VM Product pricing.')
-
- def __str__(self):
- return "VM {} ({}): {} cores {} gb ram".format(self.uuid,
- self.name,
- self.cores,
- self.ram_in_gb)
@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))
+ # @staticmethod
+ # def allowed_recurring_periods():
+ # return list(filter(
+ # lambda pair: pair[0] in [RecurringPeriod.PER_365D,
+ # RecurringPeriod.PER_30D, RecurringPeriod.PER_HOUR],
+ # RecurringPeriod.choices))
+
+
+ def __str__(self):
+ return f"VM id={self.id},name={self.name},cores={self.cores},ram_in_gb={self.ram_in_gb}"
+
class VMWithOSProduct(VMProduct):
- pass
+ primary_disk = models.ForeignKey('VMDiskProduct', on_delete=models.CASCADE, null=True)
-class VMDiskImageProduct(models.Model):
+class VMDiskImageProduct(UncloudModel):
"""
Images are used for cloning/linking.
@@ -107,16 +95,13 @@ class VMDiskImageProduct(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
)
name = models.CharField(max_length=256)
is_os_image = models.BooleanField(default=False)
- is_public = models.BooleanField(default=False)
+ is_public = models.BooleanField(default=False, editable=False) # only allow admins to set this
size_in_gb = models.FloatField(null=True, blank=True)
import_url = models.URLField(null=True, blank=True)
@@ -128,15 +113,25 @@ class VMDiskImageProduct(models.Model):
default = uncloud_storage.models.StorageClass.SSD)
status = models.CharField(
- max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT
+ max_length=32, choices=UncloudStatus.choices, default=UncloudStatus.PENDING
)
def __str__(self):
- return "VMDiskImage {} ({}): {} gb".format(self.uuid,
+ return "VMDiskImage {} ({}): {} gb".format(self.id,
self.name,
self.size_in_gb)
+# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
+class VMDiskType(models.TextChoices):
+ """
+ Types of disks that can be attached to VMs
+ """
+ CEPH_SSD = 'ceph/ssd'
+ CEPH_HDD = 'ceph/hdd'
+ LOCAL_SSD = 'local/ssd'
+ LOCAL_HDD = 'local/hdd'
+
class VMDiskProduct(models.Model):
"""
@@ -147,30 +142,33 @@ class VMDiskProduct(models.Model):
It can be enlarged, but not shrinked compared to the VMDiskImageProduct.
"""
- uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
- owner = models.ForeignKey(get_user_model(),
- on_delete=models.CASCADE,
- editable=False)
-
vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE)
- image = models.ForeignKey(VMDiskImageProduct, on_delete=models.CASCADE)
+
+ image = models.ForeignKey(VMDiskImageProduct, on_delete=models.CASCADE,
+ blank=True, null=True)
size_in_gb = models.FloatField(blank=True)
- # Sample code for clean method
+ disk_type = models.CharField(
+ max_length=20,
+ choices=VMDiskType.choices,
+ default=VMDiskType.CEPH_SSD)
- # Ensures that a VMDiskProduct can only be created from a VMDiskImageProduct
- # that is in status 'active'
+ def __str__(self):
+ return f"Disk {self.size_in_gb}GB ({self.disk_type}) for {self.vm}"
- # def clean(self):
- # if self.image.status != 'active':
- # raise ValidationError({
- # 'image': 'VM Disk must be created from an active disk image.'
- # })
+ @property
+ def recurring_price(self):
+ if self.disk_type == VMDiskType.CEPH_SSD:
+ price_per_gb = 3.5/10
+ elif self.disk_type == VMDiskType.CEPH_HDD:
+ price_per_gb = 1.5/100
+ elif self.disk_type == VMDiskType.LOCAL_SSD:
+ price_per_gb = 3.5/10
+ elif self.disk_type == VMDiskType.CEPH_HDD:
+ price_per_gb = 1.5/100
- def save(self, *args, **kwargs):
- self.full_clean()
- super().save(*args, **kwargs)
+ return self.size_in_gb * price_per_gb
class VMNetworkCard(models.Model):
@@ -182,7 +180,7 @@ class VMNetworkCard(models.Model):
null=True)
-class VMSnapshotProduct(Product):
+class VMSnapshotProduct(models.Model):
gb_ssd = models.FloatField(editable=False)
gb_hdd = models.FloatField(editable=False)
diff --git a/uncloud_vm/serializers.py b/uncloud_vm/serializers.py
new file mode 100644
index 0000000..a60d10b
--- /dev/null
+++ b/uncloud_vm/serializers.py
@@ -0,0 +1,143 @@
+from django.contrib.auth import get_user_model
+
+from rest_framework import serializers
+
+from .models import *
+from uncloud_pay.models import RecurringPeriod, BillingAddress
+
+# XXX: does not seem to be used?
+
+GB_SSD_PER_DAY=0.012
+GB_HDD_PER_DAY=0.0006
+
+GB_SSD_PER_DAY=0.012
+GB_HDD_PER_DAY=0.0006
+
+###
+# Admin views.
+
+class VMHostSerializer(serializers.HyperlinkedModelSerializer):
+ vms = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
+
+ class Meta:
+ model = VMHost
+ fields = '__all__'
+ read_only_fields = [ 'vms' ]
+
+class VMClusterSerializer(serializers.HyperlinkedModelSerializer):
+ class Meta:
+ model = VMCluster
+ fields = '__all__'
+
+
+###
+# Disks.
+
+class VMDiskProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskProduct
+ fields = '__all__'
+
+class CreateVMDiskProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskProduct
+ fields = ['size_in_gb', 'image']
+
+class CreateManagedVMDiskProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskProduct
+ fields = ['size_in_gb']
+
+class VMDiskImageProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskImageProduct
+ fields = '__all__'
+
+class VMSnapshotProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMSnapshotProduct
+ fields = '__all__'
+
+
+ # verify that vm.owner == user.request
+ def validate_vm(self, value):
+ if not value.owner == self.context['request'].user:
+ raise serializers.ValidationError("VM {} not found for owner {}.".format(value,
+ self.context['request'].user))
+ disks = VMDiskProduct.objects.filter(vm=value)
+
+ if len(disks) == 0:
+ raise serializers.ValidationError("VM {} does not have any disks, cannot snapshot".format(value.id))
+
+ return value
+
+ pricing = {}
+ pricing['per_gb_ssd'] = 0.012
+ pricing['per_gb_hdd'] = 0.0006
+ pricing['recurring_period'] = 'per_day'
+
+###
+# VMs
+
+# Helper used in uncloud_service for services allocating VM.
+class ManagedVMProductSerializer(serializers.ModelSerializer):
+ """
+ Managed VM serializer used in ungleich_service app.
+ """
+ primary_disk = CreateManagedVMDiskProductSerializer()
+ class Meta:
+ model = VMWithOSProduct
+ fields = [ 'cores', 'ram_in_gb', 'primary_disk']
+
+class VMProductSerializer(serializers.ModelSerializer):
+ primary_disk = CreateVMDiskProductSerializer()
+ snapshots = VMSnapshotProductSerializer(many=True, read_only=True)
+ disks = VMDiskProductSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = VMWithOSProduct
+ fields = ['order', 'owner', 'status', 'name', 'cores',
+ 'ram_in_gb', 'primary_disk', 'snapshots', 'disks', 'extra_data']
+ read_only_fields = ['order', 'owner', 'status']
+
+class OrderVMProductSerializer(VMProductSerializer):
+ # recurring_period = serializers.ChoiceField(
+ # choices=VMWithOSProduct.allowed_recurring_periods())
+
+ def __init__(self, *args, **kwargs):
+ super(VMProductSerializer, self).__init__(*args, **kwargs)
+
+ class Meta:
+ model = VMProductSerializer.Meta.model
+ fields = VMProductSerializer.Meta.fields + [ 'recurring_period' ]
+ read_only_fields = VMProductSerializer.Meta.read_only_fields
+
+# Nico's playground.
+class NicoVMProductSerializer(serializers.ModelSerializer):
+ snapshots = VMSnapshotProductSerializer(many=True, read_only=True)
+ order = serializers.StringRelatedField()
+
+ class Meta:
+ model = VMProduct
+ read_only_fields = ['order', 'owner', 'status',
+ 'vmhost', 'vmcluster', 'snapshots',
+ 'extra_data' ]
+ fields = read_only_fields + [ 'name',
+ 'cores',
+ 'ram_in_gb'
+ ]
+
+class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer):
+ """
+ Create an interface similar to standard DCL
+ """
+
+ # Custom field used at creation (= ordering) only.
+ # recurring_period = serializers.ChoiceField(
+ # choices=VMProduct.allowed_recurring_periods())
+
+ os_disk_uuid = serializers.UUIDField()
+ # os_disk_size =
+
+ class Meta:
+ model = VMProduct
diff --git a/uncloud_vm/tests.py b/uncloud_vm/tests.py
new file mode 100644
index 0000000..e5d403f
--- /dev/null
+++ b/uncloud_vm/tests.py
@@ -0,0 +1,98 @@
+import datetime
+
+import parsedatetime
+
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from django.utils import timezone
+from django.core.exceptions import ValidationError
+
+from uncloud_vm.models import VMDiskImageProduct, VMDiskProduct, VMProduct, VMHost
+from uncloud_pay.models import Order, RecurringPeriod
+
+User = get_user_model()
+cal = parsedatetime.Calendar()
+
+
+# If you want to check the test database using some GUI/cli tool
+# then use the following connecting parameters
+
+# host: localhost
+# database: test_uncloud
+# user: root
+# password:
+# port: 5432
+
+class VMTestCase(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ # Setup vm host
+ cls.vm_host, created = VMHost.objects.get_or_create(
+ hostname='serverx.placey.ungleich.ch', physical_cores=32, usable_cores=320,
+ usable_ram_in_gb=512.0, status='active'
+ )
+ super().setUpClass()
+
+ def setUp(self) -> None:
+ # Setup two users as it is common to test with different user
+ self.user = User.objects.create_user(
+ username='testuser', email='test@test.com', first_name='Test', last_name='User'
+ )
+ self.user2 = User.objects.create_user(
+ username='Meow', email='meow123@test.com', first_name='Meow', last_name='Cat'
+ )
+ super().setUp()
+
+ def create_sample_vm(self, owner):
+ one_month_later, parse_status = cal.parse("1 month later")
+ return VMProduct.objects.create(
+ vmhost=self.vm_host, cores=2, ram_in_gb=4, owner=owner,
+ order=Order.objects.create(
+ owner=owner,
+ creation_date=datetime.datetime.now(tz=timezone.utc),
+ starting_date=datetime.datetime.now(tz=timezone.utc),
+ ending_date=datetime.datetime(*one_month_later[:6], tzinfo=timezone.utc),
+ recurring_period=RecurringPeriod.PER_MONTH
+ )
+ )
+
+# TODO: the logic tested by this test is not implemented yet.
+# def test_disk_product(self):
+# """Ensures that a VMDiskProduct can only be created from a VMDiskImageProduct
+# that is in status 'active'"""
+#
+# vm = self.create_sample_vm(owner=self.user)
+#
+# pending_disk_image = VMDiskImageProduct.objects.create(
+# owner=self.user, name='pending_disk_image', is_os_image=True, is_public=True, size_in_gb=10,
+# status='pending'
+# )
+# try:
+# vm_disk_product = VMDiskProduct.objects.create(
+# owner=self.user, vm=vm, image=pending_disk_image, size_in_gb=10
+# )
+# except ValidationError:
+# vm_disk_product = None
+#
+# self.assertIsNone(
+# vm_disk_product,
+# msg='VMDiskProduct created with disk image whose status is not active.'
+# )
+
+# TODO: the logic tested by this test is not implemented yet.
+# def test_vm_disk_product_creation_for_someone_else(self):
+# """Ensure that a user can only create a VMDiskProduct for his/her own VM"""
+#
+# # Create a VM which is ownership of self.user2
+# someone_else_vm = self.create_sample_vm(owner=self.user2)
+#
+# # 'self.user' would try to create a VMDiskProduct for 'user2's VM
+# with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for someone else VM.'):
+# vm_disk_product = VMDiskProduct.objects.create(
+# owner=self.user, vm=someone_else_vm,
+# size_in_gb=10,
+# image=VMDiskImageProduct.objects.create(
+# owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10,
+# status='active'
+# )
+# )
diff --git a/uncloud_vm/views.py b/uncloud_vm/views.py
new file mode 100644
index 0000000..67f8656
--- /dev/null
+++ b/uncloud_vm/views.py
@@ -0,0 +1,261 @@
+from django.db import transaction
+from django.shortcuts import render
+from django.utils import timezone
+
+from django.contrib.auth.models import User
+from django.shortcuts import get_object_or_404
+
+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, VMCluster
+from uncloud_pay.models import Order, BillingAddress
+
+from .serializers import *
+from uncloud_pay.helpers import ProductViewSet
+
+import datetime
+
+###
+# Generic disk image views. Do not require orders / billing.
+
+class VMDiskImageProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMDiskImageProductSerializer
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMDiskImageProduct.objects.all()
+ else:
+ obj = VMDiskImageProduct.objects.filter(owner=self.request.user) | VMDiskImageProduct.objects.filter(is_public=True)
+
+ return obj
+
+
+ def create(self, request):
+ serializer = VMDiskImageProductSerializer(data=request.data, context={'request': request})
+ serializer.is_valid(raise_exception=True)
+
+ # did not specify size NOR import url?
+ if not serializer.validated_data['size_in_gb']:
+ if not serializer.validated_data['import_url']:
+ raise ValidationError(detail={ 'error_mesage': 'Specify either import_url or size_in_gb' })
+
+ serializer.save(owner=request.user)
+ return Response(serializer.data)
+
+class VMDiskImageProductPublicViewSet(viewsets.ReadOnlyModelViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMDiskImageProductSerializer
+
+ def get_queryset(self):
+ return VMDiskImageProduct.objects.filter(is_public=True)
+
+###
+# User VM disk and snapshots.
+
+class VMDiskProductViewSet(viewsets.ModelViewSet):
+ """
+ Let a user modify their own VMDisks
+ """
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMDiskProductSerializer
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMDiskProduct.objects.all()
+ else:
+ obj = VMDiskProduct.objects.filter(owner=self.request.user)
+
+ return obj
+
+ def create(self, request):
+ serializer = VMDiskProductSerializer(data=request.data, context={'request': request})
+ serializer.is_valid(raise_exception=True)
+
+ # get disk size from image, if not specified
+ if not 'size_in_gb' in serializer.validated_data:
+ size_in_gb = serializer.validated_data['image'].size_in_gb
+ else:
+ size_in_gb = serializer.validated_data['size_in_gb']
+
+ if size_in_gb < serializer.validated_data['image'].size_in_gb:
+ raise ValidationError(detail={ 'error_mesage': 'Size is smaller than original image' })
+
+ serializer.save(owner=request.user, size_in_gb=size_in_gb)
+ return Response(serializer.data)
+
+class VMSnapshotProductViewSet(viewsets.ModelViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMSnapshotProductSerializer
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMSnapshotProduct.objects.all()
+ else:
+ obj = VMSnapshotProduct.objects.filter(owner=self.request.user)
+
+ return obj
+
+ def create(self, request):
+ serializer = VMSnapshotProductSerializer(data=request.data, context={'request': request})
+
+ # This verifies that the VM belongs to the request user
+ serializer.is_valid(raise_exception=True)
+
+ vm = vm=serializer.validated_data['vm']
+ disks = VMDiskProduct.objects.filter(vm=vm)
+ ssds_size = sum([d.size_in_gb for d in disks if d.image.storage_class == 'ssd'])
+ hdds_size = sum([d.size_in_gb for d in disks if d.image.storage_class == 'hdd'])
+
+ recurring_price = serializer.pricing['per_gb_ssd'] * ssds_size + serializer.pricing['per_gb_hdd'] * hdds_size
+ recurring_period = serializer.pricing['recurring_period']
+
+ # Create order
+ now = datetime.datetime.now()
+ order = Order(owner=request.user,
+ recurring_period=recurring_period)
+ order.save()
+ order.add_record(one_time_price=0,
+ recurring_price=recurring_price,
+ description="Snapshot of VM {} from {}".format(vm, now))
+
+ serializer.save(owner=request.user,
+ order=order,
+ gb_ssd=ssds_size,
+ gb_hdd=hdds_size)
+
+ return Response(serializer.data)
+
+###
+# User VMs.
+
+class VMProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMWithOSProduct.objects.all()
+ else:
+ obj = VMWithOSProduct.objects.filter(owner=self.request.user)
+
+ return obj
+
+ def get_serializer_class(self):
+ if self.action == 'create':
+ return OrderVMProductSerializer
+ else:
+ return VMProductSerializer
+
+ # 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 = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ order_recurring_period = serializer.validated_data.pop("recurring_period")
+
+ # Create disk image.
+ disk = VMDiskProduct(owner=request.user,
+ **serializer.validated_data.pop("primary_disk"))
+ vm = VMWithOSProduct(owner=request.user, primary_disk=disk,
+ **serializer.validated_data)
+ disk.vm = vm # XXX: Is this really needed?
+
+ # Create VM and Disk orders.
+ vm_order = Order.from_product(
+ vm,
+ recurring_period=order_recurring_period,
+ starting_date=timezone.now()
+ )
+
+ disk_order = Order.from_product(
+ disk,
+ recurring_period=order_recurring_period,
+ starting_date=timezone.now(),
+ depends_on=vm_order
+ )
+
+
+ # Commit to DB.
+ vm.order = vm_order
+ vm.save()
+ vm_order.save()
+
+ disk.order = disk_order
+ disk_order.save()
+ disk.save()
+
+ return Response(VMProductSerializer(vm, context={'request': request}).data)
+
+class NicoVMProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = NicoVMProductSerializer
+
+ def get_queryset(self):
+ obj = VMProduct.objects.filter(owner=self.request.user)
+ return obj
+
+ def create(self, request):
+ serializer = self.serializer_class(data=request.data, context={'request': request})
+ serializer.is_valid(raise_exception=True)
+ vm = serializer.save(owner=request.user)
+
+ return Response(serializer.data)
+
+
+###
+# Admin stuff.
+
+class VMHostViewSet(viewsets.ModelViewSet):
+ serializer_class = VMHostSerializer
+ queryset = VMHost.objects.all()
+ permission_classes = [permissions.IsAdminUser]
+
+class VMClusterViewSet(viewsets.ModelViewSet):
+ serializer_class = VMClusterSerializer
+ queryset = VMCluster.objects.all()
+ permission_classes = [permissions.IsAdminUser]
+
+##
+# Nico's playground.
+
+# Also create:
+# - /dcl/available_os
+# Basically a view of public and my disk images
+# -
+class DCLCreateVMProductViewSet(ProductViewSet):
+ """
+ This view resembles the way how DCL VMs are created by default.
+
+ The user chooses an OS, os disk size, ram, cpu and whether or not to have a mapped IPv4 address
+ """
+
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = DCLVMProductSerializer
+
+ 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)
+ 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 VM.
+ vm = serializer.save(owner=request.user, order=order)
+
+ return Response(serializer.data)