Merge branch 'master' into stripe-js

This commit is contained in:
fnux 2020-04-08 17:08:09 +02:00
commit f2a797874a
260 changed files with 12754 additions and 424 deletions

View file

@ -0,0 +1,4 @@
db.sqlite3
uncloud/secrets.py
debug.log
uncloud/local_settings.py

View file

@ -0,0 +1,5 @@
* What is a remote uncloud client?
** Systems that configure themselves for the use with uncloud
** Examples are VMHosts, VPN Servers, etc.
* Which access do these clients need?
** They need read / write access to the database

View file

@ -0,0 +1,9 @@
## 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

View file

@ -0,0 +1,82 @@
## 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

View file

@ -0,0 +1,8 @@
* uncloud clients access the data base from a variety of outside hosts
* So the postgresql data base needs to be remotely accessible
* Instead of exposing the tcp socket, we make postgresql bind to localhost via IPv6
** ::1, port 5432
* Then we remotely connect to the database server with ssh tunneling
** ssh -L5432:localhost:5432 uncloud-database-host
* Configuring your database for SSH based remote access
** host all all ::1/128 trust

View file

@ -0,0 +1,25 @@
* How to add a new VPN Host
** Install wireguard to the host
** Install uncloud to the host
** Add `python manage.py vpn --hostname fqdn-of-this-host` to the crontab
** Use the CLI to configure one or more VPN Networks for this host
* Example of adding a VPN host at ungleich
** Create a new dual stack alpine VM
** Add it to DNS as vpn-XXX.ungleich.ch
** Route a /40 network to its IPv6 address
** Install wireguard on it
** TODO Enable wireguard on boot
** TODO Create a new VPNPool on uncloud with
*** the network address (selecting from our existing pool)
*** the network size (/...)
*** the vpn host that provides the network (selecting the created VM)
*** the wireguard private key of the vpn host (using wg genkey)
*** http command
```
http -a nicoschottelius:$(pass
ungleich.ch/nico.schottelius@ungleich.ch)
http://localhost:8000/admin/vpnpool/ network=2a0a:e5c1:200:: \
network_size=40 subnetwork_size=48
vpn_hostname=vpn-2a0ae5c1200.ungleich.ch
wireguard_private_key=...
```

View file

