-#
-# This work is licensed under the terms of the GNU GPL, version 2. See
-# the COPYING file in the top-level directory.
-
-import errno
-import json
-import logging
-import socket
-
-
-class QMPError(Exception):
- pass
-
-
-class QMPConnectError(QMPError):
- pass
-
-
-class QMPCapabilitiesError(QMPError):
- pass
-
-
-class QMPTimeoutError(QMPError):
- pass
-
-
-class QEMUMonitorProtocol(object):
- #: Logger object for debugging messages
- logger = logging.getLogger('QMP')
- #: Socket's error class
- error = socket.error
- #: Socket's timeout
- timeout = socket.timeout
-
- def __init__(self, address, server=False):
- """
- Create a QEMUMonitorProtocol class.
-
- @param address: QEMU address, can be either a unix socket path (string)
- or a tuple in the form ( address, port ) for a TCP
- connection
- @param server: server mode listens on the socket (bool)
- @raise socket.error on socket connection errors
- @note No connection is established, this is done by the connect() or
- accept() methods
- """
- self.__events = []
- self.__address = address
- self.__sock = self.__get_sock()
- self.__sockfile = None
- if server:
- self.__sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- self.__sock.bind(self.__address)
- self.__sock.listen(1)
-
- def __get_sock(self):
- if isinstance(self.__address, tuple):
- family = socket.AF_INET
- else:
- family = socket.AF_UNIX
- return socket.socket(family, socket.SOCK_STREAM)
-
- def __negotiate_capabilities(self):
- greeting = self.__json_read()
- if greeting is None or "QMP" not in greeting:
- raise QMPConnectError
- # Greeting seems ok, negotiate capabilities
- resp = self.cmd('qmp_capabilities')
- if "return" in resp:
- return greeting
- raise QMPCapabilitiesError
-
- def __json_read(self, only_event=False):
- while True:
- data = self.__sockfile.readline()
- if not data:
- return
- resp = json.loads(data)
- if 'event' in resp:
- self.logger.debug("<<< %s", resp)
- self.__events.append(resp)
- if not only_event:
- continue
- return resp
-
- def __get_events(self, wait=False):
- """
- Check for new events in the stream and cache them in __events.
-
- @param wait (bool): block until an event is available.
- @param wait (float): If wait is a float, treat it as a timeout value.
-
- @raise QMPTimeoutError: If a timeout float is provided and the timeout
- period elapses.
- @raise QMPConnectError: If wait is True but no events could be
- retrieved or if some other error occurred.
- """
-
- # Check for new events regardless and pull them into the cache:
- self.__sock.setblocking(0)
- try:
- self.__json_read()
- except socket.error as err:
- if err[0] == errno.EAGAIN:
- # No data available
- pass
- self.__sock.setblocking(1)
-
- # Wait for new events, if needed.
- # if wait is 0.0, this means "no wait" and is also implicitly false.
- if not self.__events and wait:
- if isinstance(wait, float):
- self.__sock.settimeout(wait)
- try:
- ret = self.__json_read(only_event=True)
- except socket.timeout:
- raise QMPTimeoutError("Timeout waiting for event")
- except:
- raise QMPConnectError("Error while reading from socket")
- if ret is None:
- raise QMPConnectError("Error while reading from socket")
- self.__sock.settimeout(None)
-
- def connect(self, negotiate=True):
- """
- Connect to the QMP Monitor and perform capabilities negotiation.
-
- @return QMP greeting dict
- @raise socket.error on socket connection errors
- @raise QMPConnectError if the greeting is not received
- @raise QMPCapabilitiesError if fails to negotiate capabilities
- """
- self.__sock.connect(self.__address)
- self.__sockfile = self.__sock.makefile()
- if negotiate:
- return self.__negotiate_capabilities()
-
- def accept(self):
- """
- Await connection from QMP Monitor and perform capabilities negotiation.
-
- @return QMP greeting dict
- @raise socket.error on socket connection errors
- @raise QMPConnectError if the greeting is not received
- @raise QMPCapabilitiesError if fails to negotiate capabilities
- """
- self.__sock.settimeout(15)
- self.__sock, _ = self.__sock.accept()
- self.__sockfile = self.__sock.makefile()
- return self.__negotiate_capabilities()
-
- def cmd_obj(self, qmp_cmd):
- """
- Send a QMP command to the QMP Monitor.
-
- @param qmp_cmd: QMP command to be sent as a Python dict
- @return QMP response as a Python dict or None if the connection has
- been closed
- """
- self.logger.debug(">>> %s", qmp_cmd)
- try:
- self.__sock.sendall(json.dumps(qmp_cmd).encode('utf-8'))
- except socket.error as err:
- if err[0] == errno.EPIPE:
- return
- raise socket.error(err)
- resp = self.__json_read()
- self.logger.debug("<<< %s", resp)
- return resp
-
- def cmd(self, name, args=None, cmd_id=None):
- """
- Build a QMP command and send it to the QMP Monitor.
-
- @param name: command name (string)
- @param args: command arguments (dict)
- @param cmd_id: command id (dict, list, string or int)
- """
- qmp_cmd = {'execute': name}
- if args:
- qmp_cmd['arguments'] = args
- if cmd_id:
- qmp_cmd['id'] = cmd_id
- return self.cmd_obj(qmp_cmd)
-
- def command(self, cmd, **kwds):
- """
- Build and send a QMP command to the monitor, report errors if any
- """
- ret = self.cmd(cmd, kwds)
- if "error" in ret:
- raise Exception(ret['error']['desc'])
- return ret['return']
-
- def pull_event(self, wait=False):
- """
- Pulls a single event.
-
- @param wait (bool): block until an event is available.
- @param wait (float): If wait is a float, treat it as a timeout value.
-
- @raise QMPTimeoutError: If a timeout float is provided and the timeout
- period elapses.
- @raise QMPConnectError: If wait is True but no events could be
- retrieved or if some other error occurred.
-
- @return The first available QMP event, or None.
- """
- self.__get_events(wait)
-
- if self.__events:
- return self.__events.pop(0)
- return None
-
- def get_events(self, wait=False):
- """
- Get a list of available QMP events.
-
- @param wait (bool): block until an event is available.
- @param wait (float): If wait is a float, treat it as a timeout value.
-
- @raise QMPTimeoutError: If a timeout float is provided and the timeout
- period elapses.
- @raise QMPConnectError: If wait is True but no events could be
- retrieved or if some other error occurred.
-
- @return The list of available QMP events.
- """
- self.__get_events(wait)
- return self.__events
-
- def clear_events(self):
- """
- Clear current list of pending events.
- """
- self.__events = []
-
- def close(self):
- self.__sock.close()
- self.__sockfile.close()
-
- def settimeout(self, timeout):
- self.__sock.settimeout(timeout)
-
- def get_sock_fd(self):
- return self.__sock.fileno()
-
- def is_scm_available(self):
- return self.__sock.family == socket.AF_UNIX
diff --git a/ucloud/host/virtualmachine.py b/ucloud/host/virtualmachine.py
deleted file mode 100755
index 7524083..0000000
--- a/ucloud/host/virtualmachine.py
+++ /dev/null
@@ -1,384 +0,0 @@
-# QEMU Manual
-# https://qemu.weilnetz.de/doc/qemu-doc.html
-
-# For QEMU Monitor Protocol Commands Information, See
-# https://qemu.weilnetz.de/doc/qemu-doc.html#pcsys_005fmonitor
-
-import os
-import random
-import subprocess as sp
-import tempfile
-import time
-
-from functools import wraps
-from string import Template
-from typing import Union
-from os.path import join as join_path
-
-import bitmath
-import sshtunnel
-
-from ucloud.common.helpers import get_ipv6_address
-from ucloud.common.request import RequestEntry, RequestType
-from ucloud.common.vm import VMEntry, VMStatus
-from ucloud.config import (etcd_client, request_pool,
- running_vms, vm_pool, env_vars,
- image_storage_handler)
-from . import qmp
-from ucloud.host import logger
-
-
-class VM:
- def __init__(self, key, handle, vnc_socket_file):
- self.key = key # type: str
- self.handle = handle # type: qmp.QEMUMachine
- self.vnc_socket_file = vnc_socket_file # type: tempfile.NamedTemporaryFile
-
- def __repr__(self):
- return "VM({})".format(self.key)
-
-
-def delete_network_interface(iface):
- try:
- sp.check_output(['ip', 'link', 'del', iface])
- except Exception:
- pass
-
-
-def resolve_network(network_name, network_owner):
- network = etcd_client.get(join_path(env_vars.get("NETWORK_PREFIX"),
- network_owner,
- network_name),
- value_in_json=True)
- return network
-
-
-def delete_vm_network(vm_entry):
- try:
- for network in vm_entry.network:
- network_name = network[0]
- tap_mac = network[1]
- tap_id = network[2]
-
- delete_network_interface('tap{}'.format(tap_id))
-
- owners_vms = vm_pool.by_owner(vm_entry.owner)
- owners_running_vms = vm_pool.by_status(VMStatus.running,
- _vms=owners_vms)
-
- networks = map(lambda n: n[0],
- map(lambda vm: vm.network, owners_running_vms)
- )
- networks_in_use_by_user_vms = [vm[0] for vm in networks]
- if network_name not in networks_in_use_by_user_vms:
- network_entry = resolve_network(network[0], vm_entry.owner)
- if network_entry:
- network_type = network_entry.value["type"]
- network_id = network_entry.value["id"]
- if network_type == "vxlan":
- delete_network_interface('br{}'.format(network_id))
- delete_network_interface('vxlan{}'.format(network_id))
- except Exception:
- logger.exception("Exception in network interface deletion")
-
-
-def create_dev(script, _id, dev, ip=None):
- command = [script, _id, dev]
- if ip:
- command.append(ip)
- try:
- output = sp.check_output(command, stderr=sp.PIPE)
- except Exception as e:
- print(e.stderr)
- return None
- else:
- return output.decode("utf-8").strip()
-
-
-def create_vxlan_br_tap(_id, _dev, tap_id, ip=None):
- network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network')
- vxlan = create_dev(script=os.path.join(network_script_base, 'create-vxlan.sh'),
- _id=_id, dev=_dev)
- if vxlan:
- bridge = create_dev(script=os.path.join(network_script_base, 'create-bridge.sh'),
- _id=_id, dev=vxlan, ip=ip)
- if bridge:
- tap = create_dev(script=os.path.join(network_script_base, 'create-tap.sh'),
- _id=str(tap_id), dev=bridge)
- if tap:
- return tap
-
-
-def random_bytes(num=6):
- return [random.randrange(256) for _ in range(num)]
-
-
-def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt='%02x'):
- mac = random_bytes()
- if oui:
- if type(oui) == str:
- oui = [int(chunk) for chunk in oui.split(separator)]
- mac = oui + random_bytes(num=6 - len(oui))
- else:
- if multicast:
- mac[0] |= 1 # set bit 0
- else:
- mac[0] &= ~1 # clear bit 0
- if uaa:
- mac[0] &= ~(1 << 1) # clear bit 1
- else:
- mac[0] |= 1 << 1 # set bit 1
- return separator.join(byte_fmt % b for b in mac)
-
-
-def update_radvd_conf(etcd_client):
- network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network')
-
- networks = {
- net.value['ipv6']: net.value['id']
- for net in etcd_client.get_prefix('/v1/network/', value_in_json=True)
- if net.value.get('ipv6')
- }
- radvd_template = open(os.path.join(network_script_base,
- 'radvd-template.conf'), 'r').read()
- radvd_template = Template(radvd_template)
-
- content = [radvd_template.safe_substitute(bridge='br{}'.format(networks[net]),
- prefix=net)
- for net in networks if networks.get(net)]
-
- with open('/etc/radvd.conf', 'w') as radvd_conf:
- radvd_conf.writelines(content)
- try:
- sp.check_output(['systemctl', 'restart', 'radvd'])
- except Exception:
- sp.check_output(['service', 'radvd', 'restart'])
-
-
-def get_start_command_args(vm_entry, vnc_sock_filename: str, migration=False, migration_port=None):
- threads_per_core = 1
- vm_memory = int(bitmath.parse_string_unsafe(vm_entry.specs["ram"]).to_MB())
- vm_cpus = int(vm_entry.specs["cpu"])
- vm_uuid = vm_entry.uuid
- vm_networks = vm_entry.network
-
- command = "-name {}_{}".format(vm_entry.owner, vm_entry.name)
-
- command += " -drive file={},format=raw,if=virtio,cache=none".format(
- image_storage_handler.qemu_path_string(vm_uuid)
- )
- command += " -device virtio-rng-pci -vnc unix:{}".format(vnc_sock_filename)
- command += " -m {} -smp cores={},threads={}".format(
- vm_memory, vm_cpus, threads_per_core
- )
-
- if migration:
- command += " -incoming tcp:[::]:{}".format(migration_port)
-
- tap = None
- for network_mac_and_tap in vm_networks:
- network_name, mac, tap = network_mac_and_tap
-
- _key = os.path.join(env_vars.get('NETWORK_PREFIX'), vm_entry.owner, network_name)
- network = etcd_client.get(_key, value_in_json=True)
- network_type = network.value["type"]
- network_id = str(network.value["id"])
- network_ipv6 = network.value["ipv6"]
-
- if network_type == "vxlan":
- tap = create_vxlan_br_tap(_id=network_id,
- _dev=env_vars.get("VXLAN_PHY_DEV"),
- tap_id=tap,
- ip=network_ipv6)
- update_radvd_conf(etcd_client)
-
- command += " -netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no" \
- " -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}" \
- .format(tap=tap, net_id=network_id, mac=mac)
-
- return command.split(" ")
-
-
-def create_vm_object(vm_entry, migration=False, migration_port=None):
- # NOTE: If migration suddenly stop working, having different
- # VNC unix filename on source and destination host can
- # be a possible cause of it.
-
- # REQUIREMENT: Use Unix Socket instead of TCP Port for VNC
- vnc_sock_file = tempfile.NamedTemporaryFile()
-
- qemu_args = get_start_command_args(
- vm_entry=vm_entry,
- vnc_sock_filename=vnc_sock_file.name,
- migration=migration,
- migration_port=migration_port,
- )
- qemu_machine = qmp.QEMUMachine("/usr/bin/qemu-system-x86_64", args=qemu_args)
- return VM(vm_entry.key, qemu_machine, vnc_sock_file)
-
-
-def get_vm(vm_list: list, vm_key) -> Union[VM, None]:
- return next((vm for vm in vm_list if vm.key == vm_key), None)
-
-
-def need_running_vm(func):
- @wraps(func)
- def wrapper(e):
- vm = get_vm(running_vms, e.key)
- if vm:
- try:
- status = vm.handle.command("query-status")
- logger.debug("VM Status Check - %s", status)
- except Exception as exception:
- logger.info("%s failed - VM %s %s", func.__name__, e, exception)
- else:
- return func(e)
-
- return None
- else:
- logger.info("%s failed because VM %s is not running", func.__name__, e.key)
- return None
-
- return wrapper
-
-
-def create(vm_entry: VMEntry):
- if image_storage_handler.is_vm_image_exists(vm_entry.uuid):
- # File Already exists. No Problem Continue
- logger.debug("Image for vm %s exists", vm_entry.uuid)
- else:
- vm_hdd = int(bitmath.parse_string_unsafe(vm_entry.specs["os-ssd"]).to_MB())
- if image_storage_handler.make_vm_image(src=vm_entry.image_uuid, dest=vm_entry.uuid):
- if not image_storage_handler.resize_vm_image(path=vm_entry.uuid, size=vm_hdd):
- vm_entry.status = VMStatus.error
- else:
- logger.info("New VM Created")
-
-
-def start(vm_entry: VMEntry, destination_host_key=None, migration_port=None):
- _vm = get_vm(running_vms, vm_entry.key)
-
- # VM already running. No need to proceed further.
- if _vm:
- logger.info("VM %s already running" % vm_entry.uuid)
- return
- else:
- logger.info("Trying to start %s" % vm_entry.uuid)
- if destination_host_key:
- launch_vm(vm_entry, migration=True, migration_port=migration_port,
- destination_host_key=destination_host_key)
- else:
- create(vm_entry)
- launch_vm(vm_entry)
-
-
-@need_running_vm
-def stop(vm_entry):
- vm = get_vm(running_vms, vm_entry.key)
- vm.handle.shutdown()
- if not vm.handle.is_running():
- vm_entry.add_log("Shutdown successfully")
- vm_entry.declare_stopped()
- vm_pool.put(vm_entry)
- running_vms.remove(vm)
- delete_vm_network(vm_entry)
-
-
-def delete(vm_entry):
- logger.info("Deleting VM | %s", vm_entry)
- stop(vm_entry)
-
- if image_storage_handler.is_vm_image_exists(vm_entry.uuid):
- r_status = image_storage_handler.delete_vm_image(vm_entry.uuid)
- if r_status:
- etcd_client.client.delete(vm_entry.key)
- else:
- etcd_client.client.delete(vm_entry.key)
-
-def transfer(request_event):
- # This function would run on source host i.e host on which the vm
- # is running initially. This host would be responsible for transferring
- # vm state to destination host.
-
- _host, _port = request_event.parameters["host"], request_event.parameters["port"]
- _uuid = request_event.uuid
- _destination = request_event.destination_host_key
- vm = get_vm(running_vms, join_path(env_vars.get('VM_PREFIX'), _uuid))
-
- if vm:
- tunnel = sshtunnel.SSHTunnelForwarder(
- _host,
- ssh_username=env_vars.get("ssh_username"),
- ssh_pkey=env_vars.get("ssh_pkey"),
- remote_bind_address=("127.0.0.1", _port),
- ssh_proxy_enabled=True,
- ssh_proxy=(_host, 22)
- )
- try:
- tunnel.start()
- except sshtunnel.BaseSSHTunnelForwarderError:
- logger.exception("Couldn't establish connection to (%s, 22)", _host)
- else:
- vm.handle.command(
- "migrate", uri="tcp:0.0.0.0:{}".format(tunnel.local_bind_port)
- )
-
- status = vm.handle.command("query-migrate")["status"]
- while status not in ["failed", "completed"]:
- time.sleep(2)
- status = vm.handle.command("query-migrate")["status"]
-
- with vm_pool.get_put(request_event.uuid) as source_vm:
- if status == "failed":
- source_vm.add_log("Migration Failed")
- elif status == "completed":
- # If VM is successfully migrated then shutdown the VM
- # on this host and update hostname to destination host key
- source_vm.add_log("Successfully migrated")
- source_vm.hostname = _destination
- running_vms.remove(vm)
- vm.handle.shutdown()
- source_vm.in_migration = False # VM transfer finished
- finally:
- tunnel.close()
-
-
-def launch_vm(vm_entry, migration=False, migration_port=None, destination_host_key=None):
- logger.info("Starting %s" % vm_entry.key)
-
- vm = create_vm_object(vm_entry, migration=migration, migration_port=migration_port)
- try:
- vm.handle.launch()
- except Exception:
- logger.exception("Error Occured while starting VM")
- vm.handle.shutdown()
-
- if migration:
- # We don't care whether MachineError or any other error occurred
- pass
- else:
- # Error during typical launch of a vm
- vm.handle.shutdown()
- vm_entry.declare_killed()
- vm_pool.put(vm_entry)
- else:
- vm_entry.vnc_socket = vm.vnc_socket_file.name
- running_vms.append(vm)
-
- if migration:
- vm_entry.in_migration = True
- r = RequestEntry.from_scratch(
- type=RequestType.TransferVM,
- hostname=vm_entry.hostname,
- parameters={"host": get_ipv6_address(), "port": migration_port},
- uuid=vm_entry.uuid,
- destination_host_key=destination_host_key,
- request_prefix=env_vars.get("REQUEST_PREFIX")
- )
- request_pool.put(r)
- else:
- # Typical launching of a vm
- vm_entry.status = VMStatus.running
- vm_entry.add_log("Started successfully")
-
- vm_pool.put(vm_entry)
diff --git a/ucloud/imagescanner/main.py b/ucloud/imagescanner/main.py
deleted file mode 100755
index 20ce9d5..0000000
--- a/ucloud/imagescanner/main.py
+++ /dev/null
@@ -1,78 +0,0 @@
-import json
-import os
-import subprocess
-
-from os.path import join as join_path
-from ucloud.config import etcd_client, env_vars, image_storage_handler
-from ucloud.imagescanner import logger
-
-
-def qemu_img_type(path):
- qemu_img_info_command = ["qemu-img", "info", "--output", "json", path]
- try:
- qemu_img_info = subprocess.check_output(qemu_img_info_command)
- except Exception as e:
- logger.exception(e)
- return None
- else:
- qemu_img_info = json.loads(qemu_img_info.decode("utf-8"))
- return qemu_img_info["format"]
-
-
-def main():
- # We want to get images entries that requests images to be created
- images = etcd_client.get_prefix(env_vars.get('IMAGE_PREFIX'), value_in_json=True)
- images_to_be_created = list(filter(lambda im: im.value['status'] == 'TO_BE_CREATED', images))
-
- for image in images_to_be_created:
- try:
- image_uuid = image.key.split('/')[-1]
- image_owner = image.value['owner']
- image_filename = image.value['filename']
- image_store_name = image.value['store_name']
- image_full_path = join_path(env_vars.get('BASE_DIR'), image_owner, image_filename)
-
- image_stores = etcd_client.get_prefix(env_vars.get('IMAGE_STORE_PREFIX'), value_in_json=True)
- user_image_store = next(filter(
- lambda s, store_name=image_store_name: s.value["name"] == store_name,
- image_stores
- ))
-
- image_store_pool = user_image_store.value['attributes']['pool']
-
- except Exception as e:
- logger.exception(e)
- else:
- # At least our basic data is available
- qemu_img_convert_command = ["qemu-img", "convert", "-f", "qcow2",
- "-O", "raw", image_full_path, "image.raw"]
-
- if qemu_img_type(image_full_path) == "qcow2":
- try:
- # Convert .qcow2 to .raw
- subprocess.check_output(qemu_img_convert_command)
- except Exception as e:
- logger.exception(e)
- else:
- # Import and Protect
- r_status = image_storage_handler.import_image(src="image.raw",
- dest=image_uuid,
- protect=True)
- if r_status:
- # Everything is successfully done
- image.value["status"] = "CREATED"
- etcd_client.put(image.key, json.dumps(image.value))
-
- else:
- # The user provided image is either not found or of invalid format
- image.value["status"] = "INVALID_IMAGE"
- etcd_client.put(image.key, json.dumps(image.value))
-
- try:
- os.remove("image.raw")
- except Exception:
- pass
-
-
-if __name__ == "__main__":
- main()
diff --git a/ucloud/metadata/main.py b/ucloud/metadata/main.py
deleted file mode 100644
index e7cb33b..0000000
--- a/ucloud/metadata/main.py
+++ /dev/null
@@ -1,91 +0,0 @@
-import os
-
-from flask import Flask, request
-from flask_restful import Resource, Api
-
-from ucloud.config import etcd_client, env_vars, vm_pool
-
-app = Flask(__name__)
-api = Api(app)
-
-
-def get_vm_entry(mac_addr):
- return next(filter(lambda vm: mac_addr in list(zip(*vm.network))[1], vm_pool.vms), None)
-
-
-# https://stackoverflow.com/questions/37140846/how-to-convert-ipv6-link-local-address-to-mac-address-in-python
-def ipv62mac(ipv6):
- # remove subnet info if given
- subnet_index = ipv6.find('/')
- if subnet_index != -1:
- ipv6 = ipv6[:subnet_index]
-
- ipv6_parts = ipv6.split(':')
- mac_parts = list()
- for ipv6_part in ipv6_parts[-4:]:
- while len(ipv6_part) < 4:
- ipv6_part = '0' + ipv6_part
- mac_parts.append(ipv6_part[:2])
- mac_parts.append(ipv6_part[-2:])
-
- # modify parts to match MAC value
- mac_parts[0] = '%02x' % (int(mac_parts[0], 16) ^ 2)
- del mac_parts[4]
- del mac_parts[3]
- return ':'.join(mac_parts)
-
-
-class Root(Resource):
- @staticmethod
- def get():
- data = get_vm_entry(ipv62mac(request.remote_addr))
-
- if not data:
- return {'message': 'Metadata for such VM does not exists.'}, 404
- else:
-
- # {env_vars.get('USER_PREFIX')}/{realm}/{name}/key
- etcd_key = os.path.join(env_vars.get('USER_PREFIX'), data.value['owner_realm'],
- data.value['owner'], 'key')
- etcd_entry = etcd_client.get_prefix(etcd_key, value_in_json=True)
- user_personal_ssh_keys = [key.value for key in etcd_entry]
- data.value['metadata']['ssh-keys'] += user_personal_ssh_keys
- return data.value['metadata'], 200
-
- @staticmethod
- def post():
- return {'message': 'Previous Implementation is deprecated.'}
- # data = etcd_client.get("/v1/metadata/{}".format(request.remote_addr), value_in_json=True)
- # print(data)
- # if data:
- # for k in request.json:
- # if k not in data.value:
- # data.value[k] = request.json[k]
- # if k.endswith("-list"):
- # data.value[k] = [request.json[k]]
- # else:
- # if k.endswith("-list"):
- # data.value[k].append(request.json[k])
- # else:
- # data.value[k] = request.json[k]
- # etcd_client.put("/v1/metadata/{}".format(request.remote_addr),
- # data.value, value_in_json=True)
- # else:
- # data = {}
- # for k in request.json:
- # data[k] = request.json[k]
- # if k.endswith("-list"):
- # data[k] = [request.json[k]]
- # etcd_client.put("/v1/metadata/{}".format(request.remote_addr),
- # data, value_in_json=True)
-
-
-api.add_resource(Root, '/')
-
-
-def main():
- app.run(debug=True, host="::", port="80")
-
-
-if __name__ == '__main__':
- main()
diff --git a/ucloud/sanity_checks.py b/ucloud/sanity_checks.py
deleted file mode 100644
index 143f767..0000000
--- a/ucloud/sanity_checks.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import sys
-import subprocess as sp
-
-from os.path import isdir
-from ucloud.config import env_vars
-
-
-def check():
- #########################
- # ucloud-image-scanner #
- #########################
- if env_vars.get('STORAGE_BACKEND') == 'filesystem' and not isdir(env_vars.get('IMAGE_DIR')):
- print("You have set STORAGE_BACKEND to filesystem. So,"
- "the {} must exists. But, it don't".format(env_vars.get('IMAGE_DIR')))
- sys.exit(1)
-
- try:
- sp.check_output(['which', 'qemu-img'])
- except Exception:
- print("qemu-img missing")
- sys.exit(1)
-
- ###############
- # ucloud-host #
- ###############
-
- if env_vars.get('STORAGE_BACKEND') == 'filesystem' and not isdir(env_vars.get('VM_DIR')):
- print("You have set STORAGE_BACKEND to filesystem. So, the vm directory mentioned"
- " in .env file must exists. But, it don't.")
- sys.exit(1)
-
-if __name__ == "__main__":
- check()
\ No newline at end of file
diff --git a/ucloud/scheduler/__init__.py b/ucloud/scheduler/__init__.py
deleted file mode 100644
index 95e1be0..0000000
--- a/ucloud/scheduler/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-import logging
-
-logger = logging.getLogger(__name__)
\ No newline at end of file
diff --git a/ucloud/scheduler/main.py b/ucloud/scheduler/main.py
deleted file mode 100755
index e2c975a..0000000
--- a/ucloud/scheduler/main.py
+++ /dev/null
@@ -1,93 +0,0 @@
-# TODO
-# 1. send an email to an email address defined by env['admin-email']
-# if resources are finished
-# 2. Introduce a status endpoint of the scheduler -
-# maybe expose a prometheus compatible output
-
-from ucloud.common.request import RequestEntry, RequestType
-from ucloud.config import etcd_client
-from ucloud.config import host_pool, request_pool, vm_pool, env_vars
-from .helper import (get_suitable_host, dead_host_mitigation, dead_host_detection,
- assign_host, NoSuitableHostFound)
-from . import logger
-
-
-def main():
- logger.info("%s SESSION STARTED %s", '*' * 5, '*' * 5)
-
- pending_vms = []
-
- for request_iterator in [
- etcd_client.get_prefix(env_vars.get('REQUEST_PREFIX'), value_in_json=True),
- etcd_client.watch_prefix(env_vars.get('REQUEST_PREFIX'), timeout=5, value_in_json=True),
- ]:
- for request_event in request_iterator:
- request_entry = RequestEntry(request_event)
- # Never Run time critical mechanism inside timeout
- # mechanism because timeout mechanism only comes
- # when no other event is happening. It means under
- # heavy load there would not be a timeout event.
- if request_entry.type == "TIMEOUT":
-
- # Detect hosts that are dead and set their status
- # to "DEAD", and their VMs' status to "KILLED"
- dead_hosts = dead_host_detection()
- if dead_hosts:
- logger.debug("Dead hosts: %s", dead_hosts)
- dead_host_mitigation(dead_hosts)
-
- # If there are VMs that weren't assigned a host
- # because there wasn't a host available which
- # meets requirement of that VM then we would
- # create a new ScheduleVM request for that VM
- # on our behalf.
- while pending_vms:
- pending_vm_entry = pending_vms.pop()
- r = RequestEntry.from_scratch(type="ScheduleVM",
- uuid=pending_vm_entry.uuid,
- hostname=pending_vm_entry.hostname,
- request_prefix=env_vars.get("REQUEST_PREFIX"))
- request_pool.put(r)
-
- elif request_entry.type == RequestType.ScheduleVM:
- logger.debug("%s, %s", request_entry.key, request_entry.value)
-
- vm_entry = vm_pool.get(request_entry.uuid)
- if vm_entry is None:
- logger.info("Trying to act on {} but it is deleted".format(request_entry.uuid))
- continue
- etcd_client.client.delete(request_entry.key) # consume Request
-
- # If the Request is about a VM which is labelled as "migration"
- # and has a destination
- if hasattr(request_entry, "migration") and request_entry.migration \
- and hasattr(request_entry, "destination") and request_entry.destination:
- try:
- get_suitable_host(vm_specs=vm_entry.specs,
- hosts=[host_pool.get(request_entry.destination)])
- except NoSuitableHostFound:
- logger.info("Requested destination host doesn't have enough capacity"
- "to hold %s" % vm_entry.uuid)
- else:
- r = RequestEntry.from_scratch(type=RequestType.InitVMMigration,
- uuid=request_entry.uuid,
- destination=request_entry.destination,
- request_prefix=env_vars.get("REQUEST_PREFIX"))
- request_pool.put(r)
-
- # If the Request is about a VM that just want to get started/created
- else:
- # assign_host only returns None when we couldn't be able to assign
- # a host to a VM because of resource constraints
- try:
- assign_host(vm_entry)
- except NoSuitableHostFound:
- vm_entry.add_log("Can't schedule VM. No Resource Left.")
- vm_pool.put(vm_entry)
-
- pending_vms.append(vm_entry)
- logger.info("No Resource Left. Emailing admin....")
-
-
-if __name__ == "__main__":
- main()
diff --git a/uncloud/.gitignore b/uncloud/.gitignore
new file mode 100644
index 0000000..b03e0a5
--- /dev/null
+++ b/uncloud/.gitignore
@@ -0,0 +1,2 @@
+local_settings.py
+ldap_max_uid_file
\ No newline at end of file
diff --git a/uncloud/__init__.py b/uncloud/__init__.py
new file mode 100644
index 0000000..e073dd5
--- /dev/null
+++ b/uncloud/__init__.py
@@ -0,0 +1,254 @@
+from django.utils.translation import gettext_lazy as _
+import decimal
+from .celery import app as celery_app
+
+# Define DecimalField properties, used to represent amounts of money.
+AMOUNT_MAX_DIGITS=10
+AMOUNT_DECIMALS=2
+
+decimal.getcontext().prec = AMOUNT_DECIMALS
+
+# http://xml.coverpages.org/country3166.html
+COUNTRIES = (
+ ('AD', _('Andorra')),
+ ('AE', _('United Arab Emirates')),
+ ('AF', _('Afghanistan')),
+ ('AG', _('Antigua & Barbuda')),
+ ('AI', _('Anguilla')),
+ ('AL', _('Albania')),
+ ('AM', _('Armenia')),
+ ('AN', _('Netherlands Antilles')),
+ ('AO', _('Angola')),
+ ('AQ', _('Antarctica')),
+ ('AR', _('Argentina')),
+ ('AS', _('American Samoa')),
+ ('AT', _('Austria')),
+ ('AU', _('Australia')),
+ ('AW', _('Aruba')),
+ ('AZ', _('Azerbaijan')),
+ ('BA', _('Bosnia and Herzegovina')),
+ ('BB', _('Barbados')),
+ ('BD', _('Bangladesh')),
+ ('BE', _('Belgium')),
+ ('BF', _('Burkina Faso')),
+ ('BG', _('Bulgaria')),
+ ('BH', _('Bahrain')),
+ ('BI', _('Burundi')),
+ ('BJ', _('Benin')),
+ ('BM', _('Bermuda')),
+ ('BN', _('Brunei Darussalam')),
+ ('BO', _('Bolivia')),
+ ('BR', _('Brazil')),
+ ('BS', _('Bahama')),
+ ('BT', _('Bhutan')),
+ ('BV', _('Bouvet Island')),
+ ('BW', _('Botswana')),
+ ('BY', _('Belarus')),
+ ('BZ', _('Belize')),
+ ('CA', _('Canada')),
+ ('CC', _('Cocos (Keeling) Islands')),
+ ('CF', _('Central African Republic')),
+ ('CG', _('Congo')),
+ ('CH', _('Switzerland')),
+ ('CI', _('Ivory Coast')),
+ ('CK', _('Cook Iislands')),
+ ('CL', _('Chile')),
+ ('CM', _('Cameroon')),
+ ('CN', _('China')),
+ ('CO', _('Colombia')),
+ ('CR', _('Costa Rica')),
+ ('CU', _('Cuba')),
+ ('CV', _('Cape Verde')),
+ ('CX', _('Christmas Island')),
+ ('CY', _('Cyprus')),
+ ('CZ', _('Czech Republic')),
+ ('DE', _('Germany')),
+ ('DJ', _('Djibouti')),
+ ('DK', _('Denmark')),
+ ('DM', _('Dominica')),
+ ('DO', _('Dominican Republic')),
+ ('DZ', _('Algeria')),
+ ('EC', _('Ecuador')),
+ ('EE', _('Estonia')),
+ ('EG', _('Egypt')),
+ ('EH', _('Western Sahara')),
+ ('ER', _('Eritrea')),
+ ('ES', _('Spain')),
+ ('ET', _('Ethiopia')),
+ ('FI', _('Finland')),
+ ('FJ', _('Fiji')),
+ ('FK', _('Falkland Islands (Malvinas)')),
+ ('FM', _('Micronesia')),
+ ('FO', _('Faroe Islands')),
+ ('FR', _('France')),
+ ('FX', _('France, Metropolitan')),
+ ('GA', _('Gabon')),
+ ('GB', _('United Kingdom (Great Britain)')),
+ ('GD', _('Grenada')),
+ ('GE', _('Georgia')),
+ ('GF', _('French Guiana')),
+ ('GH', _('Ghana')),
+ ('GI', _('Gibraltar')),
+ ('GL', _('Greenland')),
+ ('GM', _('Gambia')),
+ ('GN', _('Guinea')),
+ ('GP', _('Guadeloupe')),
+ ('GQ', _('Equatorial Guinea')),
+ ('GR', _('Greece')),
+ ('GS', _('South Georgia and the South Sandwich Islands')),
+ ('GT', _('Guatemala')),
+ ('GU', _('Guam')),
+ ('GW', _('Guinea-Bissau')),
+ ('GY', _('Guyana')),
+ ('HK', _('Hong Kong')),
+ ('HM', _('Heard & McDonald Islands')),
+ ('HN', _('Honduras')),
+ ('HR', _('Croatia')),
+ ('HT', _('Haiti')),
+ ('HU', _('Hungary')),
+ ('ID', _('Indonesia')),
+ ('IE', _('Ireland')),
+ ('IL', _('Israel')),
+ ('IN', _('India')),
+ ('IO', _('British Indian Ocean Territory')),
+ ('IQ', _('Iraq')),
+ ('IR', _('Islamic Republic of Iran')),
+ ('IS', _('Iceland')),
+ ('IT', _('Italy')),
+ ('JM', _('Jamaica')),
+ ('JO', _('Jordan')),
+ ('JP', _('Japan')),
+ ('KE', _('Kenya')),
+ ('KG', _('Kyrgyzstan')),
+ ('KH', _('Cambodia')),
+ ('KI', _('Kiribati')),
+ ('KM', _('Comoros')),
+ ('KN', _('St. Kitts and Nevis')),
+ ('KP', _('Korea, Democratic People\'s Republic of')),
+ ('KR', _('Korea, Republic of')),
+ ('KW', _('Kuwait')),
+ ('KY', _('Cayman Islands')),
+ ('KZ', _('Kazakhstan')),
+ ('LA', _('Lao People\'s Democratic Republic')),
+ ('LB', _('Lebanon')),
+ ('LC', _('Saint Lucia')),
+ ('LI', _('Liechtenstein')),
+ ('LK', _('Sri Lanka')),
+ ('LR', _('Liberia')),
+ ('LS', _('Lesotho')),
+ ('LT', _('Lithuania')),
+ ('LU', _('Luxembourg')),
+ ('LV', _('Latvia')),
+ ('LY', _('Libyan Arab Jamahiriya')),
+ ('MA', _('Morocco')),
+ ('MC', _('Monaco')),
+ ('MD', _('Moldova, Republic of')),
+ ('MG', _('Madagascar')),
+ ('MH', _('Marshall Islands')),
+ ('ML', _('Mali')),
+ ('MN', _('Mongolia')),
+ ('MM', _('Myanmar')),
+ ('MO', _('Macau')),
+ ('MP', _('Northern Mariana Islands')),
+ ('MQ', _('Martinique')),
+ ('MR', _('Mauritania')),
+ ('MS', _('Monserrat')),
+ ('MT', _('Malta')),
+ ('MU', _('Mauritius')),
+ ('MV', _('Maldives')),
+ ('MW', _('Malawi')),
+ ('MX', _('Mexico')),
+ ('MY', _('Malaysia')),
+ ('MZ', _('Mozambique')),
+ ('NA', _('Namibia')),
+ ('NC', _('New Caledonia')),
+ ('NE', _('Niger')),
+ ('NF', _('Norfolk Island')),
+ ('NG', _('Nigeria')),
+ ('NI', _('Nicaragua')),
+ ('NL', _('Netherlands')),
+ ('NO', _('Norway')),
+ ('NP', _('Nepal')),
+ ('NR', _('Nauru')),
+ ('NU', _('Niue')),
+ ('NZ', _('New Zealand')),
+ ('OM', _('Oman')),
+ ('PA', _('Panama')),
+ ('PE', _('Peru')),
+ ('PF', _('French Polynesia')),
+ ('PG', _('Papua New Guinea')),
+ ('PH', _('Philippines')),
+ ('PK', _('Pakistan')),
+ ('PL', _('Poland')),
+ ('PM', _('St. Pierre & Miquelon')),
+ ('PN', _('Pitcairn')),
+ ('PR', _('Puerto Rico')),
+ ('PT', _('Portugal')),
+ ('PW', _('Palau')),
+ ('PY', _('Paraguay')),
+ ('QA', _('Qatar')),
+ ('RE', _('Reunion')),
+ ('RO', _('Romania')),
+ ('RU', _('Russian Federation')),
+ ('RW', _('Rwanda')),
+ ('SA', _('Saudi Arabia')),
+ ('SB', _('Solomon Islands')),
+ ('SC', _('Seychelles')),
+ ('SD', _('Sudan')),
+ ('SE', _('Sweden')),
+ ('SG', _('Singapore')),
+ ('SH', _('St. Helena')),
+ ('SI', _('Slovenia')),
+ ('SJ', _('Svalbard & Jan Mayen Islands')),
+ ('SK', _('Slovakia')),
+ ('SL', _('Sierra Leone')),
+ ('SM', _('San Marino')),
+ ('SN', _('Senegal')),
+ ('SO', _('Somalia')),
+ ('SR', _('Suriname')),
+ ('ST', _('Sao Tome & Principe')),
+ ('SV', _('El Salvador')),
+ ('SY', _('Syrian Arab Republic')),
+ ('SZ', _('Swaziland')),
+ ('TC', _('Turks & Caicos Islands')),
+ ('TD', _('Chad')),
+ ('TF', _('French Southern Territories')),
+ ('TG', _('Togo')),
+ ('TH', _('Thailand')),
+ ('TJ', _('Tajikistan')),
+ ('TK', _('Tokelau')),
+ ('TM', _('Turkmenistan')),
+ ('TN', _('Tunisia')),
+ ('TO', _('Tonga')),
+ ('TP', _('East Timor')),
+ ('TR', _('Turkey')),
+ ('TT', _('Trinidad & Tobago')),
+ ('TV', _('Tuvalu')),
+ ('TW', _('Taiwan, Province of China')),
+ ('TZ', _('Tanzania, United Republic of')),
+ ('UA', _('Ukraine')),
+ ('UG', _('Uganda')),
+ ('UM', _('United States Minor Outlying Islands')),
+ ('US', _('United States of America')),
+ ('UY', _('Uruguay')),
+ ('UZ', _('Uzbekistan')),
+ ('VA', _('Vatican City State (Holy See)')),
+ ('VC', _('St. Vincent & the Grenadines')),
+ ('VE', _('Venezuela')),
+ ('VG', _('British Virgin Islands')),
+ ('VI', _('United States Virgin Islands')),
+ ('VN', _('Viet Nam')),
+ ('VU', _('Vanuatu')),
+ ('WF', _('Wallis & Futuna Islands')),
+ ('WS', _('Samoa')),
+ ('YE', _('Yemen')),
+ ('YT', _('Mayotte')),
+ ('YU', _('Yugoslavia')),
+ ('ZA', _('South Africa')),
+ ('ZM', _('Zambia')),
+ ('ZR', _('Zaire')),
+ ('ZW', _('Zimbabwe')),
+)
+
+
+__all__ = ('celery_app',)
diff --git a/uncloud/admin.py b/uncloud/admin.py
new file mode 100644
index 0000000..a89a574
--- /dev/null
+++ b/uncloud/admin.py
@@ -0,0 +1,6 @@
+from django.contrib import admin
+
+from .models import *
+
+for m in [ UncloudProvider, UncloudNetwork, UncloudTask ]:
+ admin.site.register(m)
diff --git a/uncloud/asgi.py b/uncloud/asgi.py
new file mode 100644
index 0000000..2b5a7a3
--- /dev/null
+++ b/uncloud/asgi.py
@@ -0,0 +1,16 @@
+"""
+ASGI config for uncloud project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/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()
diff --git a/uncloud/celery.py b/uncloud/celery.py
new file mode 100644
index 0000000..3408634
--- /dev/null
+++ b/uncloud/celery.py
@@ -0,0 +1,17 @@
+import os
+
+from celery import Celery
+
+# set the default Django settings module for the 'celery' program.
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings')
+
+app = Celery('uncloud')
+
+# Using a string here means the worker doesn't have to serialize
+# the configuration object to child processes.
+# - namespace='CELERY' means all celery-related configuration keys
+# should have a `CELERY_` prefix.
+app.config_from_object('django.conf:settings', namespace='CELERY')
+
+# Load task modules from all registered Django app configs.
+app.autodiscover_tasks()
diff --git a/uncloud/management/commands/db-add-defaults.py b/uncloud/management/commands/db-add-defaults.py
new file mode 100644
index 0000000..605c8f5
--- /dev/null
+++ b/uncloud/management/commands/db-add-defaults.py
@@ -0,0 +1,43 @@
+import random
+import string
+
+from django.core.management.base import BaseCommand
+from django.core.exceptions import ObjectDoesNotExist
+from django.contrib.auth import get_user_model
+from django.conf import settings
+
+from uncloud_pay.models import BillingAddress, RecurringPeriod, Product
+from uncloud.models import UncloudProvider, UncloudNetwork
+
+
+class Command(BaseCommand):
+ help = 'Add standard uncloud values'
+
+ def add_arguments(self, parser):
+ pass
+
+ def handle(self, *args, **options):
+ # Order matters, objects can be dependent on each other
+
+ admin_username="uncloud-admin"
+ pw_length = 32
+
+ # Only set password if the user did not exist before
+ try:
+ admin_user = get_user_model().objects.get(username=settings.UNCLOUD_ADMIN_NAME)
+ except ObjectDoesNotExist:
+ random_password = ''.join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(pw_length))
+
+ admin_user = get_user_model().objects.create_user(username=settings.UNCLOUD_ADMIN_NAME, password=random_password)
+ admin_user.is_superuser=True
+ admin_user.is_staff=True
+ admin_user.save()
+
+ print(f"Created admin user '{admin_username}' with password '{random_password}'")
+
+ BillingAddress.populate_db_defaults()
+ RecurringPeriod.populate_db_defaults()
+ Product.populate_db_defaults()
+
+ UncloudNetwork.populate_db_defaults()
+ UncloudProvider.populate_db_defaults()
diff --git a/uncloud/management/commands/uncloud.py b/uncloud/management/commands/uncloud.py
new file mode 100644
index 0000000..bd47c6b
--- /dev/null
+++ b/uncloud/management/commands/uncloud.py
@@ -0,0 +1,28 @@
+import sys
+from datetime import datetime
+
+from django.core.management.base import BaseCommand
+
+from django.contrib.auth import get_user_model
+
+from opennebula.models import VM as VMModel
+from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct, VMCluster
+
+import logging
+log = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = 'General uncloud commands'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--bootstrap', action='store_true', help='Bootstrap a typical uncloud installation')
+
+ def handle(self, *args, **options):
+
+ if options['bootstrap']:
+ self.bootstrap()
+
+ def bootstrap(self):
+ default_cluster = VMCluster.objects.get_or_create(name="default")
+# local_host =
diff --git a/uncloud/migrations/0001_initial.py b/uncloud/migrations/0001_initial.py
new file mode 100644
index 0000000..10d1144
--- /dev/null
+++ b/uncloud/migrations/0001_initial.py
@@ -0,0 +1,46 @@
+# Generated by Django 3.1 on 2020-12-13 10:38
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import uncloud.models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UncloudNetwork',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('network_address', models.GenericIPAddressField(unique=True)),
+ ('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
+ ('description', models.CharField(max_length=256)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='UncloudProvider',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('full_name', models.CharField(max_length=256)),
+ ('organization', models.CharField(blank=True, max_length=256, null=True)),
+ ('street', models.CharField(max_length=256)),
+ ('city', models.CharField(max_length=256)),
+ ('postal_code', models.CharField(max_length=64)),
+ ('country', uncloud.models.CountryField(blank=True, choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2)),
+ ('starting_date', models.DateField()),
+ ('ending_date', models.DateField(blank=True, null=True)),
+ ('billing_network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderbill', to='uncloud.uncloudnetwork')),
+ ('coupon_network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudprovidercoupon', to='uncloud.uncloudnetwork')),
+ ('referral_network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderreferral', to='uncloud.uncloudnetwork')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/uncloud/migrations/0002_uncloudtasks.py b/uncloud/migrations/0002_uncloudtasks.py
new file mode 100644
index 0000000..9c69606
--- /dev/null
+++ b/uncloud/migrations/0002_uncloudtasks.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1 on 2020-12-20 17:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UncloudTasks',
+ fields=[
+ ('task_id', models.UUIDField(primary_key=True, serialize=False)),
+ ],
+ ),
+ ]
diff --git a/uncloud/migrations/0003_auto_20201220_1728.py b/uncloud/migrations/0003_auto_20201220_1728.py
new file mode 100644
index 0000000..2ec0eec
--- /dev/null
+++ b/uncloud/migrations/0003_auto_20201220_1728.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-12-20 17:28
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud', '0002_uncloudtasks'),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name='UncloudTasks',
+ new_name='UncloudTask',
+ ),
+ ]
diff --git a/uncloud/migrations/__init__.py b/uncloud/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud/models.py b/uncloud/models.py
new file mode 100644
index 0000000..5545303
--- /dev/null
+++ b/uncloud/models.py
@@ -0,0 +1,172 @@
+from django.db import models
+from django.db.models import JSONField, Q
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+from django.core.validators import MinValueValidator, MaxValueValidator
+from django.core.exceptions import FieldError
+
+from uncloud import COUNTRIES
+
+class UncloudModel(models.Model):
+ """
+ This class extends the standard model with an
+ extra_data field that can be used to include public,
+ but internal information.
+
+ For instance if you migrate from an existing virtualisation
+ framework to uncloud.
+
+ The extra_data attribute should be considered a hack and whenever
+ data is necessary for running uncloud, it should **not** be stored
+ in there.
+
+ """
+
+ extra_data = JSONField(editable=False, blank=True, null=True)
+
+ class Meta:
+ abstract = True
+
+# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
+class UncloudStatus(models.TextChoices):
+ PENDING = 'PENDING', _('Pending')
+ AWAITING_PAYMENT = 'AWAITING_PAYMENT', _('Awaiting payment')
+ BEING_CREATED = 'BEING_CREATED', _('Being created')
+ SCHEDULED = 'SCHEDULED', _('Scheduled') # resource selected, waiting for dispatching
+ ACTIVE = 'ACTIVE', _('Active')
+ MODIFYING = 'MODIFYING', _('Modifying') # Resource is being changed
+ DELETED = 'DELETED', _('Deleted') # Resource has been deleted
+ DISABLED = 'DISABLED', _('Disabled') # Is usable, but cannot be used for new things
+ UNUSABLE = 'UNUSABLE', _('Unusable'), # Has some kind of error
+
+
+
+###
+# General address handling
+class CountryField(models.CharField):
+ def __init__(self, *args, **kwargs):
+ kwargs.setdefault('choices', COUNTRIES)
+ kwargs.setdefault('default', 'CH')
+ kwargs.setdefault('max_length', 2)
+
+ super().__init__(*args, **kwargs)
+
+ def get_internal_type(self):
+ return "CharField"
+
+
+class UncloudAddress(models.Model):
+ full_name = models.CharField(max_length=256)
+ organization = models.CharField(max_length=256, blank=True, null=True)
+ street = models.CharField(max_length=256)
+ city = models.CharField(max_length=256)
+ postal_code = models.CharField(max_length=64)
+ country = CountryField(blank=True)
+
+ class Meta:
+ abstract = True
+
+
+###
+# UncloudNetworks are used as identifiers - such they are a base of uncloud
+
+class UncloudNetwork(models.Model):
+ """
+ Storing IP networks
+ """
+
+ network_address = models.GenericIPAddressField(null=False, unique=True)
+ network_mask = models.IntegerField(null=False,
+ validators=[MinValueValidator(0),
+ MaxValueValidator(128)]
+ )
+
+ description = models.CharField(max_length=256)
+
+ @classmethod
+ def populate_db_defaults(cls):
+ for net, desc in [
+ ( "2a0a:e5c0:11::", "uncloud Billing" ),
+ ( "2a0a:e5c0:11:1::", "uncloud Referral" ),
+ ( "2a0a:e5c0:11:2::", "uncloud Coupon" )
+ ]:
+ obj, created = cls.objects.get_or_create(network_address=net,
+ defaults= {
+ 'network_mask': 64,
+ 'description': desc
+ }
+ )
+
+
+ def save(self, *args, **kwargs):
+ if not ':' in self.network_address and self.network_mask > 32:
+ raise FieldError("Mask cannot exceed 32 for IPv4")
+
+ super().save(*args, **kwargs)
+
+
+ def __str__(self):
+ return f"{self.network_address}/{self.network_mask} {self.description}"
+
+###
+# Who is running / providing this instance of uncloud?
+
+class UncloudProvider(UncloudAddress):
+ """
+ A class resembling who is running this uncloud instance.
+ This might change over time so we allow starting/ending dates
+
+ This also defines the taxation rules.
+
+ starting/ending date define from when to when this is valid. This way
+ we can model address changes and have it correct in the bills.
+ """
+
+ # Meta:
+ # FIXMe: only allow non overlapping time frames -- how to define this as a constraint?
+ starting_date = models.DateField()
+ ending_date = models.DateField(blank=True, null=True)
+
+ billing_network = models.ForeignKey(UncloudNetwork, related_name="uncloudproviderbill", on_delete=models.CASCADE)
+ referral_network = models.ForeignKey(UncloudNetwork, related_name="uncloudproviderreferral", on_delete=models.CASCADE)
+ coupon_network = models.ForeignKey(UncloudNetwork, related_name="uncloudprovidercoupon", on_delete=models.CASCADE)
+
+
+ @classmethod
+ def get_provider(cls, when=None):
+ """
+ Find active provide at a certain time - if there was any
+ """
+
+ if not when:
+ when = timezone.now()
+
+
+ return cls.objects.get(Q(starting_date__gte=when, ending_date__lte=when) |
+ Q(starting_date__gte=when, ending_date__isnull=True))
+
+
+ @classmethod
+ def populate_db_defaults(cls):
+ obj, created = cls.objects.get_or_create(full_name="ungleich glarus ag",
+ street="Bahnhofstrasse 1",
+ postal_code="8783",
+ city="Linthal",
+ country="CH",
+ starting_date=timezone.now(),
+ billing_network=UncloudNetwork.objects.get(description="uncloud Billing"),
+ referral_network=UncloudNetwork.objects.get(description="uncloud Referral"),
+ coupon_network=UncloudNetwork.objects.get(description="uncloud Coupon")
+ )
+
+
+ def __str__(self):
+ return f"{self.full_name} {self.country}"
+
+
+class UncloudTask(models.Model):
+ """
+ Class to store dispatched tasks to be handled
+ """
+
+ task_id = models.UUIDField(primary_key=True)
diff --git a/uncloud/settings.py b/uncloud/settings.py
new file mode 100644
index 0000000..ae734dc
--- /dev/null
+++ b/uncloud/settings.py
@@ -0,0 +1,240 @@
+"""
+Django settings for uncloud project.
+
+Generated by 'django-admin startproject' using Django 3.0.3.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/3.0/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/3.0/ref/settings/
+"""
+
+import os
+import re
+import ldap
+
+from django.core.management.utils import get_random_secret_key
+from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
+
+
+LOGGING = {}
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
+ }
+}
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+
+
+# 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',
+ 'uncloud_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 = ""
+AUTH_LDAP_BIND_DN = ""
+AUTH_LDAP_BIND_PASSWORD = ""
+AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=example,dc=com",
+ ldap.SCOPE_SUBTREE,
+ "(uid=%(user)s)")
+
+AUTH_LDAP_USER_ATTR_MAP = {
+ "first_name": "givenName",
+ "last_name": "sn",
+ "email": "mail"
+}
+
+################################################################################
+# 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") ]
+
+# XML-RPC interface of opennebula
+OPENNEBULA_URL = 'https://opennebula.example.com:2634/RPC2'
+
+# user:pass for accessing opennebula
+OPENNEBULA_USER_PASS = 'user:password'
+
+# Stripe (Credit Card payments)
+STRIPE_KEY=""
+STRIPE_PUBLIC_KEY=""
+
+# The django secret key
+SECRET_KEY=get_random_secret_key()
+
+ALLOWED_HOSTS = []
+
+# required for hardcopy / pdf rendering: https://github.com/loftylabs/django-hardcopy
+CHROME_PATH = '/usr/bin/chromium-browser'
+
+# Username that is created by default and owns the configuration objects
+UNCLOUD_ADMIN_NAME = "uncloud-admin"
+
+LOGIN_REDIRECT_URL = '/'
+LOGOUT_REDIRECT_URL = '/'
+
+# replace these in local_settings.py
+AUTH_LDAP_SERVER_URI = "ldaps://ldap1.example.com,ldaps://ldap2.example.com"
+AUTH_LDAP_BIND_DN="uid=django,ou=system,dc=example,dc=com"
+AUTH_LDAP_BIND_PASSWORD="a very secure ldap password"
+AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=example,dc=com",
+ ldap.SCOPE_SUBTREE,
+ "(uid=%(user)s)")
+
+# where to create customers
+LDAP_CUSTOMER_DN="ou=customer,dc=example,dc=com"
+
+# def route_task(name, args, kwargs, options, task=None, **kw):
+# print(f"{name} - {args} - {kwargs}")
+# # if name == 'myapp.tasks.compress_video':
+# return {'queue': 'vpn1' }
+# # 'exchange_type': 'topic',
+# # 'routing_key': 'video.compress'}
+
+
+# CELERY_TASK_ROUTES = (route_task,)
+
+# CELERY_TASK_ROUTES = {
+# '*': {
+# 'queue': 'vpn1'
+# }
+# }
+
+
+CELERY_BROKER_URL = 'redis://:uncloud.example.com:6379/0'
+CELERY_RESULT_BACKEND = 'redis://:uncloud.example.com:6379/0'
+
+CELERY_TASK_ROUTES = {
+ re.compile(r'.*.tasks.cdist.*'): { 'queue': 'cdist' } # cdist tasks go into cdist queue
+}
+
+CELERY_BEAT_SCHEDULE = {
+ 'cleanup_tasks': {
+ 'task': 'uncloud.tasks.cleanup_tasks',
+ 'schedule': 10
+ }
+}
+
+# CELERY_TASK_CREATE_MISSING_QUEUES = False
+
+# Overwrite settings with local settings, if existing
+try:
+ from uncloud.local_settings import *
+except (ModuleNotFoundError, ImportError):
+ pass
diff --git a/uncloud/tasks.py b/uncloud/tasks.py
new file mode 100644
index 0000000..5a13ec5
--- /dev/null
+++ b/uncloud/tasks.py
@@ -0,0 +1,19 @@
+from celery import shared_task
+from celery.result import AsyncResult
+
+from .models import UncloudTask
+
+@shared_task(bind=True)
+def cleanup_tasks(self):
+ print(f"Cleanup time from {self}: {self.request.id}")
+ for task in UncloudTask.objects.all():
+ print(f"Pruning {task}...")
+
+ if str(task.task_id) == str(self.request.id):
+ print("Skipping myself")
+ continue
+
+ res = AsyncResult(id=str(task.task_id))
+ if res.ready():
+ print(res.get())
+ task.delete()
diff --git a/uncloud/templates/uncloud/base.html b/uncloud/templates/uncloud/base.html
new file mode 100644
index 0000000..034fa7c
--- /dev/null
+++ b/uncloud/templates/uncloud/base.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ {% block title %}Welcome to uncloud{% endblock %}
+ {% block header %}{% endblock %}
+
+
+ {% block body %}{% endblock %}
+
+
diff --git a/uncloud/templates/uncloud/index.html b/uncloud/templates/uncloud/index.html
new file mode 100644
index 0000000..b40c3b4
--- /dev/null
+++ b/uncloud/templates/uncloud/index.html
@@ -0,0 +1,15 @@
+{% extends 'uncloud/base.html' %}
+{% block title %}{% endblock %}
+
+{% block body %}
+
+
Welcome to uncloud
+ Welcome to uncloud, checkout the following locations:
+
+
+
+
+{% endblock %}
diff --git a/uncloud/urls.py b/uncloud/urls.py
new file mode 100644
index 0000000..169be7f
--- /dev/null
+++ b/uncloud/urls.py
@@ -0,0 +1,94 @@
+"""uncloud URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/3.0/topics/http/urls/
+"""
+
+from django.contrib import admin
+from django.urls import path, include
+from django.conf import settings
+from django.conf.urls.static import static
+
+from rest_framework import routers
+from rest_framework.schemas import get_schema_view
+
+#from opennebula import views as oneviews
+from uncloud import views as uncloudviews
+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 uncloud_service import views as serviceviews
+
+router = routers.DefaultRouter()
+
+# Beta endpoints
+router.register(r'beta/vm', vmviews.NicoVMProductViewSet, basename='nicovmproduct')
+
+# VM
+router.register(r'v1/vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct')
+router.register(r'v1/vm/diskimage', vmviews.VMDiskImageProductViewSet, basename='vmdiskimageproduct')
+router.register(r'v1/vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct')
+router.register(r'v1/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'v1/service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct')
+router.register(r'v1/service/generic', serviceviews.GenericServiceProductViewSet, basename='genericserviceproduct')
+
+
+
+# Pay
+router.register(r'v1/my/address', payviews.BillingAddressViewSet, basename='billingaddress')
+router.register(r'v1/my/bill', payviews.BillViewSet, basename='bill')
+router.register(r'v1/my/order', payviews.OrderViewSet, basename='order')
+router.register(r'v1/my/payment', payviews.PaymentViewSet, basename='payment')
+router.register(r'v1/my/payment-method', payviews.PaymentMethodViewSet, basename='payment-method')
+
+# admin/staff urls
+router.register(r'v1/admin/bill', payviews.AdminBillViewSet, basename='admin/bill')
+router.register(r'v1/admin/payment', payviews.AdminPaymentViewSet, basename='admin/payment')
+router.register(r'v1/admin/order', payviews.AdminOrderViewSet, basename='admin/order')
+router.register(r'v1/admin/vmhost', vmviews.VMHostViewSet)
+router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet)
+#router.register(r'v1/admin/vpnpool', netviews.VPNPoolViewSet)
+#router.register(r'v1/admin/opennebula', oneviews.VMViewSet, basename='opennebula')
+
+# User/Account
+router.register(r'v1/my/user', authviews.UserViewSet, basename='user')
+router.register(r'v1/admin/user', authviews.AdminUserViewSet, basename='useradmin')
+router.register(r'v1/user/register', authviews.AccountManagementViewSet, basename='user/register')
+
+
+################################################################################
+# v2
+
+# Net
+router.register(r'v2/net/wireguardvpn', netviews.WireGuardVPNViewSet, basename='wireguardvpnnetwork')
+router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basename='wireguardvpnnetworksizes')
+
+
+
+urlpatterns = [
+ path(r'api/', include(router.urls)),
+
+ path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), # for login to REST API
+ path('openapi', get_schema_view(
+ title="uncloud",
+ description="uncloud API",
+ version="1.0.0"
+ ), name='openapi-schema'),
+
+ # web/ = stuff to view in the browser
+# path('web/vpn/create/', netviews.WireGuardVPNCreateView.as_view(), name="vpncreate"),
+ path('login/', authviews.LoginView.as_view(), name="login"),
+ path('logout/', authviews.LogoutView.as_view(), name="logout"),
+ path('admin/', admin.site.urls),
+ path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"),
+ path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"),
+]
diff --git a/uncloud/views.py b/uncloud/views.py
new file mode 100644
index 0000000..198abd0
--- /dev/null
+++ b/uncloud/views.py
@@ -0,0 +1,4 @@
+from django.views.generic.base import TemplateView
+
+class UncloudIndex(TemplateView):
+ template_name = "uncloud/index.html"
diff --git a/uncloud/wsgi.py b/uncloud/wsgi.py
new file mode 100644
index 0000000..c4a07b8
--- /dev/null
+++ b/uncloud/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for uncloud project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/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()
diff --git a/uncloud_auth/__init__.py b/uncloud_auth/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_auth/admin.py b/uncloud_auth/admin.py
new file mode 100644
index 0000000..f91be8f
--- /dev/null
+++ b/uncloud_auth/admin.py
@@ -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)
diff --git a/uncloud_auth/apps.py b/uncloud_auth/apps.py
new file mode 100644
index 0000000..c16bd7a
--- /dev/null
+++ b/uncloud_auth/apps.py
@@ -0,0 +1,4 @@
+from django.apps import AppConfig
+
+class AuthConfig(AppConfig):
+ name = 'uncloud_auth'
diff --git a/uncloud_auth/management/commands/make-admin.py b/uncloud_auth/management/commands/make-admin.py
new file mode 100644
index 0000000..9157439
--- /dev/null
+++ b/uncloud_auth/management/commands/make-admin.py
@@ -0,0 +1,21 @@
+from django.core.management.base import BaseCommand
+from django.contrib.auth import get_user_model
+import sys
+
+class Command(BaseCommand):
+ help = 'Give Admin rights to existing user'
+
+ def add_arguments(self, parser):
+ parser.add_argument('username', type=str)
+ parser.add_argument('--superuser', action='store_true')
+
+ def handle(self, *args, **options):
+ user = get_user_model().objects.get(username=options['username'])
+ user.is_staff = True
+
+ if options['superuser']:
+ user.is_superuser = True
+
+ user.save()
+
+ print(f"{user.username} is now admin (superuser={user.is_superuser})")
diff --git a/uncloud_auth/migrations/0001_initial.py b/uncloud_auth/migrations/0001_initial.py
new file mode 100644
index 0000000..b263dc6
--- /dev/null
+++ b/uncloud_auth/migrations/0001_initial.py
@@ -0,0 +1,46 @@
+# Generated by Django 3.1 on 2020-12-13 10:38
+
+import django.contrib.auth.models
+import django.contrib.auth.validators
+import django.core.validators
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('auth', '0012_alter_user_first_name_max_length'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='User',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('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=150, verbose_name='first name')),
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+ ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+ ('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')),
+ ('maximum_credit', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
+ ('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()),
+ ],
+ ),
+ ]
diff --git a/uncloud_auth/migrations/__init__.py b/uncloud_auth/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_auth/models.py b/uncloud_auth/models.py
new file mode 100644
index 0000000..90463e1
--- /dev/null
+++ b/uncloud_auth/models.py
@@ -0,0 +1,17 @@
+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
+
+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)])
diff --git a/uncloud_auth/serializers.py b/uncloud_auth/serializers.py
new file mode 100644
index 0000000..c3f6694
--- /dev/null
+++ b/uncloud_auth/serializers.py
@@ -0,0 +1,72 @@
+from django.contrib.auth import get_user_model
+from django.db import transaction
+from ldap3.core.exceptions import LDAPEntryAlreadyExistsResult
+from rest_framework import serializers
+
+from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
+from uncloud_pay.models import BillingAddress
+
+from .ungleich_ldap import LdapManager
+
+
+class UserSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = get_user_model()
+ read_only_fields = [ 'username', 'balance', 'maximum_credit' ]
+ fields = read_only_fields + [ 'email' ] # , 'primary_billing_address' ]
+
+ def validate(self, data):
+ """
+ Ensure that the primary billing address belongs to the user
+ """
+ # The following is raising exceptions probably, it is WIP somewhere
+ # if 'primary_billing_address' in data:
+ # if not data['primary_billing_address'].owner == self.instance:
+ # raise serializers.ValidationError('Invalid data')
+
+ return data
+
+ def update(self, instance, validated_data):
+ ldap_manager = LdapManager()
+ return_val, _ = ldap_manager.change_user_details(
+ instance.username, {'mail': validated_data.get('email')}
+ )
+ if not return_val:
+ raise serializers.ValidationError('Couldn\'t update email')
+ instance.email = validated_data.get('email')
+ instance.save()
+ return instance
+
+
+class UserRegistrationSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = get_user_model()
+ fields = ['username', 'first_name', 'last_name', 'email', 'password']
+ extra_kwargs = {
+ 'password': {'style': {'input_type': 'password'}},
+ 'first_name': {'allow_blank': False, 'required': True},
+ 'last_name': {'allow_blank': False, 'required': True},
+ 'email': {'allow_blank': False, 'required': True},
+ }
+
+ def create(self, validated_data):
+ ldap_manager = LdapManager()
+ try:
+ data = {
+ 'user': validated_data['username'],
+ 'password': validated_data['password'],
+ 'email': validated_data['email'],
+ 'firstname': validated_data['first_name'],
+ 'lastname': validated_data['last_name'],
+ }
+ ldap_manager.create_user(**data)
+ except LDAPEntryAlreadyExistsResult:
+ raise serializers.ValidationError(
+ {'username': ['A user with that username already exists.']}
+ )
+ else:
+ return get_user_model().objects.create_user(**validated_data)
+
+
+class ImportUserSerializer(serializers.Serializer):
+ username = serializers.CharField()
diff --git a/uncloud_auth/templates/uncloud_auth/login.html b/uncloud_auth/templates/uncloud_auth/login.html
new file mode 100644
index 0000000..04f9a15
--- /dev/null
+++ b/uncloud_auth/templates/uncloud_auth/login.html
@@ -0,0 +1,13 @@
+{% extends 'uncloud/base.html' %}
+
+{% block body %}
+
+
+
+
+
+{% endblock %}
diff --git a/uncloud_auth/uldap.py b/uncloud_auth/uldap.py
new file mode 100644
index 0000000..aa90c77
--- /dev/null
+++ b/uncloud_auth/uldap.py
@@ -0,0 +1,42 @@
+import ldap
+# from django.conf import settings
+
+AUTH_LDAP_SERVER_URI = "ldaps://ldap1.ungleich.ch,ldaps://ldap2.ungleich.ch"
+AUTH_LDAP_BIND_DN="uid=django-create,ou=system,dc=ungleich,dc=ch"
+AUTH_LDAP_BIND_PASSWORD="kS#e+v\zjKn]L!,RIu2}V+DUS"
+# AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=ungleich,dc=ch",
+# ldap.SCOPE_SUBTREE,
+# "(uid=%(user)s)")
+
+
+
+ldap_object = ldap.initialize(AUTH_LDAP_SERVER_URI)
+cancelid = ldap_object.bind(AUTH_LDAP_BIND_DN, AUTH_LDAP_BIND_PASSWORD)
+
+res = ldap_object.search_s("dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=nico)")
+print(res)
+
+# class LDAP(object):
+# """
+# Managing users in LDAP
+
+# Requires the following settings?
+
+# LDAP_USER_DN: where to create users in the tree
+
+# LDAP_ADMIN_DN: which DN to use for managing users
+# LDAP_ADMIN_PASSWORD: which password to used
+
+# This module will reuse information from djagno_auth_ldap, including:
+
+# AUTH_LDAP_SERVER_URI
+
+# """
+# def __init__(self):
+# pass
+
+# def create_user(self):
+# pass
+
+# def change_password(self):
+# pass
diff --git a/uncloud_auth/ungleich_ldap.py b/uncloud_auth/ungleich_ldap.py
new file mode 100644
index 0000000..f22b423
--- /dev/null
+++ b/uncloud_auth/ungleich_ldap.py
@@ -0,0 +1,284 @@
+import base64
+import hashlib
+import logging
+import random
+
+import ldap3
+from django.conf import settings
+
+logger = logging.getLogger(__name__)
+
+
+class LdapManager:
+ __instance = None
+ def __new__(cls):
+ if LdapManager.__instance is None:
+ LdapManager.__instance = object.__new__(cls)
+ return LdapManager.__instance
+
+ def __init__(self):
+ """
+ Initialize the LDAP subsystem.
+ """
+ self.rng = random.SystemRandom()
+ self.server = ldap3.Server(settings.AUTH_LDAP_SERVER)
+
+
+ def get_admin_conn(self):
+ """
+ Return a bound :class:`ldap3.Connection` instance which has write
+ permissions on the dn in which the user accounts reside.
+ """
+ conn = self.get_conn(user=settings.LDAP_ADMIN_DN,
+ password=settings.LDAP_ADMIN_PASSWORD,
+ raise_exceptions=True)
+ conn.bind()
+ return conn
+
+
+ def get_conn(self, **kwargs):
+ """
+ Return an unbound :class:`ldap3.Connection` which talks to the configured
+ LDAP server.
+
+ The *kwargs* are passed to the constructor of :class:`ldap3.Connection` and
+ can be used to set *user*, *password* and other useful arguments.
+ """
+ return ldap3.Connection(self.server, **kwargs)
+
+
+ def _ssha_password(self, password):
+ """
+ Apply the SSHA password hashing scheme to the given *password*.
+ *password* must be a :class:`bytes` object, containing the utf-8
+ encoded password.
+
+ Return a :class:`bytes` object containing ``ascii``-compatible data
+ which can be used as LDAP value, e.g. after armoring it once more using
+ base64 or decoding it to unicode from ``ascii``.
+ """
+ SALT_BYTES = 15
+
+ sha1 = hashlib.sha1()
+ salt = self.rng.getrandbits(SALT_BYTES * 8).to_bytes(SALT_BYTES,
+ "little")
+ sha1.update(password)
+ sha1.update(salt)
+
+ digest = sha1.digest()
+ passwd = b"{SSHA}" + base64.b64encode(digest + salt)
+ return passwd
+
+
+ def create_user(self, user, password, firstname, lastname, email):
+ conn = self.get_admin_conn()
+ uidNumber = self._get_max_uid() + 1
+ logger.debug("uidNumber={uidNumber}".format(uidNumber=uidNumber))
+ user_exists = True
+ while user_exists:
+ user_exists, _ = self.check_user_exists(
+ "",
+ '(&(objectClass=inetOrgPerson)(objectClass=posixAccount)'
+ '(objectClass=top)(uidNumber={uidNumber}))'.format(
+ uidNumber=uidNumber
+ )
+ )
+ if user_exists:
+ logger.debug(
+ "{uid} exists. Trying next.".format(uid=uidNumber)
+ )
+ uidNumber += 1
+ logger.debug("{uid} does not exist. Using it".format(uid=uidNumber))
+ self._set_max_uid(uidNumber)
+ try:
+ uid = user # user.encode("utf-8")
+ conn.add("uid={uid},{customer_dn}".format(
+ uid=uid, customer_dn=settings.LDAP_CUSTOMER_DN
+ ),
+ ["inetOrgPerson", "posixAccount", "ldapPublickey"],
+ {
+ "uid": [uid],
+ "sn": [lastname.encode("utf-8")],
+ "givenName": [firstname.encode("utf-8")],
+ "cn": [uid],
+ "displayName": ["{} {}".format(firstname, lastname).encode("utf-8")],
+ "uidNumber": [str(uidNumber)],
+ "gidNumber": [str(settings.LDAP_CUSTOMER_GROUP_ID)],
+ "loginShell": ["/bin/bash"],
+ "homeDirectory": ["/home/{}".format(user).encode("utf-8")],
+ "mail": email.encode("utf-8"),
+ "userPassword": [self._ssha_password(
+ password.encode("utf-8")
+ )]
+ }
+ )
+ logger.debug('Created user %s %s' % (user.encode('utf-8'),
+ uidNumber))
+ except Exception as ex:
+ logger.debug('Could not create user %s' % user.encode('utf-8'))
+ logger.error("Exception: " + str(ex))
+ raise
+ finally:
+ conn.unbind()
+
+
+ def change_password(self, uid, new_password):
+ """
+ Changes the password of the user identified by user_dn
+
+ :param uid: str The uid that identifies the user
+ :param new_password: str The new password string
+ :return: True if password was changed successfully False otherwise
+ """
+ conn = self.get_admin_conn()
+
+ # Make sure the user exists first to change his/her details
+ user_exists, entries = self.check_user_exists(
+ uid=uid,
+ search_base=settings.ENTIRE_SEARCH_BASE
+ )
+ return_val = False
+ if user_exists:
+ try:
+ return_val = conn.modify(
+ entries[0].entry_dn,
+ {
+ "userpassword": (
+ ldap3.MODIFY_REPLACE,
+ [self._ssha_password(new_password.encode("utf-8"))]
+ )
+ }
+ )
+ except Exception as ex:
+ logger.error("Exception: " + str(ex))
+ else:
+ logger.error("User {} not found".format(uid))
+
+ conn.unbind()
+ return return_val
+
+ def change_user_details(self, uid, details):
+ """
+ Updates the user details as per given values in kwargs of the user
+ identified by user_dn.
+
+ Assumes that all attributes passed in kwargs are valid.
+
+ :param uid: str The uid that identifies the user
+ :param details: dict A dictionary containing the new values
+ :return: True if user details were updated successfully False otherwise
+ """
+ conn = self.get_admin_conn()
+
+ # Make sure the user exists first to change his/her details
+ user_exists, entries = self.check_user_exists(
+ uid=uid,
+ search_base=settings.ENTIRE_SEARCH_BASE
+ )
+
+ return_val = False
+ if user_exists:
+ details_dict = {k: (ldap3.MODIFY_REPLACE, [v.encode("utf-8")]) for
+ k, v in details.items()}
+ try:
+ return_val = conn.modify(entries[0].entry_dn, details_dict)
+ msg = "success"
+ except Exception as ex:
+ msg = str(ex)
+ logger.error("Exception: " + msg)
+ finally:
+ conn.unbind()
+ else:
+ msg = "User {} not found".format(uid)
+ logger.error(msg)
+ conn.unbind()
+ return return_val, msg
+
+ def check_user_exists(self, uid, search_filter="", attributes=None,
+ search_base=settings.LDAP_CUSTOMER_DN):
+ """
+ Check if the user with the given uid exists in the customer group.
+
+ :param uid: str representing the user
+ :param search_filter: str representing the filter condition to find
+ users. If its empty, the search finds the user with
+ the given uid.
+ :param attributes: list A list of str representing all the attributes
+ to be obtained in the result entries
+ :param search_base: str
+ :return: tuple (bool, [ldap3.abstract.entry.Entry ..])
+ A bool indicating if the user exists
+ A list of all entries obtained in the search
+ """
+ conn = self.get_admin_conn()
+ entries = []
+ try:
+ result = conn.search(
+ search_base=search_base,
+ search_filter=search_filter if len(search_filter)> 0 else
+ '(uid={uid})'.format(uid=uid),
+ attributes=attributes
+ )
+ entries = conn.entries
+ finally:
+ conn.unbind()
+ return result, entries
+
+ def delete_user(self, uid):
+ """
+ Deletes the user with the given uid from ldap
+
+ :param uid: str representing the user
+ :return: True if the delete was successful False otherwise
+ """
+ conn = self.get_admin_conn()
+ try:
+ return_val = conn.delete(
+ ("uid={uid}," + settings.LDAP_CUSTOMER_DN).format(uid=uid),
+ )
+ msg = "success"
+ except Exception as ex:
+ msg = str(ex)
+ logger.error("Exception: " + msg)
+ return_val = False
+ finally:
+ conn.unbind()
+ return return_val, msg
+
+ def _set_max_uid(self, max_uid):
+ """
+ a utility function to save max_uid value to a file
+
+ :param max_uid: an integer representing the max uid
+ :return:
+ """
+ with open(settings.LDAP_MAX_UID_FILE_PATH, 'w+') as handler:
+ handler.write(str(max_uid))
+
+ def _get_max_uid(self):
+ """
+ A utility function to read the max uid value that was previously set
+
+ :return: An integer representing the max uid value that was previously
+ set
+ """
+ try:
+ with open(settings.LDAP_MAX_UID_FILE_PATH, 'r+') as handler:
+ try:
+ return_value = int(handler.read())
+ except ValueError as ve:
+ logger.error(
+ "Error reading int value from {}. {}"
+ "Returning default value {} instead".format(
+ settings.LDAP_MAX_UID_PATH,
+ str(ve),
+ settings.LDAP_DEFAULT_START_UID
+ )
+ )
+ return_value = settings.LDAP_DEFAULT_START_UID
+ return return_value
+ except FileNotFoundError as fnfe:
+ logger.error("File not found : " + str(fnfe))
+ return_value = settings.LDAP_DEFAULT_START_UID
+ logger.error("So, returning UID={}".format(return_value))
+ return return_value
diff --git a/uncloud_auth/views.py b/uncloud_auth/views.py
new file mode 100644
index 0000000..9310a4c
--- /dev/null
+++ b/uncloud_auth/views.py
@@ -0,0 +1,77 @@
+from django.contrib.auth import views as auth_views
+from django.contrib.auth import logout
+
+from django_auth_ldap.backend import LDAPBackend
+from rest_framework import mixins, permissions, status, viewsets
+from rest_framework.decorators import action
+from rest_framework.response import Response
+
+from .serializers import *
+
+
+class LoginView(auth_views.LoginView):
+ template_name = 'uncloud_auth/login.html'
+
+class LogoutView(auth_views.LogoutView):
+ pass
+# template_name = 'uncloud_auth/logo.html'
+
+
+class UserViewSet(viewsets.GenericViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = UserSerializer
+
+ def get_queryset(self):
+ return self.request.user
+
+ def list(self, request, format=None):
+ # This is a bit stupid: we have a user, we create a queryset by
+ # matching on the username. But I don't know a "nicer" way.
+ # Nico, 2020-03-18
+ user = request.user
+ serializer = self.get_serializer(user, context = {'request': request})
+ return Response(serializer.data)
+
+ @action(detail=False, methods=['post'])
+ def change_email(self, request):
+ serializer = self.get_serializer(
+ request.user, data=request.data, context={'request': request}
+ )
+ serializer.is_valid(raise_exception=True)
+ serializer.save()
+ return Response(serializer.data)
+
+
+class AccountManagementViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
+ serializer_class = UserRegistrationSerializer
+
+ def create(self, request, *args, **kwargs):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ self.perform_create(serializer)
+ headers = self.get_success_headers(serializer.data)
+ return Response(
+ serializer.data, status=status.HTTP_201_CREATED, headers=headers
+ )
+
+
+class AdminUserViewSet(viewsets.ReadOnlyModelViewSet):
+ permission_classes = [permissions.IsAdminUser]
+
+ def get_serializer_class(self):
+ if self.action == 'import_from_ldap':
+ return ImportUserSerializer
+ else:
+ return UserSerializer
+
+ def get_queryset(self):
+ return get_user_model().objects.all()
+
+ @action(detail=False, methods=['post'], url_path='import_from_ldap')
+ def import_from_ldap(self, request, pk=None):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ ldap_username = serializer.validated_data.pop("username")
+ user = LDAPBackend().populate_user(ldap_username)
+
+ return Response(UserSerializer(user, context = {'request': request}).data)
diff --git a/uncloud_net/__init__.py b/uncloud_net/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_net/admin.py b/uncloud_net/admin.py
new file mode 100644
index 0000000..ca6aaa1
--- /dev/null
+++ b/uncloud_net/admin.py
@@ -0,0 +1,7 @@
+from django.contrib import admin
+
+from .models import *
+
+
+for m in [ ReverseDNSEntry, WireGuardVPNPool, WireGuardVPN ]:
+ admin.site.register(m)
diff --git a/uncloud_net/apps.py b/uncloud_net/apps.py
new file mode 100644
index 0000000..489beb1
--- /dev/null
+++ b/uncloud_net/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class UncloudNetConfig(AppConfig):
+ name = 'uncloud_net'
diff --git a/uncloud_net/forms.py b/uncloud_net/forms.py
new file mode 100644
index 0000000..ad4e013
--- /dev/null
+++ b/uncloud_net/forms.py
@@ -0,0 +1,11 @@
+from django import forms
+
+from .models import *
+from .selectors import *
+
+class WireGuardVPNForm(forms.ModelForm):
+ network_size = forms.ChoiceField(choices=allowed_vpn_network_reservation_size)
+
+ class Meta:
+ model = WireGuardVPN
+ fields = [ "wireguard_public_key" ]
diff --git a/uncloud_net/management/commands/vpn.py b/uncloud_net/management/commands/vpn.py
new file mode 100644
index 0000000..9fdc80d
--- /dev/null
+++ b/uncloud_net/management/commands/vpn.py
@@ -0,0 +1,44 @@
+import sys
+from datetime import datetime
+
+from django.core.management.base import BaseCommand
+
+from django.contrib.auth import get_user_model
+
+from opennebula.models import VM as VMModel
+from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct, VMCluster
+
+import logging
+log = logging.getLogger(__name__)
+
+
+
+peer_template="""
+# {username}
+[Peer]
+PublicKey = {public_key}
+AllowedIPs = {vpnnetwork}
+"""
+
+class Command(BaseCommand):
+ help = 'General uncloud commands'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--hostname',
+ action='store_true',
+ help='Name of this VPN Host',
+ required=True)
+
+ def handle(self, *args, **options):
+ if options['bootstrap']:
+ self.bootstrap()
+
+ self.create_vpn_config(options['hostname'])
+
+ def create_vpn_config(self, hostname):
+ configs = []
+
+ for pool in VPNPool.objects.filter(vpn_hostname=hostname):
+ configs.append(pool_config)
+
+ print(configs)
diff --git a/uncloud_net/migrations/0001_initial.py b/uncloud_net/migrations/0001_initial.py
new file mode 100644
index 0000000..6794156
--- /dev/null
+++ b/uncloud_net/migrations/0001_initial.py
@@ -0,0 +1,62 @@
+# Generated by Django 3.1 on 2020-12-13 13:42
+
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='MACAdress',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='WireGuardVPNPool',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('network', models.GenericIPAddressField(unique=True)),
+ ('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
+ ('subnetwork_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
+ ('vpn_server_hostname', models.CharField(max_length=256)),
+ ('wireguard_private_key', models.CharField(max_length=48)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='WireGuardVPNFreeLeases',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('pool_index', models.IntegerField(unique=True)),
+ ('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.wireguardvpnpool')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='WireGuardVPN',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('pool_index', models.IntegerField(unique=True)),
+ ('wireguard_public_key', models.CharField(max_length=48)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.wireguardvpnpool')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ReverseDNSEntry',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('ip_address', models.GenericIPAddressField(unique=True)),
+ ('name', models.CharField(max_length=253)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/uncloud_net/migrations/0002_wireguardvpnpool_wireguard_public_key.py b/uncloud_net/migrations/0002_wireguardvpnpool_wireguard_public_key.py
new file mode 100644
index 0000000..479aba1
--- /dev/null
+++ b/uncloud_net/migrations/0002_wireguardvpnpool_wireguard_public_key.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1 on 2020-12-13 17:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='wireguardvpnpool',
+ name='wireguard_public_key',
+ field=models.CharField(default='', max_length=48),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud_net/migrations/0003_wireguardvpnpool_wg_name.py b/uncloud_net/migrations/0003_wireguardvpnpool_wg_name.py
new file mode 100644
index 0000000..9ecf52c
--- /dev/null
+++ b/uncloud_net/migrations/0003_wireguardvpnpool_wg_name.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1 on 2020-12-13 17:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0002_wireguardvpnpool_wireguard_public_key'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='wireguardvpnpool',
+ name='wg_name',
+ field=models.CharField(default='wg0', max_length=15),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud_net/migrations/0004_auto_20201213_1734.py b/uncloud_net/migrations/0004_auto_20201213_1734.py
new file mode 100644
index 0000000..24e46e7
--- /dev/null
+++ b/uncloud_net/migrations/0004_auto_20201213_1734.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-12-13 17:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0003_wireguardvpnpool_wg_name'),
+ ]
+
+ operations = [
+ migrations.AddConstraint(
+ model_name='wireguardvpnpool',
+ constraint=models.UniqueConstraint(fields=('wg_name', 'vpn_server_hostname'), name='unique_interface_name_per_host'),
+ ),
+ ]
diff --git a/uncloud_net/migrations/0005_auto_20201220_1837.py b/uncloud_net/migrations/0005_auto_20201220_1837.py
new file mode 100644
index 0000000..1dbabe6
--- /dev/null
+++ b/uncloud_net/migrations/0005_auto_20201220_1837.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1 on 2020-12-20 18:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0004_auto_20201213_1734'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='wireguardvpn',
+ name='wireguard_public_key',
+ field=models.CharField(max_length=48, unique=True),
+ ),
+ ]
diff --git a/uncloud_net/migrations/0006_auto_20201224_1626.py b/uncloud_net/migrations/0006_auto_20201224_1626.py
new file mode 100644
index 0000000..c0dd2ef
--- /dev/null
+++ b/uncloud_net/migrations/0006_auto_20201224_1626.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-12-24 16:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0005_auto_20201220_1837'),
+ ]
+
+ operations = [
+ migrations.AddConstraint(
+ model_name='wireguardvpn',
+ constraint=models.UniqueConstraint(fields=('vpnpool', 'wireguard_public_key'), name='wg_key_unique_per_pool'),
+ ),
+ ]
diff --git a/uncloud_net/migrations/__init__.py b/uncloud_net/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_net/models.py b/uncloud_net/models.py
new file mode 100644
index 0000000..0c8b02a
--- /dev/null
+++ b/uncloud_net/models.py
@@ -0,0 +1,192 @@
+import uuid
+import ipaddress
+
+from django.db import models
+from django.contrib.auth import get_user_model
+from django.core.validators import MinValueValidator, MaxValueValidator
+from django.core.exceptions import FieldError, ValidationError
+
+from uncloud_pay.models import Order
+
+class WireGuardVPNPool(models.Model):
+ """
+ Network address pools from which VPNs can be created
+ """
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['wg_name', 'vpn_server_hostname' ],
+ name='unique_interface_name_per_host')
+ ]
+
+
+ # Linux interface naming is restricing to max 15 characters
+ wg_name = models.CharField(max_length=15)
+
+ network = models.GenericIPAddressField(unique=True)
+ network_mask = models.IntegerField(validators=[MinValueValidator(0),
+ MaxValueValidator(128)])
+
+ subnetwork_mask = models.IntegerField(validators=[
+ MinValueValidator(0),
+ MaxValueValidator(128)
+ ])
+
+ vpn_server_hostname = models.CharField(max_length=256)
+ wireguard_private_key = models.CharField(max_length=48)
+ wireguard_public_key = models.CharField(max_length=48)
+
+ @property
+ def max_pool_index(self):
+ """
+ Return the highest possible network / last network id
+ """
+
+ bits = self.subnetwork_mask - self.network_mask
+
+ return (2**bits)-1
+
+ @property
+ def ip_network(self):
+ return ipaddress.ip_network(f"{self.network}/{self.network_mask}")
+
+ def __str__(self):
+ return f"{self.ip_network} (subnets: /{self.subnetwork_mask})"
+
+ @property
+ def wireguard_config(self):
+ wireguard_config = [ f"[Interface]\nListenPort = 51820\nPrivateKey = {self.wireguard_private_key}\n" ]
+
+ peers = []
+
+ for vpn in self.wireguardvpn_set.all():
+ public_key = vpn.wireguard_public_key
+ peer_network = f"{vpn.address}/{self.subnetwork_mask}"
+ owner = vpn.owner
+
+ peers.append(f"# Owner: {owner}\n[Peer]\nPublicKey = {public_key}\nAllowedIPs = {peer_network}\n\n")
+
+ wireguard_config.extend(peers)
+
+ return "\n".join(wireguard_config)
+
+
+class WireGuardVPN(models.Model):
+ """
+ Created VPNNetworks
+ """
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE)
+ vpnpool = models.ForeignKey(WireGuardVPNPool,
+ on_delete=models.CASCADE)
+
+ pool_index = models.IntegerField(unique=True)
+
+ wireguard_public_key = models.CharField(max_length=48, unique=True)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['vpnpool', 'wireguard_public_key'],
+ name='wg_key_unique_per_pool')
+ ]
+
+
+ @property
+ def network_mask(self):
+ return self.vpnpool.subnetwork_mask
+
+ @property
+ def vpn_server(self):
+ return self.vpnpool.vpn_server_hostname
+
+ @property
+ def vpn_server_public_key(self):
+ return self.vpnpool.wireguard_public_key
+
+ @property
+ def address(self):
+ """
+ Locate the correct subnet in the supernet
+
+ First get the network itself
+
+ """
+
+ net = self.vpnpool.ip_network
+ subnet = net[(2**(128-self.vpnpool.subnetwork_mask)) * self.pool_index]
+
+ return str(subnet)
+
+ def __str__(self):
+ return f"{self.address} ({self.pool_index})"
+
+
+class WireGuardVPNFreeLeases(models.Model):
+ """
+ Previously used VPNNetworks
+ """
+ vpnpool = models.ForeignKey(WireGuardVPNPool,
+ on_delete=models.CASCADE)
+
+ pool_index = models.IntegerField(unique=True)
+
+################################################################################
+
+class MACAdress(models.Model):
+ default_prefix = 0x420000000000
+
+
+class ReverseDNSEntry(models.Model):
+ """
+ A reverse DNS entry
+ """
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE)
+
+ ip_address = models.GenericIPAddressField(null=False, unique=True)
+
+ name = models.CharField(max_length=253, null=False)
+
+ @property
+ def reverse_pointer(self):
+ return ipaddress.ip_address(self.ip_address).reverse_pointer
+
+ def implement(self):
+ """
+ The implement function implements the change
+ """
+
+ # Get all DNS entries (?) / update this DNS entry
+ # convert to DNS name
+ #
+ pass
+
+
+ def save(self, *args, **kwargs):
+ # Product.objects.filter(config__parameters__contains='reverse_dns_network')
+ # FIXME: check if order is still active / not replaced
+
+ allowed = False
+ product = None
+
+ for order in Order.objects.filter(config__parameters__reverse_dns_network__isnull=False,
+ owner=self.owner):
+ network = order.config['parameters']['reverse_dns_network']
+
+ net = ipaddress.ip_network(network)
+ addr = ipaddress.ip_address(self.ip_address)
+
+ if addr in net:
+ allowed = True
+ product = order.product
+ break
+
+
+ if not allowed:
+ raise ValidationError(f"User {self.owner} does not have the right to create reverse DNS entry for {self.ip_address}")
+
+ super().save(*args, **kwargs)
+
+
+ def __str__(self):
+ return f"{self.ip_address} - {self.name}"
diff --git a/uncloud_net/selectors.py b/uncloud_net/selectors.py
new file mode 100644
index 0000000..6e12e8b
--- /dev/null
+++ b/uncloud_net/selectors.py
@@ -0,0 +1,43 @@
+from django.db import transaction
+from django.db.models import Count, F
+from .models import *
+
+def get_suitable_pools(subnetwork_mask):
+ """
+ Find suitable pools for a certain network size.
+
+ First, filter for all pools that offer the requested subnetwork_size.
+
+ Then find those pools that are not fully exhausted:
+
+ The number of available networks in a pool is 2^(subnetwork_size-network_size.
+
+ The number of available networks in a pool is given by the number of VPNNetworkreservations.
+
+ """
+
+ return WireGuardVPNPool.objects.annotate(
+ num_reservations=Count('wireguardvpn'),
+ max_reservations=2**(F('subnetwork_mask')-F('network_mask'))).filter(
+ num_reservations__lt=F('max_reservations'),
+ subnetwork_mask=subnetwork_mask)
+
+
+def allowed_vpn_network_reservation_size():
+ """
+ Find all possible sizes of subnetworks that are available.
+
+ Select all pools with free networks.
+
+ Get their subnetwork sizes, reduce to a set
+
+ """
+
+ pools = WireGuardVPNPool.objects.annotate(num_reservations=Count('wireguardvpn'),
+ max_reservations=2**(F('subnetwork_mask')-F('network_mask'))).filter(
+ num_reservations__lt=F('max_reservations'))
+
+ # Need to return set of tuples, see
+ # https://docs.djangoproject.com/en/3.1/ref/models/fields/#field-choices
+# return set([ (pool.subnetwork_mask, pool.subnetwork_mask) for pool in pools ])
+ return set([pool.subnetwork_mask for pool in pools ])
diff --git a/uncloud_net/serializers.py b/uncloud_net/serializers.py
new file mode 100644
index 0000000..09baa59
--- /dev/null
+++ b/uncloud_net/serializers.py
@@ -0,0 +1,57 @@
+import base64
+
+from django.contrib.auth import get_user_model
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from .models import *
+from .services import *
+from .selectors import *
+
+
+class WireGuardVPNSerializer(serializers.ModelSerializer):
+ address = serializers.CharField(read_only=True)
+ vpn_server = serializers.CharField(read_only=True)
+ vpn_server_public_key = serializers.CharField(read_only=True)
+ network_mask = serializers.IntegerField()
+
+ class Meta:
+ model = WireGuardVPN
+ fields = [ 'wireguard_public_key', 'address', 'network_mask', 'vpn_server',
+ 'vpn_server_public_key' ]
+
+ extra_kwargs = {
+ 'network_mask': {'write_only': True }
+ }
+
+
+ def validate_network_mask(self, value):
+ msg = _(f"No pool for network size {value}")
+ sizes = allowed_vpn_network_reservation_size()
+
+ if not value in sizes:
+ raise serializers.ValidationError(msg)
+
+ return value
+
+ def validate_wireguard_public_key(self, value):
+ msg = _("Supplied key is not a valid wireguard public key")
+
+ """
+ Verify wireguard key.
+ See https://lists.zx2c4.com/pipermail/wireguard/2020-December/006221.html
+ """
+
+ try:
+ decoded_key = base64.standard_b64decode(value)
+ except Exception as e:
+ raise serializers.ValidationError(msg)
+
+ if not len(decoded_key) == 32:
+ raise serializers.ValidationError(msg)
+
+ return value
+
+
+class WireGuardVPNSizesSerializer(serializers.Serializer):
+ size = serializers.IntegerField(min_value=0, max_value=128)
diff --git a/uncloud_net/services.py b/uncloud_net/services.py
new file mode 100644
index 0000000..4f80c44
--- /dev/null
+++ b/uncloud_net/services.py
@@ -0,0 +1,47 @@
+from django.db import transaction
+
+from .models import *
+from .selectors import *
+from .tasks import *
+
+@transaction.atomic
+def create_wireguard_vpn(owner, public_key, network_mask):
+
+ pool = get_suitable_pools(network_mask)[0]
+ count = pool.wireguardvpn_set.count()
+
+ # Try re-using previously used networks first
+ try:
+ free_lease = WireGuardVPNFreeLeases.objects.get(vpnpool=pool)
+
+ vpn = WireGuardVPN.objects.create(owner=owner,
+ vpnpool=pool,
+ pool_index=free_lease.pool_index,
+ wireguard_public_key=public_key)
+
+ free_lease.delete()
+
+ except WireGuardVPNFreeLeases.DoesNotExist:
+
+ # First object
+ if count == 0:
+ vpn = WireGuardVPN.objects.create(owner=owner,
+ vpnpool=pool,
+ pool_index=0,
+ wireguard_public_key=public_key)
+
+ else: # Select last network and try +1 it
+ last_net = WireGuardVPN.objects.filter(vpnpool=pool).order_by('pool_index').last()
+
+ next_index = last_net.pool_index + 1
+
+ if next_index <= pool.max_pool_index:
+ vpn = WireGuardVPN.objects.create(owner=owner,
+ vpnpool=pool,
+ pool_index=next_index,
+ wireguard_public_key=public_key)
+
+
+
+ configure_wireguard_server(pool)
+ return vpn
diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py
new file mode 100644
index 0000000..78ae80c
--- /dev/null
+++ b/uncloud_net/tasks.py
@@ -0,0 +1,60 @@
+from celery import shared_task
+from .models import *
+
+from uncloud.models import UncloudTask
+
+import os
+import subprocess
+import logging
+import uuid
+
+log = logging.getLogger(__name__)
+
+@shared_task
+def whereami():
+ print(os.uname())
+ return os.uname()
+
+def configure_wireguard_server(wireguardvpnpool):
+ """
+ - Create wireguard config (DB query -> string)
+ - Submit config to cdist worker
+ - Change config locally on worker / commit / shared
+
+ """
+
+ config = wireguardvpnpool.wireguard_config
+ server = wireguardvpnpool.vpn_server_hostname
+
+ log.info(f"Configuring VPN server {server} (async)")
+
+ task_id = uuid.UUID(cdist_configure_wireguard_server.apply_async((config, server)).id)
+ UncloudTask.objects.create(task_id=task_id)
+
+
+@shared_task
+def cdist_configure_wireguard_server(config, server):
+ """
+ Create config and configure server.
+
+ To be executed on the cdist workers.
+ """
+
+ dirname= "/home/app/.cdist/type/__ungleich_wireguard/files/"
+ fname = os.path.join(dirname,server)
+
+ log.info(f"Configuring VPN server {server} (on cdist host)")
+ with open(fname, "w") as fd:
+ fd.write(config)
+
+ log.debug("git committing wireguard changes")
+ subprocess.run(f"cd {dirname} && git pull && git add {server} && git commit -m 'Updating config for {server}' && git push",
+ shell=True, check=True)
+
+ log.debug(f"Configuring VPN server {server} with cdist")
+ subprocess.run(f"cdist config {server}", shell=True, check=True)
+
+ # FIXME:
+ # ensure logs are on the server
+ # ensure exit codes are known
+ return True
diff --git a/uncloud_net/templates/uncloud_net/wireguardvpn_form.html b/uncloud_net/templates/uncloud_net/wireguardvpn_form.html
new file mode 100644
index 0000000..1463f41
--- /dev/null
+++ b/uncloud_net/templates/uncloud_net/wireguardvpn_form.html
@@ -0,0 +1,25 @@
+{% extends 'uncloud/base.html' %}
+
+{% block body %}
+
+
+
+
+ Create a VPN Network
+
+ Create a new wireguard based VPN network.
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/uncloud_net/tests.py b/uncloud_net/tests.py
new file mode 100644
index 0000000..4491551
--- /dev/null
+++ b/uncloud_net/tests.py
@@ -0,0 +1,102 @@
+from django.test import TestCase
+from rest_framework.test import APIRequestFactory, force_authenticate
+
+from rest_framework.reverse import reverse
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError, FieldError
+
+from .views import *
+from .models import *
+
+from uncloud_pay.models import BillingAddress, Order
+from uncloud.models import UncloudNetwork
+
+class UncloudNetworkTests(TestCase):
+ def test_invalid_IPv4_network(self):
+ with self.assertRaises(FieldError):
+ UncloudNetwork.objects.create(network_address="192.168.1.0",
+ network_mask=33)
+
+class VPNTests(TestCase):
+ def setUp(self):
+ self.user = get_user_model().objects.create_user('django-test-user', 'noreply@ungleich.ch')
+ self.admin_user = get_user_model().objects.create_user('django-test-adminuser',
+ 'noreply-admin@ungleich.ch')
+
+
+
+ self.admin_user.is_staff = True
+ self.admin_user.save()
+
+ self.pool_network = '2001:db8::'
+ self.pool_network2 = '2001:db8:1::'
+ self.pool_network_size = '48'
+ self.pool_subnetwork_size = '64'
+ self.pool_vpn_hostname = 'vpn.example.org'
+ self.pool_wireguard_private_key = 'MOz8kk0m4jhNtAXlge0qzexZh1MipIhu4HJwtdvZ2EY='
+
+ self.vpn_wireguard_public_key = 'B2b78eWBIXPMM1x4DDjkCDZepS0qDgcLN3T3PjcgXkY='
+
+ self.vpnpool = VPNPool.objects.get_or_create(network=self.pool_network,
+ network_size=self.pool_network_size,
+ subnetwork_size=self.pool_subnetwork_size,
+ vpn_hostname=self.pool_vpn_hostname,
+ wireguard_private_key=self.pool_wireguard_private_key
+ )
+
+ self.factory = APIRequestFactory()
+
+
+ def test_create_vpnpool(self):
+ url = reverse("vpnpool-list")
+ view = VPNPoolViewSet.as_view({'post': 'create'})
+ request = self.factory.post(url, { 'network': self.pool_network2,
+ 'network_size': self.pool_network_size,
+ 'subnetwork_size': self.pool_subnetwork_size,
+ 'vpn_hostname': self.pool_vpn_hostname,
+ 'wireguard_private_key': self.pool_wireguard_private_key
+
+ })
+ force_authenticate(request, user=self.admin_user)
+ response = view(request)
+
+ # This raises an exception if the request was not successful
+ # No assert needed
+ pool = VPNPool.objects.get(network=self.pool_network2)
+
+ # def test_create_vpn(self):
+ # url = reverse("vpnnetwork-list")
+ # view = VPNNetworkViewSet.as_view({'post': 'create'})
+ # request = self.factory.post(url, { 'network_size': self.pool_subnetwork_size,
+ # 'wireguard_public_key': self.vpn_wireguard_public_key
+
+ # })
+ # force_authenticate(request, user=self.user)
+
+
+ # # we don't have a billing address -> should raise an error
+ # # with self.assertRaises(ValidationError):
+ # # response = view(request)
+
+ # addr = BillingAddress.objects.get_or_create(
+ # owner=self.user,
+ # active=True,
+ # defaults={'organization': 'ungleich',
+ # 'name': 'Nico Schottelius',
+ # 'street': 'Hauptstrasse 14',
+ # 'city': 'Luchsingen',
+ # 'postal_code': '8775',
+ # 'country': 'CH' }
+ # )
+
+ # # This should work now
+ # response = view(request)
+
+ # # Verify that an order was created successfully - there should only be one order at
+ # # this point in time
+ # order = Order.objects.get(owner=self.user)
+
+
+ def tearDown(self):
+ self.user.delete()
+ self.admin_user.delete()
diff --git a/uncloud_net/views.py b/uncloud_net/views.py
new file mode 100644
index 0000000..77ba952
--- /dev/null
+++ b/uncloud_net/views.py
@@ -0,0 +1,70 @@
+from django.views.generic.edit import CreateView
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.messages.views import SuccessMessageMixin
+from rest_framework.response import Response
+
+from django.shortcuts import render
+
+from rest_framework import viewsets, permissions
+
+from .models import *
+from .serializers import *
+from .selectors import *
+from .services import *
+from .forms import *
+from .tasks import *
+
+class WireGuardVPNViewSet(viewsets.ModelViewSet):
+ serializer_class = WireGuardVPNSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = WireGuardVPN.objects.all()
+ else:
+ obj = WireGuardVPN.objects.filter(owner=self.request.user)
+
+ return obj
+
+ def create(self, request):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ vpn = create_wireguard_vpn(
+ owner=self.request.user,
+ public_key=serializer.validated_data['wireguard_public_key'],
+ network_mask=serializer.validated_data['network_mask']
+ )
+ configure_wireguard_server(vpn.vpnpool)
+ return Response(WireGuardVPNSerializer(vpn).data)
+
+
+class WireGuardVPNCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
+ model = WireGuardVPN
+
+ login_url = '/login/'
+ success_url = '/'
+ success_message = "%(network) was created successfully"
+
+ form_class = WireGuardVPNForm
+
+ def get_success_message(self, cleaned_data):
+ return self.success_message % dict(cleaned_data,
+ the_prefix = self.object.prefix)
+
+class WireGuardVPNSizes(viewsets.ViewSet):
+ def list(self, request):
+ sizes = allowed_vpn_network_reservation_size()
+ print(sizes)
+
+ sizes = [ { 'size': size } for size in sizes ]
+ print(sizes)
+
+ return Response(WireGuardVPNSizesSerializer(sizes, many=True).data)
+
+
+
+# class VPNPoolViewSet(viewsets.ModelViewSet):
+# serializer_class = VPNPoolSerializer
+# permission_classes = [permissions.IsAdminUser]
+# queryset = VPNPool.objects.all()
diff --git a/uncloud_pay/__init__.py b/uncloud_pay/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/uncloud_pay/__init__.py
@@ -0,0 +1 @@
+
diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py
new file mode 100644
index 0000000..2c72274
--- /dev/null
+++ b/uncloud_pay/admin.py
@@ -0,0 +1,92 @@
+from django.contrib import admin
+from django.template.response import TemplateResponse
+from django.urls import path
+from django.shortcuts import render
+from django.conf.urls import url
+
+from uncloud_pay.views import BillViewSet
+from hardcopy import bytestring_to_pdf
+from django.core.files.temp import NamedTemporaryFile
+from django.http import FileResponse
+from django.template.loader import render_to_string
+
+
+from uncloud_pay.models import *
+
+
+class BillRecordInline(admin.TabularInline):
+ model = BillRecord
+
+class RecurringPeriodInline(admin.TabularInline):
+ model = ProductToRecurringPeriod
+
+class ProductAdmin(admin.ModelAdmin):
+ inlines = [ RecurringPeriodInline ]
+
+class BillAdmin(admin.ModelAdmin):
+ inlines = [ BillRecordInline ]
+
+ def get_urls(self):
+ """
+ Create URLs for PDF view
+ """
+
+ info = "%s_%s" % (self.model._meta.app_label, self.model._meta.model_name)
+ pat = lambda regex, fn: url(regex, self.admin_site.admin_view(fn), name='%s_%s' % (info, fn.__name__))
+
+ url_patterns = [
+ pat(r'^([0-9]+)/as_pdf/$', self.as_pdf),
+ pat(r'^([0-9]+)/as_html/$', self.as_html),
+ ] + super().get_urls()
+
+ return url_patterns
+
+ def as_pdf(self, request, object_id):
+ bill = self.get_object(request, object_id=object_id)
+ print(bill)
+
+ if bill is None:
+ raise self._get_404_exception(object_id)
+
+ output_file = NamedTemporaryFile()
+ bill_html = render_to_string("bill.html.j2", {'bill': bill,
+ 'bill_records': bill.billrecord_set.all()
+ })
+
+ bytestring_to_pdf(bill_html.encode('utf-8'), output_file)
+ response = FileResponse(output_file, content_type="application/pdf")
+ response['Content-Disposition'] = f'filename="bill_{bill}.pdf"'
+
+ return response
+
+ def as_html(self, request, object_id):
+ bill = self.get_object(request, object_id=object_id)
+
+ if bill is None:
+ raise self._get_404_exception(object_id)
+
+ return render(request, 'bill.html.j2',
+ {'bill': bill,
+ 'bill_records': bill.billrecord_set.all()
+ })
+
+
+ bill_html = render_to_string("bill.html.j2", {'bill': bill,
+ 'bill_records': bill.billrecord_set.all()
+ })
+
+ bytestring_to_pdf(bill_html.encode('utf-8'), output_file)
+ response = FileResponse(output_file, content_type="application/pdf")
+
+ response['Content-Disposition'] = f'filename="bill_{bill}.pdf"'
+
+ return HttpResponse(template.render(context, request))
+ return response
+
+
+admin.site.register(Bill, BillAdmin)
+admin.site.register(ProductToRecurringPeriod)
+admin.site.register(Product, ProductAdmin)
+
+for m in [ Order, BillRecord, BillingAddress, RecurringPeriod, VATRate, StripeCustomer ]:
+ admin.site.register(m)
diff --git a/uncloud_pay/apps.py b/uncloud_pay/apps.py
new file mode 100644
index 0000000..051ffb4
--- /dev/null
+++ b/uncloud_pay/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class UncloudPayConfig(AppConfig):
+ name = 'uncloud_pay'
diff --git a/uncloud_pay/helpers.py b/uncloud_pay/helpers.py
new file mode 100644
index 0000000..f791564
--- /dev/null
+++ b/uncloud_pay/helpers.py
@@ -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
diff --git a/uncloud_pay/management/commands/.gitignore b/uncloud_pay/management/commands/.gitignore
new file mode 100644
index 0000000..cf5c7fa
--- /dev/null
+++ b/uncloud_pay/management/commands/.gitignore
@@ -0,0 +1,2 @@
+# Customer tests
+customer-*.py
diff --git a/uncloud_pay/management/commands/add-opennebula-vm-orders.py b/uncloud_pay/management/commands/add-opennebula-vm-orders.py
new file mode 100644
index 0000000..e0b6758
--- /dev/null
+++ b/uncloud_pay/management/commands/add-opennebula-vm-orders.py
@@ -0,0 +1,152 @@
+import datetime
+import sys
+
+from django.contrib.auth import get_user_model
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+
+from uncloud_pay.models import (
+ BillingAddress
+)
+from uncloud_vm.models import (
+ VMDiskType, VMProduct
+)
+
+
+def vm_price_2020(cpu=1, ram=2, v6only=False):
+ if v6only:
+ discount = 9
+ else:
+ discount = 0
+
+ return cpu * 3 + ram * 4 - discount
+
+
+def disk_price_2020(size_in_gb, disk_type):
+ if disk_type == VMDiskType.CEPH_SSD:
+ price = 3.5 / 10
+ elif disk_type == VMDiskType.CEPH_HDD:
+ price = 1.5 / 100
+ else:
+ raise Exception("not yet defined price")
+
+ return size_in_gb * price
+
+
+class Command(BaseCommand):
+ help = 'Adding VMs / creating orders for user'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--username', type=str, required=True)
+
+ def handle(self, *args, **options):
+ user = get_user_model().objects.get(username=options['username'])
+
+ addr, created = BillingAddress.objects.get_or_create(
+ owner=user,
+ active=True,
+ defaults={'organization': 'Undefined organisation',
+ 'full_name': 'Undefined name',
+ 'street': 'Undefined Street',
+ 'city': 'Undefined city',
+ 'postal_code': '8750',
+ 'country': 'CH',
+ 'active': True
+ }
+ )
+
+ # 25206 + SSD
+ vm25206 = VMProduct.objects.create(name="one-25206", cores=1,
+ ram_in_gb=4, owner=user)
+ vm25206.create_order_at(
+ timezone.make_aware(datetime.datetime(2020, 3, 3)))
+
+ # vm25206_ssd = VMDiskProduct.objects.create(vm=vm25206, owner=user, size_in_gb=30)
+ # vm25206_ssd.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3)))
+
+ # change 1
+ vm25206.cores = 2
+ vm25206.ram_in_gb = 8
+ vm25206.save()
+ vm25206.create_or_update_order(
+ when_to_start=timezone.make_aware(datetime.datetime(2020, 4, 17)))
+
+ sys.exit(0)
+
+ # change 2
+ # vm25206_ssd.size_in_gb = 50
+ # vm25206_ssd.save()
+ # vm25206_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5)))
+
+ # 25206 done.
+
+ # 25615
+ vm25615 = VMProduct.objects.create(name="one-25615", cores=1,
+ ram_in_gb=4, owner=user)
+ vm25615.create_order_at(
+ timezone.make_aware(datetime.datetime(2020, 3, 3)))
+
+ # Change 2020-04-17
+ vm25615.cores = 2
+ vm25615.ram_in_gb = 8
+ vm25615.save()
+ vm25615.create_or_update_order(
+ when_to_start=timezone.make_aware(datetime.datetime(2020, 4, 17)))
+
+ # vm25615_ssd = VMDiskProduct(vm=vm25615, owner=user, size_in_gb=30)
+ # vm25615_ssd.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3)))
+ # vm25615_ssd.save()
+
+ vm25208 = VMProduct.objects.create(name="one-25208", cores=1,
+ ram_in_gb=4, owner=user)
+ vm25208.create_order_at(
+ timezone.make_aware(datetime.datetime(2020, 3, 5)))
+
+ vm25208.cores = 2
+ vm25208.ram_in_gb = 8
+ vm25208.save()
+ vm25208.create_or_update_order(
+ when_to_start=timezone.make_aware(datetime.datetime(2020, 4, 17)))
+
+ Bill.create_next_bills_for_user(user, ending_date=end_of_month(
+ timezone.make_aware(datetime.datetime(2020, 7, 31))))
+
+ sys.exit(0)
+
+ vm25615_ssd.size_in_gb = 50
+ vm25615_ssd.save()
+ vm25615_ssd.create_or_update_order(
+ when_to_start=timezone.make_aware(datetime.datetime(2020, 8, 5)))
+
+ vm25208_ssd = VMDiskProduct.objects.create(vm=vm25208,
+ owner=user,
+ size_in_gb=30)
+
+ vm25208_ssd.size_in_gb = 50
+ vm25208_ssd.save()
+ vm25208_ssd.create_or_update_order(
+ when_to_start=timezone.make_aware(datetime.datetime(2020, 8, 5)))
+
+ # 25207
+ vm25207 = VMProduct.objects.create(name="OpenNebula 25207",
+ cores=1,
+ ram_in_gb=4,
+ owner=user)
+
+ vm25207_ssd = VMDiskProduct.objects.create(vm=vm25207,
+ owner=user,
+ size_in_gb=30)
+
+ vm25207_ssd.size_in_gb = 50
+ vm25207_ssd.save()
+ vm25207_ssd.create_or_update_order(
+ when_to_start=timezone.make_aware(datetime.datetime(2020, 8, 5)))
+
+ vm25207.cores = 2
+ vm25207.ram_in_gb = 8
+ vm25207.save()
+ vm25207.create_or_update_order(
+ when_to_start=timezone.make_aware(datetime.datetime(2020, 6, 19)))
+
+ # FIXES: check starting times (they are slightly different)
+ # add vm 25236
diff --git a/uncloud_pay/management/commands/bootstrap-user.py b/uncloud_pay/management/commands/bootstrap-user.py
new file mode 100644
index 0000000..b78e80c
--- /dev/null
+++ b/uncloud_pay/management/commands/bootstrap-user.py
@@ -0,0 +1,40 @@
+from django.core.management.base import BaseCommand
+from django.contrib.auth import get_user_model
+import datetime
+
+from uncloud_pay.models import *
+
+class Command(BaseCommand):
+ help = 'Bootstrap user (for testing)'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--username', type=str, required=True)
+
+ def handle(self, *args, **options):
+ user = get_user_model().objects.get(username=options['username'])
+
+ addr = BillingAddress.objects.get_or_create(
+ owner=user,
+ active=True,
+ defaults={'organization': 'ungleich',
+ 'name': 'Nico Schottelius',
+ 'street': 'Hauptstrasse 14',
+ 'city': 'Luchsingen',
+ 'postal_code': '8775',
+ 'country': 'CH' }
+ )
+
+
+ bills = Bill.objects.filter(owner=user)
+
+ # not even one bill? create!
+ if bills:
+ bill = bills[0]
+ else:
+ bill = Bill.objects.create(owner=user)
+
+ # find any order that is associated to this bill
+ orders = Order.objects.filter(owner=user)
+
+ print(f"Addr: {addr}")
+ print(f"Bill: {bill}")
diff --git a/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud_pay/management/commands/charge-negative-balance.py
new file mode 100644
index 0000000..8ee8736
--- /dev/null
+++ b/uncloud_pay/management/commands/charge-negative-balance.py
@@ -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_user
+
+from datetime import timedelta
+from django.utils import timezone
+
+class Command(BaseCommand):
+ help = 'Generate bills and charge customers if necessary.'
+
+ def add_arguments(self, parser):
+ pass
+
+ def handle(self, *args, **options):
+ users = User.objects.all()
+ print("Processing {} users.".format(users.count()))
+ for user in users:
+ balance = get_balance_for_user(user)
+ if balance < 0:
+ print("User {} has negative balance ({}), charging.".format(user.username, balance))
+ payment_method = PaymentMethod.get_primary_for(user)
+ if payment_method != None:
+ amount_to_be_charged = abs(balance)
+ charge_ok = payment_method.charge(amount_to_be_charged)
+ if not charge_ok:
+ print("ERR: charging {} with method {} failed"
+ .format(user.username, payment_method.uuid)
+ )
+ else:
+ print("ERR: no payment method registered for {}".format(user.username))
+ print("=> Done.")
diff --git a/uncloud_pay/management/commands/generate-bills.py b/uncloud_pay/management/commands/generate-bills.py
new file mode 100644
index 0000000..5bd4519
--- /dev/null
+++ b/uncloud_pay/management/commands/generate-bills.py
@@ -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.")
diff --git a/uncloud_pay/management/commands/handle-overdue-bills.py b/uncloud_pay/management/commands/handle-overdue-bills.py
new file mode 100644
index 0000000..595fbc2
--- /dev/null
+++ b/uncloud_pay/management/commands/handle-overdue-bills.py
@@ -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.")
diff --git a/uncloud_pay/management/commands/import-vat-rates.py b/uncloud_pay/management/commands/import-vat-rates.py
new file mode 100644
index 0000000..46848cd
--- /dev/null
+++ b/uncloud_pay/management/commands/import-vat-rates.py
@@ -0,0 +1,35 @@
+from django.core.management.base import BaseCommand
+from uncloud_pay.models import VATRate
+
+import urllib
+import csv
+import sys
+import io
+
+class Command(BaseCommand):
+ help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv'''
+ vat_url = "https://raw.githubusercontent.com/ungleich/vat-rates/main/vat_rates.csv"
+
+
+ def add_arguments(self, parser):
+ parser.add_argument('--vat-url', default=self.vat_url)
+
+ def handle(self, *args, **options):
+ vat_url = options['vat_url']
+ url_open = urllib.request.urlopen(vat_url)
+
+ # map to fileio using stringIO
+ csv_file = io.StringIO(url_open.read().decode('utf-8'))
+ reader = csv.DictReader(csv_file)
+
+ for row in reader:
+# print(row)
+ obj, created = VATRate.objects.get_or_create(
+ starting_date=row["start_date"],
+ ending_date=row["stop_date"] if row["stop_date"] != "" else None,
+ territory_codes=row["territory_codes"],
+ currency_code=row["currency_code"],
+ rate=row["rate"],
+ rate_type=row["rate_type"],
+ description=row["description"]
+ )
diff --git a/uncloud_pay/migrations/0001_initial.py b/uncloud_pay/migrations/0001_initial.py
new file mode 100644
index 0000000..b1b68c5
--- /dev/null
+++ b/uncloud_pay/migrations/0001_initial.py
@@ -0,0 +1,181 @@
+# Generated by Django 3.1 on 2020-12-13 10:38
+
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import uncloud.models
+import uncloud_pay.models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('uncloud_auth', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Bill',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('creation_date', models.DateTimeField(auto_now_add=True)),
+ ('starting_date', models.DateTimeField(default=uncloud_pay.models.start_of_this_month)),
+ ('ending_date', models.DateTimeField()),
+ ('due_date', models.DateField(default=uncloud_pay.models.default_payment_delay)),
+ ('is_final', models.BooleanField(default=False)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='BillingAddress',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('full_name', models.CharField(max_length=256)),
+ ('organization', models.CharField(blank=True, max_length=256, null=True)),
+ ('street', models.CharField(max_length=256)),
+ ('city', models.CharField(max_length=256)),
+ ('postal_code', models.CharField(max_length=64)),
+ ('country', uncloud.models.CountryField(blank=True, choices=[('AD', 'Andorra'), ('AE', 'United Arab Emirates'), ('AF', 'Afghanistan'), ('AG', 'Antigua & Barbuda'), ('AI', 'Anguilla'), ('AL', 'Albania'), ('AM', 'Armenia'), ('AN', 'Netherlands Antilles'), ('AO', 'Angola'), ('AQ', 'Antarctica'), ('AR', 'Argentina'), ('AS', 'American Samoa'), ('AT', 'Austria'), ('AU', 'Australia'), ('AW', 'Aruba'), ('AZ', 'Azerbaijan'), ('BA', 'Bosnia and Herzegovina'), ('BB', 'Barbados'), ('BD', 'Bangladesh'), ('BE', 'Belgium'), ('BF', 'Burkina Faso'), ('BG', 'Bulgaria'), ('BH', 'Bahrain'), ('BI', 'Burundi'), ('BJ', 'Benin'), ('BM', 'Bermuda'), ('BN', 'Brunei Darussalam'), ('BO', 'Bolivia'), ('BR', 'Brazil'), ('BS', 'Bahama'), ('BT', 'Bhutan'), ('BV', 'Bouvet Island'), ('BW', 'Botswana'), ('BY', 'Belarus'), ('BZ', 'Belize'), ('CA', 'Canada'), ('CC', 'Cocos (Keeling) Islands'), ('CF', 'Central African Republic'), ('CG', 'Congo'), ('CH', 'Switzerland'), ('CI', 'Ivory Coast'), ('CK', 'Cook Iislands'), ('CL', 'Chile'), ('CM', 'Cameroon'), ('CN', 'China'), ('CO', 'Colombia'), ('CR', 'Costa Rica'), ('CU', 'Cuba'), ('CV', 'Cape Verde'), ('CX', 'Christmas Island'), ('CY', 'Cyprus'), ('CZ', 'Czech Republic'), ('DE', 'Germany'), ('DJ', 'Djibouti'), ('DK', 'Denmark'), ('DM', 'Dominica'), ('DO', 'Dominican Republic'), ('DZ', 'Algeria'), ('EC', 'Ecuador'), ('EE', 'Estonia'), ('EG', 'Egypt'), ('EH', 'Western Sahara'), ('ER', 'Eritrea'), ('ES', 'Spain'), ('ET', 'Ethiopia'), ('FI', 'Finland'), ('FJ', 'Fiji'), ('FK', 'Falkland Islands (Malvinas)'), ('FM', 'Micronesia'), ('FO', 'Faroe Islands'), ('FR', 'France'), ('FX', 'France, Metropolitan'), ('GA', 'Gabon'), ('GB', 'United Kingdom (Great Britain)'), ('GD', 'Grenada'), ('GE', 'Georgia'), ('GF', 'French Guiana'), ('GH', 'Ghana'), ('GI', 'Gibraltar'), ('GL', 'Greenland'), ('GM', 'Gambia'), ('GN', 'Guinea'), ('GP', 'Guadeloupe'), ('GQ', 'Equatorial Guinea'), ('GR', 'Greece'), ('GS', 'South Georgia and the South Sandwich Islands'), ('GT', 'Guatemala'), ('GU', 'Guam'), ('GW', 'Guinea-Bissau'), ('GY', 'Guyana'), ('HK', 'Hong Kong'), ('HM', 'Heard & McDonald Islands'), ('HN', 'Honduras'), ('HR', 'Croatia'), ('HT', 'Haiti'), ('HU', 'Hungary'), ('ID', 'Indonesia'), ('IE', 'Ireland'), ('IL', 'Israel'), ('IN', 'India'), ('IO', 'British Indian Ocean Territory'), ('IQ', 'Iraq'), ('IR', 'Islamic Republic of Iran'), ('IS', 'Iceland'), ('IT', 'Italy'), ('JM', 'Jamaica'), ('JO', 'Jordan'), ('JP', 'Japan'), ('KE', 'Kenya'), ('KG', 'Kyrgyzstan'), ('KH', 'Cambodia'), ('KI', 'Kiribati'), ('KM', 'Comoros'), ('KN', 'St. Kitts and Nevis'), ('KP', "Korea, Democratic People's Republic of"), ('KR', 'Korea, Republic of'), ('KW', 'Kuwait'), ('KY', 'Cayman Islands'), ('KZ', 'Kazakhstan'), ('LA', "Lao People's Democratic Republic"), ('LB', 'Lebanon'), ('LC', 'Saint Lucia'), ('LI', 'Liechtenstein'), ('LK', 'Sri Lanka'), ('LR', 'Liberia'), ('LS', 'Lesotho'), ('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('LV', 'Latvia'), ('LY', 'Libyan Arab Jamahiriya'), ('MA', 'Morocco'), ('MC', 'Monaco'), ('MD', 'Moldova, Republic of'), ('MG', 'Madagascar'), ('MH', 'Marshall Islands'), ('ML', 'Mali'), ('MN', 'Mongolia'), ('MM', 'Myanmar'), ('MO', 'Macau'), ('MP', 'Northern Mariana Islands'), ('MQ', 'Martinique'), ('MR', 'Mauritania'), ('MS', 'Monserrat'), ('MT', 'Malta'), ('MU', 'Mauritius'), ('MV', 'Maldives'), ('MW', 'Malawi'), ('MX', 'Mexico'), ('MY', 'Malaysia'), ('MZ', 'Mozambique'), ('NA', 'Namibia'), ('NC', 'New Caledonia'), ('NE', 'Niger'), ('NF', 'Norfolk Island'), ('NG', 'Nigeria'), ('NI', 'Nicaragua'), ('NL', 'Netherlands'), ('NO', 'Norway'), ('NP', 'Nepal'), ('NR', 'Nauru'), ('NU', 'Niue'), ('NZ', 'New Zealand'), ('OM', 'Oman'), ('PA', 'Panama'), ('PE', 'Peru'), ('PF', 'French Polynesia'), ('PG', 'Papua New Guinea'), ('PH', 'Philippines'), ('PK', 'Pakistan'), ('PL', 'Poland'), ('PM', 'St. Pierre & Miquelon'), ('PN', 'Pitcairn'), ('PR', 'Puerto Rico'), ('PT', 'Portugal'), ('PW', 'Palau'), ('PY', 'Paraguay'), ('QA', 'Qatar'), ('RE', 'Reunion'), ('RO', 'Romania'), ('RU', 'Russian Federation'), ('RW', 'Rwanda'), ('SA', 'Saudi Arabia'), ('SB', 'Solomon Islands'), ('SC', 'Seychelles'), ('SD', 'Sudan'), ('SE', 'Sweden'), ('SG', 'Singapore'), ('SH', 'St. Helena'), ('SI', 'Slovenia'), ('SJ', 'Svalbard & Jan Mayen Islands'), ('SK', 'Slovakia'), ('SL', 'Sierra Leone'), ('SM', 'San Marino'), ('SN', 'Senegal'), ('SO', 'Somalia'), ('SR', 'Suriname'), ('ST', 'Sao Tome & Principe'), ('SV', 'El Salvador'), ('SY', 'Syrian Arab Republic'), ('SZ', 'Swaziland'), ('TC', 'Turks & Caicos Islands'), ('TD', 'Chad'), ('TF', 'French Southern Territories'), ('TG', 'Togo'), ('TH', 'Thailand'), ('TJ', 'Tajikistan'), ('TK', 'Tokelau'), ('TM', 'Turkmenistan'), ('TN', 'Tunisia'), ('TO', 'Tonga'), ('TP', 'East Timor'), ('TR', 'Turkey'), ('TT', 'Trinidad & Tobago'), ('TV', 'Tuvalu'), ('TW', 'Taiwan, Province of China'), ('TZ', 'Tanzania, United Republic of'), ('UA', 'Ukraine'), ('UG', 'Uganda'), ('UM', 'United States Minor Outlying Islands'), ('US', 'United States of America'), ('UY', 'Uruguay'), ('UZ', 'Uzbekistan'), ('VA', 'Vatican City State (Holy See)'), ('VC', 'St. Vincent & the Grenadines'), ('VE', 'Venezuela'), ('VG', 'British Virgin Islands'), ('VI', 'United States Virgin Islands'), ('VN', 'Viet Nam'), ('VU', 'Vanuatu'), ('WF', 'Wallis & Futuna Islands'), ('WS', 'Samoa'), ('YE', 'Yemen'), ('YT', 'Mayotte'), ('YU', 'Yugoslavia'), ('ZA', 'South Africa'), ('ZM', 'Zambia'), ('ZR', 'Zaire'), ('ZW', 'Zimbabwe')], default='CH', max_length=2)),
+ ('vat_number', models.CharField(blank=True, default='', max_length=100)),
+ ('active', models.BooleanField(default=False)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Product',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=256, unique=True)),
+ ('description', models.CharField(max_length=1024)),
+ ('config', models.JSONField()),
+ ('currency', models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='RecurringPeriod',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('duration_seconds', models.IntegerField(unique=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='StripeCustomer',
+ fields=[
+ ('owner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='uncloud_auth.user')),
+ ('stripe_id', models.CharField(max_length=32)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VATRate',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('starting_date', models.DateField(blank=True, null=True)),
+ ('ending_date', models.DateField(blank=True, null=True)),
+ ('territory_codes', models.TextField(blank=True, default='')),
+ ('currency_code', models.CharField(max_length=10)),
+ ('rate', models.FloatField()),
+ ('rate_type', models.TextField(blank=True, default='')),
+ ('description', models.TextField(blank=True, default='')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ProductToRecurringPeriod',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('is_default', models.BooleanField(default=False)),
+ ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product')),
+ ('recurring_period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod')),
+ ],
+ ),
+ migrations.AddField(
+ model_name='product',
+ name='recurring_periods',
+ field=models.ManyToManyField(through='uncloud_pay.ProductToRecurringPeriod', to='uncloud_pay.RecurringPeriod'),
+ ),
+ migrations.CreateModel(
+ name='PaymentMethod',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('source', models.CharField(choices=[('stripe', 'Stripe'), ('unknown', 'Unknown')], default='stripe', max_length=256)),
+ ('description', models.TextField()),
+ ('primary', models.BooleanField(default=False, editable=False)),
+ ('stripe_payment_method_id', models.CharField(blank=True, max_length=32, null=True)),
+ ('stripe_setup_intent_id', models.CharField(blank=True, max_length=32, null=True)),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Payment',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('amount', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
+ ('source', models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256)),
+ ('timestamp', models.DateTimeField(auto_now_add=True)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Order',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('description', models.TextField()),
+ ('config', models.JSONField()),
+ ('creation_date', models.DateTimeField(auto_now_add=True)),
+ ('starting_date', models.DateTimeField(default=django.utils.timezone.now)),
+ ('ending_date', models.DateTimeField(blank=True, null=True)),
+ ('one_time_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
+ ('recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
+ ('currency', models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32)),
+ ('should_be_billed', models.BooleanField(default=True)),
+ ('billing_address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress')),
+ ('depends_on', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_of', to='uncloud_pay.order')),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product')),
+ ('recurring_period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod')),
+ ('replaces', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replaced_by', to='uncloud_pay.order')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='BillRecord',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('creation_date', models.DateTimeField(auto_now_add=True)),
+ ('starting_date', models.DateTimeField()),
+ ('ending_date', models.DateTimeField()),
+ ('is_recurring_record', models.BooleanField()),
+ ('bill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.bill')),
+ ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.order')),
+ ],
+ ),
+ migrations.AddField(
+ model_name='bill',
+ name='billing_address',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress'),
+ ),
+ migrations.AddField(
+ model_name='bill',
+ name='owner',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddConstraint(
+ model_name='producttorecurringperiod',
+ constraint=models.UniqueConstraint(condition=models.Q(is_default=True), fields=('product',), name='one_default_recurring_period_per_product'),
+ ),
+ migrations.AddConstraint(
+ model_name='producttorecurringperiod',
+ constraint=models.UniqueConstraint(fields=('product', 'recurring_period'), name='recurring_period_once_per_product'),
+ ),
+ migrations.AddConstraint(
+ model_name='billingaddress',
+ constraint=models.UniqueConstraint(condition=models.Q(active=True), fields=('owner',), name='one_active_billing_address_per_user'),
+ ),
+ migrations.AddConstraint(
+ model_name='bill',
+ constraint=models.UniqueConstraint(fields=('owner', 'starting_date', 'ending_date'), name='one_bill_per_month_per_user'),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/__init__.py b/uncloud_pay/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py
new file mode 100644
index 0000000..18e6f85
--- /dev/null
+++ b/uncloud_pay/models.py
@@ -0,0 +1,1263 @@
+import logging
+import itertools
+import datetime
+from math import ceil
+from calendar import monthrange
+from decimal import Decimal
+from functools import reduce
+
+from django.db import models
+from django.db.models import Q
+from django.contrib.auth import get_user_model
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import gettext_lazy as _
+from django.core.validators import MinValueValidator
+from django.utils import timezone
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.conf import settings
+
+import uncloud_pay.stripe
+from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
+from uncloud.models import UncloudAddress
+
+# Used to generate bill due dates.
+BILL_PAYMENT_DELAY=datetime.timedelta(days=10)
+
+# Initialize logger.
+logger = logging.getLogger(__name__)
+
+def start_of_month(a_day):
+ """ Returns first of the month of a given datetime object"""
+ return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0)
+
+def end_of_month(a_day):
+ """ Returns first of the month of a given datetime object"""
+
+ _, last_day = monthrange(a_day.year, a_day.month)
+ return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0)
+
+def start_of_this_month():
+ """ Returns first of this month"""
+ a_day = timezone.now()
+ return a_day.replace(day=1,hour=0,minute=0,second=0, microsecond=0)
+
+def end_of_this_month():
+ """ Returns first of this month"""
+ a_day = timezone.now()
+
+ _, last_day = monthrange(a_day.year, a_day.month)
+ return a_day.replace(day=last_day,hour=23,minute=59,second=59, microsecond=0)
+
+def end_before(a_date):
+ """ Return suitable datetimefield for ending just before a_date """
+ return a_date - datetime.timedelta(seconds=1)
+
+def start_after(a_date):
+ """ Return suitable datetimefield for starting just after a_date """
+ return a_date + datetime.timedelta(seconds=1)
+
+def default_payment_delay():
+ return timezone.now() + BILL_PAYMENT_DELAY
+
+class Currency(models.TextChoices):
+ """
+ Possible currencies to be billed
+ """
+ CHF = 'CHF', _('Swiss Franc')
+ EUR = 'EUR', _('Euro')
+ USD = 'USD', _('US Dollar')
+
+
+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
+
+###
+# Stripe
+
+class StripeCustomer(models.Model):
+ owner = models.OneToOneField( get_user_model(),
+ primary_key=True,
+ on_delete=models.CASCADE)
+ stripe_id = models.CharField(max_length=32)
+
+ def __str__(self):
+ return self.owner.username
+
+###
+# Payments and Payment Methods.
+
+class Payment(models.Model):
+ 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)
+
+ # We override save() in order to active products awaiting payment.
+ def save(self, *args, **kwargs):
+ # _state.adding is switched to false after super(...) call.
+ being_created = self._state.adding
+
+ 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_products()
+
+
+class PaymentMethod(models.Model):
+ 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=False, editable=False)
+
+ # 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 set_as_primary_for(self, user):
+ methods = PaymentMethod.objects.filter(owner=user, primary=True)
+ for method in methods:
+ print(method)
+ method.primary = False
+ method.save()
+
+ self.primary = True
+ self.save()
+
+ 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
+
+# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
+class RecurringPeriodDefaultChoices(models.IntegerChoices):
+ """
+ This is an old class and being superseeded by the database model below
+ """
+ PER_365D = 365*24*3600, _('Per 365 days')
+ PER_30D = 30*24*3600, _('Per 30 days')
+ PER_WEEK = 7*24*3600, _('Per Week')
+ PER_DAY = 24*3600, _('Per Day')
+ PER_HOUR = 3600, _('Per Hour')
+ PER_MINUTE = 60, _('Per Minute')
+ PER_SECOND = 1, _('Per Second')
+ ONE_TIME = 0, _('Onetime')
+
+# RecurringPeriods
+class RecurringPeriod(models.Model):
+ """
+ Available recurring periods.
+ By default seeded from RecurringPeriodChoices
+ """
+
+ name = models.CharField(max_length=100, unique=True)
+ duration_seconds = models.IntegerField(unique=True)
+
+ @classmethod
+ def populate_db_defaults(cls):
+ for (seconds, name) in RecurringPeriodDefaultChoices.choices:
+ obj, created = cls.objects.get_or_create(name=name,
+ defaults={ 'duration_seconds': seconds })
+
+ @staticmethod
+ def secs_to_name(secs):
+ name = ""
+ days = 0
+ hours = 0
+
+ if secs > 24*3600:
+ days = secs // (24*3600)
+ secs -= (days*24*3600)
+
+ if secs > 3600:
+ hours = secs // 3600
+ secs -= hours*3600
+
+ return f"{days} days {hours} hours {secs} seconds"
+
+ def __str__(self):
+ duration = self.secs_to_name(self.duration_seconds)
+
+ return f"{self.name} ({duration})"
+
+
+###
+# Bills.
+
+class BillingAddress(UncloudAddress):
+ owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
+ vat_number = models.CharField(max_length=100, default="", blank=True)
+ active = models.BooleanField(default=False)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['owner'],
+ condition=Q(active=True),
+ name='one_active_billing_address_per_user')
+ ]
+
+ @classmethod
+ def populate_db_defaults(cls):
+ """
+ Ensure we have at least one billing address that is associated with the uncloud-admin.
+
+ This way we are sure that an UncloudProvider can be created.
+
+ Cannot use get_or_create as that looks for exactly one.
+
+ """
+
+ owner = get_user_model().objects.get(username=settings.UNCLOUD_ADMIN_NAME)
+ billing_address = cls.objects.filter(owner=owner).first()
+
+ if not billing_address:
+ billing_address = cls.objects.create(owner=owner,
+ organization="uncloud admins",
+ name="Uncloud Admin",
+ street="Uncloudstreet. 42",
+ city="Luchsingen",
+ postal_code="8775",
+ country="CH",
+ active=True)
+
+
+ @staticmethod
+ def get_address_for(user):
+ return BillingAddress.objects.get(owner=user, active=True)
+
+ def __str__(self):
+ return "{} - {}, {}, {} {}, {}".format(
+ self.owner,
+ self.full_name, self.street, self.postal_code, self.city,
+ self.country)
+
+###
+# VAT
+
+class VATRate(models.Model):
+ starting_date = models.DateField(blank=True, null=True)
+ ending_date = models.DateField(blank=True, null=True)
+ territory_codes = models.TextField(blank=True, default='')
+ currency_code = models.CharField(max_length=10)
+ rate = models.FloatField()
+ rate_type = models.TextField(blank=True, default='')
+ description = models.TextField(blank=True, default='')
+
+ @staticmethod
+ def get_for_country(country_code):
+ vat_rate = None
+ try:
+ vat_rate = VATRate.objects.get(
+ territory_codes=country_code, start_date__isnull=False, stop_date=None
+ )
+ return vat_rate.rate
+ except VATRate.DoesNotExist as dne:
+ logger.debug(str(dne))
+ logger.debug("Did not find VAT rate for %s, returning 0" % country_code)
+ return 0
+
+
+ def __str__(self):
+ return f"{self.territory_codes}: {self.starting_date} - {self.ending_date}: {self.rate_type}"
+
+###
+# Products
+
+class Product(models.Model):
+ """
+ A product is something a user can order. To record the pricing, we
+ create order that define a state in time.
+
+ A product can have *one* one_time_order and/or *one*
+ recurring_order.
+
+ If either of them needs to be updated, a new order of the same
+ type will be created and links to the previous order.
+
+ """
+
+ name = models.CharField(max_length=256, unique=True)
+ description = models.CharField(max_length=1024)
+ config = models.JSONField()
+ recurring_periods = models.ManyToManyField(RecurringPeriod, through='ProductToRecurringPeriod')
+ currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF)
+
+ @property
+ def default_recurring_period(self):
+ """
+ Return the default recurring Period
+ """
+ return self.recurring_periods.get(producttorecurringperiod__is_default=True)
+
+ @classmethod
+ def populate_db_defaults(cls):
+ recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
+
+ obj, created = cls.objects.get_or_create(name="Dual Stack Virtual Machine v1",
+ description="A standard virtual machine",
+ currency=Currency.CHF,
+ config={
+ 'features': {
+ 'cores':
+ { 'min': 1,
+ 'max': 48,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 3
+ },
+ 'ram_gb':
+ { 'min': 1,
+ 'max': 256,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 4
+ },
+ 'ssd_gb':
+ { 'min': 10,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 0.35
+ },
+ 'hdd_gb':
+ { 'min': 0,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 15/1000
+ },
+ 'additional_ipv4_address':
+ { 'min': 0,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 8
+ },
+ }
+ }
+ )
+
+ obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True })
+
+ obj, created = cls.objects.get_or_create(name="Dual Stack Virtual Machine v2",
+ description="A standard virtual machine",
+ currency=Currency.CHF,
+ config={
+ 'features': {
+ 'base':
+ { 'min': 1,
+ 'max': 1,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 1
+ },
+ 'cores':
+ { 'min': 1,
+ 'max': 48,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 3
+ },
+ 'ram_gb':
+ { 'min': 1,
+ 'max': 256,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 4
+ },
+ 'ssd_gb':
+ { 'min': 10,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 0.35
+ },
+ 'hdd_gb':
+ { 'min': 0,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 15/1000
+ },
+ 'additional_ipv4_address':
+ { 'min': 0,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 9
+ },
+ }
+ }
+ )
+
+ obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True })
+
+ obj, created = cls.objects.get_or_create(name="reverse DNS",
+ description="Reverse DNS network",
+ currency=Currency.CHF,
+ config={
+ 'parameters': [
+ 'network'
+ ]
+ })
+ obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True })
+
+
+ def __str__(self):
+ return f"{self.name} - {self.description}"
+
+ @property
+ def recurring_orders(self):
+ return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"))
+
+ @property
+ def last_recurring_order(self):
+ return self.recurring_orders.last()
+
+ @property
+ def one_time_orders(self):
+ return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"))
+
+ @property
+ def last_one_time_order(self):
+ return self.one_time_orders.last()
+
+ def create_order(self, when_to_start=None, recurring_period=None):
+ billing_address = BillingAddress.get_address_for(self.owner)
+
+ if not billing_address:
+ raise ValidationError("Cannot order without a billing address")
+
+ if not when_to_start:
+ when_to_start = timezone.now()
+
+ if not recurring_period:
+ recurring_period = self.default_recurring_period
+
+
+ # Create one time order if we did not create one already
+ if self.one_time_price > 0 and not self.last_one_time_order:
+ one_time_order = Order.objects.create(owner=self.owner,
+ billing_address=billing_address,
+ starting_date=when_to_start,
+ price=self.one_time_price,
+ recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"),
+ description=str(self))
+ self.orders.add(one_time_order)
+ else:
+ one_time_order = None
+
+ if recurring_period != RecurringPeriod.objects.get(name="ONE_TIME"):
+ if one_time_order:
+ recurring_order = Order.objects.create(owner=self.owner,
+ billing_address=billing_address,
+ starting_date=when_to_start,
+ price=self.recurring_price,
+ recurring_period=recurring_period,
+ depends_on=one_time_order,
+ description=str(self))
+ else:
+ recurring_order = Order.objects.create(owner=self.owner,
+ billing_address=billing_address,
+ starting_date=when_to_start,
+ price=self.recurring_price,
+ recurring_period=recurring_period,
+ description=str(self))
+ self.orders.add(recurring_order)
+
+
+ # FIXME: this could/should be part of Order (?)
+ def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None):
+ if not self.recurring_price:
+ return
+
+ if not recurring_period:
+ recurring_period = self.default_recurring_period
+
+ if not when_to_start:
+ when_to_start = timezone.now()
+
+ if self.last_recurring_order:
+ if self.recurring_price < self.last_recurring_order.price:
+
+ if when_to_start < self.last_recurring_order.next_cancel_or_downgrade_date:
+ when_to_start = start_after(self.last_recurring_order.next_cancel_or_downgrade_date)
+
+ when_to_end = end_before(when_to_start)
+
+ new_order = Order.objects.create(owner=self.owner,
+ billing_address=self.last_recurring_order.billing_address,
+ starting_date=when_to_start,
+ price=self.recurring_price,
+ recurring_period=recurring_period,
+ description=str(self),
+ replaces=self.last_recurring_order)
+
+ self.last_recurring_order.replace_with(new_order)
+ self.orders.add(new_order)
+ else:
+ self.create_order(when_to_start, recurring_period)
+
+ @property
+ def is_recurring(self):
+ return self.recurring_price > 0
+
+ @property
+ def billing_address(self):
+ return self.order.billing_address
+
+ def discounted_price_by_period(self, requested_period):
+ """
+ Each product has a standard recurring period for which
+ we define a pricing. I.e. VPN is usually year, VM is usually monthly.
+
+ The user can opt-in to use a different period, which influences the price:
+ The longer a user commits, the higher the discount.
+
+ Products can also be limited in the available periods. For instance
+ a VPN only makes sense to be bought for at least one day.
+
+ Rules are as follows:
+
+ given a standard recurring period of ..., changing to ... modifies price ...
+
+
+ # One month for free if buying / year, compared to a month: about 8.33% discount
+ per_year -> per_month -> /11
+ per_month -> per_year -> *11
+
+ # Month has 30.42 days on average. About 7.9% discount to go monthly
+ per_month -> per_day -> /28
+ per_day -> per_month -> *28
+
+ # Day has 24h, give one for free
+ per_day -> per_hour -> /23
+ per_hour -> per_day -> /23
+
+
+ Examples
+
+ VPN @ 120CHF/y becomes
+ - 10.91 CHF/month (130.91 CHF/year)
+ - 0.39 CHF/day (142.21 CHF/year)
+
+ VM @ 15 CHF/month becomes
+ - 165 CHF/month (13.75 CHF/month)
+ - 0.54 CHF/day (16.30 CHF/month)
+
+ """
+
+ # FIXME: This logic needs to be phased out / replaced by product specific (?)
+ # proportions. Maybe using the RecurringPeriod table to link the possible discounts/add ups
+
+ if self.default_recurring_period == RecurringPeriod.PER_365D:
+ if requested_period == RecurringPeriod.PER_365D:
+ return self.recurring_price
+ if requested_period == RecurringPeriod.PER_30D:
+ return self.recurring_price/11.
+ if requested_period == RecurringPeriod.PER_DAY:
+ return self.recurring_price/11./28.
+
+ elif self.default_recurring_period == RecurringPeriod.PER_30D:
+ if requested_period == RecurringPeriod.PER_365D:
+ return self.recurring_price*11
+ if requested_period == RecurringPeriod.PER_30D:
+ return self.recurring_price
+ if requested_period == RecurringPeriod.PER_DAY:
+ return self.recurring_price/28.
+
+ elif self.default_recurring_period == RecurringPeriod.PER_DAY:
+ if requested_period == RecurringPeriod.PER_365D:
+ return self.recurring_price*11*28
+ if requested_period == RecurringPeriod.PER_30D:
+ return self.recurring_price*28
+ if requested_period == RecurringPeriod.PER_DAY:
+ return self.recurring_price
+ else:
+ # FIXME: use the right type of exception here!
+ raise Exception("Did not implement the discounter for this case")
+
+
+ def save(self, *args, **kwargs):
+ # try:
+ # ba = BillingAddress.get_address_for(self.owner)
+ # except BillingAddress.DoesNotExist:
+ # raise ValidationError("User does not have a billing address")
+
+ # if not ba.active:
+ # raise ValidationError("User does not have an active billing address")
+
+
+ # Verify the required JSON fields
+
+ super().save(*args, **kwargs)
+
+
+
+###
+# Orders.
+
+class Order(models.Model):
+ """
+ Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
+ bills. Do **NOT** mutate then!
+
+ An one time order is "closed" (does not need to be billed anymore)
+ if it has one bill record. Having more than one is a programming
+ error.
+
+ A recurring order is closed if it has been replaced
+ (replaces__isnull=False) AND the ending_date is set AND it was
+ billed the last time it needed to be billed (how to check the last
+ item?)
+
+ BOTH are closed, if they are ended/closed AND have been fully
+ charged.
+
+ Fully charged == fully billed: sum_of_order_usage == sum_of_bill_records
+
+ """
+
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE,
+ editable=True)
+
+ billing_address = models.ForeignKey(BillingAddress,
+ on_delete=models.CASCADE)
+
+ description = models.TextField()
+
+ product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE)
+ config = models.JSONField()
+
+ creation_date = models.DateTimeField(auto_now_add=True)
+ starting_date = models.DateTimeField(default=timezone.now)
+ ending_date = models.DateTimeField(blank=True, null=True)
+
+ recurring_period = models.ForeignKey(RecurringPeriod,
+ on_delete=models.CASCADE,
+ editable=True)
+
+ one_time_price = models.DecimalField(default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS,
+ validators=[MinValueValidator(0)])
+
+ recurring_price = models.DecimalField(default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS,
+ validators=[MinValueValidator(0)])
+
+ currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF)
+
+ replaces = models.ForeignKey('self',
+ related_name='replaced_by',
+ on_delete=models.CASCADE,
+ blank=True,
+ null=True)
+
+ depends_on = models.ForeignKey('self',
+ related_name='parent_of',
+ on_delete=models.CASCADE,
+ blank=True,
+ null=True)
+
+ should_be_billed = models.BooleanField(default=True)
+
+ @property
+ def earliest_ending_date(self):
+ """
+ Recurring orders cannot end before finishing at least one recurring period.
+
+ One time orders have a recurring period of 0, so this work universally
+ """
+
+ return self.starting_date + datetime.timedelta(seconds=self.recurring_period.duration_seconds)
+
+
+ def next_cancel_or_downgrade_date(self, until_when=None):
+ """
+ Return the next proper ending date after n times the
+ recurring_period, where n is an integer that applies for downgrading
+ or cancelling.
+ """
+
+ if not until_when:
+ until_when = timezone.now()
+
+ if until_when < self.starting_date:
+ raise ValidationError("Cannot end before start of start of order")
+
+ if self.recurring_period.duration_seconds > 0:
+ delta = until_when - self.starting_date
+
+ num_times = ceil(delta.total_seconds() / self.recurring_period.duration_seconds)
+
+ next_date = self.starting_date + datetime.timedelta(seconds=num_times * self.recurring_period.duration_seconds)
+ else:
+ next_date = self.starting_date
+
+ return next_date
+
+ def get_ending_date_for_bill(self, bill):
+ """
+ Determine the ending date given a specific bill
+ """
+
+ # If the order is quit, charge the final amount / finish (????)
+ # Probably not a good idea -- FIXME :continue until usual
+ if self.ending_date:
+ this_ending_date = self.ending_date
+ else:
+ if self.next_cancel_or_downgrade_date(bill.ending_date) > bill.ending_date:
+ this_ending_date = self.next_cancel_or_downgrade_date(bill.ending_date)
+ else:
+ this_ending_date = bill.ending_date
+
+ return this_ending_date
+
+
+ @property
+ def count_billed(self):
+ """
+ How many times this order was billed so far.
+ This logic is mainly thought to be for recurring bills, but also works for one time bills
+ """
+
+ return sum([ br.quantity for br in self.bill_records.all() ])
+
+ def count_used(self, when=None):
+ """
+ How many times this order was billed so far.
+ This logic is mainly thought to be for recurring bills, but also works for one time bills
+ """
+
+ if self.is_one_time:
+ return 1
+
+ if not when:
+ when = timezone.now()
+
+ # Cannot be used after it ended
+ if self.ending_date and when > self.ending_date:
+ when = self.ending_date
+
+ return (when - self.starting_date) / self.default_recurring_period
+
+ @property
+ def all_usage_billed(self, when=None):
+ """
+ Returns true if this order does not need any further billing
+ ever. In other words: is this order "closed"?
+ """
+
+ if self.count_billed == self.count_used(when):
+ return True
+ else:
+ return False
+
+ @property
+ def is_closed(self):
+ if self.all_usage_billed and self.ending_date:
+ return True
+ else:
+ return False
+
+ @property
+ def is_recurring(self):
+ return not self.recurring_period == RecurringPeriod.objects.get(name="ONE_TIME")
+
+ @property
+ def is_one_time(self):
+ return not self.is_recurring
+
+ def replace_with(self, new_order):
+ new_order.replaces = self
+ self.ending_date = end_before(new_order.starting_date)
+ self.save()
+
+ def update_order(self, config, starting_date=None):
+ """
+ Updating an order means creating a new order and reference the previous order
+ """
+
+ if not starting_date:
+ starting_date = timezone.now()
+
+ new_order = self.__class__(owner=self.owner,
+ billing_address=self.billing_address,
+ description=self.description,
+ product=self.product,
+ config=config,
+ starting_date=starting_date,
+ currency=self.currency
+ )
+
+ (new_order.one_time_price, new_order.recurring_price, new_order.config) = new_order.calculate_prices_and_config()
+
+
+
+ new_order.replaces = self
+ new_order.save()
+
+ self.ending_date = end_before(new_order.starting_date)
+ self.save()
+
+ return new_order
+
+
+ def create_bill_record(self, bill):
+ br = None
+
+ # Note: check for != 0 not > 0, as we allow discounts to be expressed with < 0
+ if self.one_time_price != 0 and self.billrecord_set.count() == 0:
+ br = BillRecord.objects.create(bill=bill,
+ order=self,
+ starting_date=self.starting_date,
+ ending_date=self.starting_date,
+ is_recurring_record=False)
+
+ if self.recurring_price != 0:
+ br = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first()
+
+ if br:
+ self.update_bill_record_for_recurring_order(br, bill)
+ else:
+ br = self.create_new_bill_record_for_recurring_order(bill)
+
+ return br
+
+ def update_bill_record_for_recurring_order(self,
+ bill_record,
+ bill):
+ """
+ Possibly update a bill record according to the information in the bill
+ """
+
+ # If the order has an ending date set, we might need to adjust the bill_record
+ if self.ending_date:
+ if bill_record_for_this_bill.ending_date != self.ending_date:
+ bill_record_for_this_bill.ending_date = self.ending_date
+
+ else:
+ # recurring, not terminated, should go until at least end of bill
+ if bill_record_for_this_bill.ending_date < bill.ending_date:
+ bill_record_for_this_bill.ending_date = bill.ending_date
+
+ bill_record_for_this_bill.save()
+
+ def create_new_bill_record_for_recurring_order(self, bill):
+ """
+ Create a new bill record
+ """
+
+ last_bill_record = BillRecord.objects.filter(order=self, is_recurring_record=True).order_by('id').last()
+
+ starting_date=self.starting_date
+
+ if last_bill_record:
+ # We already charged beyond the end of this bill's period
+ if last_bill_record.ending_date >= bill.ending_date:
+ return
+
+ # This order is terminated or replaced
+ if self.ending_date:
+ # And the last bill record already covered us -> nothing to be done anymore
+ if last_bill_record.ending_date == self.ending_date:
+ return
+
+ starting_date = start_after(last_bill_record.ending_date)
+
+ ending_date = self.get_ending_date_for_bill(bill)
+
+ return BillRecord.objects.create(bill=bill,
+ order=self,
+ starting_date=starting_date,
+ ending_date=ending_date,
+ is_recurring_record=True)
+
+ def calculate_prices_and_config(self):
+ one_time_price = 0
+ recurring_price = 0
+
+ if self.config:
+ config = self.config
+
+ if 'features' not in self.config:
+ self.config['features'] = {}
+
+ else:
+ config = {
+ 'features': {}
+ }
+
+ # FIXME: adjust prices to the selected recurring_period to the
+
+ if 'features' in self.product.config:
+ for feature in self.product.config['features']:
+
+ # Set min to 0 if not specified
+ min_val = self.product.config['features'][feature].get('min', 0)
+
+ # We might not even have 'features' cannot use .get() on it
+ try:
+ value = self.config['features'][feature]
+ except (KeyError, TypeError):
+ value = self.product.config['features'][feature]['min']
+
+ # Set max to current value if not specified
+ max_val = self.product.config['features'][feature].get('max', value)
+
+
+ if value < min_val or value > max_val:
+ raise ValidationError(f"Feature '{feature}' must be at least {min_val} and at maximum {max_val}. Value is: {value}")
+
+ one_time_price += self.product.config['features'][feature]['one_time_price_per_unit'] * value
+ recurring_price += self.product.config['features'][feature]['recurring_price_per_unit'] * value
+ config['features'][feature] = value
+
+ return (one_time_price, recurring_price, config)
+
+ def check_parameters(self):
+ if 'parameters' in self.product.config:
+ for parameter in self.product.config['parameters']:
+ if not parameter in self.config['parameters']:
+ raise ValidationError(f"Required parameter '{parameter}' is missing.")
+
+
+ def save(self, *args, **kwargs):
+ # Calculate the price of the order when we create it
+ # IMMUTABLE fields -- need to create new order to modify them
+ # However this is not enforced here...
+ if self._state.adding:
+ (self.one_time_price, self.recurring_price, self.config) = self.calculate_prices_and_config()
+
+ if self.recurring_period_id is None:
+ self.recurring_period = self.product.default_recurring_period
+
+ try:
+ prod_period = self.product.recurring_periods.get(producttorecurringperiod__recurring_period=self.recurring_period)
+ except ObjectDoesNotExist:
+ raise ValidationError(f"Recurring Period {self.recurring_period} not allowed for product {self.product}")
+
+ self.check_parameters()
+
+ if self.ending_date and self.ending_date < self.starting_date:
+ raise ValidationError("End date cannot be before starting date")
+
+
+ super().save(*args, **kwargs)
+
+
+ def __str__(self):
+ try:
+ conf = " ".join([ f"{key}:{val}" for key,val in self.config['features'].items() if val != 0 ])
+ except KeyError:
+ conf = ""
+
+ return f"Order {self.id}: {self.description} {conf}"
+
+class Bill(models.Model):
+ """
+ A bill is a representation of usage at a specific time
+ """
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE)
+
+ creation_date = models.DateTimeField(auto_now_add=True)
+ starting_date = models.DateTimeField(default=start_of_this_month)
+ ending_date = models.DateTimeField()
+ due_date = models.DateField(default=default_payment_delay)
+
+
+ billing_address = models.ForeignKey(BillingAddress,
+ on_delete=models.CASCADE,
+ editable=True,
+ null=False)
+
+ # FIXME: editable=True -> is in the admin, but also editable in DRF
+ # Maybe filter fields in the serializer?
+
+ is_final = models.BooleanField(default=False)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['owner',
+ 'starting_date',
+ 'ending_date' ],
+ name='one_bill_per_month_per_user')
+ ]
+
+ def close(self):
+ """
+ Close/finish a bill
+ """
+
+ self.is_final = True
+ self.save()
+
+ @property
+ def sum(self):
+ bill_records = BillRecord.objects.filter(bill=self)
+ return sum([ br.sum for br in bill_records ])
+
+ @property
+ def vat_rate(self):
+ """
+ Handling VAT is a tricky business - thus we only implement the cases
+ that we clearly now and leave it open to fellow developers to implement
+ correct handling for other cases.
+
+ Case CH:
+
+ - If the customer is in .ch -> apply standard rate
+ - If the customer is in EU AND private -> apply country specific rate
+ - If the customer is in EU AND business -> do not apply VAT
+ - If the customer is outside EU and outside CH -> do not apply VAT
+ """
+
+ provider = UncloudProvider.objects.get()
+
+ # Assume always VAT inside the country
+ if provider.country == self.billing_address.country:
+ vat_rate = VATRate.objects.get(country=provider.country,
+ when=self.ending_date)
+ elif self.billing_address.country in EU:
+ # FIXME: need to check for validated vat number
+ if self.billing_address.vat_number:
+ return 0
+ else:
+ return VATRate.objects.get(country=self.biling_address.country,
+ when=self.ending_date)
+ else: # non-EU, non-national
+ return 0
+
+
+ @classmethod
+ def create_bills_for_all_users(cls):
+ """
+ Create next bill for each user
+ """
+
+ for owner in get_user_model().objects.all():
+ cls.create_next_bills_for_user(owner)
+
+ @classmethod
+ def create_next_bills_for_user(cls, owner, ending_date=None):
+ """
+ Create one bill per billing address, as the VAT rates might be different
+ for each address
+ """
+
+ bills = []
+
+ for billing_address in BillingAddress.objects.filter(owner=owner):
+ bills.append(cls.create_next_bill_for_user_address(billing_address, ending_date))
+
+ return bills
+
+ @classmethod
+ def create_next_bill_for_user_address(cls, billing_address, ending_date=None):
+ """
+ Create the next bill for a specific billing address of a user
+ """
+
+ owner = billing_address.owner
+
+ all_orders = Order.objects.filter(owner=owner,
+ billing_address=billing_address).order_by('id')
+
+ bill = cls.get_or_create_bill(billing_address, ending_date=ending_date)
+
+ for order in all_orders:
+ order.create_bill_record(bill)
+
+ return bill
+
+
+ @classmethod
+ def get_or_create_bill(cls, billing_address, ending_date=None):
+ """
+ Get / reuse last bill if it is not yet closed
+
+ Create bill, if there is no bill or if bill is closed.
+ """
+
+ last_bill = cls.objects.filter(billing_address=billing_address).order_by('id').last()
+
+ all_orders = Order.objects.filter(billing_address=billing_address).order_by('id')
+ first_order = all_orders.first()
+
+ bill = None
+
+ # Get date & bill from previous bill, if it exists
+ if last_bill:
+ if not last_bill.is_final:
+ bill = last_bill
+ starting_date = last_bill.starting_date
+ ending_date = bill.ending_date
+ else:
+ starting_date = last_bill.ending_date + datetime.timedelta(seconds=1)
+ else:
+ # Might be an idea to make this the start of the month, too
+ if first_order:
+ starting_date = first_order.starting_date
+ else:
+ starting_date = timezone.now()
+
+ if not ending_date:
+ ending_date = end_of_month(starting_date)
+
+ if not bill:
+ bill = cls.objects.create(
+ owner=billing_address.owner,
+ starting_date=starting_date,
+ ending_date=ending_date,
+ billing_address=billing_address)
+
+
+ return bill
+
+ def __str__(self):
+ return f"Bill {self.owner}-{self.id}"
+
+
+class BillRecord(models.Model):
+ """
+ Entry of a bill, dynamically generated from an order.
+ """
+
+ bill = models.ForeignKey(Bill, on_delete=models.CASCADE)
+ order = models.ForeignKey(Order, on_delete=models.CASCADE)
+
+ creation_date = models.DateTimeField(auto_now_add=True)
+ starting_date = models.DateTimeField()
+ ending_date = models.DateTimeField()
+
+ is_recurring_record = models.BooleanField(blank=False, null=False)
+
+ @property
+ def quantity(self):
+ """ Determine the quantity by the duration"""
+ if not self.is_recurring_record:
+ return 1
+
+ record_delta = self.ending_date - self.starting_date
+
+ return record_delta.total_seconds()/self.order.recurring_period.duration_seconds
+
+ @property
+ def sum(self):
+ if self.is_recurring_record:
+ return self.order.recurring_price * Decimal(self.quantity)
+ else:
+ return self.order.one_time_price
+
+ @property
+ def price(self):
+ if self.is_recurring_record:
+ return self.order.recurring_price
+ else:
+ return self.order.one_time_price
+
+ def __str__(self):
+ if self.is_recurring_record:
+ bill_line = f"{self.starting_date} - {self.ending_date}: {self.quantity} x {self.order}"
+ else:
+ bill_line = f"{self.starting_date}: {self.order}"
+
+ return bill_line
+
+ def save(self, *args, **kwargs):
+ if self.ending_date < self.starting_date:
+ raise ValidationError("End date cannot be before starting date")
+
+ super().save(*args, **kwargs)
+
+
+class ProductToRecurringPeriod(models.Model):
+ """
+ Intermediate manytomany mapping class that allows storing the default recurring period
+ for a product
+ """
+
+ recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE)
+ product = models.ForeignKey(Product, on_delete=models.CASCADE)
+
+ is_default = models.BooleanField(default=False)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['product'],
+ condition=Q(is_default=True),
+ name='one_default_recurring_period_per_product'),
+ models.UniqueConstraint(fields=['product', 'recurring_period'],
+ name='recurring_period_once_per_product')
+ ]
+
+ def __str__(self):
+ return f"{self.product} - {self.recurring_period} (default: {self.is_default})"
diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py
new file mode 100644
index 0000000..9214105
--- /dev/null
+++ b/uncloud_pay/serializers.py
@@ -0,0 +1,112 @@
+from django.contrib.auth import get_user_model
+from rest_framework import serializers
+from uncloud_auth.serializers import UserSerializer
+from django.utils.translation import gettext_lazy as _
+
+from .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 OrderSerializer(serializers.ModelSerializer):
+ owner = serializers.PrimaryKeyRelatedField(queryset=get_user_model().objects.all())
+
+ def __init__(self, *args, **kwargs):
+ # Don't pass the 'fields' arg up to the superclass
+ admin = kwargs.pop('admin', None)
+
+ # Instantiate the superclass normally
+ super(OrderSerializer, self).__init__(*args, **kwargs)
+
+ # Only allows owner in admin mode.
+ if not admin:
+ self.fields.pop('owner')
+
+ def create(self, validated_data):
+ billing_address = BillingAddress.get_preferred_address_for(validated_data["owner"])
+ instance = Order(billing_address=billing_address, **validated_data)
+ instance.save()
+
+ return instance
+
+ def validate_owner(self, value):
+ if BillingAddress.get_preferred_address_for(value) == None:
+ raise serializers.ValidationError("Owner does not have a valid billing address.")
+
+ return value
+
+ class Meta:
+ model = Order
+ read_only_fields = ['replaced_by', 'depends_on']
+ fields = ['uuid', 'owner', 'description', 'creation_date', 'starting_date', 'ending_date',
+ 'bill', 'recurring_period', 'recurring_price', 'one_time_price'] + read_only_fields
+
+
+###
+# Bills
+
+# TODO: remove magic numbers for decimal fields
+class BillRecordSerializer(serializers.Serializer):
+ order = serializers.HyperlinkedRelatedField(
+ view_name='order-detail',
+ read_only=True)
+ description = serializers.CharField()
+ one_time_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+ recurring_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+# recurring_period = serializers.ChoiceField()
+ recurring_count = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+ vat_rate = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+ vat_amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+ amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+ total = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
+
+class BillingAddressSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = BillingAddress
+ fields = ['uuid', 'organization', 'name', 'street', 'city', 'postal_code', 'country', 'vat_number']
+
+class BillSerializer(serializers.ModelSerializer):
+ billing_address = BillingAddressSerializer(read_only=True)
+ records = BillRecordSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = Bill
+ fields = ['uuid', 'reference', 'owner', 'amount', 'vat_amount', 'total',
+ 'due_date', 'creation_date', 'starting_date', 'ending_date',
+ 'records', 'final', 'billing_address']
+
+# We do not want users to mutate the country / VAT number of an address, as it
+# will change VAT on existing bills.
+class UpdateBillingAddressSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = BillingAddress
+ fields = ['uuid', 'street', 'city', 'postal_code']
diff --git a/uncloud_pay/stripe.py b/uncloud_pay/stripe.py
new file mode 100644
index 0000000..2ed4ef2
--- /dev/null
+++ b/uncloud_pay/stripe.py
@@ -0,0 +1,114 @@
+import stripe
+import stripe.error
+import logging
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.conf import settings
+
+import uncloud_pay.models
+
+# 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 = settings.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 settings.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)
diff --git a/uncloud_pay/templates/bill.html.j2 b/uncloud_pay/templates/bill.html.j2
new file mode 100644
index 0000000..c227f43
--- /dev/null
+++ b/uncloud_pay/templates/bill.html.j2
@@ -0,0 +1,1061 @@
+{% load static %}
+
+
+
+
+
+
+
+
+ {{ bill }}
+
+
+
+
+
+
+
+
+

+
+
+
+
+ ungleich glarus ag
+
Bahnhofstrasse 1
+
8783 Linthal
+
Switzerland
+
+
+
+ {{ bill.billing_address.organization }}
+ {{ bill.billing_address.name }}
+ {{ bill.owner.email }}
+ {{ bill.billing_address.street }}
+ {{ bill.billing_address.country }} {{ bill.billing_address.postal_code }} {{ bill.billing_address.city }}
+
+
+
+
+ {{ bill.starting_date|date:"c" }} -
+ {{ bill.ending_date|date:"c" }}
+
Bill id: {{ bill }}
+
Due: {{ bill.due_date }}
+
+
+
+
+
+
Invoice
+
+
+
+
+ | Detail |
+ Price/Unit |
+ Units |
+ Total price |
+
+
+
+ {% for record in bill_records %}
+
+ | {{ record.starting_date|date:"c" }}
+ - {{ record.ending_date|date:"c" }}
+ {{ record.order }}
+ |
+ {{ record.price|floatformat:2 }} |
+ {{ record.quantity|floatformat:2 }} |
+ {{ record.sum|floatformat:2 }} |
+
+ {% endfor %}
+
+
+
+
+ Total (excl. VAT)
+ {{ bill.amount }}
+
+
+ VAT 7.7%
+ {{ bill.vat_amount|floatformat:2 }}
+
+
+
+
+ Total amount to be paid
+ {{ bill.sum|floatformat:2 }}
+
+
+
+
+
+
diff --git a/uncloud_pay/templates/error.html.j2 b/uncloud_pay/templates/error.html.j2
new file mode 100644
index 0000000..ba9209c
--- /dev/null
+++ b/uncloud_pay/templates/error.html.j2
@@ -0,0 +1,18 @@
+
+
+
+ Error
+
+
+
+
+
+
diff --git a/uncloud_pay/templates/stripe-payment.html.j2 b/uncloud_pay/templates/stripe-payment.html.j2
new file mode 100644
index 0000000..6c59740
--- /dev/null
+++ b/uncloud_pay/templates/stripe-payment.html.j2
@@ -0,0 +1,76 @@
+
+
+
+ Stripe Card Registration
+
+
+
+
+
+
+
+
+
+
Registering Stripe Credit Card
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/uncloud_pay/templates/uncloud_pay/stripe.html b/uncloud_pay/templates/uncloud_pay/stripe.html
new file mode 100644
index 0000000..3051bf0
--- /dev/null
+++ b/uncloud_pay/templates/uncloud_pay/stripe.html
@@ -0,0 +1,72 @@
+{% extends 'uncloud/base.html' %}
+
+{% block header %}
+
+
+{% endblock %}
+
+{% block body %}
+
+
Registering Stripe Credit Card
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/uncloud_pay/tests.py b/uncloud_pay/tests.py
new file mode 100644
index 0000000..ca91cc9
--- /dev/null
+++ b/uncloud_pay/tests.py
@@ -0,0 +1,465 @@
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from datetime import datetime, date, timedelta
+from django.utils import timezone
+
+from .models import *
+from uncloud_service.models import GenericServiceProduct
+
+import json
+
+chocolate_product_config = {
+ 'features': {
+ 'gramm':
+ { 'min': 100,
+ 'max': 5000,
+ 'one_time_price_per_unit': 0.2,
+ 'recurring_price_per_unit': 0
+ },
+ },
+}
+
+chocolate_order_config = {
+ 'features': {
+ 'gramm': 500,
+ }
+}
+
+chocolate_one_time_price = chocolate_order_config['features']['gramm'] * chocolate_product_config['features']['gramm']['one_time_price_per_unit']
+
+vm_product_config = {
+ 'features': {
+ 'cores':
+ { 'min': 1,
+ 'max': 48,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 4
+ },
+ 'ram_gb':
+ { 'min': 1,
+ 'max': 256,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 4
+ },
+ },
+}
+
+vm_order_config = {
+ 'features': {
+ 'cores': 2,
+ 'ram_gb': 2
+ }
+}
+
+vm_order_downgrade_config = {
+ 'features': {
+ 'cores': 1,
+ 'ram_gb': 1
+ }
+}
+
+vm_order_upgrade_config = {
+ 'features': {
+ 'cores': 4,
+ 'ram_gb': 4
+ }
+}
+
+
+class ProductTestCase(TestCase):
+ """
+ Test products and products <-> order interaction
+ """
+
+ def setUp(self):
+ self.user = get_user_model().objects.create(
+ username='random_user',
+ email='jane.random@domain.tld')
+
+ self.ba = BillingAddress.objects.create(
+ owner=self.user,
+ organization = 'Test org',
+ street="unknown",
+ city="unknown",
+ postal_code="somewhere else",
+ active=True)
+
+ RecurringPeriod.populate_db_defaults()
+ self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
+
+ def test_create_product(self):
+ """
+ Create a sample product
+ """
+
+ p = Product.objects.create(name="Testproduct",
+ description="Only for testing",
+ config=vm_product_config)
+
+ p.recurring_periods.add(self.default_recurring_period,
+ through_defaults= { 'is_default': True })
+
+
+class OrderTestCase(TestCase):
+ """
+ The heart of ordering products
+ """
+
+ def setUp(self):
+ self.user = get_user_model().objects.create(
+ username='random_user',
+ email='jane.random@domain.tld')
+
+ self.ba = BillingAddress.objects.create(
+ owner=self.user,
+ organization = 'Test org',
+ street="unknown",
+ city="unknown",
+ postal_code="somewhere else",
+ active=True)
+
+ self.product = Product.objects.create(name="Testproduct",
+ description="Only for testing",
+ config=vm_product_config)
+
+ RecurringPeriod.populate_db_defaults()
+ self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
+
+ self.product.recurring_periods.add(self.default_recurring_period,
+ through_defaults= { 'is_default': True })
+
+
+ def test_order_invalid_recurring_period(self):
+ """
+ Order a products with a recurringperiod that is not added to the product
+ """
+
+ o = Order.objects.create(owner=self.user,
+ billing_address=self.ba,
+ product=self.product,
+ config=vm_order_config)
+
+
+ def test_order_product(self):
+ """
+ Order a product, ensure the order has correct price setup
+ """
+
+ o = Order.objects.create(owner=self.user,
+ billing_address=self.ba,
+ product=self.product)
+
+ self.assertEqual(o.one_time_price, 0)
+ self.assertEqual(o.recurring_price, 16)
+
+ def test_change_order(self):
+ """
+ Change an order and ensure that
+ - a new order is created
+ - the price is correct in the new order
+ """
+ order1 = Order.objects.create(owner=self.user,
+ billing_address=self.ba,
+ product=self.product,
+ config=vm_order_config)
+
+
+ self.assertEqual(order1.one_time_price, 0)
+ self.assertEqual(order1.recurring_price, 16)
+
+
+class ModifyOrderTestCase(TestCase):
+ """
+ Test typical order flows like
+ - cancelling
+ - downgrading
+ - upgrading
+ """
+
+ def setUp(self):
+ self.user = get_user_model().objects.create(
+ username='random_user',
+ email='jane.random@domain.tld')
+
+ self.ba = BillingAddress.objects.create(
+ owner=self.user,
+ organization = 'Test org',
+ street="unknown",
+ city="unknown",
+ postal_code="somewhere else",
+ active=True)
+
+ self.product = Product.objects.create(name="Testproduct",
+ description="Only for testing",
+ config=vm_product_config)
+
+ RecurringPeriod.populate_db_defaults()
+ self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
+
+ self.product.recurring_periods.add(self.default_recurring_period,
+ through_defaults= { 'is_default': True })
+
+
+ def test_change_order(self):
+ """
+ Test changing an order
+
+ Expected result:
+
+ - Old order should be closed before new order starts
+ - New order should start at starting data
+ """
+
+ user = self.user
+
+ starting_price = 16
+ downgrade_price = 8
+
+ starting_date = timezone.make_aware(datetime.datetime(2019,3,3))
+ ending1_date = starting_date + datetime.timedelta(days=15)
+ change1_date = start_after(ending1_date)
+
+ bill_ending_date = change1_date + datetime.timedelta(days=1)
+
+
+ order1 = Order.objects.create(owner=self.user,
+ billing_address=BillingAddress.get_address_for(self.user),
+ product=self.product,
+ config=vm_order_config,
+ starting_date=starting_date)
+
+ order1.update_order(vm_order_downgrade_config, starting_date=change1_date)
+
+ bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
+
+ bill = bills[0]
+ bill_records = BillRecord.objects.filter(bill=bill)
+
+ self.assertEqual(len(bill_records), 2)
+
+ self.assertEqual(bill_records[0].starting_date, starting_date)
+ self.assertEqual(bill_records[0].ending_date, ending1_date)
+
+ self.assertEqual(bill_records[1].starting_date, change1_date)
+
+
+
+ def test_downgrade_product(self):
+ """
+ Test downgrading behaviour:
+
+ We create a recurring product (recurring time: 30 days) and downgrade after 15 days.
+
+ We create the bill right AFTER the end of the first order.
+
+ Expected result:
+
+ - First bill record for 30 days
+ - Second bill record starting after 30 days
+ - Bill contains two bill records
+
+ """
+
+ user = self.user
+
+ starting_price = 16
+ downgrade_price = 8
+
+ starting_date = timezone.make_aware(datetime.datetime(2019,3,3))
+ first_order_should_end_at = starting_date + datetime.timedelta(days=30)
+ change1_date = start_after(starting_date + datetime.timedelta(days=15))
+ bill_ending_date = change1_date + datetime.timedelta(days=1)
+
+ order1 = Order.objects.create(owner=self.user,
+ billing_address=BillingAddress.get_address_for(self.user),
+ product=self.product,
+ config=vm_order_config,
+ starting_date=starting_date)
+
+ order1.update_order(vm_order_downgrade_config, starting_date=change1_date)
+
+ bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
+
+ bill = bills[0]
+ bill_records = BillRecord.objects.filter(bill=bill)
+
+ self.assertEqual(len(bill_records), 2)
+
+ self.assertEqual(bill_records[0].starting_date, starting_date)
+ self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at)
+
+
+class BillTestCase(TestCase):
+ """
+ Test aspects of billing / creating a bill
+ """
+
+ def setUp(self):
+ RecurringPeriod.populate_db_defaults()
+
+ self.user_without_address = get_user_model().objects.create(
+ username='no_home_person',
+ email='far.away@domain.tld')
+
+ self.user = get_user_model().objects.create(
+ username='jdoe',
+ email='john.doe@domain.tld')
+
+ self.recurring_user = get_user_model().objects.create(
+ username='recurrent_product_user',
+ email='jane.doe@domain.tld')
+
+ self.user_addr = BillingAddress.objects.create(
+ owner=self.user,
+ organization = 'Test org',
+ street="unknown",
+ city="unknown",
+ postal_code="unknown",
+ active=True)
+
+ self.recurring_user_addr = BillingAddress.objects.create(
+ owner=self.recurring_user,
+ organization = 'Test org',
+ street="Somewhere",
+ city="Else",
+ postal_code="unknown",
+ active=True)
+
+ self.order_meta = {}
+ self.order_meta[1] = {
+ 'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)),
+ 'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)),
+ 'price': 15,
+ 'description': 'One chocolate bar'
+ }
+
+ self.chocolate = Product.objects.create(name="Swiss Chocolate",
+ description="Not only for testing, but for joy",
+ config=chocolate_product_config)
+
+
+ self.vm = Product.objects.create(name="Super Fast VM",
+ description="Zooooom",
+ config=vm_product_config)
+
+
+ RecurringPeriod.populate_db_defaults()
+ self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
+
+ self.onetime_recurring_period = RecurringPeriod.objects.get(name="Onetime")
+
+ self.chocolate.recurring_periods.add(self.onetime_recurring_period,
+ through_defaults= { 'is_default': True })
+
+ self.vm.recurring_periods.add(self.default_recurring_period,
+ through_defaults= { 'is_default': True })
+
+
+ # used for generating multiple bills
+ self.bill_dates = [
+ timezone.make_aware(datetime.datetime(2020,3,31)),
+ timezone.make_aware(datetime.datetime(2020,4,30)),
+ timezone.make_aware(datetime.datetime(2020,5,31)),
+ ]
+
+
+ def order_chocolate(self):
+ return Order.objects.create(
+ owner=self.user,
+ recurring_period=RecurringPeriod.objects.get(name="Onetime"),
+ product=self.chocolate,
+ billing_address=BillingAddress.get_address_for(self.user),
+ starting_date=self.order_meta[1]['starting_date'],
+ ending_date=self.order_meta[1]['ending_date'],
+ config=chocolate_order_config)
+
+ def order_vm(self, owner=None):
+
+ if not owner:
+ owner = self.recurring_user
+
+ return Order.objects.create(
+ owner=owner,
+ product=self.vm,
+ config=vm_order_config,
+ billing_address=BillingAddress.get_address_for(self.recurring_user),
+ starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
+ )
+
+ return Order.objects.create(
+ owner=self.user,
+ recurring_period=RecurringPeriod.objects.get(name="Onetime"),
+ product=self.chocolate,
+ billing_address=BillingAddress.get_address_for(self.user),
+ starting_date=self.order_meta[1]['starting_date'],
+ ending_date=self.order_meta[1]['ending_date'],
+ config=chocolate_order_config)
+
+
+
+ def test_bill_one_time_one_bill_record(self):
+ """
+ Ensure there is only 1 bill record per order
+ """
+
+ order = self.order_chocolate()
+
+ bill = Bill.create_next_bill_for_user_address(self.user_addr)
+
+ self.assertEqual(order.billrecord_set.count(), 1)
+
+ def test_bill_sum_onetime(self):
+ """
+ Check the bill sum for a single one time order
+ """
+
+ order = self.order_chocolate()
+ bill = Bill.create_next_bill_for_user_address(self.user_addr)
+ self.assertEqual(bill.sum, chocolate_one_time_price)
+
+
+ def test_bill_creates_record_for_recurring_order(self):
+ """
+ Ensure there is only 1 bill record per order
+ """
+
+ order = self.order_vm()
+ bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr)
+
+ self.assertEqual(order.billrecord_set.count(), 1)
+ self.assertEqual(bill.billrecord_set.count(), 1)
+
+
+ def test_new_bill_after_closing(self):
+ """
+ After closing a bill and the user has a recurring product,
+ the next bill run should create e new bill
+ """
+
+ order = self.order_vm()
+
+ for ending_date in self.bill_dates:
+ b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date)
+ b.close()
+
+ bill_count = Bill.objects.filter(owner=self.recurring_user).count()
+
+ self.assertEqual(len(self.bill_dates), bill_count)
+
+
+
+class BillingAddressTestCase(TestCase):
+ def setUp(self):
+ self.user = get_user_model().objects.create(
+ username='random_user',
+ email='jane.random@domain.tld')
+
+
+ def test_user_no_address(self):
+ """
+ Raise an error, when there is no address
+ """
+
+ self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist,
+ BillingAddress.get_address_for,
+ self.user)
diff --git a/uncloud_pay/views.py b/uncloud_pay/views.py
new file mode 100644
index 0000000..53d6ef4
--- /dev/null
+++ b/uncloud_pay/views.py
@@ -0,0 +1,398 @@
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.views.generic.base import TemplateView
+
+
+from django.shortcuts import render
+from django.db import transaction
+from django.contrib.auth import get_user_model
+from rest_framework import viewsets, mixins, permissions, status, views
+from rest_framework.renderers import TemplateHTMLRenderer
+from rest_framework.response import Response
+from rest_framework.decorators import action
+from rest_framework.reverse import reverse
+from rest_framework.decorators import renderer_classes
+from vat_validator import validate_vat, vies
+from vat_validator.countries import EU_COUNTRY_CODES
+from hardcopy import bytestring_to_pdf
+from django.core.files.temp import NamedTemporaryFile
+from django.http import FileResponse
+from django.template.loader import render_to_string
+from copy import deepcopy
+
+import json
+import logging
+
+from .models import *
+from .serializers import *
+from datetime import datetime
+from vat_validator import sanitize_vat
+import uncloud_pay.stripe as uncloud_stripe
+
+logger = logging.getLogger(__name__)
+
+###
+# 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 RegisterCard(LoginRequiredMixin, TemplateView):
+ login_url = '/login/'
+
+ # This is not supposed to be "static" --
+ # the idea is to be able to switch the provider when needed
+ template_name = "uncloud_pay/stripe.html"
+
+ def get_context_data(self, **kwargs):
+ customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
+ setup_intent = uncloud_stripe.create_setup_intent(customer_id)
+
+ context = super().get_context_data(**kwargs)
+ context['client_secret'] = setup_intent.client_secret
+ context['username'] = self.request.user
+ context['stripe_pk'] = uncloud_stripe.public_api_key
+ return context
+
+
+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)
+
+ # Set newly created method as primary if no other method is.
+ if PaymentMethod.get_primary_for(request.user) == None:
+ serializer.validated_data['primary'] = 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})
+
+ @action(detail=True, methods=['post'], url_path='set-as-primary')
+ def set_as_primary(self, request, pk=None):
+ payment_method = self.get_object()
+ payment_method.set_as_primary_for(request.user)
+
+ serializer = self.get_serializer(payment_method)
+ return Response(serializer.data)
+
+###
+# Bills and Orders.
+
+class BillViewSet(viewsets.ReadOnlyModelViewSet):
+ serializer_class = BillSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return Bill.objects.filter(owner=self.request.user)
+
+
+ @action(detail=False, methods=['get'])
+ def unpaid(self, request):
+ serializer = self.get_serializer(
+ Bill.get_unpaid_for(self.request.user),
+ many=True)
+ return Response(serializer.data)
+
+ @action(detail=True, methods=['get'])
+ def download(self, *args, **kwargs):
+ """
+ Allow to download
+ """
+ bill = self.get_object()
+ provider = UncloudProvider.get_provider()
+ output_file = NamedTemporaryFile()
+ bill_html = render_to_string("bill.html.j2", {'bill': bill})
+
+ bytestring_to_pdf(bill_html.encode('utf-8'), output_file)
+ response = FileResponse(output_file, content_type="application/pdf")
+ response['Content-Disposition'] = 'filename="{}_{}.pdf"'.format(
+ bill.reference, bill.uuid
+ )
+
+ return response
+
+
+class OrderViewSet(viewsets.ReadOnlyModelViewSet):
+ serializer_class = OrderSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return Order.objects.filter(owner=self.request.user)
+
+class BillingAddressViewSet(mixins.CreateModelMixin,
+ mixins.RetrieveModelMixin,
+ mixins.UpdateModelMixin,
+ mixins.ListModelMixin,
+ viewsets.GenericViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_serializer_class(self):
+ if self.action == 'update':
+ return UpdateBillingAddressSerializer
+ else:
+ return BillingAddressSerializer
+
+ def get_queryset(self):
+ return self.request.user.billingaddress_set.all()
+
+ def create(self, request):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ # Validate VAT numbers.
+ country = serializer.validated_data["country"]
+
+ # We ignore empty VAT numbers.
+ if 'vat_number' in serializer.validated_data and serializer.validated_data["vat_number"] != "":
+ vat_number = serializer.validated_data["vat_number"]
+
+ if not validate_vat(country, vat_number):
+ return Response(
+ {'error': 'Malformed VAT number.'},
+ status=status.HTTP_400_BAD_REQUEST)
+ elif country in EU_COUNTRY_CODES:
+ # XXX: make a synchroneous call to a third patry API here might not be a good idea..
+ try:
+ vies_state = vies.check_vat(country, vat_number)
+ if not vies_state.valid:
+ return Response(
+ {'error': 'European VAT number does not exist in VIES.'},
+ status=status.HTTP_400_BAD_REQUEST)
+ except Exception as e:
+ logger.warning(e)
+ return Response(
+ {'error': 'Could not validate EU VAT number against VIES. Try again later..'},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+
+ serializer.save(owner=request.user)
+ return Response(serializer.data)
+
+###
+# Admin stuff.
+
+class AdminPaymentViewSet(viewsets.ModelViewSet):
+ serializer_class = PaymentSerializer
+ permission_classes = [permissions.IsAdminUser]
+
+ def get_queryset(self):
+ return Payment.objects.all()
+
+ def create(self, request):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ serializer.save(timestamp=datetime.now())
+
+ headers = self.get_success_headers(serializer.data)
+ return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+
+# Bills are generated from orders and should not be created or updated by hand.
+class AdminBillViewSet(BillViewSet):
+ serializer_class = BillSerializer
+ permission_classes = [permissions.IsAdminUser]
+
+ def get_queryset(self):
+ return Bill.objects.all()
+
+ @action(detail=False, methods=['get'])
+ def unpaid(self, request):
+ unpaid_bills = []
+ # XXX: works but we can do better than number of users + 1 SQL requests...
+ for user in get_user_model().objects.all():
+ unpaid_bills = unpaid_bills + Bill.get_unpaid_for(self.request.user)
+
+ serializer = self.get_serializer(unpaid_bills, many=True)
+ return Response(serializer.data)
+
+ @action(detail=False, methods=['post'])
+ def generate(self, request):
+ users = get_user_model().objects.all()
+
+ generated_bills = []
+ for user in users:
+ now = timezone.now()
+ generated_bills = generated_bills + Bill.generate_for(
+ year=now.year,
+ month=now.month,
+ user=user)
+
+ return Response(
+ map(lambda b: b.reference, generated_bills),
+ status=status.HTTP_200_OK)
+
+class AdminOrderViewSet(mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+ mixins.CreateModelMixin,
+ mixins.UpdateModelMixin,
+ viewsets.GenericViewSet):
+ serializer_class = OrderSerializer
+ permission_classes = [permissions.IsAdminUser]
+
+ def get_serializer(self, *args, **kwargs):
+ return self.serializer_class(*args, **kwargs, admin=True)
+
+ def get_queryset(self):
+ return Order.objects.all()
+
+ # Updates create a new order and terminate the 'old' one.
+ @transaction.atomic
+ def update(self, request, *args, **kwargs):
+ order = self.get_object()
+ partial = kwargs.pop('partial', False)
+ serializer = self.get_serializer(order, data=request.data, partial=partial)
+ serializer.is_valid(raise_exception=True)
+
+ # Clone existing order for replacement.
+ replacing_order = deepcopy(order)
+
+ # Yes, that's how you make a new entry in DB:
+ # https://docs.djangoproject.com/en/3.0/topics/db/queries/#copying-model-instances
+ replacing_order.pk = None
+
+ for attr, value in serializer.validated_data.items():
+ setattr(replacing_order, attr, value)
+
+ # Save replacing order and terminate 'previous' one.
+ replacing_order.save()
+ order.replaced_by = replacing_order
+ order.save()
+ order.terminate()
+
+ return Response(replacing_order)
+
+ @action(detail=True, methods=['post'])
+ def terminate(self, request, pk):
+ order = self.get_object()
+ if order.is_terminated:
+ return Response(
+ {'error': 'Order is already terminated.'},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+ else:
+ order.terminate()
+ return Response({}, status=status.HTTP_200_OK)
diff --git a/uncloud_service/__init__.py b/uncloud_service/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_service/admin.py b/uncloud_service/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/uncloud_service/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/uncloud_service/apps.py b/uncloud_service/apps.py
new file mode 100644
index 0000000..190bd35
--- /dev/null
+++ b/uncloud_service/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class UngleichServiceConfig(AppConfig):
+ name = 'uncloud_service'
diff --git a/uncloud_service/models.py b/uncloud_service/models.py
new file mode 100644
index 0000000..a37e42b
--- /dev/null
+++ b/uncloud_service/models.py
@@ -0,0 +1,63 @@
+from django.db import models
+from uncloud_pay.models import Product, RecurringPeriod, AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS
+from uncloud_vm.models import VMProduct, VMDiskImageProduct
+from django.core.validators import MinValueValidator
+
+class MatrixServiceProduct(models.Model):
+ monthly_managment_fee = 20
+
+ description = "Managed Matrix HomeServer"
+
+ # Specific to Matrix-as-a-Service
+ vm = models.ForeignKey(
+ VMProduct, on_delete=models.CASCADE
+ )
+ domain = models.CharField(max_length=255, default='domain.tld')
+
+ # Default recurring price is PER_MONT, see Product class.
+ # def recurring_price(self, recurring_period=RecurringPeriod.PER_30D):
+ # return self.monthly_managment_fee
+
+ @staticmethod
+ def base_image():
+ # TODO: find a way to safely reference debian 10 image.
+#e return VMDiskImageProduct.objects.get(uuid="93e564c5-adb3-4741-941f-718f76075f02")
+ return False
+
+ # @staticmethod
+ # def allowed_recurring_periods():
+ # return list(filter(
+ # lambda pair: pair[0] in [RecurringPeriod.PER_30D],
+ # RecurringPeriod.choices))
+
+ @property
+ def one_time_price(self):
+ return 30
+
+class GenericServiceProduct(models.Model):
+ custom_description = models.TextField()
+ custom_recurring_price = models.DecimalField(default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS,
+ validators=[MinValueValidator(0)])
+ custom_one_time_price = models.DecimalField(default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS,
+ validators=[MinValueValidator(0)])
+
+ @property
+ def recurring_price(self):
+ # FIXME: handle recurring_period somehow.
+ return self.custom_recurring_price
+
+ @property
+ def description(self):
+ return self.custom_description
+
+ @property
+ def one_time_price(self):
+ return self.custom_one_time_price
+
+ @staticmethod
+ def allowed_recurring_periods():
+ return RecurringPeriod.choices
diff --git a/uncloud_service/serializers.py b/uncloud_service/serializers.py
new file mode 100644
index 0000000..bc6d753
--- /dev/null
+++ b/uncloud_service/serializers.py
@@ -0,0 +1,60 @@
+from rest_framework import serializers
+from .models import *
+from uncloud_vm.serializers import ManagedVMProductSerializer
+from uncloud_vm.models import VMProduct
+from uncloud_pay.models import RecurringPeriod, BillingAddress
+
+# XXX: the OrderSomethingSomthingProductSerializer classes add a lot of
+# boilerplate: can we reduce it somehow?
+
+class MatrixServiceProductSerializer(serializers.ModelSerializer):
+ vm = ManagedVMProductSerializer()
+
+ class Meta:
+ model = MatrixServiceProduct
+ fields = ['order', 'owner', 'status', 'vm', 'domain',
+ 'recurring_period']
+ read_only_fields = ['order', 'owner', 'status']
+
+class OrderMatrixServiceProductSerializer(MatrixServiceProductSerializer):
+ # recurring_period = serializers.ChoiceField(
+ # choices=MatrixServiceProduct.allowed_recurring_periods())
+
+ def __init__(self, *args, **kwargs):
+ super(OrderMatrixServiceProductSerializer, self).__init__(*args, **kwargs)
+ self.fields['billing_address'] = serializers.ChoiceField(
+ choices=BillingAddress.get_addresses_for(
+ self.context['request'].user)
+ )
+
+ class Meta:
+ model = MatrixServiceProductSerializer.Meta.model
+ fields = MatrixServiceProductSerializer.Meta.fields + [
+ 'recurring_period', 'billing_address'
+ ]
+ read_only_fields = MatrixServiceProductSerializer.Meta.read_only_fields
+
+class GenericServiceProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = GenericServiceProduct
+ fields = ['order', 'owner', 'status', 'custom_recurring_price',
+ 'custom_description', 'custom_one_time_price']
+ read_only_fields = [ 'owner', 'status']
+
+class OrderGenericServiceProductSerializer(GenericServiceProductSerializer):
+ # recurring_period = serializers.ChoiceField(
+ # choices=GenericServiceProduct.allowed_recurring_periods())
+
+ def __init__(self, *args, **kwargs):
+ super(OrderGenericServiceProductSerializer, self).__init__(*args, **kwargs)
+ self.fields['billing_address'] = serializers.ChoiceField(
+ choices=BillingAddress.get_addresses_for(
+ self.context['request'].user)
+ )
+
+ class Meta:
+ model = GenericServiceProductSerializer.Meta.model
+ fields = GenericServiceProductSerializer.Meta.fields + [
+ 'recurring_period', 'billing_address'
+ ]
+ read_only_fields = GenericServiceProductSerializer.Meta.read_only_fields
diff --git a/uncloud_service/tests.py b/uncloud_service/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/uncloud_service/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/uncloud_service/views.py b/uncloud_service/views.py
new file mode 100644
index 0000000..abd4a05
--- /dev/null
+++ b/uncloud_service/views.py
@@ -0,0 +1,128 @@
+from rest_framework import viewsets, permissions
+from rest_framework.response import Response
+from django.db import transaction
+from django.utils import timezone
+
+from .models import *
+from .serializers import *
+
+from uncloud_pay.helpers import ProductViewSet
+from uncloud_pay.models import Order
+from uncloud_vm.models import VMProduct, VMDiskProduct
+
+def create_managed_vm(cores, ram, disk_size, image, order):
+ # Create VM
+ disk = VMDiskProduct(
+ owner=order.owner,
+ order=order,
+ size_in_gb=disk_size,
+ image=image)
+ vm = VMProduct(
+ name="Managed Service Host",
+ owner=order.owner,
+ cores=cores,
+ ram_in_gb=ram,
+ primary_disk=disk)
+ disk.vm = vm
+
+ vm.save()
+ disk.save()
+
+ return vm
+
+
+class MatrixServiceProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = MatrixServiceProductSerializer
+
+ def get_queryset(self):
+ return MatrixServiceProduct.objects.filter(owner=self.request.user)
+
+ def get_serializer_class(self):
+ if self.action == 'create':
+ return OrderMatrixServiceProductSerializer
+ else:
+ return MatrixServiceProductSerializer
+
+ @transaction.atomic
+ def create(self, request):
+ # Extract serializer data.
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ order_recurring_period = serializer.validated_data.pop("recurring_period")
+ order_billing_address = serializer.validated_data.pop("billing_address")
+
+ # Create base order.)
+ order = Order.objects.create(
+ recurring_period=order_recurring_period,
+ owner=request.user,
+ billing_address=order_billing_address,
+ starting_date=timezone.now()
+ )
+ order.save()
+
+ # Create unerderlying VM.
+ data = serializer.validated_data.pop('vm')
+ vm = create_managed_vm(
+ order=order,
+ cores=data['cores'],
+ ram=data['ram_in_gb'],
+ disk_size=data['primary_disk']['size_in_gb'],
+ image=MatrixServiceProduct.base_image())
+
+ # Create service.
+ service = serializer.save(
+ order=order,
+ owner=request.user,
+ vm=vm)
+
+ return Response(serializer.data)
+
+class GenericServiceProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return GenericServiceProduct.objects.filter(owner=self.request.user)
+
+ def get_serializer_class(self):
+ if self.action == 'create':
+ return OrderGenericServiceProductSerializer
+ else:
+ return GenericServiceProductSerializer
+
+ @transaction.atomic
+ def create(self, request):
+ # Extract serializer data.
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ order_recurring_period = serializer.validated_data.pop("recurring_period")
+ order_billing_address = serializer.validated_data.pop("billing_address")
+
+ # Create base order.
+ order = Order.objects.create(
+ recurring_period=order_recurring_period,
+ owner=request.user,
+ billing_address=order_billing_address,
+ starting_date=timezone.now()
+ )
+ order.save()
+
+ # Create service.
+ print(serializer.validated_data)
+ service = serializer.save(order=order, owner=request.user)
+
+ # XXX: Move this to some kind of on_create hook in parent
+ # Product class?
+ order.add_record(
+ service.one_time_price,
+ service.recurring_price,
+ service.description)
+
+ # XXX: Move this to some kind of on_create hook in parent
+ # Product class?
+ order.add_record(
+ service.one_time_price,
+ service.recurring_price,
+ service.description)
+
+ return Response(serializer.data)
diff --git a/uncloud_storage/__init__.py b/uncloud_storage/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_storage/admin.py b/uncloud_storage/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/uncloud_storage/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/uncloud_storage/apps.py b/uncloud_storage/apps.py
new file mode 100644
index 0000000..38b2301
--- /dev/null
+++ b/uncloud_storage/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class UncloudStorageConfig(AppConfig):
+ name = 'uncloud_storage'
diff --git a/uncloud_storage/models.py b/uncloud_storage/models.py
new file mode 100644
index 0000000..0dac5c2
--- /dev/null
+++ b/uncloud_storage/models.py
@@ -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')
diff --git a/uncloud_storage/tests.py b/uncloud_storage/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/uncloud_storage/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/uncloud_storage/views.py b/uncloud_storage/views.py
new file mode 100644
index 0000000..91ea44a
--- /dev/null
+++ b/uncloud_storage/views.py
@@ -0,0 +1,3 @@
+from django.shortcuts import render
+
+# Create your views here.
diff --git a/uncloud_vm/__init__.py b/uncloud_vm/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_vm/admin.py b/uncloud_vm/admin.py
new file mode 100644
index 0000000..6f3bc50
--- /dev/null
+++ b/uncloud_vm/admin.py
@@ -0,0 +1,19 @@
+from django.contrib import admin
+
+# Register your models here.
+from uncloud_vm.models import *
+from uncloud_pay.models import Order
+
+class VMDiskInline(admin.TabularInline):
+ model = VMDiskProduct
+
+class OrderInline(admin.TabularInline):
+ model = Order
+
+class VMProductAdmin(admin.ModelAdmin):
+ inlines = [
+ VMDiskInline
+ ]
+
+admin.site.register(VMProduct, VMProductAdmin)
+admin.site.register(VMDiskProduct)
diff --git a/uncloud_vm/apps.py b/uncloud_vm/apps.py
new file mode 100644
index 0000000..c5e94a5
--- /dev/null
+++ b/uncloud_vm/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class UncloudVmConfig(AppConfig):
+ name = 'uncloud_vm'
diff --git a/uncloud_vm/management/commands/vm.py b/uncloud_vm/management/commands/vm.py
new file mode 100644
index 0000000..667c5ad
--- /dev/null
+++ b/uncloud_vm/management/commands/vm.py
@@ -0,0 +1,119 @@
+import json
+
+import uncloud.secrets as secrets
+
+from django.core.management.base import BaseCommand
+from django.contrib.auth import get_user_model
+
+from uncloud_vm.models import VMSnapshotProduct, VMProduct, VMHost
+from datetime import datetime
+
+class Command(BaseCommand):
+ help = 'Select VM Host for VMs'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--this-hostname', required=True)
+ parser.add_argument('--this-cluster', required=True)
+
+ parser.add_argument('--create-vm-snapshots', action='store_true')
+ parser.add_argument('--schedule-vms', action='store_true')
+ parser.add_argument('--start-vms', action='store_true')
+
+
+ def handle(self, *args, **options):
+ for cmd in [ 'create_vm_snapshots', 'schedule_vms', 'start_vms' ]:
+ if options[cmd]:
+ f = getattr(self, cmd)
+ f(args, options)
+
+ def schedule_vms(self, *args, **options):
+ for pending_vm in VMProduct.objects.filter(status='PENDING'):
+ cores_needed = pending_vm.cores
+ ram_needed = pending_vm.ram_in_gb
+
+ # Database filtering
+ possible_vmhosts = VMHost.objects.filter(physical_cores__gte=cores_needed)
+
+ # Logical filtering
+ possible_vmhosts = [ vmhost for vmhost in possible_vmhosts
+ if vmhost.available_cores >=cores_needed
+ and vmhost.available_ram_in_gb >= ram_needed ]
+
+ if not possible_vmhosts:
+ log.error("No suitable Host found - cannot schedule VM {}".format(pending_vm))
+ continue
+
+ vmhost = possible_vmhosts[0]
+ pending_vm.vmhost = vmhost
+ pending_vm.status = 'SCHEDULED'
+ pending_vm.save()
+
+ print("Scheduled VM {} on VMHOST {}".format(pending_vm, pending_vm.vmhost))
+
+ print(self)
+
+ def start_vms(self, *args, **options):
+ vmhost = VMHost.objects.get(hostname=options['this_hostname'])
+
+ if not vmhost:
+ raise Exception("No vmhost {} exists".format(options['vmhostname']))
+
+ # not active? done here
+ if not vmhost.status = 'ACTIVE':
+ return
+
+ vms_to_start = VMProduct.objects.filter(vmhost=vmhost,
+ status='SCHEDULED')
+ for vm in vms_to_start:
+ """ run qemu:
+ check if VM is not already active / qemu running
+ prepare / create the Qemu arguments
+ """
+ print("Starting VM {}".format(VM))
+
+ def check_vms(self, *args, **options):
+ """
+ Check if all VMs that are supposed to run are running
+ """
+
+ def modify_vms(self, *args, **options):
+ """
+ Check all VMs that are requested to be modified and restart them
+ """
+
+ def create_vm_snapshots(self, *args, **options):
+ this_cluster = VMCluster(option['this_cluster'])
+
+ for snapshot in VMSnapshotProduct.objects.filter(status='PENDING',
+ cluster=this_cluster):
+ if not snapshot.extra_data:
+ snapshot.extra_data = {}
+
+ # TODO: implement locking here
+ if 'creating_hostname' in snapshot.extra_data:
+ pass
+
+ snapshot.extra_data['creating_hostname'] = options['this_hostname']
+ snapshot.extra_data['creating_start'] = str(datetime.now())
+ snapshot.save()
+
+ # something on the line of:
+ # for disk im vm.disks:
+ # rbd snap create pool/image-name@snapshot name
+ # snapshot.extra_data['snapshots']
+ # register the snapshot names in extra_data (?)
+
+ print(snapshot)
+
+ def check_health(self, *args, **options):
+ pending_vms = VMProduct.objects.filter(status='PENDING')
+ vmhosts = VMHost.objects.filter(status='active')
+
+ # 1. Check that all active hosts reported back N seconds ago
+ # 2. Check that no VM is running on a dead host
+ # 3. Migrate VMs if necessary
+ # 4. Check that no VMs have been pending for longer than Y seconds
+
+ # If VM snapshots exist without a VM -> notify user (?)
+
+ print("Nothing is good, you should implement me")
diff --git a/uncloud_vm/migrations/0001_initial.py b/uncloud_vm/migrations/0001_initial.py
new file mode 100644
index 0000000..4ec089a
--- /dev/null
+++ b/uncloud_vm/migrations/0001_initial.py
@@ -0,0 +1,111 @@
+# Generated by Django 3.1 on 2020-12-13 10:38
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='VMCluster',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('extra_data', models.JSONField(blank=True, editable=False, null=True)),
+ ('name', models.CharField(max_length=128, unique=True)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='VMDiskImageProduct',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('extra_data', models.JSONField(blank=True, editable=False, null=True)),
+ ('name', models.CharField(max_length=256)),
+ ('is_os_image', models.BooleanField(default=False)),
+ ('is_public', models.BooleanField(default=False, editable=False)),
+ ('size_in_gb', models.FloatField(blank=True, null=True)),
+ ('import_url', models.URLField(blank=True, null=True)),
+ ('image_source', models.CharField(max_length=128, null=True)),
+ ('image_source_type', models.CharField(max_length=128, null=True)),
+ ('storage_class', models.CharField(choices=[('HDD', 'HDD'), ('SSD', 'SSD')], default='SSD', max_length=32)),
+ ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32)),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='VMHost',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('extra_data', models.JSONField(blank=True, editable=False, null=True)),
+ ('hostname', models.CharField(max_length=253, unique=True)),
+ ('physical_cores', models.IntegerField(default=0)),
+ ('usable_cores', models.IntegerField(default=0)),
+ ('usable_ram_in_gb', models.FloatField(default=0)),
+ ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32)),
+ ('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmcluster')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='VMProduct',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(blank=True, max_length=32, null=True)),
+ ('cores', models.IntegerField()),
+ ('ram_in_gb', models.FloatField()),
+ ('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmcluster')),
+ ('vmhost', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmhost')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VMSnapshotProduct',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('gb_ssd', models.FloatField(editable=False)),
+ ('gb_hdd', models.FloatField(editable=False)),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='uncloud_vm.vmproduct')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VMNetworkCard',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('mac_address', models.BigIntegerField()),
+ ('ip_address', models.GenericIPAddressField(blank=True, null=True)),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmproduct')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VMDiskProduct',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('size_in_gb', models.FloatField(blank=True)),
+ ('disk_type', models.CharField(choices=[('ceph/ssd', 'Ceph Ssd'), ('ceph/hdd', 'Ceph Hdd'), ('local/ssd', 'Local Ssd'), ('local/hdd', 'Local Hdd')], default='ceph/ssd', max_length=20)),
+ ('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmdiskimageproduct')),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmproduct')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VMWithOSProduct',
+ fields=[
+ ('vmproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.vmproduct')),
+ ('primary_disk', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmdiskproduct')),
+ ],
+ bases=('uncloud_vm.vmproduct',),
+ ),
+ ]
diff --git a/uncloud_vm/migrations/0002_vmproduct_owner.py b/uncloud_vm/migrations/0002_vmproduct_owner.py
new file mode 100644
index 0000000..3b96a87
--- /dev/null
+++ b/uncloud_vm/migrations/0002_vmproduct_owner.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1.4 on 2021-04-14 10:40
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('uncloud_vm', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vmproduct',
+ name='owner',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+ ),
+ ]
diff --git a/uncloud_vm/migrations/0003_vmproduct_created_order_at.py b/uncloud_vm/migrations/0003_vmproduct_created_order_at.py
new file mode 100644
index 0000000..8f5d0c4
--- /dev/null
+++ b/uncloud_vm/migrations/0003_vmproduct_created_order_at.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.1.4 on 2021-04-14 10:46
+
+import datetime
+from django.db import migrations, models
+from django.utils.timezone import utc
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_vm', '0002_vmproduct_owner'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vmproduct',
+ name='created_order_at',
+ field=models.DateTimeField(default=datetime.datetime(2021, 4, 14, 10, 46, 14, 96330, tzinfo=utc)),
+ ),
+ ]
diff --git a/uncloud_vm/migrations/0004_auto_20210414_1048.py b/uncloud_vm/migrations/0004_auto_20210414_1048.py
new file mode 100644
index 0000000..20214bc
--- /dev/null
+++ b/uncloud_vm/migrations/0004_auto_20210414_1048.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.1.4 on 2021-04-14 10:48
+
+import datetime
+from django.db import migrations, models
+from django.utils.timezone import utc
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_vm', '0003_vmproduct_created_order_at'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='vmproduct',
+ name='created_order_at',
+ ),
+ migrations.AddField(
+ model_name='vmproduct',
+ name='create_order_at',
+ field=models.DateTimeField(default=datetime.datetime(2021, 4, 14, 10, 48, 6, 641056, tzinfo=utc)),
+ ),
+ ]
diff --git a/uncloud_vm/migrations/0005_auto_20210414_1119.py b/uncloud_vm/migrations/0005_auto_20210414_1119.py
new file mode 100644
index 0000000..ef9df79
--- /dev/null
+++ b/uncloud_vm/migrations/0005_auto_20210414_1119.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.1.4 on 2021-04-14 11:19
+
+import datetime
+from django.db import migrations, models
+from django.utils.timezone import utc
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_vm', '0004_auto_20210414_1048'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='vmproduct',
+ name='create_order_at',
+ ),
+ migrations.AddField(
+ model_name='vmproduct',
+ name='created_order_at',
+ field=models.DateTimeField(default=datetime.datetime(2021, 4, 14, 11, 19, 39, 447274, tzinfo=utc)),
+ ),
+ ]
diff --git a/uncloud_vm/migrations/0006_auto_20210414_1122.py b/uncloud_vm/migrations/0006_auto_20210414_1122.py
new file mode 100644
index 0000000..2c302fb
--- /dev/null
+++ b/uncloud_vm/migrations/0006_auto_20210414_1122.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.1.4 on 2021-04-14 11:22
+
+import datetime
+from django.db import migrations, models
+from django.utils.timezone import utc
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_vm', '0005_auto_20210414_1119'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='vmproduct',
+ name='created_order_at',
+ field=models.DateTimeField(default=datetime.datetime(2021, 4, 14, 11, 22, 11, 352536, tzinfo=utc)),
+ ),
+ ]
diff --git a/uncloud_vm/migrations/__init__.py b/uncloud_vm/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_vm/models.py b/uncloud_vm/models.py
new file mode 100644
index 0000000..952cde9
--- /dev/null
+++ b/uncloud_vm/models.py
@@ -0,0 +1,200 @@
+import datetime
+from django.utils import timezone
+
+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):
+ name = models.CharField(max_length=128, unique=True)
+
+class VMHost(UncloudModel):
+ # 253 is the maximum DNS name length
+ hostname = models.CharField(max_length=253, unique=True)
+
+ vmcluster = models.ForeignKey(
+ VMCluster, on_delete=models.CASCADE, editable=False, blank=True, null=True
+ )
+
+ # indirectly gives a maximum number of cores / VM - f.i. 32
+ physical_cores = models.IntegerField(default=0)
+
+ # 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(models.Model):
+ owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE,
+ blank=True, null=True)
+ 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
+ )
+
+ name = models.CharField(max_length=32, blank=True, null=True)
+ cores = models.IntegerField()
+ ram_in_gb = models.FloatField()
+ created_order_at = models.DateTimeField(default=timezone.make_aware(datetime.datetime.now()))
+
+ @property
+ def recurring_price(self):
+ return self.cores * 3 + self.ram_in_gb * 4
+
+ @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_365D,
+ # RecurringPeriod.PER_30D, RecurringPeriod.PER_HOUR],
+ # RecurringPeriod.choices))
+
+ def create_order_at(self, dt):
+ self.created_order_at = dt
+
+ def create_or_update_order(self, when_to_start):
+ self.created_order_at = when_to_start
+
+ def __str__(self):
+ return f"VM id={self.id},name={self.name},cores={self.cores},ram_in_gb={self.ram_in_gb}"
+
+
+class VMWithOSProduct(VMProduct):
+ primary_disk = models.ForeignKey('VMDiskProduct', on_delete=models.CASCADE, null=True)
+
+
+class VMDiskImageProduct(UncloudModel):
+ """
+ Images are used for cloning/linking.
+
+ They are the base for images.
+
+ """
+
+ 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.id,
+ self.name,
+ self.size_in_gb)
+
+
+# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
+class VMDiskType(models.TextChoices):
+ """
+ Types of disks that can be attached to VMs
+ """
+ CEPH_SSD = 'ceph/ssd'
+ CEPH_HDD = 'ceph/hdd'
+ LOCAL_SSD = 'local/ssd'
+ LOCAL_HDD = 'local/hdd'
+
+
+class VMDiskProduct(models.Model):
+ """
+ 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.
+ """
+
+ vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE)
+
+ image = models.ForeignKey(VMDiskImageProduct, on_delete=models.CASCADE,
+ blank=True, null=True)
+
+ size_in_gb = models.FloatField(blank=True)
+
+ disk_type = models.CharField(
+ max_length=20,
+ choices=VMDiskType.choices,
+ default=VMDiskType.CEPH_SSD)
+
+ def __str__(self):
+ return f"Disk {self.size_in_gb}GB ({self.disk_type}) for {self.vm}"
+
+ @property
+ def recurring_price(self):
+ if self.disk_type == VMDiskType.CEPH_SSD:
+ price_per_gb = 3.5/10
+ elif self.disk_type == VMDiskType.CEPH_HDD:
+ price_per_gb = 1.5/100
+ elif self.disk_type == VMDiskType.LOCAL_SSD:
+ price_per_gb = 3.5/10
+ elif self.disk_type == VMDiskType.CEPH_HDD:
+ price_per_gb = 1.5/100
+
+ return self.size_in_gb * price_per_gb
+
+
+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(models.Model):
+ gb_ssd = models.FloatField(editable=False)
+ gb_hdd = models.FloatField(editable=False)
+
+ vm = models.ForeignKey(VMProduct,
+ related_name='snapshots',
+ on_delete=models.CASCADE)
diff --git a/uncloud_vm/serializers.py b/uncloud_vm/serializers.py
new file mode 100644
index 0000000..a60d10b
--- /dev/null
+++ b/uncloud_vm/serializers.py
@@ -0,0 +1,143 @@
+from django.contrib.auth import get_user_model
+
+from rest_framework import serializers
+
+from .models import *
+from uncloud_pay.models import RecurringPeriod, BillingAddress
+
+# XXX: does not seem to be used?
+
+GB_SSD_PER_DAY=0.012
+GB_HDD_PER_DAY=0.0006
+
+GB_SSD_PER_DAY=0.012
+GB_HDD_PER_DAY=0.0006
+
+###
+# Admin views.
+
+class VMHostSerializer(serializers.HyperlinkedModelSerializer):
+ vms = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
+
+ class Meta:
+ model = VMHost
+ fields = '__all__'
+ read_only_fields = [ 'vms' ]
+
+class VMClusterSerializer(serializers.HyperlinkedModelSerializer):
+ class Meta:
+ model = VMCluster
+ fields = '__all__'
+
+
+###
+# Disks.
+
+class VMDiskProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskProduct
+ fields = '__all__'
+
+class CreateVMDiskProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskProduct
+ fields = ['size_in_gb', 'image']
+
+class CreateManagedVMDiskProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskProduct
+ fields = ['size_in_gb']
+
+class VMDiskImageProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskImageProduct
+ fields = '__all__'
+
+class VMSnapshotProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMSnapshotProduct
+ fields = '__all__'
+
+
+ # verify that vm.owner == user.request
+ def validate_vm(self, value):
+ if not value.owner == self.context['request'].user:
+ raise serializers.ValidationError("VM {} not found for owner {}.".format(value,
+ self.context['request'].user))
+ disks = VMDiskProduct.objects.filter(vm=value)
+
+ if len(disks) == 0:
+ raise serializers.ValidationError("VM {} does not have any disks, cannot snapshot".format(value.id))
+
+ return value
+
+ pricing = {}
+ pricing['per_gb_ssd'] = 0.012
+ pricing['per_gb_hdd'] = 0.0006
+ pricing['recurring_period'] = 'per_day'
+
+###
+# VMs
+
+# Helper used in uncloud_service for services allocating VM.
+class ManagedVMProductSerializer(serializers.ModelSerializer):
+ """
+ Managed VM serializer used in ungleich_service app.
+ """
+ primary_disk = CreateManagedVMDiskProductSerializer()
+ class Meta:
+ model = VMWithOSProduct
+ fields = [ 'cores', 'ram_in_gb', 'primary_disk']
+
+class VMProductSerializer(serializers.ModelSerializer):
+ primary_disk = CreateVMDiskProductSerializer()
+ snapshots = VMSnapshotProductSerializer(many=True, read_only=True)
+ disks = VMDiskProductSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = VMWithOSProduct
+ fields = ['order', 'owner', 'status', 'name', 'cores',
+ 'ram_in_gb', 'primary_disk', 'snapshots', 'disks', 'extra_data']
+ read_only_fields = ['order', 'owner', 'status']
+
+class OrderVMProductSerializer(VMProductSerializer):
+ # recurring_period = serializers.ChoiceField(
+ # choices=VMWithOSProduct.allowed_recurring_periods())
+
+ def __init__(self, *args, **kwargs):
+ super(VMProductSerializer, self).__init__(*args, **kwargs)
+
+ class Meta:
+ model = VMProductSerializer.Meta.model
+ fields = VMProductSerializer.Meta.fields + [ 'recurring_period' ]
+ read_only_fields = VMProductSerializer.Meta.read_only_fields
+
+# Nico's playground.
+class NicoVMProductSerializer(serializers.ModelSerializer):
+ snapshots = VMSnapshotProductSerializer(many=True, read_only=True)
+ order = serializers.StringRelatedField()
+
+ class Meta:
+ model = VMProduct
+ read_only_fields = ['order', 'owner', 'status',
+ 'vmhost', 'vmcluster', 'snapshots',
+ 'extra_data' ]
+ fields = read_only_fields + [ 'name',
+ 'cores',
+ 'ram_in_gb'
+ ]
+
+class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer):
+ """
+ Create an interface similar to standard DCL
+ """
+
+ # Custom field used at creation (= ordering) only.
+ # recurring_period = serializers.ChoiceField(
+ # choices=VMProduct.allowed_recurring_periods())
+
+ os_disk_uuid = serializers.UUIDField()
+ # os_disk_size =
+
+ class Meta:
+ model = VMProduct
diff --git a/uncloud_vm/tests.py b/uncloud_vm/tests.py
new file mode 100644
index 0000000..e5d403f
--- /dev/null
+++ b/uncloud_vm/tests.py
@@ -0,0 +1,98 @@
+import datetime
+
+import parsedatetime
+
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from django.utils import timezone
+from django.core.exceptions import ValidationError
+
+from uncloud_vm.models import VMDiskImageProduct, VMDiskProduct, VMProduct, VMHost
+from uncloud_pay.models import Order, RecurringPeriod
+
+User = get_user_model()
+cal = parsedatetime.Calendar()
+
+
+# If you want to check the test database using some GUI/cli tool
+# then use the following connecting parameters
+
+# host: localhost
+# database: test_uncloud
+# user: root
+# password:
+# port: 5432
+
+class VMTestCase(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ # Setup vm host
+ cls.vm_host, created = VMHost.objects.get_or_create(
+ hostname='serverx.placey.ungleich.ch', physical_cores=32, usable_cores=320,
+ usable_ram_in_gb=512.0, status='active'
+ )
+ super().setUpClass()
+
+ def setUp(self) -> None:
+ # Setup two users as it is common to test with different user
+ self.user = User.objects.create_user(
+ username='testuser', email='test@test.com', first_name='Test', last_name='User'
+ )
+ self.user2 = User.objects.create_user(
+ username='Meow', email='meow123@test.com', first_name='Meow', last_name='Cat'
+ )
+ super().setUp()
+
+ def create_sample_vm(self, owner):
+ one_month_later, parse_status = cal.parse("1 month later")
+ return VMProduct.objects.create(
+ vmhost=self.vm_host, cores=2, ram_in_gb=4, owner=owner,
+ order=Order.objects.create(
+ owner=owner,
+ creation_date=datetime.datetime.now(tz=timezone.utc),
+ starting_date=datetime.datetime.now(tz=timezone.utc),
+ ending_date=datetime.datetime(*one_month_later[:6], tzinfo=timezone.utc),
+ recurring_period=RecurringPeriod.PER_MONTH
+ )
+ )
+
+# TODO: the logic tested by this test is not implemented yet.
+# def test_disk_product(self):
+# """Ensures that a VMDiskProduct can only be created from a VMDiskImageProduct
+# that is in status 'active'"""
+#
+# vm = self.create_sample_vm(owner=self.user)
+#
+# pending_disk_image = VMDiskImageProduct.objects.create(
+# owner=self.user, name='pending_disk_image', is_os_image=True, is_public=True, size_in_gb=10,
+# status='pending'
+# )
+# try:
+# vm_disk_product = VMDiskProduct.objects.create(
+# owner=self.user, vm=vm, image=pending_disk_image, size_in_gb=10
+# )
+# except ValidationError:
+# vm_disk_product = None
+#
+# self.assertIsNone(
+# vm_disk_product,
+# msg='VMDiskProduct created with disk image whose status is not active.'
+# )
+
+# TODO: the logic tested by this test is not implemented yet.
+# def test_vm_disk_product_creation_for_someone_else(self):
+# """Ensure that a user can only create a VMDiskProduct for his/her own VM"""
+#
+# # Create a VM which is ownership of self.user2
+# someone_else_vm = self.create_sample_vm(owner=self.user2)
+#
+# # 'self.user' would try to create a VMDiskProduct for 'user2's VM
+# with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for someone else VM.'):
+# vm_disk_product = VMDiskProduct.objects.create(
+# owner=self.user, vm=someone_else_vm,
+# size_in_gb=10,
+# image=VMDiskImageProduct.objects.create(
+# owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10,
+# status='active'
+# )
+# )
diff --git a/uncloud_vm/views.py b/uncloud_vm/views.py
new file mode 100644
index 0000000..67f8656
--- /dev/null
+++ b/uncloud_vm/views.py
@@ -0,0 +1,261 @@
+from django.db import transaction
+from django.shortcuts import render
+from django.utils import timezone
+
+from django.contrib.auth.models import User
+from django.shortcuts import get_object_or_404
+
+from rest_framework import viewsets, permissions
+from rest_framework.response import Response
+from rest_framework.exceptions import ValidationError
+
+from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct, VMCluster
+from uncloud_pay.models import Order, BillingAddress
+
+from .serializers import *
+from uncloud_pay.helpers import ProductViewSet
+
+import datetime
+
+###
+# Generic disk image views. Do not require orders / billing.
+
+class VMDiskImageProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMDiskImageProductSerializer
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMDiskImageProduct.objects.all()
+ else:
+ obj = VMDiskImageProduct.objects.filter(owner=self.request.user) | VMDiskImageProduct.objects.filter(is_public=True)
+
+ return obj
+
+
+ def create(self, request):
+ serializer = VMDiskImageProductSerializer(data=request.data, context={'request': request})
+ serializer.is_valid(raise_exception=True)
+
+ # did not specify size NOR import url?
+ if not serializer.validated_data['size_in_gb']:
+ if not serializer.validated_data['import_url']:
+ raise ValidationError(detail={ 'error_mesage': 'Specify either import_url or size_in_gb' })
+
+ serializer.save(owner=request.user)
+ return Response(serializer.data)
+
+class VMDiskImageProductPublicViewSet(viewsets.ReadOnlyModelViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMDiskImageProductSerializer
+
+ def get_queryset(self):
+ return VMDiskImageProduct.objects.filter(is_public=True)
+
+###
+# User VM disk and snapshots.
+
+class VMDiskProductViewSet(viewsets.ModelViewSet):
+ """
+ Let a user modify their own VMDisks
+ """
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMDiskProductSerializer
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMDiskProduct.objects.all()
+ else:
+ obj = VMDiskProduct.objects.filter(owner=self.request.user)
+
+ return obj
+
+ def create(self, request):
+ serializer = VMDiskProductSerializer(data=request.data, context={'request': request})
+ serializer.is_valid(raise_exception=True)
+
+ # get disk size from image, if not specified
+ if not 'size_in_gb' in serializer.validated_data:
+ size_in_gb = serializer.validated_data['image'].size_in_gb
+ else:
+ size_in_gb = serializer.validated_data['size_in_gb']
+
+ if size_in_gb < serializer.validated_data['image'].size_in_gb:
+ raise ValidationError(detail={ 'error_mesage': 'Size is smaller than original image' })
+
+ serializer.save(owner=request.user, size_in_gb=size_in_gb)
+ return Response(serializer.data)
+
+class VMSnapshotProductViewSet(viewsets.ModelViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMSnapshotProductSerializer
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMSnapshotProduct.objects.all()
+ else:
+ obj = VMSnapshotProduct.objects.filter(owner=self.request.user)
+
+ return obj
+
+ def create(self, request):
+ serializer = VMSnapshotProductSerializer(data=request.data, context={'request': request})
+
+ # This verifies that the VM belongs to the request user
+ serializer.is_valid(raise_exception=True)
+
+ vm = vm=serializer.validated_data['vm']
+ disks = VMDiskProduct.objects.filter(vm=vm)
+ ssds_size = sum([d.size_in_gb for d in disks if d.image.storage_class == 'ssd'])
+ hdds_size = sum([d.size_in_gb for d in disks if d.image.storage_class == 'hdd'])
+
+ recurring_price = serializer.pricing['per_gb_ssd'] * ssds_size + serializer.pricing['per_gb_hdd'] * hdds_size
+ recurring_period = serializer.pricing['recurring_period']
+
+ # Create order
+ now = datetime.datetime.now()
+ order = Order(owner=request.user,
+ recurring_period=recurring_period)
+ order.save()
+ order.add_record(one_time_price=0,
+ recurring_price=recurring_price,
+ description="Snapshot of VM {} from {}".format(vm, now))
+
+ serializer.save(owner=request.user,
+ order=order,
+ gb_ssd=ssds_size,
+ gb_hdd=hdds_size)
+
+ return Response(serializer.data)
+
+###
+# User VMs.
+
+class VMProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMWithOSProduct.objects.all()
+ else:
+ obj = VMWithOSProduct.objects.filter(owner=self.request.user)
+
+ return obj
+
+ def get_serializer_class(self):
+ if self.action == 'create':
+ return OrderVMProductSerializer
+ else:
+ return VMProductSerializer
+
+ # Use a database transaction so that we do not get half-created structure
+ # if something goes wrong.
+ @transaction.atomic
+ def create(self, request):
+ # Extract serializer data.
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ order_recurring_period = serializer.validated_data.pop("recurring_period")
+
+ # Create disk image.
+ disk = VMDiskProduct(owner=request.user,
+ **serializer.validated_data.pop("primary_disk"))
+ vm = VMWithOSProduct(owner=request.user, primary_disk=disk,
+ **serializer.validated_data)
+ disk.vm = vm # XXX: Is this really needed?
+
+ # Create VM and Disk orders.
+ vm_order = Order.from_product(
+ vm,
+ recurring_period=order_recurring_period,
+ starting_date=timezone.now()
+ )
+
+ disk_order = Order.from_product(
+ disk,
+ recurring_period=order_recurring_period,
+ starting_date=timezone.now(),
+ depends_on=vm_order
+ )
+
+
+ # Commit to DB.
+ vm.order = vm_order
+ vm.save()
+ vm_order.save()
+
+ disk.order = disk_order
+ disk_order.save()
+ disk.save()
+
+ return Response(VMProductSerializer(vm, context={'request': request}).data)
+
+class NicoVMProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = NicoVMProductSerializer
+
+ def get_queryset(self):
+ obj = VMProduct.objects.filter(owner=self.request.user)
+ return obj
+
+ def create(self, request):
+ serializer = self.serializer_class(data=request.data, context={'request': request})
+ serializer.is_valid(raise_exception=True)
+ vm = serializer.save(owner=request.user)
+
+ return Response(serializer.data)
+
+
+###
+# Admin stuff.
+
+class VMHostViewSet(viewsets.ModelViewSet):
+ serializer_class = VMHostSerializer
+ queryset = VMHost.objects.all()
+ permission_classes = [permissions.IsAdminUser]
+
+class VMClusterViewSet(viewsets.ModelViewSet):
+ serializer_class = VMClusterSerializer
+ queryset = VMCluster.objects.all()
+ permission_classes = [permissions.IsAdminUser]
+
+##
+# Nico's playground.
+
+# Also create:
+# - /dcl/available_os
+# Basically a view of public and my disk images
+# -
+class DCLCreateVMProductViewSet(ProductViewSet):
+ """
+ This view resembles the way how DCL VMs are created by default.
+
+ The user chooses an OS, os disk size, ram, cpu and whether or not to have a mapped IPv4 address
+ """
+
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = DCLVMProductSerializer
+
+ def get_queryset(self):
+ return VMProduct.objects.filter(owner=self.request.user)
+
+ # Use a database transaction so that we do not get half-created structure
+ # if something goes wrong.
+ @transaction.atomic
+ def create(self, request):
+ # Extract serializer data.
+ serializer = VMProductSerializer(data=request.data, context={'request': request})
+ serializer.is_valid(raise_exception=True)
+ order_recurring_period = serializer.validated_data.pop("recurring_period")
+
+ # Create base order.
+ order = Order.objects.create(
+ recurring_period=order_recurring_period,
+ owner=request.user
+ )
+ order.save()
+
+ # Create VM.
+ vm = serializer.save(owner=request.user, order=order)
+
+ return Response(serializer.data)