@ -0,0 +1,95 @@
## 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)
```

View file

@ -0,0 +1,21 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
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()

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class OpennebulaConfig(AppConfig):
name = 'opennebula'

View file

@ -0,0 +1,74 @@
import json
import uncloud.secrets as secrets
from xmlrpc.client import ServerProxy as RPCClient
from django.core.management.base import BaseCommand
from xmltodict import parse
from enum import IntEnum
from opennebula.models import VM as VMModel
from uncloud_vm.models import VMHost
from django_auth_ldap.backend import LDAPBackend
class HostStates(IntEnum):
"""
The following flags are copied from
https://docs.opennebula.org/5.8/integration/system_interfaces/api.html#schemas-for-host
"""
INIT = 0 # Initial state for enabled hosts
MONITORING_MONITORED = 1 # Monitoring the host (from monitored)
MONITORED = 2 # The host has been successfully monitored
ERROR = 3 # An error ocurrer while monitoring the host
DISABLED = 4 # The host is disabled
MONITORING_ERROR = 5 # Monitoring the host (from error)
MONITORING_INIT = 6 # Monitoring the host (from init)
MONITORING_DISABLED = 7 # Monitoring the host (from disabled)
OFFLINE = 8 # The host is totally offline
class Command(BaseCommand):
help = 'Syncronize Host information from OpenNebula'
def add_arguments(self, parser):
pass
def handle(self, *args, **options):
with RPCClient(secrets.OPENNEBULA_URL) as rpc_client:
success, response, *_ = rpc_client.one.hostpool.info(secrets.OPENNEBULA_USER_PASS)
if success:
response = json.loads(json.dumps(parse(response)))
host_pool = response.get('HOST_POOL', {}).get('HOST', {})
for host in host_pool:
host_share = host.get('HOST_SHARE', {})
host_name = host.get('NAME')
state = int(host.get('STATE', HostStates.OFFLINE.value))
if state == HostStates.MONITORED:
status = 'active'
elif state == HostStates.DISABLED:
status = 'disabled'
else:
status = 'unusable'
usable_cores = host_share.get('TOTAL_CPU')
usable_ram_in_kb = int(host_share.get('TOTAL_MEM', 0))
usable_ram_in_gb = int(usable_ram_in_kb / 2 ** 20)
# vms cannot be created like this -- Nico, 2020-03-17
# vms = host.get('VMS', {}) or {}
# vms = vms.get('ID', []) or []
# vms = ','.join(vms)
VMHost.objects.update_or_create(
hostname=host_name,
defaults={
'usable_cores': usable_cores,
'usable_ram_in_gb': usable_ram_in_gb,
'status': status
}
)
else:
print(response)

View file

@ -0,0 +1,47 @@
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 xmltodict import parse
from opennebula.models import VM as VMModel
class Command(BaseCommand):
help = 'Syncronize VM information from OpenNebula'
def add_arguments(self, parser):
pass
def handle(self, *args, **options):
with RPCClient(secrets.OPENNEBULA_URL) as rpc_client:
success, response, *_ = rpc_client.one.vmpool.infoextended(
secrets.OPENNEBULA_USER_PASS, -2, -1, -1, -1
)
if success:
vms = json.loads(json.dumps(parse(response)))['VM_POOL']['VM']
unknown_user = set()
backend = LDAPBackend()
for vm in vms:
vm_id = vm['ID']
vm_owner = vm['UNAME']
user = backend.populate_user(username=vm_owner)
if not user:
unknown_user.add(vm_owner)
else:
VMModel.objects.update_or_create(
vmid=vm_id,
defaults={'data': vm, 'owner': user}
)
print('User not found in ldap:', unknown_user)
else:
print(response)

View file

@ -0,0 +1,193 @@
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
mac_address = mac_address.replace(':', '')
mac_address = mac_address.replace('.', '')
mac_address = mac_address.replace('-', '')
mac_address = mac_address.replace(' ', '')
# Parse the resulting number as hexadecimal
mac_address = int(mac_address, base=16)
return mac_address
def get_vm_price(core, ram, ssd_size, hdd_size, n_of_ipv4, n_of_ipv6):
total = 3 * core + 4 * ram + (3.5 * ssd_size/10.) + (1.5 * hdd_size/100.) + 8 * n_of_ipv4 + 0 * n_of_ipv6
# TODO: Find some reason about the following magical subtraction.
total -= 8
return total
def create_nics(one_vm, vm_product):
for nic in one_vm.nics:
mac_address = convert_mac_to_int(nic.get('MAC'))
ip_address = nic.get('IP', None) or nic.get('IP6_GLOBAL', None)
VMNetworkCard.objects.update_or_create(
mac_address=mac_address, vm=vm_product, defaults={'ip_address': ip_address}
)
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
"""
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('storage_class')
image_source = disk.get('source')
image_source_type = disk.get('source_type')
image, _ = VMDiskImageProduct.objects.update_or_create(
name=name,
defaults={
'owner': disk_owner,
'is_os_image': is_os_image,
'is_public': is_public,
'size_in_gb': image_size_in_gb,
'storage_class': storage_class,
'image_source': image_source,
'image_source_type': image_source_type,
'status': status
}
)
# 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:
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:
log.error("VMHost {} does not exist, aborting".format(one_vm.last_host))
raise
cores = one_vm.cores
ram_in_gb = one_vm.ram_in_gb
owner = one_vm.owner
status = 'active'
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'] ])
# List of IPv4 addresses and Global IPv6 addresses
ipv4, ipv6 = one_vm.ips
# TODO: Insert actual/real creation_date, starting_date, ending_date
# instead of pseudo one we are putting currently
creation_date = starting_date = datetime.now(tz=timezone.utc)
# Price calculation based on datacenterlight.ch
one_time_price = 0
recurring_period = 'per_month'
recurring_price = get_vm_price(cores, ram_in_gb,
ssd_size, hdd_size,
len(ipv4), len(ipv6))
try:
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
)
vm_product = VMProduct(
extra_data={ 'opennebula_id': one_vm.vmid },
name=one_vm.uncloud_name,
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
sync_disk_and_image(one_vm, vm_product, disk_owner=disk_owner)

View file

@ -0,0 +1,28 @@
# 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)),
],
),
]

View file

@ -0,0 +1,27 @@
# 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),
),
]

View file

@ -0,0 +1,19 @@
# 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),
),
]

View file

@ -0,0 +1,23 @@
# 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,
),
]

View file

@ -0,0 +1,91 @@
import uuid
from django.db import models
from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import JSONField
# 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()
@property
def uncloud_name(self):
return "opennebula-{}".format(self.vmid)
@property
def cores(self):
return int(self.data['TEMPLATE']['VCPU'])
@property
def ram_in_gb(self):
return int(self.data['TEMPLATE']['MEMORY'])/1024
@property
def disks(self):
"""
If there is no disk then the key DISK does not exist.
If there is only one disk, we have a dictionary in the database.
If there are multiple disks, we have a list of dictionaries in the database.
"""
disks = []
if 'DISK' in self.data['TEMPLATE']:
if type(self.data['TEMPLATE']['DISK']) is dict:
disks = [self.data['TEMPLATE']['DISK']]
else:
disks = self.data['TEMPLATE']['DISK']
disks = [
{
'size_in_gb': int(d['SIZE'])/1024,
'opennebula_source': d['SOURCE'],
'opennebula_name': d['IMAGE'],
'image_size_in_gb': int(d['ORIGINAL_SIZE'])/1024,
'pool_name': d['POOL_NAME'],
'image': d['IMAGE'],
'source': d['SOURCE'],
'source_type': d['TM_MAD'],
'storage_class': storage_class_mapping[d['POOL_NAME']]
}
for d in disks
]
return disks
@property
def last_host(self):
return ((self.data.get('HISTORY_RECORDS', {}) or {}).get('HISTORY', {}) or {}).get('HOSTNAME', None)
@property
def graphics(self):
return self.data.get('TEMPLATE', {}).get('GRAPHICS', {})
@property
def nics(self):
_nics = self.data.get('TEMPLATE', {}).get('NIC', {})
if isinstance(_nics, dict):
_nics = [_nics]
return _nics
@property
def ips(self):
ipv4, ipv6 = [], []
for nic in self.nics:
ip = nic.get('IP')
ip6 = nic.get('IP6_GLOBAL')
if ip:
ipv4.append(ip)
if ip6:
ipv6.append(ip6)
return ipv4, ipv6

View file

@ -0,0 +1,10 @@
from rest_framework import serializers
from opennebula.models import VM
class OpenNebulaVMSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = VM
fields = [ 'vmid', 'owner', 'data',
'uncloud_name', 'cores', 'ram_in_gb',
'disks', 'nics', 'ips' ]

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -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

View file

@ -0,0 +1,16 @@
django
djangorestframework
django-auth-ldap
stripe
xmltodict
psycopg2
parsedatetime
# Follow are for creating graph models
pyparsing
pydot
django-extensions
# PDF creating
django-hardcopy

View file

@ -0,0 +1 @@
secrets.py

View file

@ -0,0 +1,4 @@
# Define DecimalField properties, used to represent amounts of money.
# Used in pay and auth
AMOUNT_MAX_DIGITS=10
AMOUNT_DECIMALS=2

View file

@ -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/3.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()

View file

@ -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 =

View file

@ -0,0 +1,35 @@
from django.db import models
from django.contrib.postgres.fields import JSONField
from django.utils.translation import gettext_lazy as _
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

View file

@ -0,0 +1,21 @@
# 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_KEY=""
STRIPE_PUBLIC_KEY=""
SECRET_KEY="dx$iqt=lc&yrp^!z5$ay^%g5lhx1y3bcu=jg(jx0yj0ogkfqvf"

View file

@ -0,0 +1,178 @@
"""
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',
'HOST': '::1', # connecting via tcp, v6, to allow ssh forwarding to work
'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',
'uncloud_pay',
'uncloud_auth',
'uncloud_net',
'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/'
STATICFILES_DIRS = [ os.path.join(BASE_DIR, "static") ]

View file

@ -0,0 +1,79 @@
"""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 django.conf import settings
from django.conf.urls.static import static
from rest_framework import routers
from opennebula import views as oneviews
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 ungleich_service import views as serviceviews
router = routers.DefaultRouter()
# VM
router.register(r'vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct')
router.register(r'vm/diskimage', vmviews.VMDiskImageProductViewSet, basename='vmdiskimageproduct')
router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct')
router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct')
# 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')
# Services
router.register(r'service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct')
# Net
router.register(r'net/vpn', netviews.VPNNetworkViewSet, basename='vpnnet')
# 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')
# 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/vmcluster', vmviews.VMClusterViewSet)
router.register(r'admin/vpnpool', netviews.VPNPoolViewSet)
router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula')
# User/Account
router.register(r'user', authviews.UserViewSet, basename='user')
urlpatterns = [
path('', include(router.urls)),
# web/ = stuff to view in the browser
path('web/pdf/', payviews.MyPDFView.as_view(), name='pdf'),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) # for login to REST API
]

View file

@ -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/3.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()

View file

@ -0,0 +1,5 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User
admin.site.register(User, UserAdmin)

View file

@ -0,0 +1,4 @@
from django.apps import AppConfig
class AuthConfig(AppConfig):
name = 'uncloud_auth'

View file

@ -0,0 +1,44 @@
# Generated by Django 3.0.3 on 2020-03-03 16:49
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0011_update_proxy_permissions'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.AutoField(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')),
('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')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View file

@ -0,0 +1,25 @@
# 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,
),
]

View file

@ -0,0 +1,23 @@
# 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)]),
),
]

View file

@ -0,0 +1,23 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.core.validators import MinValueValidator
from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
from uncloud_pay.models import get_balance_for_user
class User(AbstractUser):
"""
We use the standard user and add a maximum credit that is allowed
to be accumulated. After that we need to have warnings, cancellation, etc.
"""
maximum_credit = models.DecimalField(
default=0.0,
max_digits=AMOUNT_MAX_DIGITS,
decimal_places=AMOUNT_DECIMALS,
validators=[MinValueValidator(0)])
@property
def balance(self):
return get_balance_for_user(self)

View file

@ -0,0 +1,15 @@
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)

View file

@ -0,0 +1,17 @@
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

View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class UncloudNetConfig(AppConfig):
name = 'uncloud_net'

View file

@ -0,0 +1,64 @@
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__)
wireguard_template="""
[Interface]
ListenPort = 51820
PrivateKey = {privatekey}
"""
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):
pool_config = {
'private_key': pool.wireguard_private_key,
'subnetwork_size': pool.subnetwork_size,
'config_file': '/etc/wireguard/{}.conf'.format(pool.network),
'peers': []
}
for vpnnetwork in VPNNetworkReservation.objects.filter(vpnpool=pool):
pool_config['peers'].append({
'vpnnetwork': "{}/{}".format(vpnnetwork.address,
pool_config['subnetwork_size']),
'public_key': vpnnetwork.wireguard_public_key,
}
)
configs.append(pool_config)
print(configs)

View file

@ -0,0 +1,68 @@
# Generated by Django 3.0.5 on 2020-04-06 21:38
from django.conf import settings
import django.contrib.postgres.fields.jsonb
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_pay', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='MACAdress',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
],
),
migrations.CreateModel(
name='VPNPool',
fields=[
('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('network', models.GenericIPAddressField(unique=True)),
('network_size', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
('subnetwork_size', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
('vpn_hostname', models.CharField(max_length=256)),
('wireguard_private_key', models.CharField(max_length=48)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='VPNNetworkReservation',
fields=[
('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)),
('address', models.GenericIPAddressField(primary_key=True, serialize=False)),
('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNPool')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='VPNNetwork',
fields=[
('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)),
('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'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32)),
('wireguard_public_key', models.CharField(max_length=48)),
('network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNNetworkReservation')),
('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)),
],
options={
'abstract': False,
},
),
]

View file

@ -0,0 +1,118 @@
import uuid
from django.db import models
from django.contrib.auth import get_user_model
from django.core.validators import MinValueValidator, MaxValueValidator
from uncloud_pay.models import Product, RecurringPeriod
from uncloud.models import UncloudModel, UncloudStatus
class MACAdress(models.Model):
default_prefix = 0x420000000000
class VPNPool(UncloudModel):
"""
Network address pools from which VPNs can be created
"""
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
network = models.GenericIPAddressField(unique=True)
network_size = models.IntegerField(validators=[MinValueValidator(0),
MaxValueValidator(128)])
subnetwork_size = models.IntegerField(validators=[
MinValueValidator(0),
MaxValueValidator(128)
])
vpn_hostname = models.CharField(max_length=256)
wireguard_private_key = models.CharField(max_length=48)
@property
def num_maximum_networks(self):
"""
sample:
network_size = 40
subnetwork_size = 48
maximum_networks = 2^(48-40)
2nd sample:
network_size = 8
subnetwork_size = 24
maximum_networks = 2^(24-8)
"""
return 2**(subnetwork_size - network_size)
@property
def used_networks(self):
return self.vpnnetworkreservation_set.objects.filter(vpnpool=self, status='used')
@property
def num_used_networks(self):
return len(self.used_networks)
@property
def num_free_networks(self):
return self.num_maximum_networks - self.num_used_networks
@property
def next_free_network(self):
free_net = self.vpnnetworkreservation_set.objects.filter(vpnpool=self,
status='free')
last_net = self.vpnnetworkreservation_set.objects.filter(vpnpool=self,
status='used')
if num_free_networks == 0:
raise Exception("No free networks")
if len(free_net) > 0:
return free_net[0].address
if len(used_net) > 0:
"""
sample:
pool = 2a0a:e5c1:200::/40
last_used = 2a0a:e5c1:204::/48
next:
"""
last_ip = last_net.address
# next_ip =
class VPNNetworkReservation(UncloudModel):
"""
This class tracks the used VPN networks. It will be deleted, when the product is cancelled.
"""
vpnpool = models.ForeignKey(VPNPool,
on_delete=models.CASCADE)
address = models.GenericIPAddressField(primary_key=True)
status = models.CharField(max_length=256,
choices = (
('used', 'used'),
('free', 'free')
)
)
class VPNNetwork(Product):
"""
A selected network. Used for tracking reservations / used networks
"""
network = models.ForeignKey(VPNNetworkReservation,
on_delete=models.CASCADE,
editable=False)
wireguard_public_key = models.CharField(max_length=48)

View file

@ -0,0 +1,75 @@
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 *
class VPNPoolSerializer(serializers.ModelSerializer):
class Meta:
model = VPNPool
fields = '__all__'
class VPNNetworkSerializer(serializers.ModelSerializer):
class Meta:
model = VPNNetwork
fields = '__all__'
# This is required for finding the VPN pool, but does not
# exist in the model
network_size = serializers.IntegerField(min_value=0,
max_value=128)
def validate_wireguard_public_key(self, value):
msg = _("Supplied key is not a valid wireguard public key")
""" FIXME: verify that this does not create broken wireguard config files,
i.e. contains \n or similar!
We might even need to be more strict to not break wireguard...
"""
print(value)
try:
base64.standard_b64decode(value)
except Exception as e:
raise serializers.ValidationError(msg)
if '\n' in value:
raise serializers.ValidationError(msg)
return value
def validate(self, data):
# FIXME: filter for status = active or similar
all_pools = VPNPool.objects.all()
sizes = [ p.subnetwork_size for p in all_pools ]
pools = VPNPool.objects.filter(subnetwork_size=data['network_size'])
if len(pools) == 0:
msg = _("No pool available for networks with size = {}. Available are: {}".format(data['network_size'], sizes))
raise serializers.ValidationError(msg)
return data
def create(self, validated_data):
"""
Creating a new vpnnetwork - there are a couple of race conditions,
especially when run in parallel.
"""
pools = VPNPool.objects.filter(subnetwork_size=data['network_size'])
found_pool = False
for pool in pools:
if pool.num_free_networks > 0:
found_pool = True
# address = pool.
# reservation = VPNNetworkReservation(vpnpool=pool,
pool = VPNPool.objects.first(subnetwork_size=data['network_size'])
return VPNNetwork(**validated_data)

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,27 @@
from django.shortcuts import render
from rest_framework import viewsets, permissions
from .models import *
from .serializers import *
class VPNPoolViewSet(viewsets.ModelViewSet):
serializer_class = VPNPoolSerializer
permission_classes = [permissions.IsAdminUser]
queryset = VPNPool.objects.all()
class VPNNetworkViewSet(viewsets.ModelViewSet):
serializer_class = VPNNetworkSerializer
permission_classes = [permissions.IsAdminUser]
def get_queryset(self):
if self.request.user.is_superuser:
obj = VPNNetwork.objects.all()
else:
obj = VPNNetwork.objects.filter(owner=self.request.user)
return obj

View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class UncloudPayConfig(AppConfig):
name = 'uncloud_pay'

View file

@ -0,0 +1,26 @@
from functools import reduce
from datetime import datetime
from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet
from django.utils import timezone
from calendar import monthrange
def beginning_of_month(year, month):
tz = timezone.get_current_timezone()
return datetime(year=year, month=month, day=1, tzinfo=tz)
def end_of_month(year, month):
(_, days) = monthrange(year, month)
tz = timezone.get_current_timezone()
return datetime(year=year, month=month, day=days,
hour=23, minute=59, second=59, tzinfo=tz)
class ProductViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
GenericViewSet):
"""
A customer-facing viewset that provides default `create()`, `retrieve()`
and `list()`.
"""
pass

View file

@ -0,0 +1,31 @@
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.")

View file

@ -0,0 +1,35 @@
import logging
from django.core.management.base import BaseCommand
from uncloud_auth.models import User
from uncloud_pay.models import Order, Bill
from django.core.exceptions import ObjectDoesNotExist
from datetime import timedelta, date
from django.utils import timezone
from uncloud_pay.models import Bill
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Generate bills and charge customers if necessary.'
def add_arguments(self, parser):
pass
# TODO: use logger.*
def handle(self, *args, **options):
# Iterate over all 'active' users.
# TODO: filter out inactive users.
users = User.objects.all()
print("Processing {} users.".format(users.count()))
for user in users:
now = timezone.now()
Bill.generate_for(
year=now.year,
month=now.month,
user=user)
# We're done for this round :-)
print("=> Done.")

View file

@ -0,0 +1,23 @@
from django.core.management.base import BaseCommand
from uncloud_auth.models import User
from uncloud_pay.models import Bill
from datetime import timedelta
from django.utils import timezone
class Command(BaseCommand):
help = 'Take action on overdue bills.'
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:
for bill in Bill.get_overdue_for(user):
print("/!\ Overdue bill for {}, {} with amount {}"
.format(user.username, bill.uuid, bill.amount))
# TODO: take action?
print("=> Done.")

View file

@ -0,0 +1,85 @@
# 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')},
},
),
]

View file

@ -0,0 +1,27 @@
# Generated by Django 3.0.3 on 2020-03-05 15:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='paymentmethod',
old_name='stripe_card_id',
new_name='stripe_payment_method_id',
),
migrations.AddField(
model_name='paymentmethod',
name='stripe_setup_intent_id',
field=models.CharField(blank=True, max_length=32, null=True),
),
migrations.AlterUniqueTogether(
name='paymentmethod',
unique_together=set(),
),
]

View file

@ -0,0 +1,476 @@
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
from uncloud.models import UncloudModel, UncloudStatus
# 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')
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_payment_method_id = models.CharField(max_length=32, blank=True, null=True)
stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True)
@property
def stripe_card_last4(self):
if self.source == 'stripe' and self.active:
payment_method = uncloud_pay.stripe.get_payment_method(
self.stripe_payment_method_id)
return payment_method.card.last4
else:
return None
@property
def active(self):
if self.source == 'stripe' and self.stripe_payment_method_id != None:
return True
else:
return False
def charge(self, amount):
if not self.active:
raise Exception('This payment method is inactive.')
if amount < 0: # Make sure we don't charge negative amount by errors...
raise Exception('Cannot charge negative amount.')
if self.source == 'stripe':
stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id
stripe_payment = uncloud_pay.stripe.charge_customer(
amount, stripe_customer, self.stripe_payment_method_id)
if 'paid' in stripe_payment and stripe_payment['paid'] == False:
raise Exception(stripe_payment['error'])
else:
payment = Payment.objects.create(
owner=self.owner, source=self.source, amount=amount)
return payment
else:
raise Exception('This payment method is unsupported/cannot be charged.')
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.active and method.primary:
return method
return None
class Meta:
# TODO: limit to one primary method per user.
# unique_together is no good since it won't allow more than one
# non-primary method.
pass
###
# Bills.
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(UncloudModel):
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=UncloudStatus.choices,
default=UncloudStatus.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

View file

@ -0,0 +1,71 @@
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 = '__all__'
class PaymentMethodSerializer(serializers.ModelSerializer):
stripe_card_last4 = serializers.IntegerField()
class Meta:
model = PaymentMethod
fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4', 'active']
class UpdatePaymentMethodSerializer(serializers.ModelSerializer):
class Meta:
model = PaymentMethod
fields = ['description', 'primary']
class ChargePaymentMethodSerializer(serializers.Serializer):
amount = serializers.DecimalField(max_digits=10, decimal_places=2)
class CreatePaymentMethodSerializer(serializers.ModelSerializer):
please_visit = serializers.CharField(read_only=True)
class Meta:
model = PaymentMethod
fields = ['source', 'description', 'primary', 'please_visit']
###
# 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']

View file

@ -0,0 +1,114 @@
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'
# README: We use the Payment Intent API as described on
# https://stripe.com/docs/payments/save-and-reuse
# For internal use only.
stripe.api_key = uncloud.secrets.STRIPE_KEY
# Helper (decorator) used to catch errors raised by stripe logic.
# Catch errors that should not be displayed to the end user, raise again.
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)
except Exception as e:
# maybe send email
logging.error(str(e))
raise Exception(common_message)
return handle_problems
# Actual Stripe logic.
def public_api_key():
return uncloud.secrets.STRIPE_PUBLIC_KEY
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.
try:
customer = create_customer(user.username, user.email)
uncloud_stripe_mapping = uncloud_pay.models.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)
def get_payment_method(payment_method_id):
return stripe.PaymentMethod.retrieve(payment_method_id)
@handle_stripe_error
def charge_customer(amount, customer_id, card_id):
# Amount is in CHF but stripes requires smallest possible unit.
# https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount
adjusted_amount = int(amount * 100)
return stripe.PaymentIntent.create(
amount=adjusted_amount,
currency=CURRENCY,
customer=customer_id,
payment_method=card_id,
off_session=True,
confirm=True,
)
@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)

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
#content {
width: 400px;
margin: auto;
}
</style>
</head>
<body>
<div id="content">
<h1>Error</h1>
<p>{{ error }}</p>
</div>
</body>
</html>

View file

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html>
<head>
<title>Stripe Card Registration</title>
<!-- https://stripe.com/docs/js/appendix/viewport_meta_requirements -->
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://js.stripe.com/v3/"></script>
<style>
#content {
width: 400px;
margin: auto;
}
#callback-form {
display: none;
}
</style>
</head>
<body>
<div id="content">
<h1>Registering Stripe Credit Card</h1>
<!-- Stripe form and messages -->
<span id="message"></span>
<form id="setup-form">
<div id="card-element"></div>
<button type='button' id="card-button">
Save
</button>
</form>
<!-- Dirty hack used for callback to API -->
<form id="callback-form" action="{{ callback }}" method="post"></form>
</div>
<!-- Enable Stripe from UI elements -->
<script>
var stripe = Stripe('{{ stripe_pk }}');
var elements = stripe.elements();
var cardElement = elements.create('card');
cardElement.mount('#card-element');
</script>
<!-- Handle card submission -->
<script>
var cardButton = document.getElementById('card-button');
var messageContainer = document.getElementById('message');
var clientSecret = '{{ client_secret }}';
cardButton.addEventListener('click', function(ev) {
stripe.confirmCardSetup(
clientSecret,
{
payment_method: {
card: cardElement,
billing_details: {
},
},
}
).then(function(result) {
if (result.error) {
var message = document.createTextNode('Error:' + result.error.message);
messageContainer.appendChild(message);
} else {
// Return to API on success.
document.getElementById("callback-form").submit();
}
});
});
</script>
</body>
</html>

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,238 @@
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, 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
import json
from .models import *
from .serializers import *
from datetime import datetime
import uncloud_pay.stripe as uncloud_stripe
###
# Users.
class UserViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return get_user_model().objects.all()
###
# Payments and Payment Methods.
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 == 'update':
return UpdatePaymentMethodSerializer
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)
if serializer.validated_data['source'] == "stripe":
# 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)
try:
setup_intent = uncloud_stripe.create_setup_intent(customer_id)
except Exception as e:
return Response({'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
payment_method = PaymentMethod.objects.create(
owner=request.user,
stripe_setup_intent_id=setup_intent.id,
**serializer.validated_data)
# TODO: find a way to use reverse properly:
# https://www.django-rest-framework.org/api-guide/reverse/
path = "payment-method/{}/register-stripe-cc".format(
payment_method.uuid)
stripe_registration_url = reverse('api-root', request=request) + path
return Response({'please_visit': stripe_registration_url})
else:
serializer.save(owner=request.user, **serializer.validated_data)
return Response(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)
@action(detail=True, methods=['get'], url_path='register-stripe-cc', renderer_classes=[TemplateHTMLRenderer])
def register_stripe_cc(self, request, pk=None):
payment_method = self.get_object()
if payment_method.source != 'stripe':
return Response(
{'error': 'This is not a Stripe-based payment method.'},
template_name='error.html.j2')
if payment_method.active:
return Response(
{'error': 'This payment method is already active'},
template_name='error.html.j2')
try:
setup_intent = uncloud_stripe.get_setup_intent(
payment_method.stripe_setup_intent_id)
except Exception as e:
return Response(
{'error': str(e)},
template_name='error.html.j2')
# TODO: find a way to use reverse properly:
# https://www.django-rest-framework.org/api-guide/reverse/
callback_path= "payment-method/{}/activate-stripe-cc/".format(
payment_method.uuid)
callback = reverse('api-root', request=request) + callback_path
# Render stripe card registration form.
template_args = {
'client_secret': setup_intent.client_secret,
'stripe_pk': uncloud_stripe.public_api_key,
'callback': callback
}
return Response(template_args, template_name='stripe-payment.html.j2')
@action(detail=True, methods=['post'], url_path='activate-stripe-cc')
def activate_stripe_cc(self, request, pk=None):
payment_method = self.get_object()
try:
setup_intent = uncloud_stripe.get_setup_intent(
payment_method.stripe_setup_intent_id)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Card had been registered, fetching payment method.
print(setup_intent)
if setup_intent.payment_method:
payment_method.stripe_payment_method_id = setup_intent.payment_method
payment_method.save()
return Response({
'uuid': payment_method.uuid,
'activated': payment_method.active})
else:
error = 'Could not fetch payment method from stripe. Please try again.'
return Response({'error': error})
###
# 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)
def unpaid(self, request):
return Bill.objects.filter(owner=self.request.user, paid=False)
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Order.objects.filter(owner=self.request.user)
###
# Old admin stuff.
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()
# PDF tests
from django.views.generic import TemplateView
from hardcopy.views import PDFViewMixin, PNGViewMixin
class MyPDFView(PDFViewMixin, TemplateView):
template_name = "bill.html"
# def get_filename(self):
# return "my_file_{}.pdf".format(now().strftime('Y-m-d'))

View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class UncloudStorageConfig(AppConfig):
name = 'uncloud_storage'

View file

@ -0,0 +1,7 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
class StorageClass(models.TextChoices):
HDD = 'HDD', _('HDD')
SSD = 'SSD', _('SSD')

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class UncloudVmConfig(AppConfig):
name = 'uncloud_vm'

View file

@ -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")

View file

@ -0,0 +1,108 @@
# Generated by Django 3.0.3 on 2020-03-05 10:34
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('uncloud_pay', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='VMDiskImageProduct',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=256)),
('is_os_image', models.BooleanField(default=False)),
('is_public', models.BooleanField(default=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)),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='VMHost',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('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='')),
],
),
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)),
('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')),
],
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)),
('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')),
],
options={
'abstract': False,
},
),
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=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('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')),
],
),
]

View file

@ -0,0 +1,23 @@
# 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),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.0.3 on 2020-03-05 13:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0002_auto_20200305_1321'),
]
operations = [
migrations.RemoveField(
model_name='vmhost',
name='vms',
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.0.3 on 2020-03-17 14:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0003_remove_vmhost_vms'),
]
operations = [
migrations.RemoveField(
model_name='vmproduct',
name='vmid',
),
]

View file

@ -0,0 +1,50 @@
# Generated by Django 3.0.3 on 2020-03-21 10:58
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0004_remove_vmproduct_vmid'),
]
operations = [
migrations.AddField(
model_name='vmdiskimageproduct',
name='extra_data',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='vmdiskproduct',
name='extra_data',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='vmhost',
name='extra_data',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='vmproduct',
name='extra_data',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='vmsnapshotproduct',
name='extra_data',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True),
),
migrations.AlterField(
model_name='vmdiskproduct',
name='vm',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='disks', to='uncloud_vm.VMProduct'),
),
migrations.AlterField(
model_name='vmsnapshotproduct',
name='vm',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='uncloud_vm.VMProduct'),
),
]

View file

@ -0,0 +1,57 @@
# Generated by Django 3.0.3 on 2020-03-22 17:58
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0005_auto_20200321_1058'),
]
operations = [
migrations.CreateModel(
name='VMCluster',
fields=[
('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=128, unique=True)),
],
options={
'abstract': False,
},
),
migrations.AlterField(
model_name='vmdiskimageproduct',
name='is_public',
field=models.BooleanField(default=False, editable=False),
),
migrations.AlterField(
model_name='vmdiskimageproduct',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32),
),
migrations.AlterField(
model_name='vmhost',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32),
),
migrations.AlterField(
model_name='vmproduct',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32),
),
migrations.AlterField(
model_name='vmsnapshotproduct',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32),
),
migrations.AddField(
model_name='vmproduct',
name='vmcluster',
field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMCluster'),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.3 on 2020-03-22 18:09
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0006_auto_20200322_1758'),
]
operations = [
migrations.AddField(
model_name='vmhost',
name='vmcluster',
field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMCluster'),
),
]

View file

@ -0,0 +1,33 @@
# Generated by Django 3.0.5 on 2020-04-03 17:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0007_vmhost_vmcluster'),
]
operations = [
migrations.AlterField(
model_name='vmdiskimageproduct',
name='status',
field=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),
),
migrations.AlterField(
model_name='vmhost',
name='status',
field=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),
),
migrations.AlterField(
model_name='vmproduct',
name='status',
field=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),
),
migrations.AlterField(
model_name='vmsnapshotproduct',
name='status',
field=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),
),
]

View file

@ -0,0 +1,193 @@
import uuid
from django.db import models
from django.contrib.auth import get_user_model
from uncloud_pay.models import Product, RecurringPeriod
from uncloud.models import UncloudModel, UncloudStatus
import uncloud_pay.models as pay_models
import uncloud_storage.models
class VMCluster(UncloudModel):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=128, unique=True)
class VMHost(UncloudModel):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# 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)
# determines the maximum usable cores - f.i. 320 if you overbook by a factor of 10
usable_cores = models.IntegerField(default=0)
# ram that can be used of the server
usable_ram_in_gb = models.FloatField(default=0)
status = models.CharField(
max_length=32, choices=UncloudStatus.choices, default=UncloudStatus.PENDING
)
@property
def vms(self):
return VMProduct.objects.filter(vmhost=self)
@property
def used_ram_in_gb(self):
return sum([vm.ram_in_gb for vm in VMProduct.objects.filter(vmhost=self)])
@property
def available_ram_in_gb(self):
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):
vmhost = models.ForeignKey(
VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True
)
vmcluster = models.ForeignKey(
VMCluster, 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)!
name = models.CharField(max_length=32, blank=True, null=True)
cores = models.IntegerField()
ram_in_gb = models.FloatField()
def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH):
# TODO: move magic numbers in variables
if recurring_period == RecurringPeriod.PER_MONTH:
return self.cores * 3 + self.ram_in_gb * 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))
class VMWithOSProduct(VMProduct):
pass
class VMDiskImageProduct(UncloudModel):
"""
Images are used for cloning/linking.
They are the base for images.
"""
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, 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)
image_source = models.CharField(max_length=128, null=True)
image_source_type = models.CharField(max_length=128, null=True)
storage_class = models.CharField(max_length=32,
choices = uncloud_storage.models.StorageClass.choices,
default = uncloud_storage.models.StorageClass.SSD)
status = models.CharField(
max_length=32, choices=UncloudStatus.choices, default=UncloudStatus.PENDING
)
def __str__(self):
return "VMDiskImage {} ({}): {} gb".format(self.uuid,
self.name,
self.size_in_gb)
class VMDiskProduct(UncloudModel):
"""
The VMDiskProduct is attached to a VM.
It is based on a VMDiskImageProduct that will be used as a basis.
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,
related_name='disks',
on_delete=models.CASCADE)
image = models.ForeignKey(VMDiskImageProduct, on_delete=models.CASCADE)
size_in_gb = models.FloatField(blank=True)
# Sample code for clean method
# Ensures that a VMDiskProduct can only be created from a VMDiskImageProduct
# that is in status 'active'
# def clean(self):
# if self.image.status != 'active':
# raise ValidationError({
# 'image': 'VM Disk must be created from an active disk image.'
# })
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
class VMNetworkCard(models.Model):
vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE)
mac_address = models.BigIntegerField()
ip_address = models.GenericIPAddressField(blank=True,
null=True)
class VMSnapshotProduct(Product):
gb_ssd = models.FloatField(editable=False)
gb_hdd = models.FloatField(editable=False)
vm = models.ForeignKey(VMProduct,
related_name='snapshots',
on_delete=models.CASCADE)

View file

@ -0,0 +1,104 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct, VMCluster
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.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__'
class VMDiskProductSerializer(serializers.ModelSerializer):
class Meta:
model = VMDiskProduct
fields = '__all__'
class VMDiskImageProductSerializer(serializers.ModelSerializer):
class Meta:
model = VMDiskImageProduct
fields = '__all__'
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'
class VMProductSerializer(serializers.ModelSerializer):
class Meta:
model = VMProduct
fields = ['uuid', 'order', 'owner', 'status', 'name',
'cores', 'ram_in_gb', 'recurring_period',
'snapshots', 'disks',
'extra_data' ]
read_only_fields = ['uuid', 'order', 'owner', 'status' ]
# Custom field used at creation (= ordering) only.
recurring_period = serializers.ChoiceField(
choices=VMProduct.allowed_recurring_periods())
snapshots = VMSnapshotProductSerializer(many=True,
read_only=True)
disks = VMDiskProductSerializer(many=True,
read_only=True)

View file

@ -0,0 +1,112 @@
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'
)
)

View file

@ -0,0 +1,224 @@
from django.db import transaction
from django.shortcuts import render
from django.contrib.auth.models import User
from django.shortcuts import get_object_or_404
from rest_framework import viewsets, permissions
from rest_framework.response import Response
from rest_framework.exceptions import ValidationError
from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct, VMCluster
from uncloud_pay.models import Order
from .serializers import (VMHostSerializer, VMProductSerializer,
VMSnapshotProductSerializer, VMDiskImageProductSerializer,
VMDiskProductSerializer, DCLVMProductSerializer,
VMClusterSerializer)
from uncloud_pay.helpers import ProductViewSet
import datetime
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]
class VMDiskImageProductViewSet(viewsets.ModelViewSet):
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)
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 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):
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)
# 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)
# 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)

View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class UngleichServiceConfig(AppConfig):
name = 'ungleich_service'

View file

@ -0,0 +1,34 @@
# 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,
},
),
]

Some files were not shown because too many files have changed in this diff Show more