diff --git a/uncloud_django_based/abk-hacks.py b/uncloud_django_based/abk-hacks.py
new file mode 100644
index 0000000..abc63d3
--- /dev/null
+++ b/uncloud_django_based/abk-hacks.py
@@ -0,0 +1,55 @@
+"""
+investigate into a simple python function that maps an ldap user to a vat percentage. Basically you need to
+lookup the customer address, check if she is a business/registered tax number and if not apply the local
+vat
+"""
+
+import iso3166
+import datetime
+
+from csv import DictReader
+
+
+def get_vat(street_address, city, postal_code, country, vat_number=None):
+ vat = {
+ 'Austria': [
+ {'period': '1984-01-01/', 'rate': 0.2},
+ {'period': '1976-01-01/1984-01-01', 'rate': 0.18},
+ {'period': '1973-01-01/1976-01-01', 'rate': 0.16},
+ ]
+ }
+ return iso3166.countries.get(country)
+
+ # return iso3166.countries_by_name[country]
+
+
+def main():
+ # vat = get_vat(
+ # street_address='82 Nasheman-e-Iqbal near Wapda Town',
+ # city='Lahore',
+ # postal_code=53700,
+ # country='Pakistan',
+ # )
+ # print(vat)
+ vat_rates = {}
+ with open('vat_rates.csv', newline='') as csvfile:
+ reader = DictReader(csvfile)
+ for row in reader:
+ territory_codes = row['territory_codes'].split('\n')
+ for code in territory_codes:
+ if code not in vat_rates:
+ vat_rates[code] = {}
+
+ start_date = row['start_date']
+ stop_data = row['stop_date']
+ time_period = f'{start_date}|{stop_data}'
+ r = row.copy()
+ del r['start_date']
+ del r['stop_date']
+ del r['territory_codes']
+ vat_rates[code][time_period] = r
+ print(vat_rates)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/uncloud_django_based/abkhack/opennebula_hacks.py b/uncloud_django_based/abkhack/opennebula_hacks.py
new file mode 100644
index 0000000..c0bbaf8
--- /dev/null
+++ b/uncloud_django_based/abkhack/opennebula_hacks.py
@@ -0,0 +1,46 @@
+import importlib
+import sys
+import os
+
+from os.path import join as join_path
+from xmlrpc.client import ServerProxy as RPCClient
+
+root = os.path.dirname(os.getcwd())
+sys.path.append(join_path(root, 'uncloud'))
+secrets = importlib.import_module('uncloud.secrets')
+
+
+class OpenNebula:
+ def __init__(self, url, session_string):
+ self.session_string = session_string
+ self.client = RPCClient(secrets.OPENNEBULA_URL)
+
+ def create_user(self, username, password, authentication_driver='', group_id=None):
+ # https://docs.opennebula.org/5.10/integration/system_interfaces/api.html#one-user-allocate
+
+ if group_id is None:
+ group_id = []
+
+ return self.client.one.user.allocate(
+ self.session_string,
+ username,
+ password,
+ authentication_driver,
+ group_id
+ )
+
+ def chmod(self, vm_id, user_id=-1, group_id=-1):
+ # https://docs.opennebula.org/5.10/integration/system_interfaces/api.html#one-vm-chown
+
+ return self.client.one.vm.chown(self.session_string, vm_id, user_id, group_id)
+
+
+one = OpenNebula(secrets.OPENNEBULA_URL, secrets.OPENNEBULA_USER_PASS)
+
+# Create User in OpenNebula
+# success, response, *_ = one.create_user(username='meow12345', password='hello_world')
+# print(success, response)
+
+# Change owner of a VM
+# success, response, *_ = one.chmod(vm_id=25589, user_id=706)
+# print(success, response)
diff --git a/uncloud_django_based/meow-payv1/README.md b/uncloud_django_based/meow-payv1/README.md
new file mode 100644
index 0000000..fe6a2a3
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/README.md
@@ -0,0 +1,51 @@
+# uncloud-pay
+
+The generic product/payment system.
+
+## Installation
+
+```shell script
+pip3 install -r requirements.txt
+```
+
+## Getting Started
+
+```shell script
+python ucloud_pay.py
+```
+
+## Usage
+
+#### 1. Adding of products
+```shell script
+http --json http://[::]:5000/product/add username=your_username_here password=your_password_here specs:=@ipv6-only-vm.json
+```
+
+#### 2. Listing of products
+```shell script
+http --json http://[::]:5000/product/list
+```
+
+#### 3. Registering user's payment method (credit card for now using Stripe)
+
+```shell script
+http --json http://[::]:5000/user/register_payment card_number=4111111111111111 cvc=123 expiry_year=2020 expiry_month=8 card_holder_name="The test user" username=your_username_here password=your_password_here line1="your_billing_address" city="your_city" country="your_country"
+```
+
+#### 4. Ordering products
+
+First of all, user have to buy the membership first.
+
+```shell script
+http --json http://[::]:5000/product/order username=your_username_here password=your_password_here product_id=membership pay=True
+```
+
+```shell script
+http --json http://[::]:5000/product/order username=your_username_here password=your_password_here product_id=ipv6-only-vm cpu=1 ram=1 os-disk-space=10 os=alpine pay=True
+```
+
+#### 5. Listing users orders
+
+```shell script
+http --json POST http://[::]:5000/order/list username=your_username_here password=your_password_here
+```
diff --git a/uncloud_django_based/meow-payv1/config.py b/uncloud_django_based/meow-payv1/config.py
new file mode 100644
index 0000000..16804af
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/config.py
@@ -0,0 +1,21 @@
+import os
+
+from ungleich_common.ldap.ldap_manager import LdapManager
+from ungleich_common.std.configparser import StrictConfigParser
+from ungleich_common.etcd.etcd_wrapper import EtcdWrapper
+
+config_file = os.environ.get('meow-pay-config-file', default='pay.conf')
+
+config = StrictConfigParser(allow_no_value=True)
+config.read(config_file)
+
+etcd_client = EtcdWrapper(
+ host=config.get('etcd', 'host'), port=config.get('etcd', 'port'),
+ ca_cert=config.get('etcd', 'ca_cert'), cert_key=config.get('etcd', 'cert_key'),
+ cert_cert=config.get('etcd', 'cert_cert')
+)
+
+ldap_manager = LdapManager(
+ server=config.get('ldap', 'server'), admin_dn=config.get('ldap', 'admin_dn'),
+ admin_password=config.get('ldap', 'admin_password')
+)
diff --git a/uncloud_django_based/meow-payv1/hack-a-vpn.py b/uncloud_django_based/meow-payv1/hack-a-vpn.py
new file mode 100644
index 0000000..e6bfb43
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/hack-a-vpn.py
@@ -0,0 +1,213 @@
+from flask import Flask, request
+from flask_restful import Resource, Api
+import etcd3
+import json
+import logging
+from functools import wraps
+
+from ldaptest import is_valid_ldap_user
+
+def authenticate(func):
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ if not getattr(func, 'authenticated', True):
+ return func(*args, **kwargs)
+
+ # pass in username/password !
+ acct = basic_authentication() # custom account lookup function
+
+ if acct:
+ return func(*args, **kwargs)
+
+ flask_restful.abort(401)
+ return wrapper
+
+def readable_errors(func):
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ try:
+ return func(*args, **kwargs)
+ except etcd3.exceptions.ConnectionFailedError as e:
+ raise UncloudException('Cannot connect to etcd: is etcd running and reachable? {}'.format(e))
+ except etcd3.exceptions.ConnectionTimeoutError as e:
+ raise UncloudException('etcd connection timeout. {}'.format(e))
+
+ return wrapper
+
+
+class DB(object):
+ def __init__(self, config, prefix="/"):
+ self.config = config
+
+ # Root for everything
+ self.base_prefix= '/nicohack'
+
+ # Can be set from outside
+ self.prefix = prefix
+
+ self.connect()
+
+ @readable_errors
+ def connect(self):
+ self._db_clients = []
+ for endpoint in self.config.etcd_hosts:
+ client = etcd3.client(host=endpoint, **self.config.etcd_args)
+ self._db_clients.append(client)
+
+ def realkey(self, key):
+ return "{}{}/{}".format(self.base_prefix,
+ self.prefix,
+ key)
+
+ @readable_errors
+ def get(self, key, as_json=False, **kwargs):
+ value, _ = self._db_clients[0].get(self.realkey(key), **kwargs)
+
+ if as_json:
+ value = json.loads(value)
+
+ return value
+
+
+ @readable_errors
+ def set(self, key, value, as_json=False, **kwargs):
+ if as_json:
+ value = json.dumps(value)
+
+ # FIXME: iterate over clients in case of failure ?
+ return self._db_clients[0].put(self.realkey(key), value, **kwargs)
+
+
+class Membership(Resource):
+ def __init__(self, config):
+ self.config = config
+
+ def get(self):
+ data = request.get_json(silent=True) or {}
+ print("{} {}".format(data, config))
+ return {'message': 'Order successful' }, 200
+
+ def post(self):
+ data = request.get_json(silent=True) or {}
+ print("{} {}".format(data, config))
+ return {'message': 'Order 2x successful' }, 200
+
+
+class Order(Resource):
+ def __init__(self, config):
+ self.config = config
+
+ @staticmethod
+ def post():
+ data = request.get_json(silent=True) or {}
+ print("{} {}".format(data, config))
+
+
+class Product(Resource):
+ def __init__(self, config):
+ self.config = config
+
+ self.products = []
+ self.products.append(
+ { "name": "membership-free",
+ "description": """
+This membership gives you access to the API and includes a VPN
+with 1 IPv6 address.
+See https://redmine.ungleich.ch/issues/7747?
+""",
+ "uuid": "a3883466-0012-4d01-80ff-cbf7469957af",
+ "recurring": True,
+ "recurring_time_frame": "per_year",
+ "features": [
+ { "name": "membership",
+ "price_one_time": 0,
+ "price_recurring": 0
+ }
+ ]
+ }
+ )
+ self.products.append(
+ { "name": "membership-standard",
+ "description": """
+This membership gives you access to the API and includes an IPv6-VPN with
+one IPv6 address ("Road warrior")
+See https://redmine.ungleich.ch/issues/7747?
+""",
+ "uuid": "1d85296b-0863-4dd6-a543-a6d5a4fbe4a6",
+ "recurring": True,
+ "recurring_time_frame": "per_month",
+ "features": [
+ { "name": "membership",
+ "price_one_time": 0,
+ "price_recurring": 5
+ }
+
+ ]
+ }
+ )
+ self.products.append(
+ { "name": "membership-premium",
+ "description": """
+This membership gives you access to the API and includes an
+IPv6-VPN with a /48 IPv6 network.
+See https://redmine.ungleich.ch/issues/7747?
+""",
+ "uuid": "bfd63fd2-d227-436f-a8b8-600de74dd6ce",
+ "recurring": True,
+ "recurring_time_frame": "per_month",
+ "features": [
+ { "name": "membership",
+ "price_one_time": 0,
+ "price_recurring": 5
+ }
+
+ ]
+ }
+ )
+ self.products.append(
+ { "name": "ipv6-vpn-with-/48",
+ "description": """
+An IPv6 VPN with a /48 network included.
+""",
+ "uuid": "fe5753f8-6fe1-4dc4-9b73-7b803de4c597",
+ "recurring": True,
+ "recurring_time_frame": "per_year",
+ "features": [
+ { "name": "vpn",
+ "price_one_time": 0,
+ "price_recurring": 120
+ }
+ ]
+ }
+ )
+
+
+ @staticmethod
+ def post():
+ data = request.get_json(silent=True) or {}
+ print("{} {}".format(data, config))
+
+ def get(self):
+ data = request.get_json(silent=True) or {}
+ print("{} {}".format(data, config))
+
+ return self.products
+
+
+
+
+
+if __name__ == '__main__':
+ app = Flask(__name__)
+
+ config = {}
+
+ config['etcd_url']="https://etcd1.ungleich.ch"
+ config['ldap_url']="ldaps://ldap1.ungleich.ch"
+
+ api = Api(app)
+ api.add_resource(Order, '/orders', resource_class_args=( config, ))
+ api.add_resource(Product, '/products', resource_class_args=( config, ))
+ api.add_resource(Membership, '/membership', resource_class_args=( config, ))
+
+ app.run(host='::', port=5000, debug=True)
diff --git a/uncloud_django_based/meow-payv1/helper.py b/uncloud_django_based/meow-payv1/helper.py
new file mode 100644
index 0000000..65a5155
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/helper.py
@@ -0,0 +1,87 @@
+import logging
+
+import parsedatetime
+
+from datetime import datetime
+from stripe_utils import StripeUtils
+
+
+def get_plan_id_from_product(product):
+ plan_id = 'ucloud-v1-'
+ plan_id += product['name'].strip().replace(' ', '-')
+ return plan_id
+
+
+def get_pricing(price_in_chf_cents, product_type, recurring_period):
+ if product_type == 'recurring':
+ return 'CHF {}/{}'.format(price_in_chf_cents/100, recurring_period)
+ elif product_type == 'one-time':
+ return 'CHF {} (One time charge)'.format(price_in_chf_cents/100)
+
+
+def get_user_friendly_product(product_dict):
+ uf_product = {
+ 'name': product_dict['name'],
+ 'description': product_dict['description'],
+ 'product_id': product_dict['usable-id'],
+ 'pricing': get_pricing(
+ product_dict['price'], product_dict['type'], product_dict['recurring_period']
+ )
+ }
+ if product_dict['type'] == 'recurring':
+ uf_product['minimum_subscription_period'] = product_dict['minimum_subscription_period']
+ return uf_product
+
+
+def get_token(card_number, cvc, exp_month, exp_year):
+ stripe_utils = StripeUtils()
+ token_response = stripe_utils.get_token_from_card(
+ card_number, cvc, exp_month, exp_year
+ )
+ if token_response['response_object']:
+ return token_response['response_object'].id
+ else:
+ return None
+
+
+def resolve_product(usable_id, etcd_client):
+ products = etcd_client.get_prefix('/v1/products/', value_in_json=True)
+ for p in products:
+ if p.value['usable-id'] == usable_id:
+ return p.value
+ return None
+
+
+def calculate_charges(specification, data):
+ logging.debug('Calculating charges for specs:{} and data:{}'.format(specification, data))
+ one_time_charge = 0
+ recurring_charge = 0
+ for feature_name, feature_detail in specification['features'].items():
+ if feature_detail['constant']:
+ data[feature_name] = 1
+
+ if feature_detail['unit']['type'] != 'str':
+ one_time_charge += feature_detail['one_time_fee']
+ recurring_charge += (
+ feature_detail['price_per_unit_per_period'] * data[feature_name] /
+ feature_detail['unit']['value']
+ )
+ return one_time_charge, recurring_charge
+
+
+def is_order_valid(order_timestamp, renewal_period):
+ """
+ Sample Code Usage
+
+ >> current_datetime, status = cal.parse('Now')
+ >> current_datetime = datetime(*current_datetime[:6])
+
+ >> print('Is order valid: ', is_order_valid(current_datetime, '1 month'))
+ >> True
+ """
+ cal = parsedatetime.Calendar()
+
+ renewal_datetime, status = cal.parse(renewal_period)
+ renewal_datetime = datetime(*renewal_datetime[:6])
+
+ return order_timestamp <= renewal_datetime
diff --git a/uncloud_django_based/meow-payv1/ldaptest.py b/uncloud_django_based/meow-payv1/ldaptest.py
new file mode 100644
index 0000000..eb5a5be
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/ldaptest.py
@@ -0,0 +1,27 @@
+import ldap3
+from ldap3 import Server, Connection, ObjectDef, Reader, ALL
+import os
+import sys
+
+def is_valid_ldap_user(username, password):
+ server = Server("ldaps://ldap1.ungleich.ch")
+ is_valid = False
+
+ try:
+ conn = Connection(server, 'cn={},ou=users,dc=ungleich,dc=ch'.format(username), password, auto_bind=True)
+ is_valid = True
+ except Exception as e:
+ print("user: {}".format(e))
+
+ try:
+ conn = Connection(server, 'uid={},ou=customer,dc=ungleich,dc=ch'.format(username), password, auto_bind=True)
+ is_valid = True
+ except Exception as e:
+ print("customer: {}".format(e))
+
+
+ return is_valid
+
+
+if __name__ == '__main__':
+ print(is_valid_ldap_user(sys.argv[1], sys.argv[2]))
diff --git a/uncloud_django_based/meow-payv1/products/ipv6-only-django.json b/uncloud_django_based/meow-payv1/products/ipv6-only-django.json
new file mode 100644
index 0000000..110027a
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/products/ipv6-only-django.json
@@ -0,0 +1,28 @@
+{
+ "usable-id": "ipv6-only-django-hosting",
+ "active": true,
+ "name": "IPv6 Only Django Hosting",
+ "description": "Host your Django application on our shiny IPv6 Only VM",
+ "recurring_period": "month",
+ "quantity": "inf",
+ "features": {
+ "cpu": {
+ "unit": {"value": 1, "type":"int"},
+ "price_per_unit_per_period": 3,
+ "one_time_fee": 0,
+ "constant": false
+ },
+ "ram": {
+ "unit": {"value": 1, "type":"int"},
+ "price_per_unit_per_period": 4,
+ "one_time_fee": 0,
+ "constant": false
+ },
+ "os-disk-space": {
+ "unit": {"value": 10, "type":"int"},
+ "one_time_fee": 0,
+ "price_per_unit_per_period": 3.5,
+ "constant": false
+ }
+ }
+}
diff --git a/uncloud_django_based/meow-payv1/products/ipv6-only-vm.json b/uncloud_django_based/meow-payv1/products/ipv6-only-vm.json
new file mode 100644
index 0000000..d07ad6c
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/products/ipv6-only-vm.json
@@ -0,0 +1,34 @@
+{
+ "usable-id": "ipv6-only-vm",
+ "active": true,
+ "name": "IPv6 Only VM",
+ "description": "IPv6 Only VM are accessible to only those having IPv6 for themselves",
+ "recurring_period": "month",
+ "quantity": "inf",
+ "features": {
+ "cpu": {
+ "unit": {"value": 1, "type":"int"},
+ "price_per_unit_per_period": 3,
+ "one_time_fee": 0,
+ "constant": false
+ },
+ "ram": {
+ "unit": {"value": 1, "type":"int"},
+ "price_per_unit_per_period": 4,
+ "one_time_fee": 0,
+ "constant": false
+ },
+ "os-disk-space": {
+ "unit": {"value": 10, "type":"int"},
+ "one_time_fee": 0,
+ "price_per_unit_per_period": 4,
+ "constant": false
+ },
+ "os": {
+ "unit": {"value": 1, "type":"str"},
+ "one_time_fee": 0,
+ "price_per_unit_per_period": 0,
+ "constant": false
+ }
+ }
+}
diff --git a/uncloud_django_based/meow-payv1/products/ipv6-only-vpn.json b/uncloud_django_based/meow-payv1/products/ipv6-only-vpn.json
new file mode 100644
index 0000000..38c6201
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/products/ipv6-only-vpn.json
@@ -0,0 +1,16 @@
+{
+ "usable-id": "ipv6-only-vpn",
+ "active": true,
+ "name": "IPv6 Only VPN",
+ "description": "IPv6 VPN enable you to access IPv6 only websites and more",
+ "recurring_period": "month",
+ "quantity": "inf",
+ "features": {
+ "vpn": {
+ "unit": {"value": 1, "type": "int"},
+ "price_per_unit_per_period": 10,
+ "one_time_fee": 0,
+ "constant": true
+ }
+ }
+}
diff --git a/uncloud_django_based/meow-payv1/products/ipv6box.json b/uncloud_django_based/meow-payv1/products/ipv6box.json
new file mode 100644
index 0000000..eca11f0
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/products/ipv6box.json
@@ -0,0 +1,16 @@
+{
+ "usable-id": "ipv6-box",
+ "active": true,
+ "name": "IPv6 Box",
+ "description": "A ready-to-go IPv6 Box: it creates a VPN to ungleich and distributes IPv6 addresses to all your computers.",
+ "recurring_period": "eternity",
+ "quantity": 4,
+ "features": {
+ "ipv6-box": {
+ "unit": {"value": 1, "type":"int"},
+ "price_per_unit_per_period": 0,
+ "one_time_fee": 250,
+ "constant": true
+ }
+ }
+}
diff --git a/uncloud_django_based/meow-payv1/products/membership.json b/uncloud_django_based/meow-payv1/products/membership.json
new file mode 100644
index 0000000..4003330
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/products/membership.json
@@ -0,0 +1,17 @@
+{
+ "usable-id": "membership",
+ "active": true,
+ "name": "Membership",
+ "description": "Membership to use uncloud-pay",
+ "recurring_period": "month",
+ "quantity": "inf",
+ "features": {
+ "membership": {
+ "unit": {"value": 1, "type":"int"},
+ "price_per_unit_per_period": 5,
+ "one_time_fee": 0,
+ "constant": true
+ }
+ },
+ "max_per_user": "1"
+}
diff --git a/uncloud_django_based/meow-payv1/requirements.txt b/uncloud_django_based/meow-payv1/requirements.txt
new file mode 100644
index 0000000..0b758ca
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/requirements.txt
@@ -0,0 +1,7 @@
+stripe
+flask
+Flask-RESTful
+git+https://code.ungleich.ch/ahmedbilal/ungleich-common/#egg=ungleich-common-etcd&subdirectory=etcd
+git+https://code.ungleich.ch/ahmedbilal/ungleich-common/#egg=ungleich-common-ldap&subdirectory=ldap
+git+https://code.ungleich.ch/ahmedbilal/ungleich-common/#egg=ungleich-common-std&subdirectory=std
+git+https://code.ungleich.ch/ahmedbilal/ungleich-common/#egg=ungleich-common-schemas&subdirectory=schemas
diff --git a/uncloud_django_based/meow-payv1/sample-pay.conf b/uncloud_django_based/meow-payv1/sample-pay.conf
new file mode 100644
index 0000000..5d1fe61
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/sample-pay.conf
@@ -0,0 +1,17 @@
+[etcd]
+host = 127.0.0.1
+port = 2379
+ca_cert
+cert_cert
+cert_key
+
+[stripe]
+private_key=stripe_private_key
+
+[app]
+port = 5000
+
+[ldap]
+server = ldap_server_url
+admin_dn = ldap_admin_dn
+admin_password = ldap_admin_password
diff --git a/uncloud_django_based/meow-payv1/schemas.py b/uncloud_django_based/meow-payv1/schemas.py
new file mode 100644
index 0000000..2e3aef7
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/schemas.py
@@ -0,0 +1,136 @@
+import logging
+import config
+import json
+import math
+
+from config import ldap_manager, etcd_client
+from helper import resolve_product
+from ungleich_common.schemas.schemas import BaseSchema, Field, ValidationException
+
+
+class AddProductSchema(BaseSchema):
+ def __init__(self, data):
+ super().__init__()
+ self.add_schema(UserCredentialSchema, data)
+ self.specs = Field('specs', dict, **self.get(data, 'specs'))
+ self.update = Field('update', bool, **self.get(data, 'update', return_default=True, default=False))
+
+ def validation(self):
+ user = self.objects['user']
+ user = json.loads(user.entry_to_json())
+ uid, ou, *dc = user['dn'].replace('ou=', '').replace('dc=', '').replace('uid=', '').split(',')
+ if ou != config.config.get('ldap', 'internal_user_ou', fallback='users'):
+ raise ValidationException('You do not have access to create product.')
+
+ product = resolve_product(self.specs.value['usable-id'], etcd_client)
+ if product:
+ self.objects['product'] = product
+
+
+class AddressSchema(BaseSchema):
+ def __init__(self, data):
+ super().__init__()
+ self.line1 = Field('line1', str, **self.get(data, 'line1'))
+ self.line2 = Field('line2', str, **self.get(data, 'line2', return_default=True))
+ self.city = Field('city', str, **self.get(data, 'city'))
+ self.country = Field('country', str, **self.get(data, 'country'))
+ self.state = Field('state', str, **self.get(data, 'state', return_default=True))
+ self.postal_code = Field('postal_code', str, **self.get(data, 'postal_code', return_default=True))
+
+
+class UserRegisterPaymentSchema(BaseSchema):
+ def __init__(self, data):
+ super().__init__()
+
+ self.add_schema(UserCredentialSchema, data)
+ self.add_schema(AddressSchema, data, under_field_name='address')
+
+ self.card_number = Field('card_number', str, **self.get(data, 'card_number'))
+ self.cvc = Field('cvc', str, **self.get(data, 'cvc'))
+ self.expiry_year = Field('expiry_year', int, **self.get(data, 'expiry_year'))
+ self.expiry_month = Field('expiry_month', int, **self.get(data, 'expiry_month'))
+ self.card_holder_name = Field('card_holder_name', str, **self.get(data, 'card_holder_name'))
+
+
+class UserCredentialSchema(BaseSchema):
+ def __init__(self, data):
+ super().__init__()
+ self.username = Field('username', str, **self.get(data, 'username'))
+ self.password = Field('password', str, **self.get(data, 'password'))
+
+ def validation(self):
+ try:
+ entry = ldap_manager.is_password_valid(self.username.value, self.password.value, query_key='uid')
+ except ValueError:
+ raise ValidationException('No user with \'{}\' username found. You can create account at '
+ 'https://account.ungleich.ch'.format(self.username.value))
+ except Exception:
+ raise ValidationException('Invalid username/password.')
+ else:
+ self.objects['user'] = entry
+
+
+class ProductOrderSchema(BaseSchema):
+ def __init__(self, data):
+ super().__init__()
+ self.product_id = Field(
+ 'product_id', str, **self.get(data, 'product_id'), validators=[self.product_id_validation]
+ )
+ self.pay_consent = Field('pay', bool, **self.get(data, 'pay', return_default=True, default=False))
+ self.add_schema(UserCredentialSchema, data)
+
+ def product_id_validation(self):
+ product = resolve_product(self.product_id.value, etcd_client)
+ if product:
+ product['quantity'] = float(product['quantity'])
+ self.product_id.value = product['uuid']
+ self.objects['product'] = product
+ logging.debug('Got product {}'.format(product))
+
+ if not product['active']:
+ raise ValidationException('Product is not active at the moment.')
+
+ if product['quantity'] <= 0:
+ raise ValidationException('Out of stock.')
+ else:
+ raise ValidationException('No such product exists.')
+
+ def validation(self):
+ username = self.objects['user'].uid
+ customer_previous_orders = etcd_client.get_prefix('/v1/user/{}'.format(username), value_in_json=True)
+ customer_previous_orders = [o.value for o in customer_previous_orders]
+ membership = next(filter(lambda o: o['product'] == 'membership', customer_previous_orders), None)
+ if membership is None and self.objects['product']['usable-id'] != 'membership':
+ raise ValidationException('Please buy membership first to use this facility')
+ max_quantity_user_can_order = float(self.objects['product'].get('max_per_user', math.inf))
+ previous_order_of_same_product = [
+ o for o in customer_previous_orders if o['product'] == self.objects['product']['usable-id']
+ ]
+ if len(previous_order_of_same_product) >= max_quantity_user_can_order:
+ raise ValidationException(
+ 'You cannot buy {} more than {} times'.format(
+ self.objects['product']['name'], int(max_quantity_user_can_order)
+ )
+ )
+
+
+class OrderListSchema(BaseSchema):
+ def __init__(self, data):
+ super().__init__()
+ self.add_schema(UserCredentialSchema, data)
+
+
+def make_return_message(err, status_code=200):
+ logging.debug('message: {}'.format(str(err)))
+ return {'message': str(err)}, status_code
+
+
+def create_schema(specification, data):
+ fields = {}
+ for feature_name, feature_detail in specification['features'].items():
+ if not feature_detail['constant']:
+ fields[feature_name] = Field(
+ feature_name, eval(feature_detail['unit']['type']), **BaseSchema.get(data, feature_name)
+ )
+
+ return type('{}Schema'.format(specification['name']), (BaseSchema,), fields)
diff --git a/uncloud_django_based/meow-payv1/stripe_hack.py b/uncloud_django_based/meow-payv1/stripe_hack.py
new file mode 100644
index 0000000..f436c62
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/stripe_hack.py
@@ -0,0 +1,7 @@
+import stripe_utils
+import os
+
+
+if __name__ == '__main__':
+ s = stripe_utils.StripeUtils(os.environ['STRIPE_PRIVATE_KEY'])
+ print(s.get_stripe_customer_from_email('coder.purple+2002@gmail.com'))
diff --git a/uncloud_django_based/meow-payv1/stripe_utils.py b/uncloud_django_based/meow-payv1/stripe_utils.py
new file mode 100644
index 0000000..6a2cd29
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/stripe_utils.py
@@ -0,0 +1,491 @@
+import re
+import stripe
+import stripe.error
+import logging
+
+from config import etcd_client as client, config as config
+
+stripe.api_key = config.get('stripe', 'private_key')
+
+
+def handle_stripe_error(f):
+ def handle_problems(*args, **kwargs):
+ response = {
+ 'paid': False,
+ 'response_object': None,
+ 'error': None
+ }
+
+ common_message = "Currently it's not possible to make payments."
+ try:
+ response_object = f(*args, **kwargs)
+ response = {
+ 'response_object': response_object,
+ 'error': None
+ }
+ return response
+ except stripe.error.CardError as e:
+ # Since it's a decline, stripe.error.CardError will be caught
+ body = e.json_body
+ err = body['error']
+ response.update({'error': err['message']})
+ logging.error(str(e))
+ return response
+ except stripe.error.RateLimitError:
+ response.update(
+ {'error': "Too many requests made to the API too quickly"})
+ return response
+ except stripe.error.InvalidRequestError as e:
+ logging.error(str(e))
+ response.update({'error': "Invalid parameters"})
+ return response
+ except stripe.error.AuthenticationError as e:
+ # Authentication with Stripe's API failed
+ # (maybe you changed API keys recently)
+ logging.error(str(e))
+ response.update({'error': common_message})
+ return response
+ except stripe.error.APIConnectionError as e:
+ logging.error(str(e))
+ response.update({'error': common_message})
+ return response
+ except stripe.error.StripeError as e:
+ # maybe send email
+ logging.error(str(e))
+ response.update({'error': common_message})
+ return response
+ except Exception as e:
+ # maybe send email
+ logging.error(str(e))
+ response.update({'error': common_message})
+ return response
+
+ return handle_problems
+
+
+class StripeUtils(object):
+ CURRENCY = 'chf'
+ INTERVAL = 'month'
+ SUCCEEDED_STATUS = 'succeeded'
+ STRIPE_PLAN_ALREADY_EXISTS = 'Plan already exists'
+ STRIPE_NO_SUCH_PLAN = 'No such plan'
+ PLAN_EXISTS_ERROR_MSG = 'Plan {} exists already.\nCreating a local StripePlan now.'
+ PLAN_DOES_NOT_EXIST_ERROR_MSG = 'Plan {} does not exist.'
+
+ def __init__(self, private_key):
+ self.stripe = stripe
+ stripe.api_key = private_key
+
+ @handle_stripe_error
+ def card_exists(self, customer, cc_number, exp_month, exp_year, cvc):
+ token_obj = stripe.Token.create(
+ card={
+ 'number': cc_number,
+ 'exp_month': exp_month,
+ 'exp_year': exp_year,
+ 'cvc': cvc,
+ },
+ )
+ cards = stripe.Customer.list_sources(
+ customer,
+ limit=20,
+ object='card'
+ )
+
+ for card in cards.data:
+ if (card.fingerprint == token_obj.card.fingerprint and
+ int(card.exp_month) == int(exp_month) and int(card.exp_year) == int(exp_year)):
+ return True
+ return False
+
+ @staticmethod
+ def get_stripe_customer_from_email(email):
+ customer = stripe.Customer.list(limit=1, email=email)
+ return customer.data[0] if len(customer.data) == 1 else None
+
+ @staticmethod
+ def update_customer_token(customer, token):
+ customer.source = token
+ customer.save()
+
+ @handle_stripe_error
+ def get_token_from_card(self, cc_number, cvc, expiry_month, expiry_year):
+ token_obj = stripe.Token.create(
+ card={
+ 'number': cc_number,
+ 'exp_month': expiry_month,
+ 'exp_year': expiry_year,
+ 'cvc': cvc,
+ },
+ )
+ return token_obj
+
+ @handle_stripe_error
+ def associate_customer_card(self, stripe_customer_id, token,
+ set_as_default=False):
+ customer = stripe.Customer.retrieve(stripe_customer_id)
+ card = customer.sources.create(source=token)
+ if set_as_default:
+ customer.default_source = card.id
+ customer.save()
+ return True
+
+ @handle_stripe_error
+ def dissociate_customer_card(self, stripe_customer_id, card_id):
+ customer = stripe.Customer.retrieve(stripe_customer_id)
+ card = customer.sources.retrieve(card_id)
+ card.delete()
+
+ @handle_stripe_error
+ def update_customer_card(self, customer_id, token):
+ customer = stripe.Customer.retrieve(customer_id)
+ current_card_token = customer.default_source
+ customer.sources.retrieve(current_card_token).delete()
+ customer.source = token
+ customer.save()
+ credit_card_raw_data = customer.sources.data.pop()
+ new_card_data = {
+ 'last4': credit_card_raw_data.last4,
+ 'brand': credit_card_raw_data.brand
+ }
+ return new_card_data
+
+ @handle_stripe_error
+ def get_card_details(self, customer_id):
+ customer = stripe.Customer.retrieve(customer_id)
+ credit_card_raw_data = customer.sources.data.pop()
+ card_details = {
+ 'last4': credit_card_raw_data.last4,
+ 'brand': credit_card_raw_data.brand,
+ 'exp_month': credit_card_raw_data.exp_month,
+ 'exp_year': credit_card_raw_data.exp_year,
+ 'fingerprint': credit_card_raw_data.fingerprint,
+ 'card_id': credit_card_raw_data.id
+ }
+ return card_details
+
+ @handle_stripe_error
+ def get_all_invoices(self, customer_id, created_gt):
+ return_list = []
+ has_more_invoices = True
+ starting_after = False
+ while has_more_invoices:
+ if starting_after:
+ invoices = stripe.Invoice.list(
+ limit=10, customer=customer_id, created={'gt': created_gt},
+ starting_after=starting_after
+ )
+ else:
+ invoices = stripe.Invoice.list(
+ limit=10, customer=customer_id, created={'gt': created_gt}
+ )
+ has_more_invoices = invoices.has_more
+ for invoice in invoices.data:
+ sub_ids = []
+ for line in invoice.lines.data:
+ if line.type == 'subscription':
+ sub_ids.append(line.id)
+ elif line.type == 'invoiceitem':
+ sub_ids.append(line.subscription)
+ else:
+ sub_ids.append('')
+ invoice_details = {
+ 'created': invoice.created,
+ 'receipt_number': invoice.receipt_number,
+ 'invoice_number': invoice.number,
+ 'paid_at': invoice.status_transitions.paid_at if invoice.paid else 0,
+ 'period_start': invoice.period_start,
+ 'period_end': invoice.period_end,
+ 'billing_reason': invoice.billing_reason,
+ 'discount': invoice.discount.coupon.amount_off if invoice.discount else 0,
+ 'total': invoice.total,
+ # to see how many line items we have in this invoice and
+ # then later check if we have more than 1
+ 'lines_data_count': len(invoice.lines.data) if invoice.lines.data is not None else 0,
+ 'invoice_id': invoice.id,
+ 'lines_meta_data_csv': ','.join(
+ [line.metadata.VM_ID if hasattr(line.metadata, 'VM_ID') else '' for line in invoice.lines.data]
+ ),
+ 'subscription_ids_csv': ','.join(sub_ids),
+ 'line_items': invoice.lines.data
+ }
+ starting_after = invoice.id
+ return_list.append(invoice_details)
+ return return_list
+
+ @handle_stripe_error
+ def get_cards_details_from_token(self, token):
+ stripe_token = stripe.Token.retrieve(token)
+ card_details = {
+ 'last4': stripe_token.card.last4,
+ 'brand': stripe_token.card.brand,
+ 'exp_month': stripe_token.card.exp_month,
+ 'exp_year': stripe_token.card.exp_year,
+ 'fingerprint': stripe_token.card.fingerprint,
+ 'card_id': stripe_token.card.id
+ }
+ return card_details
+
+ def check_customer(self, stripe_cus_api_id, user, token):
+ try:
+ customer = stripe.Customer.retrieve(stripe_cus_api_id)
+ except stripe.error.InvalidRequestError:
+ customer = self.create_customer(token, user.email, user.name)
+ user.stripecustomer.stripe_id = customer.get(
+ 'response_object').get('id')
+ user.stripecustomer.save()
+ if type(customer) is dict:
+ customer = customer['response_object']
+ return customer
+
+ @handle_stripe_error
+ def get_customer(self, stripe_api_cus_id):
+ customer = stripe.Customer.retrieve(stripe_api_cus_id)
+ # data = customer.get('response_object')
+ return customer
+
+ @handle_stripe_error
+ def create_customer(self, token, email, name=None, address=None):
+ if name is None or name.strip() == "":
+ name = email
+ customer = self.stripe.Customer.create(
+ source=token,
+ description=name,
+ email=email,
+ address=address
+ )
+ return customer
+
+ @handle_stripe_error
+ def make_charge(self, amount=None, customer=None):
+ _amount = float(amount)
+ amount = int(_amount * 100) # stripe amount unit, in cents
+ charge = self.stripe.Charge.create(
+ amount=amount, # in cents
+ currency=self.CURRENCY,
+ customer=customer
+ )
+ return charge
+
+ @staticmethod
+ def _get_all_stripe_plans():
+ all_stripe_plans = client.get("/v1/stripe_plans")
+ all_stripe_plans_set = set()
+ if all_stripe_plans:
+ all_stripe_plans_obj = all_stripe_plans.value
+ if all_stripe_plans_obj and len(all_stripe_plans_obj['plans']) > 0:
+ all_stripe_plans_set = set(all_stripe_plans_obj["plans"])
+ return all_stripe_plans_set
+
+ @staticmethod
+ def _save_all_stripe_plans(stripe_plans):
+ client.put("/v1/stripe_plans", {"plans": list(stripe_plans)})
+
+ @handle_stripe_error
+ def get_or_create_stripe_plan(self, product_name, amount, stripe_plan_id,
+ interval=INTERVAL):
+ """
+ This function checks if a StripePlan with the given
+ stripe_plan_id already exists. If it exists then the function
+ returns this object otherwise it creates a new StripePlan and
+ returns the new object.
+
+ :param amount: The amount in CHF cents
+ :param product_name: The name of the Stripe plan (product) to be created.
+ :param stripe_plan_id: The id of the Stripe plan to be
+ created. Use get_stripe_plan_id_string function to
+ obtain the name of the plan to be created
+ :param interval: The interval for subscription {month, year}. Defaults
+ to month if not provided
+ :return: The StripePlan object if it exists else creates a
+ Plan object in Stripe and a local StripePlan and
+ returns it. Returns None in case of Stripe error
+ """
+ _amount = float(amount)
+ amount = int(_amount * 100) # stripe amount unit, in cents
+ all_stripe_plans = self._get_all_stripe_plans()
+ if stripe_plan_id in all_stripe_plans:
+ logging.debug("{} plan exists in db.".format(stripe_plan_id))
+ else:
+ logging.debug(("{} plan DOES NOT exist in db. "
+ "Creating").format(stripe_plan_id))
+ try:
+ plan_obj = self.stripe.Plan.retrieve(id=stripe_plan_id)
+ logging.debug("{} plan exists in Stripe".format(stripe_plan_id))
+ all_stripe_plans.add(stripe_plan_id)
+ except stripe.error.InvalidRequestError as e:
+ if "No such plan" in str(e):
+ logging.debug("Plan {} does not exist in Stripe, Creating")
+ plan_obj = self.stripe.Plan.create(
+ amount=amount,
+ product={'name': product_name},
+ interval=interval,
+ currency=self.CURRENCY,
+ id=stripe_plan_id)
+ logging.debug(self.PLAN_EXISTS_ERROR_MSG.format(stripe_plan_id))
+ all_stripe_plans.add(stripe_plan_id)
+ self._save_all_stripe_plans(all_stripe_plans)
+ return stripe_plan_id
+
+ @handle_stripe_error
+ def delete_stripe_plan(self, stripe_plan_id):
+ """
+ Deletes the Plan in Stripe and also deletes the local db copy
+ of the plan if it exists
+
+ :param stripe_plan_id: The stripe plan id that needs to be
+ deleted
+ :return: True if the plan was deleted successfully from
+ Stripe, False otherwise.
+ """
+ return_value = False
+ try:
+ plan = self.stripe.Plan.retrieve(stripe_plan_id)
+ plan.delete()
+ return_value = True
+ all_stripe_plans = self._get_all_stripe_plans()
+ all_stripe_plans.remove(stripe_plan_id)
+ self._save_all_stripe_plans(all_stripe_plans)
+ except stripe.error.InvalidRequestError as e:
+ if self.STRIPE_NO_SUCH_PLAN in str(e):
+ logging.debug(
+ self.PLAN_DOES_NOT_EXIST_ERROR_MSG.format(stripe_plan_id))
+ return return_value
+
+ @handle_stripe_error
+ def subscribe_customer_to_plan(self, customer, plans, trial_end=None):
+ """
+ Subscribes the given customer to the list of given plans
+
+ :param customer: The stripe customer identifier
+ :param plans: A list of stripe plans.
+ :param trial_end: An integer representing when the Stripe subscription
+ is supposed to end
+ Ref: https://stripe.com/docs/api/python#create_subscription-items
+ e.g.
+ plans = [
+ {
+ "plan": "dcl-v1-cpu-2-ram-5gb-ssd-10gb",
+ },
+ ]
+ :return: The subscription StripeObject
+ """
+
+ subscription_result = self.stripe.Subscription.create(
+ customer=customer, items=plans, trial_end=trial_end
+ )
+ return subscription_result
+
+ @handle_stripe_error
+ def set_subscription_metadata(self, subscription_id, metadata):
+ subscription = stripe.Subscription.retrieve(subscription_id)
+ subscription.metadata = metadata
+ subscription.save()
+
+ @handle_stripe_error
+ def unsubscribe_customer(self, subscription_id):
+ """
+ Cancels a given subscription
+
+ :param subscription_id: The Stripe subscription id string
+ :return:
+ """
+ sub = stripe.Subscription.retrieve(subscription_id)
+ return sub.delete()
+
+ @handle_stripe_error
+ def make_payment(self, customer, amount, token):
+ charge = self.stripe.Charge.create(
+ amount=amount, # in cents
+ currency=self.CURRENCY,
+ customer=customer
+ )
+ return charge
+
+ @staticmethod
+ def get_stripe_plan_id(cpu, ram, ssd, version, app='dcl', hdd=None,
+ price=None):
+ """
+ Returns the Stripe plan id string of the form
+ `dcl-v1-cpu-2-ram-5gb-ssd-10gb` based on the input parameters
+
+ :param cpu: The number of cores
+ :param ram: The size of the RAM in GB
+ :param ssd: The size of ssd storage in GB
+ :param hdd: The size of hdd storage in GB
+ :param version: The version of the Stripe plans
+ :param app: The application to which the stripe plan belongs
+ to. By default it is 'dcl'
+ :param price: The price for this plan
+ :return: A string of the form `dcl-v1-cpu-2-ram-5gb-ssd-10gb`
+ """
+ dcl_plan_string = 'cpu-{cpu}-ram-{ram}gb-ssd-{ssd}gb'.format(cpu=cpu,
+ ram=ram,
+ ssd=ssd)
+ if hdd is not None:
+ dcl_plan_string = '{dcl_plan_string}-hdd-{hdd}gb'.format(
+ dcl_plan_string=dcl_plan_string, hdd=hdd)
+ stripe_plan_id_string = '{app}-v{version}-{plan}'.format(
+ app=app,
+ version=version,
+ plan=dcl_plan_string
+ )
+ if price is not None:
+ stripe_plan_id_string_with_price = '{}-{}chf'.format(
+ stripe_plan_id_string,
+ round(price, 2)
+ )
+ return stripe_plan_id_string_with_price
+ else:
+ return stripe_plan_id_string
+
+ @staticmethod
+ def get_vm_config_from_stripe_id(stripe_id):
+ """
+ Given a string like "dcl-v1-cpu-2-ram-5gb-ssd-10gb" return different
+ configuration params as a dict
+
+ :param stripe_id|str
+ :return: dict
+ """
+ pattern = re.compile(r'^dcl-v(\d+)-cpu-(\d+)-ram-(\d+\.?\d*)gb-ssd-(\d+)gb-?(\d*\.?\d*)(chf)?$')
+ match_res = pattern.match(stripe_id)
+ if match_res is not None:
+ price = None
+ try:
+ price = match_res.group(5)
+ except IndexError:
+ logging.debug("Did not find price in {}".format(stripe_id))
+ return {
+ 'version': match_res.group(1),
+ 'cores': match_res.group(2),
+ 'ram': match_res.group(3),
+ 'ssd': match_res.group(4),
+ 'price': price
+ }
+
+ @staticmethod
+ def get_stripe_plan_name(cpu, memory, disk_size, price):
+ """
+ Returns the Stripe plan name
+ :return:
+ """
+ return "{cpu} Cores, {memory} GB RAM, {disk_size} GB SSD, " \
+ "{price} CHF".format(
+ cpu=cpu,
+ memory=memory,
+ disk_size=disk_size,
+ price=round(price, 2)
+ )
+
+ @handle_stripe_error
+ def set_subscription_meta_data(self, subscription_id, meta_data):
+ """
+ Adds VM metadata to a subscription
+ :param subscription_id: Stripe identifier for the subscription
+ :param meta_data: A dict of meta data to be added
+ :return:
+ """
+ subscription = stripe.Subscription.retrieve(subscription_id)
+ subscription.metadata = meta_data
+ subscription.save()
diff --git a/uncloud_django_based/meow-payv1/ucloud_pay.py b/uncloud_django_based/meow-payv1/ucloud_pay.py
new file mode 100644
index 0000000..dbc0d2c
--- /dev/null
+++ b/uncloud_django_based/meow-payv1/ucloud_pay.py
@@ -0,0 +1,338 @@
+import logging
+
+from datetime import datetime
+from uuid import uuid4
+
+from flask import Flask, request
+from flask_restful import Resource, Api
+from werkzeug.exceptions import HTTPException
+from config import etcd_client as client, config as config
+from stripe_utils import StripeUtils
+from schemas import (
+ make_return_message, ValidationException, UserRegisterPaymentSchema,
+ AddProductSchema, ProductOrderSchema, OrderListSchema, create_schema
+)
+from helper import get_plan_id_from_product, calculate_charges
+
+
+class ListProducts(Resource):
+ @staticmethod
+ def get():
+ products = client.get_prefix('/v1/products/')
+ products = [
+ product
+ for product in [p.value for p in products]
+ if product['active']
+ ]
+ prod_dict = {}
+ for p in products:
+ prod_dict[p['usable-id']] = {
+ 'name': p['name'],
+ 'description': p['description'],
+ }
+ logger.debug('Products = {}'.format(prod_dict))
+ return prod_dict, 200
+
+class AddProduct(Resource):
+ @staticmethod
+ def post():
+ data = request.get_json(silent=True) or {}
+
+ try:
+ logger.debug('Got data: {}'.format(str(data)))
+ validator = AddProductSchema(data)
+ validator.is_valid()
+ except ValidationException as err:
+ return make_return_message(err, 400)
+ else:
+ cleaned_values = validator.get_cleaned_values()
+ previous_product = cleaned_values.get('product', None)
+ if previous_product:
+ if not cleaned_values['update']:
+ return make_return_message('Product already exists. Pass --update to update the product.')
+ else:
+ product_uuid = previous_product.pop('uuid')
+ else:
+ product_uuid = uuid4().hex
+
+ product_value = cleaned_values['specs']
+
+ product_key = '/v1/products/{}'.format(product_uuid)
+ product_value['uuid'] = product_uuid
+
+ logger.debug('Adding product data: {}'.format(str(product_value)))
+ client.put(product_key, product_value)
+ if not previous_product:
+ return make_return_message('Product created.')
+ else:
+ return make_return_message('Product updated.')
+
+################################################################################
+# Nico-ok-marker
+
+
+class UserRegisterPayment(Resource):
+ @staticmethod
+ def post():
+ data = request.get_json(silent=True) or {}
+
+ try:
+ logger.debug('Got data: {}'.format(str(data)))
+ validator = UserRegisterPaymentSchema(data)
+ validator.is_valid()
+ except ValidationException as err:
+ return make_return_message(err, 400)
+ else:
+ cleaned_values = validator.get_cleaned_values()
+ last4 = data['card_number'].strip()[-4:]
+
+ stripe_utils = StripeUtils()
+
+ # Does customer already exist ?
+ stripe_customer = stripe_utils.get_stripe_customer_from_email(cleaned_values['user']['mail'])
+
+ # Does customer already exist ?
+ if stripe_customer is not None:
+ logger.debug('Customer {}-{} exists already'.format(
+ cleaned_values['username'], cleaned_values['user']['mail'])
+ )
+
+ # Check if the card already exists
+ ce_response = stripe_utils.card_exists(
+ stripe_customer.id, cc_number=data['card_number'],
+ exp_month=int(data['expiry_month']),
+ exp_year=int(data['expiry_year']),
+ cvc=data['cvc'])
+
+ if ce_response['response_object']:
+ message = 'The given card ending in {} exists already.'.format(last4)
+ return make_return_message(message, 400)
+
+ elif ce_response['response_object'] is False:
+ # Associate card with user
+ logger.debug('Adding card ending in {}'.format(last4))
+ token_response = stripe_utils.get_token_from_card(
+ data['card_number'], data['cvc'], data['expiry_month'],
+ data['expiry_year']
+ )
+ if token_response['response_object']:
+ logger.debug('Token {}'.format(token_response['response_object'].id))
+ resp = stripe_utils.associate_customer_card(
+ stripe_customer.id, token_response['response_object'].id
+ )
+ if resp['response_object']:
+ return make_return_message(
+ 'Card ending in {} registered as your payment source'.format(last4)
+ )
+ else:
+ return make_return_message('Error with payment gateway. Contact support', 400)
+ else:
+ return make_return_message('Error: {}'.format(ce_response['error']), 400)
+ else:
+ # Stripe customer does not exist, create a new one
+ logger.debug(
+ 'Customer {} does not exist, creating new'.format(cleaned_values['user']['mail'])
+ )
+ token_response = stripe_utils.get_token_from_card(
+ cleaned_values['card_number'], cleaned_values['cvc'],
+ cleaned_values['expiry_month'], cleaned_values['expiry_year']
+ )
+ if token_response['response_object']:
+ logger.debug('Token {}'.format(token_response['response_object'].id))
+
+ # Create stripe customer
+ stripe_customer_resp = stripe_utils.create_customer(
+ name=cleaned_values['card_holder_name'],
+ token=token_response['response_object'].id,
+ email=cleaned_values['user']['mail'],
+ address=cleaned_values['address']
+ )
+ stripe_customer = stripe_customer_resp['response_object']
+
+ if stripe_customer:
+ logger.debug('Created stripe customer {}'.format(stripe_customer.id))
+ return make_return_message(
+ 'Card ending in {} registered as your payment source'.format(last4)
+ )
+ else:
+ return make_return_message('Error with card. Contact support', 400)
+ else:
+ return make_return_message('Error with payment gateway. Contact support', 400)
+
+
+class ProductOrder(Resource):
+ @staticmethod
+ def post():
+ data = request.get_json(silent=True) or {}
+
+ try:
+ validator = ProductOrderSchema(data)
+ validator.is_valid()
+ except ValidationException as err:
+ return make_return_message(err, 400)
+ else:
+ cleaned_values = validator.get_cleaned_values()
+ stripe_utils = StripeUtils()
+
+ product = cleaned_values['product']
+
+ # Check the user has a payment source added
+ stripe_customer = stripe_utils.get_stripe_customer_from_email(cleaned_values['user']['mail'])
+
+ if not stripe_customer or len(stripe_customer.sources) == 0:
+ return make_return_message('Please register your payment method first.', 400)
+
+ try:
+ product_schema = create_schema(product, data)
+ product_schema = product_schema()
+ product_schema.is_valid()
+ except ValidationException as err:
+ return make_return_message(err, 400)
+ else:
+ transformed_data = product_schema.get_cleaned_values()
+ logger.debug('Tranformed data: {}'.format(transformed_data))
+ one_time_charge, recurring_charge = calculate_charges(product, transformed_data)
+ recurring_charge = int(recurring_charge)
+
+ if not cleaned_values['pay']:
+ return make_return_message(
+ 'You would be charged {} CHF one time and {} CHF every {}. '
+ 'Add --pay to command to order.'.format(
+ one_time_charge, recurring_charge, product['recurring_period']
+ )
+ )
+
+ with client.client.lock('product-order') as _:
+ # Initiate a one-time/subscription based on product type
+ if recurring_charge > 0:
+ logger.debug('Product {} is recurring payment'.format(product['name']))
+ plan_id = get_plan_id_from_product(product)
+ res = stripe_utils.get_or_create_stripe_plan(
+ product_name=product['name'],
+ stripe_plan_id=plan_id, amount=recurring_charge,
+ interval=product['recurring_period'],
+ )
+ if res['response_object']:
+ logger.debug('Obtained plan {}'.format(plan_id))
+ subscription_res = stripe_utils.subscribe_customer_to_plan(
+ stripe_customer.id,
+ [{'plan': plan_id}]
+ )
+ subscription_obj = subscription_res['response_object']
+ if subscription_obj is None or subscription_obj.status != 'active':
+ return make_return_message(
+ 'Error subscribing to plan. Detail: {}'.format(subscription_res['error']), 400
+ )
+ else:
+ order_obj = {
+ 'order-id': uuid4().hex,
+ 'ordered-at': datetime.now().isoformat(),
+ 'product': product['usable-id'],
+ 'one-time-price': one_time_charge,
+ 'recurring-price': recurring_charge,
+ 'recurring-period': product['recurring_period']
+ }
+ client.put(
+ '/v1/user/{}/orders/{}'.format(
+ cleaned_values['username'], order_obj['order-id']
+ ), order_obj
+ )
+ product['quantity'] -= 1
+ client.put('/v1/products/{}'.format(product['uuid']), product)
+
+ return {
+ 'message': 'Order Successful.',
+ **order_obj
+ }
+ else:
+ logger.error('Could not create plan {}'.format(plan_id))
+ return make_return_message('Something wrong happened. Contact administrator', 400)
+
+ elif recurring_charge == 0 and one_time_charge > 0:
+ logger.debug('Product {} is one-time payment'.format(product['name']))
+ charge_response = stripe_utils.make_charge(
+ amount=one_time_charge,
+ customer=stripe_customer.id
+ )
+ stripe_onetime_charge = charge_response.get('response_object')
+
+ # Check if the payment was approved
+ if not stripe_onetime_charge:
+ msg = charge_response.get('error')
+ return make_return_message('Error subscribing to plan. Details: {}'.format(msg), 400)
+
+ order_obj = {
+ 'order-id': uuid4().hex,
+ 'ordered-at': datetime.now().isoformat(),
+ 'product': product['usable-id'],
+ 'one-time-price': one_time_charge,
+ }
+ client.put(
+ '/v1/user/{}/orders/{}'.format(cleaned_values['username'], order_obj['order-id']),
+ order_obj
+ )
+ product['quantity'] -= 1
+ client.put('/v1/products/{}'.format(product['uuid']), product)
+
+ return {'message': 'Order successful', **order_obj}, 200
+
+
+class OrderList(Resource):
+ @staticmethod
+ def post():
+ data = request.get_json(silent=True) or {}
+
+ try:
+ validator = OrderListSchema(data)
+ validator.is_valid()
+ except ValidationException as err:
+ return make_return_message(err, 400)
+ else:
+ cleaned_values = validator.get_cleaned_values()
+ orders = client.get_prefix('/v1/user/{}/orders'.format(cleaned_values['username']))
+ orders_dict = {
+ order.value['order-id']: {
+ **order.value
+ }
+ for order in orders
+ }
+ logger.debug('Orders = {}'.format(orders_dict))
+ return {'orders': orders_dict}, 200
+
+
+if __name__ == '__main__':
+ logger = logging.getLogger()
+ logger.setLevel(logging.DEBUG)
+ log_formater = logging.Formatter('[%(filename)s:%(lineno)d] %(message)s')
+
+ stream_logger = logging.StreamHandler()
+ stream_logger.setFormatter(log_formater)
+
+ # file_logger = logging.FileHandler('log.txt')
+ # file_logger.setLevel(logging.DEBUG)
+ # file_logger.setFormatter(log_formater)
+
+ logger.addHandler(stream_logger)
+ # logger.addHandler(file_logger)
+
+ app = Flask(__name__)
+
+ api = Api(app)
+ api.add_resource(ListProducts, '/product/list')
+ api.add_resource(AddProduct, '/product/add')
+ api.add_resource(ProductOrder, '/product/order')
+ api.add_resource(UserRegisterPayment, '/user/register_payment')
+ api.add_resource(OrderList, '/order/list')
+
+ app.run(host='::', port=config.get('app', 'port', fallback=5000), debug=True)
+
+
+ @app.errorhandler(Exception)
+ def handle_exception(e):
+ app.logger.error(e)
+ # pass through HTTP errors
+ if isinstance(e, HTTPException):
+ return e
+
+ # now you're handling non-HTTP exceptions only
+ return {'message': 'Server Error'}, 500
diff --git a/uncloud_django_based/notes-abk.md b/uncloud_django_based/notes-abk.md
new file mode 100644
index 0000000..6d5c223
--- /dev/null
+++ b/uncloud_django_based/notes-abk.md
@@ -0,0 +1,11 @@
+## TODO 2020-02-22
+
+* ~~move the current rest api to /opennebula~~
+* ~~make the /opennebula api only accessible by an admin account~~
+* ~~create a new filtered api on /vm/list that~~
+ * ~~a) requires authentication~~
+ * ~~b) only shows the VMs of the current user~~
+* ~~the new api should not contain all details, but: cpus (as read by the vcpu field), ram, ips, disks~~
+* ~~also make a (random) uuid the primary key for VMs - everything in this uncloud hack will use uuids as the id~~
+* ~~still expose the opennebula id as opennebula_id~~
+* ~~note put all secrets/configs into uncloud.secrets - I added a sample file into the repo~~
diff --git a/uncloud_django_based/notes-nico.org b/uncloud_django_based/notes-nico.org
new file mode 100644
index 0000000..811fbff
--- /dev/null
+++ b/uncloud_django_based/notes-nico.org
@@ -0,0 +1,102 @@
+* snapshot feature
+** product: vm-snapshot
+** flow
+*** list all my VMs
+**** get the uuid of the VM I want to take a snapshot of
+*** request a snapshot
+```
+vmuuid=$(http nicocustomer
+http -a nicocustomer:xxx http://uncloud.ch/vm/create_snapshot uuid=
+password=...
+```
+** backend realisation
+*** list snapshots
+ - have them in the DB
+ - create an entry on create
+*** creating snapshots
+ - vm sync / fsync?
+ - rbd snapshot
+ - host/cluster mapping?
+ - need image(s)
+
+* steps
+** DONE authenticate via ldap
+ CLOSED: [2020-02-20 Thu 19:05]
+** DONE Make classes / views require authentication
+ CLOSED: [2020-02-20 Thu 19:05]
+** TODO register credit card
+*** TODO find out what saving with us
+*** Info
+**** should not be fully saved in the DB
+**** model needs to be a bit different
+* Decide where to save sensitive data
+** stripe access key, etc.
+* python requirements (nicohack202002)
+ django djangorestframework django-auth-ldap stripe
+* os package requirements (alpine)
+ openldap-dev
+* VPN case
+** put on /orders with uuid
+** register cc
+* CC
+** TODO check whether we can register or not at stripe
+* membership
+** required for "smaller" / "shorter" products
+
+* TODO Membership missing
+* Flows to be implemented - see https://redmine.ungleich.ch/issues/7609
+** Membership
+*** 5 CHF
+** Django Hosting
+*** One time payment 35 CHF
+*** Monthly payment depends on VM size
+*** Parameters: same as IPv6 only VM
+** IPv6 VPN
+*** Parameters: none
+*** Is for free if the customer has an active VM
+** IPv6 only VM
+*** Parameters: cores, ram, os_disk_size, OS
+* Django rest framework
+** viewset: .list and .create
+** view: .get .post
+* TODO register CC
+* DONE list products
+ CLOSED: [2020-02-24 Mon 20:15]
+* An ungleich account - can be registered for free on
+ https://account.ungleich.ch
+* httpie installed (provides the http command)
+
+## Get a membership
+
+
+## Registering a payment method
+
+To be able to pay for the membership, you will need to register a
+credit card or apply for payment on bill (TO BE IMPLEMENTED).
+
+### Register credit card
+
+```
+http POST https://api.ungleich.ch/membership \
+ username=nico password=yourpassword \
+ cc_number=.. \
+ cc_
+
+```
+
+
+
+### Request payment via bill
+
+
+
+
+## Create the membership
+
+
+```
+http POST https://api.ungleich.ch/membership username=nico password=yourpassword
+
+```
+
+## List available products
diff --git a/uncloud_django_based/plan.org b/uncloud_django_based/plan.org
new file mode 100644
index 0000000..9f172c2
--- /dev/null
+++ b/uncloud_django_based/plan.org
@@ -0,0 +1,6 @@
+* TODO register CC
+* TODO list products
+* ahmed
+** schemas
+*** field: is_valid? - used by schemas
+*** definition of a "schema"
diff --git a/uncloud_django_based/uncloud/.gitignore b/uncloud_django_based/uncloud/.gitignore
new file mode 100644
index 0000000..71202e1
--- /dev/null
+++ b/uncloud_django_based/uncloud/.gitignore
@@ -0,0 +1,4 @@
+db.sqlite3
+uncloud/secrets.py
+debug.log
+uncloud/local_settings.py
\ No newline at end of file
diff --git a/uncloud_django_based/uncloud/README-how-to-create-a-product.md b/uncloud_django_based/uncloud/README-how-to-create-a-product.md
new file mode 100644
index 0000000..6ddd1fa
--- /dev/null
+++ b/uncloud_django_based/uncloud/README-how-to-create-a-product.md
@@ -0,0 +1,9 @@
+## Introduction
+
+This document describes how to create a product and use it.
+
+A product (like a VMSnapshotproduct) creates an order when ordered.
+The "order" is used to combine products together.
+
+Sub-products or related products link to the same order.
+Each product has one (?) orderrecord
diff --git a/uncloud_django_based/uncloud/README-object-relations.md b/uncloud_django_based/uncloud/README-object-relations.md
new file mode 100644
index 0000000..58f2413
--- /dev/null
+++ b/uncloud_django_based/uncloud/README-object-relations.md
@@ -0,0 +1,82 @@
+## Introduction
+
+This article describes how models relate to each other and what the
+design ideas are. It is meant to prevent us from double implementing
+something or changing something that is already solved.
+
+
+## Products
+
+A product is something someone can order. We might have "low level"
+products that need to be composed (= higher degree of flexibility, but
+more amount of details necessary) and "composed products" that present
+some defaults or select other products automatically (f.i. a "dual
+stack VM" can be a VM + a disk + an IPv4 address).
+
+
+## Bills
+
+Bills represent active orders of a month. Bills can be shown during a
+month but only become definitive at the end of the month.
+
+## Orders
+
+When customer X order a (set) of product, it generates an order for billing
+purposes. The ordered products point to that order and register an Order Record
+at creation.
+
+Orders and Order Records are assumed immutable => they are used to generate
+bills and should not be mutated. If a product is updated (e.g. adding RAM to
+VM), a new order should be generated.
+
+The order MUST NOT be deleted when a product is deleted, as it is used for
+billing (including past bills).
+
+### Order record
+
+Used to store billing details of a product at creation: will stay there even if
+the product change (e.g. new pricing, updated) and act as some kind of archive.
+Used to generate bills.
+
+## Payment Methods
+
+Users/customers can register payment methods.
+
+## Sample flows / products
+
+### A VM snapshot
+
+A VM snapshot creates a snapshot of all disks attached to a VM to be
+able to rollback the VM to a previous state.
+
+Creating a VM snapshot (-product) creates a related order. Deleting a
+VMSnapshotproduct sets the order to deleted.
+
+### Object Storage
+
+(tbd by Balazs)
+
+### A "raw" VM
+
+(tbd by Ahmed)
+
+### An IPv6 only VM
+
+(tbd by Ahmed)
+
+### A dual stack VM
+
+(tbd by Ahmed)
+
+### A managed service (e.g. Matrix-as-a-Service)
+
+Customer orders service with:
+ * Service-specific configuration: e.g. domain name for matrix
+ * VM configuration:
+ - CPU
+ - Memory
+ - Disk (soon)
+
+It creates a new Order with two products/records:
+ * Service itself (= management)
+ * Underlying VM
diff --git a/uncloud_django_based/uncloud/README.md b/uncloud_django_based/uncloud/README.md
new file mode 100644
index 0000000..390a3af
--- /dev/null
+++ b/uncloud_django_based/uncloud/README.md
@@ -0,0 +1,95 @@
+## Install
+
+### OS package requirements
+
+Alpine:
+
+```
+apk add openldap-dev postgresql-dev
+```
+
+Debian/Devuan:
+
+```
+apt install postgresql-server-dev-all
+```
+
+
+### Python requirements
+
+If you prefer using a venv, use:
+
+```
+python -m venv venv
+. ./venv/bin/activate
+```
+
+Then install the requirements
+
+```
+pip install -r requirements.txt
+```
+
+### Database requirements
+
+Due to the use of the JSONField, postgresql is required.
+
+First create a role to be used:
+
+```
+postgres=# create role nico login;
+```
+
+Then create the database owner by the new role:
+
+```
+postgres=# create database uncloud owner nico;
+```
+
+Installing the postgresql service is os dependent, but some hints:
+
+* Alpine: `apk add postgresql-server && rc-update add postgresql && rc-service postgresql start`
+* Debian/Devuan: `apt install postgresql`
+
+After postresql is started, apply the migrations:
+
+```
+python manage.py migrate
+```
+
+### Secrets
+
+cp `uncloud/secrets_sample.py` to `uncloud/secrets.py` and replace the
+sample values with real values.
+
+
+## Flows / Orders
+
+### Creating a VMHost
+
+
+
+### Creating a VM
+
+* Create a VMHost
+* Create a VM on a VMHost
+
+
+### Creating a VM Snapshot
+
+
+## Working Beta APIs
+
+These APIs can be used for internal testing.
+
+### URL Overview
+
+```
+http -a nicoschottelius:$(pass ungleich.ch/nico.schottelius@ungleich.ch) http://localhost:8000
+```
+
+### Snapshotting
+
+```
+http -a nicoschottelius:$(pass ungleich.ch/nico.schottelius@ungleich.ch) http://localhost:8000/vm/snapshot/ vm_uuid=$(uuidgen)
+```
diff --git a/uncloud_django_based/uncloud/manage.py b/uncloud_django_based/uncloud/manage.py
new file mode 100755
index 0000000..b050590
--- /dev/null
+++ b/uncloud_django_based/uncloud/manage.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings')
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/uncloud_django_based/uncloud/models.dot b/uncloud_django_based/uncloud/models.dot
new file mode 100644
index 0000000..0adfba8
--- /dev/null
+++ b/uncloud_django_based/uncloud/models.dot
@@ -0,0 +1,1482 @@
+digraph model_graph {
+ // Dotfile by Django-Extensions graph_models
+ // Created: 2020-03-17 12:30
+ // Cli Options: -a
+
+ fontname = "Roboto"
+ fontsize = 8
+ splines = true
+
+ node [
+ fontname = "Roboto"
+ fontsize = 8
+ shape = "plaintext"
+ ]
+
+ edge [
+ fontname = "Roboto"
+ fontsize = 8
+ ]
+
+ // Labels
+
+
+ django_contrib_admin_models_LogEntry [label=<
+
+
+
+ LogEntry
+ |
+
+
+
+ id
+ |
+ AutoField
+ |
+
+
+
+
+ content_type
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ user
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ action_flag
+ |
+ PositiveSmallIntegerField
+ |
+
+
+
+
+ action_time
+ |
+ DateTimeField
+ |
+
+
+
+
+ change_message
+ |
+ TextField
+ |
+
+
+
+
+ object_id
+ |
+ TextField
+ |
+
+
+
+
+ object_repr
+ |
+ CharField
+ |
+
+
+
+ >]
+
+
+
+
+ django_contrib_auth_models_Permission [label=<
+
+
+
+ Permission
+ |
+
+
+
+ id
+ |
+ AutoField
+ |
+
+
+
+
+ content_type
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ codename
+ |
+ CharField
+ |
+
+
+
+
+ name
+ |
+ CharField
+ |
+
+
+
+ >]
+
+ django_contrib_auth_models_Group [label=<
+
+
+
+ Group
+ |
+
+
+
+ id
+ |
+ AutoField
+ |
+
+
+
+
+ name
+ |
+ CharField
+ |
+
+
+
+ >]
+
+
+
+
+ django_contrib_contenttypes_models_ContentType [label=<
+
+
+
+ ContentType
+ |
+
+
+
+ id
+ |
+ AutoField
+ |
+
+
+
+
+ app_label
+ |
+ CharField
+ |
+
+
+
+
+ model
+ |
+ CharField
+ |
+
+
+
+ >]
+
+
+
+
+ django_contrib_sessions_base_session_AbstractBaseSession [label=<
+
+
+
+ AbstractBaseSession
+ |
+
+
+
+ expire_date
+ |
+ DateTimeField
+ |
+
+
+
+
+ session_data
+ |
+ TextField
+ |
+
+
+
+ >]
+
+ django_contrib_sessions_models_Session [label=<
+
+
+
+ Session <AbstractBaseSession>
+ |
+
+
+
+ session_key
+ |
+ CharField
+ |
+
+
+
+
+ expire_date
+ |
+ DateTimeField
+ |
+
+
+
+
+ session_data
+ |
+ TextField
+ |
+
+
+
+ >]
+
+
+
+
+ uncloud_pay_models_StripeCustomer [label=<
+
+
+
+ StripeCustomer
+ |
+
+
+
+ owner
+ |
+ OneToOneField (id)
+ |
+
+
+
+
+ stripe_id
+ |
+ CharField
+ |
+
+
+
+ >]
+
+ uncloud_pay_models_Payment [label=<
+
+
+
+ Payment
+ |
+
+
+
+ uuid
+ |
+ UUIDField
+ |
+
+
+
+
+ owner
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ amount
+ |
+ DecimalField
+ |
+
+
+
+
+ source
+ |
+ CharField
+ |
+
+
+
+
+ timestamp
+ |
+ DateTimeField
+ |
+
+
+
+ >]
+
+ uncloud_pay_models_PaymentMethod [label=<
+
+
+
+ PaymentMethod
+ |
+
+
+
+ uuid
+ |
+ UUIDField
+ |
+
+
+
+
+ owner
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ description
+ |
+ TextField
+ |
+
+
+
+
+ primary
+ |
+ BooleanField
+ |
+
+
+
+
+ source
+ |
+ CharField
+ |
+
+
+
+
+ stripe_card_id
+ |
+ CharField
+ |
+
+
+
+ >]
+
+ uncloud_pay_models_Bill [label=<
+
+
+
+ Bill
+ |
+
+
+
+ uuid
+ |
+ UUIDField
+ |
+
+
+
+
+ owner
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ creation_date
+ |
+ DateTimeField
+ |
+
+
+
+
+ due_date
+ |
+ DateField
+ |
+
+
+
+
+ ending_date
+ |
+ DateTimeField
+ |
+
+
+
+
+ starting_date
+ |
+ DateTimeField
+ |
+
+
+
+
+ valid
+ |
+ BooleanField
+ |
+
+
+
+ >]
+
+ uncloud_pay_models_Order [label=<
+
+
+
+ Order
+ |
+
+
+
+ uuid
+ |
+ UUIDField
+ |
+
+
+
+
+ owner
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ creation_date
+ |
+ DateTimeField
+ |
+
+
+
+
+ ending_date
+ |
+ DateTimeField
+ |
+
+
+
+
+ recurring_period
+ |
+ CharField
+ |
+
+
+
+
+ starting_date
+ |
+ DateTimeField
+ |
+
+
+
+ >]
+
+ uncloud_pay_models_OrderRecord [label=<
+
+
+
+ OrderRecord
+ |
+
+
+
+ id
+ |
+ AutoField
+ |
+
+
+
+
+ order
+ |
+ ForeignKey (uuid)
+ |
+
+
+
+
+ description
+ |
+ TextField
+ |
+
+
+
+
+ one_time_price
+ |
+ DecimalField
+ |
+
+
+
+
+ recurring_price
+ |
+ DecimalField
+ |
+
+
+
+ >]
+
+
+
+
+ django_contrib_auth_models_AbstractUser [label=<
+
+
+
+ AbstractUser <AbstractBaseUser,PermissionsMixin>
+ |
+
+
+
+ date_joined
+ |
+ DateTimeField
+ |
+
+
+
+
+ email
+ |
+ EmailField
+ |
+
+
+
+
+ first_name
+ |
+ CharField
+ |
+
+
+
+
+ is_active
+ |
+ BooleanField
+ |
+
+
+
+
+ is_staff
+ |
+ BooleanField
+ |
+
+
+
+
+ is_superuser
+ |
+ BooleanField
+ |
+
+
+
+
+ last_login
+ |
+ DateTimeField
+ |
+
+
+
+
+ last_name
+ |
+ CharField
+ |
+
+
+
+
+ password
+ |
+ CharField
+ |
+
+
+
+
+ username
+ |
+ CharField
+ |
+
+
+
+ >]
+
+ uncloud_auth_models_User [label=<
+
+
+
+ User <AbstractUser>
+ |
+
+
+
+ id
+ |
+ AutoField
+ |
+
+
+
+
+ date_joined
+ |
+ DateTimeField
+ |
+
+
+
+
+ email
+ |
+ EmailField
+ |
+
+
+
+
+ first_name
+ |
+ CharField
+ |
+
+
+
+
+ is_active
+ |
+ BooleanField
+ |
+
+
+
+
+ is_staff
+ |
+ BooleanField
+ |
+
+
+
+
+ is_superuser
+ |
+ BooleanField
+ |
+
+
+
+
+ last_login
+ |
+ DateTimeField
+ |
+
+
+
+
+ last_name
+ |
+ CharField
+ |
+
+
+
+
+ password
+ |
+ CharField
+ |
+
+
+
+
+ username
+ |
+ CharField
+ |
+
+
+
+ >]
+
+
+
+
+ uncloud_pay_models_Product [label=<
+
+
+
+ Product
+ |
+
+
+
+ order
+ |
+ ForeignKey (uuid)
+ |
+
+
+
+
+ owner
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ status
+ |
+ CharField
+ |
+
+
+
+ >]
+
+ uncloud_vm_models_VMHost [label=<
+
+
+
+ VMHost
+ |
+
+
+
+ uuid
+ |
+ UUIDField
+ |
+
+
+
+
+ hostname
+ |
+ CharField
+ |
+
+
+
+
+ physical_cores
+ |
+ IntegerField
+ |
+
+
+
+
+ status
+ |
+ CharField
+ |
+
+
+
+
+ usable_cores
+ |
+ IntegerField
+ |
+
+
+
+
+ usable_ram_in_gb
+ |
+ FloatField
+ |
+
+
+
+ >]
+
+ uncloud_vm_models_VMProduct [label=<
+
+
+
+ VMProduct <Product>
+ |
+
+
+
+ uuid
+ |
+ UUIDField
+ |
+
+
+
+
+ order
+ |
+ ForeignKey (uuid)
+ |
+
+
+
+
+ owner
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ vmhost
+ |
+ ForeignKey (uuid)
+ |
+
+
+
+
+ cores
+ |
+ IntegerField
+ |
+
+
+
+
+ name
+ |
+ CharField
+ |
+
+
+
+
+ ram_in_gb
+ |
+ FloatField
+ |
+
+
+
+
+ status
+ |
+ CharField
+ |
+
+
+
+
+ vmid
+ |
+ IntegerField
+ |
+
+
+
+ >]
+
+ uncloud_vm_models_VMWithOSProduct [label=<
+
+
+
+ VMWithOSProduct
+ |
+
+
+
+ vmproduct_ptr
+ |
+ OneToOneField (uuid)
+ |
+
+
+
+ >]
+
+ uncloud_vm_models_VMDiskImageProduct [label=<
+
+
+
+ VMDiskImageProduct
+ |
+
+
+
+ uuid
+ |
+ UUIDField
+ |
+
+
+
+
+ owner
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ image_source
+ |
+ CharField
+ |
+
+
+
+
+ image_source_type
+ |
+ CharField
+ |
+
+
+
+
+ import_url
+ |
+ URLField
+ |
+
+
+
+
+ is_os_image
+ |
+ BooleanField
+ |
+
+
+
+
+ is_public
+ |
+ BooleanField
+ |
+
+
+
+
+ name
+ |
+ CharField
+ |
+
+
+
+
+ size_in_gb
+ |
+ FloatField
+ |
+
+
+
+
+ status
+ |
+ CharField
+ |
+
+
+
+
+ storage_class
+ |
+ CharField
+ |
+
+
+
+ >]
+
+ uncloud_vm_models_VMDiskProduct [label=<
+
+
+
+ VMDiskProduct
+ |
+
+
+
+ uuid
+ |
+ UUIDField
+ |
+
+
+
+
+ image
+ |
+ ForeignKey (uuid)
+ |
+
+
+
+
+ owner
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ vm
+ |
+ ForeignKey (uuid)
+ |
+
+
+
+
+ size_in_gb
+ |
+ FloatField
+ |
+
+
+
+ >]
+
+ uncloud_vm_models_VMNetworkCard [label=<
+
+
+
+ VMNetworkCard
+ |
+
+
+
+ id
+ |
+ AutoField
+ |
+
+
+
+
+ vm
+ |
+ ForeignKey (uuid)
+ |
+
+
+
+
+ ip_address
+ |
+ GenericIPAddressField
+ |
+
+
+
+
+ mac_address
+ |
+ BigIntegerField
+ |
+
+
+
+ >]
+
+ uncloud_vm_models_VMSnapshotProduct [label=<
+
+
+
+ VMSnapshotProduct <Product>
+ |
+
+
+
+ uuid
+ |
+ UUIDField
+ |
+
+
+
+
+ order
+ |
+ ForeignKey (uuid)
+ |
+
+
+
+
+ owner
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ vm
+ |
+ ForeignKey (uuid)
+ |
+
+
+
+
+ gb_hdd
+ |
+ FloatField
+ |
+
+
+
+
+ gb_ssd
+ |
+ FloatField
+ |
+
+
+
+
+ status
+ |
+ CharField
+ |
+
+
+
+ >]
+
+
+
+
+ uncloud_pay_models_Product [label=<
+
+
+
+ Product
+ |
+
+
+
+ order
+ |
+ ForeignKey (uuid)
+ |
+
+
+
+
+ owner
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ status
+ |
+ CharField
+ |
+
+
+
+ >]
+
+ ungleich_service_models_MatrixServiceProduct [label=<
+
+
+
+ MatrixServiceProduct <Product>
+ |
+
+
+
+ uuid
+ |
+ UUIDField
+ |
+
+
+
+
+ order
+ |
+ ForeignKey (uuid)
+ |
+
+
+
+
+ owner
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ vm
+ |
+ ForeignKey (uuid)
+ |
+
+
+
+
+ domain
+ |
+ CharField
+ |
+
+
+
+
+ status
+ |
+ CharField
+ |
+
+
+
+ >]
+
+
+
+
+ opennebula_models_VM [label=<
+
+
+
+ VM
+ |
+
+
+
+ vmid
+ |
+ IntegerField
+ |
+
+
+
+
+ owner
+ |
+ ForeignKey (id)
+ |
+
+
+
+
+ data
+ |
+ JSONField
+ |
+
+
+
+ >]
+
+
+
+
+ // Relations
+
+ django_contrib_admin_models_LogEntry -> uncloud_auth_models_User
+ [label=" user (logentry)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ django_contrib_admin_models_LogEntry -> django_contrib_contenttypes_models_ContentType
+ [label=" content_type (logentry)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+
+ django_contrib_auth_models_Permission -> django_contrib_contenttypes_models_ContentType
+ [label=" content_type (permission)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ django_contrib_auth_models_Group -> django_contrib_auth_models_Permission
+ [label=" permissions (group)"] [arrowhead=dot arrowtail=dot, dir=both];
+
+
+
+ django_contrib_sessions_models_Session -> django_contrib_sessions_base_session_AbstractBaseSession
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
+
+
+ uncloud_pay_models_StripeCustomer -> uncloud_auth_models_User
+ [label=" owner (stripecustomer)"] [arrowhead=none, arrowtail=none, dir=both];
+
+ uncloud_pay_models_Payment -> uncloud_auth_models_User
+ [label=" owner (payment)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_pay_models_PaymentMethod -> uncloud_auth_models_User
+ [label=" owner (paymentmethod)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_pay_models_Bill -> uncloud_auth_models_User
+ [label=" owner (bill)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_pay_models_Order -> uncloud_auth_models_User
+ [label=" owner (order)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_pay_models_Order -> uncloud_pay_models_Bill
+ [label=" bill (order)"] [arrowhead=dot arrowtail=dot, dir=both];
+
+ uncloud_pay_models_OrderRecord -> uncloud_pay_models_Order
+ [label=" order (orderrecord)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ django_contrib_auth_base_user_AbstractBaseUser [label=<
+
+ >]
+ django_contrib_auth_models_AbstractUser -> django_contrib_auth_base_user_AbstractBaseUser
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
+ django_contrib_auth_models_PermissionsMixin [label=<
+
+ >]
+ django_contrib_auth_models_AbstractUser -> django_contrib_auth_models_PermissionsMixin
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
+
+ uncloud_auth_models_User -> django_contrib_auth_models_Group
+ [label=" groups (user)"] [arrowhead=dot arrowtail=dot, dir=both];
+
+ uncloud_auth_models_User -> django_contrib_auth_models_Permission
+ [label=" user_permissions (user)"] [arrowhead=dot arrowtail=dot, dir=both];
+
+ uncloud_auth_models_User -> django_contrib_auth_models_AbstractUser
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
+
+
+ uncloud_pay_models_Product -> uncloud_auth_models_User
+ [label=" owner (product)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_pay_models_Product -> uncloud_pay_models_Order
+ [label=" order (product)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_vm_models_VMProduct -> uncloud_vm_models_VMHost
+ [label=" vmhost (vmproduct)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_vm_models_VMProduct -> uncloud_pay_models_Product
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
+
+ uncloud_vm_models_VMWithOSProduct -> uncloud_vm_models_VMProduct
+ [label=" multi-table\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
+
+ uncloud_vm_models_VMDiskImageProduct -> uncloud_auth_models_User
+ [label=" owner (vmdiskimageproduct)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_vm_models_VMDiskProduct -> uncloud_auth_models_User
+ [label=" owner (vmdiskproduct)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_vm_models_VMDiskProduct -> uncloud_vm_models_VMProduct
+ [label=" vm (vmdiskproduct)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_vm_models_VMDiskProduct -> uncloud_vm_models_VMDiskImageProduct
+ [label=" image (vmdiskproduct)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_vm_models_VMNetworkCard -> uncloud_vm_models_VMProduct
+ [label=" vm (vmnetworkcard)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_vm_models_VMSnapshotProduct -> uncloud_vm_models_VMProduct
+ [label=" vm (vmsnapshotproduct)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_vm_models_VMSnapshotProduct -> uncloud_pay_models_Product
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
+
+
+ uncloud_pay_models_Product -> uncloud_auth_models_User
+ [label=" owner (product)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ uncloud_pay_models_Product -> uncloud_pay_models_Order
+ [label=" order (product)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ ungleich_service_models_MatrixServiceProduct -> uncloud_vm_models_VMProduct
+ [label=" vm (matrixserviceproduct)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+ ungleich_service_models_MatrixServiceProduct -> uncloud_pay_models_Product
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
+
+
+ opennebula_models_VM -> uncloud_auth_models_User
+ [label=" owner (vm)"] [arrowhead=none, arrowtail=dot, dir=both];
+
+
+}
diff --git a/uncloud_django_based/uncloud/models.png b/uncloud_django_based/uncloud/models.png
new file mode 100644
index 0000000..f9d0c2e
Binary files /dev/null and b/uncloud_django_based/uncloud/models.png differ
diff --git a/uncloud_django_based/uncloud/opennebula/__init__.py b/uncloud_django_based/uncloud/opennebula/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_django_based/uncloud/opennebula/admin.py b/uncloud_django_based/uncloud/opennebula/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/uncloud_django_based/uncloud/opennebula/apps.py b/uncloud_django_based/uncloud/opennebula/apps.py
new file mode 100644
index 0000000..0750576
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class OpennebulaConfig(AppConfig):
+ name = 'opennebula'
diff --git a/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-synchosts.py b/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-synchosts.py
new file mode 100644
index 0000000..29f9ac1
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-synchosts.py
@@ -0,0 +1,74 @@
+import json
+
+import uncloud.secrets as secrets
+
+from xmlrpc.client import ServerProxy as RPCClient
+
+from django.core.management.base import BaseCommand
+from xmltodict import parse
+from enum import IntEnum
+from opennebula.models import VM as VMModel
+from uncloud_vm.models import VMHost
+from django_auth_ldap.backend import LDAPBackend
+
+
+class HostStates(IntEnum):
+ """
+ The following flags are copied from
+ https://docs.opennebula.org/5.8/integration/system_interfaces/api.html#schemas-for-host
+ """
+ INIT = 0 # Initial state for enabled hosts
+ MONITORING_MONITORED = 1 # Monitoring the host (from monitored)
+ MONITORED = 2 # The host has been successfully monitored
+ ERROR = 3 # An error ocurrer while monitoring the host
+ DISABLED = 4 # The host is disabled
+ MONITORING_ERROR = 5 # Monitoring the host (from error)
+ MONITORING_INIT = 6 # Monitoring the host (from init)
+ MONITORING_DISABLED = 7 # Monitoring the host (from disabled)
+ OFFLINE = 8 # The host is totally offline
+
+
+class Command(BaseCommand):
+ help = 'Syncronize Host information from OpenNebula'
+
+ def add_arguments(self, parser):
+ pass
+
+ def handle(self, *args, **options):
+ with RPCClient(secrets.OPENNEBULA_URL) as rpc_client:
+ success, response, *_ = rpc_client.one.hostpool.info(secrets.OPENNEBULA_USER_PASS)
+ if success:
+ response = json.loads(json.dumps(parse(response)))
+ host_pool = response.get('HOST_POOL', {}).get('HOST', {})
+ for host in host_pool:
+ host_share = host.get('HOST_SHARE', {})
+
+ host_name = host.get('NAME')
+ state = int(host.get('STATE', HostStates.OFFLINE.value))
+
+ if state == HostStates.MONITORED:
+ status = 'active'
+ elif state == HostStates.DISABLED:
+ status = 'disabled'
+ else:
+ status = 'unusable'
+
+ usable_cores = host_share.get('TOTAL_CPU')
+ usable_ram_in_kb = int(host_share.get('TOTAL_MEM', 0))
+ usable_ram_in_gb = int(usable_ram_in_kb / 2 ** 20)
+
+ # vms cannot be created like this -- Nico, 2020-03-17
+ # vms = host.get('VMS', {}) or {}
+ # vms = vms.get('ID', []) or []
+ # vms = ','.join(vms)
+
+ VMHost.objects.update_or_create(
+ hostname=host_name,
+ defaults={
+ 'usable_cores': usable_cores,
+ 'usable_ram_in_gb': usable_ram_in_gb,
+ 'status': status
+ }
+ )
+ else:
+ print(response)
diff --git a/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-syncvms.py b/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-syncvms.py
new file mode 100644
index 0000000..458528b
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-syncvms.py
@@ -0,0 +1,47 @@
+import json
+
+import uncloud.secrets as secrets
+
+
+from xmlrpc.client import ServerProxy as RPCClient
+
+from django_auth_ldap.backend import LDAPBackend
+from django.core.management.base import BaseCommand
+from xmltodict import parse
+
+from opennebula.models import VM as VMModel
+
+
+class Command(BaseCommand):
+ help = 'Syncronize VM information from OpenNebula'
+
+ def add_arguments(self, parser):
+ pass
+
+ def handle(self, *args, **options):
+ with RPCClient(secrets.OPENNEBULA_URL) as rpc_client:
+ success, response, *_ = rpc_client.one.vmpool.infoextended(
+ secrets.OPENNEBULA_USER_PASS, -2, -1, -1, -1
+ )
+ if success:
+ vms = json.loads(json.dumps(parse(response)))['VM_POOL']['VM']
+ unknown_user = set()
+
+ backend = LDAPBackend()
+
+ for vm in vms:
+ vm_id = vm['ID']
+ vm_owner = vm['UNAME']
+
+ user = backend.populate_user(username=vm_owner)
+
+ if not user:
+ unknown_user.add(vm_owner)
+ else:
+ VMModel.objects.update_or_create(
+ vmid=vm_id,
+ defaults={'data': vm, 'owner': user}
+ )
+ print('User not found in ldap:', unknown_user)
+ else:
+ print(response)
diff --git a/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-to-uncloud.py b/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-to-uncloud.py
new file mode 100644
index 0000000..230159a
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-to-uncloud.py
@@ -0,0 +1,193 @@
+import sys
+from datetime import datetime
+
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+from django.contrib.auth import get_user_model
+
+from opennebula.models import VM as VMModel
+from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct
+
+from uncloud_pay.models import Order
+
+import logging
+
+log = logging.getLogger(__name__)
+
+def convert_mac_to_int(mac_address: str):
+ # Remove octet connecting characters
+ mac_address = mac_address.replace(':', '')
+ mac_address = mac_address.replace('.', '')
+ mac_address = mac_address.replace('-', '')
+ mac_address = mac_address.replace(' ', '')
+
+ # Parse the resulting number as hexadecimal
+ mac_address = int(mac_address, base=16)
+
+ return mac_address
+
+
+def get_vm_price(core, ram, ssd_size, hdd_size, n_of_ipv4, n_of_ipv6):
+ total = 3 * core + 4 * ram + (3.5 * ssd_size/10.) + (1.5 * hdd_size/100.) + 8 * n_of_ipv4 + 0 * n_of_ipv6
+
+ # TODO: Find some reason about the following magical subtraction.
+ total -= 8
+
+ return total
+
+
+def create_nics(one_vm, vm_product):
+ for nic in one_vm.nics:
+ mac_address = convert_mac_to_int(nic.get('MAC'))
+ ip_address = nic.get('IP', None) or nic.get('IP6_GLOBAL', None)
+
+ VMNetworkCard.objects.update_or_create(
+ mac_address=mac_address, vm=vm_product, defaults={'ip_address': ip_address}
+ )
+
+
+def sync_disk_and_image(one_vm, vm_product, disk_owner):
+ """
+ a) Check all opennebula disk if they are in the uncloud VM, if not add
+ b) Check all uncloud disks and remove them if they are not in the opennebula VM
+ """
+
+ vmdisknum = 0
+
+ one_disks_extra_data = []
+
+ for disk in one_vm.disks:
+ vmowner = one_vm.owner
+ name = disk.get('image')
+ vmdisknum += 1
+
+ log.info("Checking disk {} for VM {}".format(name, one_vm))
+
+ is_os_image, is_public, status = True, False, 'active'
+
+ image_size_in_gb = disk.get('image_size_in_gb')
+ disk_size_in_gb = disk.get('size_in_gb')
+ storage_class = disk.get('storage_class')
+ image_source = disk.get('source')
+ image_source_type = disk.get('source_type')
+
+ image, _ = VMDiskImageProduct.objects.update_or_create(
+ name=name,
+ defaults={
+ 'owner': disk_owner,
+ 'is_os_image': is_os_image,
+ 'is_public': is_public,
+ 'size_in_gb': image_size_in_gb,
+ 'storage_class': storage_class,
+ 'image_source': image_source,
+ 'image_source_type': image_source_type,
+ 'status': status
+ }
+ )
+
+ # identify vmdisk from opennebula - primary mapping key
+ extra_data = {
+ 'opennebula_vm': one_vm.vmid,
+ 'opennebula_size_in_gb': disk_size_in_gb,
+ 'opennebula_source': disk.get('opennebula_source'),
+ 'opennebula_disk_num': vmdisknum
+ }
+ # Save for comparing later
+ one_disks_extra_data.append(extra_data)
+
+ try:
+ vm_disk = VMDiskProduct.objects.get(extra_data=extra_data)
+ except VMDiskProduct.DoesNotExist:
+ vm_disk = VMDiskProduct.objects.create(
+ owner=vmowner,
+ vm=vm_product,
+ image=image,
+ size_in_gb=disk_size_in_gb,
+ extra_data=extra_data
+ )
+
+ # Now remove all disks that are not in above extra_data list
+ for disk in VMDiskProduct.objects.filter(vm=vm_product):
+ extra_data = disk.extra_data
+ if not extra_data in one_disks_extra_data:
+ log.info("Removing disk {} from VM {}".format(disk, vm_product))
+ disk.delete()
+
+ disks = [ disk.extra_data for disk in VMDiskProduct.objects.filter(vm=vm_product) ]
+ log.info("VM {} has disks: {}".format(vm_product, disks))
+
+class Command(BaseCommand):
+ help = 'Migrate Opennebula VM to regular (uncloud) vm'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--disk-owner', required=True, help="The user who owns the the opennebula disks")
+
+ def handle(self, *args, **options):
+ log.debug("{} {}".format(args, options))
+
+ disk_owner = get_user_model().objects.get(username=options['disk_owner'])
+
+ for one_vm in VMModel.objects.all():
+
+ if not one_vm.last_host:
+ log.warning("No VMHost for VM {} - VM might be on hold - skipping".format(one_vm.vmid))
+ continue
+
+ try:
+ vmhost = VMHost.objects.get(hostname=one_vm.last_host)
+ except VMHost.DoesNotExist:
+ log.error("VMHost {} does not exist, aborting".format(one_vm.last_host))
+ raise
+
+ cores = one_vm.cores
+ ram_in_gb = one_vm.ram_in_gb
+ owner = one_vm.owner
+ status = 'active'
+
+ ssd_size = sum([ disk['size_in_gb'] for disk in one_vm.disks if disk['pool_name'] in ['ssd', 'one'] ])
+ hdd_size = sum([ disk['size_in_gb'] for disk in one_vm.disks if disk['pool_name'] in ['hdd'] ])
+
+ # List of IPv4 addresses and Global IPv6 addresses
+ ipv4, ipv6 = one_vm.ips
+
+ # TODO: Insert actual/real creation_date, starting_date, ending_date
+ # instead of pseudo one we are putting currently
+ creation_date = starting_date = datetime.now(tz=timezone.utc)
+
+ # Price calculation based on datacenterlight.ch
+ one_time_price = 0
+ recurring_period = 'per_month'
+ recurring_price = get_vm_price(cores, ram_in_gb,
+ ssd_size, hdd_size,
+ len(ipv4), len(ipv6))
+
+ try:
+ vm_product = VMProduct.objects.get(extra_data__opennebula_id=one_vm.vmid)
+ except VMProduct.DoesNotExist:
+ order = Order.objects.create(
+ owner=owner,
+ creation_date=creation_date,
+ starting_date=starting_date
+ )
+ vm_product = VMProduct(
+ extra_data={ 'opennebula_id': one_vm.vmid },
+ name=one_vm.uncloud_name,
+ order=order
+ )
+
+ # we don't use update_or_create, as filtering by json AND setting json
+ # at the same time does not work
+
+ vm_product.vmhost = vmhost
+ vm_product.owner = owner
+ vm_product.cores = cores
+ vm_product.ram_in_gb = ram_in_gb
+ vm_product.status = status
+
+ vm_product.save()
+
+ # Create VMNetworkCards
+ create_nics(one_vm, vm_product)
+
+ # Create VMDiskImageProduct and VMDiskProduct
+ sync_disk_and_image(one_vm, vm_product, disk_owner=disk_owner)
diff --git a/uncloud_django_based/uncloud/opennebula/migrations/0001_initial.py b/uncloud_django_based/uncloud/opennebula/migrations/0001_initial.py
new file mode 100644
index 0000000..4c0527a
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/migrations/0001_initial.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.0.3 on 2020-02-23 17:12
+
+from django.conf import settings
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='VM',
+ fields=[
+ ('vmid', models.IntegerField(primary_key=True, serialize=False)),
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
+ ('data', django.contrib.postgres.fields.jsonb.JSONField()),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/opennebula/migrations/0002_auto_20200225_1335.py b/uncloud_django_based/uncloud/opennebula/migrations/0002_auto_20200225_1335.py
new file mode 100644
index 0000000..1554aa6
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/migrations/0002_auto_20200225_1335.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.0.3 on 2020-02-25 13:35
+
+from django.db import migrations, models
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('opennebula', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='vm',
+ name='uuid',
+ ),
+ migrations.RemoveField(
+ model_name='vm',
+ name='vmid',
+ ),
+ migrations.AddField(
+ model_name='vm',
+ name='id',
+ field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, unique=True),
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/opennebula/migrations/0003_auto_20200225_1428.py b/uncloud_django_based/uncloud/opennebula/migrations/0003_auto_20200225_1428.py
new file mode 100644
index 0000000..8bb3d8d
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/migrations/0003_auto_20200225_1428.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.0.3 on 2020-02-25 14:28
+
+from django.db import migrations, models
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('opennebula', '0002_auto_20200225_1335'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='vm',
+ name='id',
+ field=models.CharField(default=uuid.uuid4, max_length=64, primary_key=True, serialize=False, unique=True),
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/opennebula/migrations/0004_auto_20200225_1816.py b/uncloud_django_based/uncloud/opennebula/migrations/0004_auto_20200225_1816.py
new file mode 100644
index 0000000..5b39f26
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/migrations/0004_auto_20200225_1816.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.3 on 2020-02-25 18:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('opennebula', '0003_auto_20200225_1428'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='vm',
+ name='id',
+ ),
+ migrations.AddField(
+ model_name='vm',
+ name='vmid',
+ field=models.IntegerField(default=42, primary_key=True, serialize=False),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/opennebula/migrations/__init__.py b/uncloud_django_based/uncloud/opennebula/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_django_based/uncloud/opennebula/models.py b/uncloud_django_based/uncloud/opennebula/models.py
new file mode 100644
index 0000000..826b615
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/models.py
@@ -0,0 +1,91 @@
+import uuid
+from django.db import models
+from django.contrib.auth import get_user_model
+from django.contrib.postgres.fields import JSONField
+
+# ungleich specific
+storage_class_mapping = {
+ 'one': 'ssd',
+ 'ssd': 'ssd',
+ 'hdd': 'hdd'
+}
+
+class VM(models.Model):
+ vmid = models.IntegerField(primary_key=True)
+ owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
+ data = JSONField()
+
+ @property
+ def uncloud_name(self):
+ return "opennebula-{}".format(self.vmid)
+
+ @property
+ def cores(self):
+ return int(self.data['TEMPLATE']['VCPU'])
+
+ @property
+ def ram_in_gb(self):
+ return int(self.data['TEMPLATE']['MEMORY'])/1024
+
+ @property
+ def disks(self):
+ """
+ If there is no disk then the key DISK does not exist.
+
+ If there is only one disk, we have a dictionary in the database.
+
+ If there are multiple disks, we have a list of dictionaries in the database.
+ """
+
+ disks = []
+
+ if 'DISK' in self.data['TEMPLATE']:
+ if type(self.data['TEMPLATE']['DISK']) is dict:
+ disks = [self.data['TEMPLATE']['DISK']]
+ else:
+ disks = self.data['TEMPLATE']['DISK']
+
+ disks = [
+ {
+ 'size_in_gb': int(d['SIZE'])/1024,
+ 'opennebula_source': d['SOURCE'],
+ 'opennebula_name': d['IMAGE'],
+ 'image_size_in_gb': int(d['ORIGINAL_SIZE'])/1024,
+ 'pool_name': d['POOL_NAME'],
+ 'image': d['IMAGE'],
+ 'source': d['SOURCE'],
+ 'source_type': d['TM_MAD'],
+ 'storage_class': storage_class_mapping[d['POOL_NAME']]
+
+ }
+ for d in disks
+ ]
+
+ return disks
+
+ @property
+ def last_host(self):
+ return ((self.data.get('HISTORY_RECORDS', {}) or {}).get('HISTORY', {}) or {}).get('HOSTNAME', None)
+
+ @property
+ def graphics(self):
+ return self.data.get('TEMPLATE', {}).get('GRAPHICS', {})
+
+ @property
+ def nics(self):
+ _nics = self.data.get('TEMPLATE', {}).get('NIC', {})
+ if isinstance(_nics, dict):
+ _nics = [_nics]
+ return _nics
+
+ @property
+ def ips(self):
+ ipv4, ipv6 = [], []
+ for nic in self.nics:
+ ip = nic.get('IP')
+ ip6 = nic.get('IP6_GLOBAL')
+ if ip:
+ ipv4.append(ip)
+ if ip6:
+ ipv6.append(ip6)
+ return ipv4, ipv6
diff --git a/uncloud_django_based/uncloud/opennebula/serializers.py b/uncloud_django_based/uncloud/opennebula/serializers.py
new file mode 100644
index 0000000..cd00622
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/serializers.py
@@ -0,0 +1,10 @@
+from rest_framework import serializers
+from opennebula.models import VM
+
+
+class OpenNebulaVMSerializer(serializers.HyperlinkedModelSerializer):
+ class Meta:
+ model = VM
+ fields = [ 'vmid', 'owner', 'data',
+ 'uncloud_name', 'cores', 'ram_in_gb',
+ 'disks', 'nics', 'ips' ]
diff --git a/uncloud_django_based/uncloud/opennebula/tests.py b/uncloud_django_based/uncloud/opennebula/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/uncloud_django_based/uncloud/opennebula/views.py b/uncloud_django_based/uncloud/opennebula/views.py
new file mode 100644
index 0000000..89b1a52
--- /dev/null
+++ b/uncloud_django_based/uncloud/opennebula/views.py
@@ -0,0 +1,16 @@
+from rest_framework import viewsets, permissions
+
+from .models import VM
+from .serializers import OpenNebulaVMSerializer
+
+class VMViewSet(viewsets.ModelViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = OpenNebulaVMSerializer
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VM.objects.all()
+ else:
+ obj = VM.objects.filter(owner=self.request.user)
+
+ return obj
diff --git a/uncloud_django_based/uncloud/requirements.txt b/uncloud_django_based/uncloud/requirements.txt
new file mode 100644
index 0000000..c7ebc65
--- /dev/null
+++ b/uncloud_django_based/uncloud/requirements.txt
@@ -0,0 +1,13 @@
+django
+djangorestframework
+django-auth-ldap
+stripe
+xmltodict
+psycopg2
+
+parsedatetime
+
+# Follow are for creating graph models
+pyparsing
+pydot
+django-extensions
diff --git a/uncloud_django_based/uncloud/uncloud/.gitignore b/uncloud_django_based/uncloud/uncloud/.gitignore
new file mode 100644
index 0000000..ef418f5
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud/.gitignore
@@ -0,0 +1 @@
+secrets.py
diff --git a/uncloud_django_based/uncloud/uncloud/__init__.py b/uncloud_django_based/uncloud/uncloud/__init__.py
new file mode 100644
index 0000000..9e2545a
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud/__init__.py
@@ -0,0 +1,4 @@
+# Define DecimalField properties, used to represent amounts of money.
+# Used in pay and auth
+AMOUNT_MAX_DIGITS=10
+AMOUNT_DECIMALS=2
diff --git a/uncloud_django_based/uncloud/uncloud/asgi.py b/uncloud_django_based/uncloud/uncloud/asgi.py
new file mode 100644
index 0000000..2b5a7a3
--- /dev/null
+++ b/uncloud_django_based/uncloud/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_django_based/uncloud/uncloud/management/commands/uncloud.py b/uncloud_django_based/uncloud/uncloud/management/commands/uncloud.py
new file mode 100644
index 0000000..bd47c6b
--- /dev/null
+++ b/uncloud_django_based/uncloud/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_django_based/uncloud/uncloud/models.py b/uncloud_django_based/uncloud/uncloud/models.py
new file mode 100644
index 0000000..bd7a931
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud/models.py
@@ -0,0 +1,35 @@
+from django.db import models
+from django.contrib.postgres.fields import JSONField
+from django.utils.translation import gettext_lazy as _
+
+class UncloudModel(models.Model):
+ """
+ This class extends the standard model with an
+ extra_data field that can be used to include public,
+ but internal information.
+
+ For instance if you migrate from an existing virtualisation
+ framework to uncloud.
+
+ The extra_data attribute should be considered a hack and whenever
+ data is necessary for running uncloud, it should **not** be stored
+ in there.
+
+ """
+
+ extra_data = JSONField(editable=False, blank=True, null=True)
+
+ class Meta:
+ abstract = True
+
+# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
+class UncloudStatus(models.TextChoices):
+ PENDING = 'PENDING', _('Pending')
+ AWAITING_PAYMENT = 'AWAITING_PAYMENT', _('Awaiting payment')
+ BEING_CREATED = 'BEING_CREATED', _('Being created')
+ SCHEDULED = 'SCHEDULED', _('Scheduled') # resource selected, waiting for dispatching
+ ACTIVE = 'ACTIVE', _('Active')
+ MODIFYING = 'MODIFYING', _('Modifying') # Resource is being changed
+ DELETED = 'DELETED', _('Deleted') # Resource has been deleted
+ DISABLED = 'DISABLED', _('Disabled') # Is usable, but cannot be used for new things
+ UNUSABLE = 'UNUSABLE', _('Unusable'), # Has some kind of error
diff --git a/uncloud_django_based/uncloud/uncloud/secrets_sample.py b/uncloud_django_based/uncloud/uncloud/secrets_sample.py
new file mode 100644
index 0000000..464662f
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud/secrets_sample.py
@@ -0,0 +1,20 @@
+# Live/test key from stripe
+STRIPE_KEY = ''
+
+# XML-RPC interface of opennebula
+OPENNEBULA_URL = 'https://opennebula.ungleich.ch:2634/RPC2'
+
+# user:pass for accessing opennebula
+OPENNEBULA_USER_PASS = 'user:password'
+
+POSTGRESQL_DB_NAME="uncloud"
+
+# See https://django-auth-ldap.readthedocs.io/en/latest/authentication.html
+LDAP_ADMIN_DN=""
+LDAP_ADMIN_PASSWORD=""
+LDAP_SERVER_URI = ""
+
+# Stripe (Credit Card payments)
+STRIPE_API_key=""
+
+SECRET_KEY="dx$iqt=lc&yrp^!z5$ay^%g5lhx1y3bcu=jg(jx0yj0ogkfqvf"
diff --git a/uncloud_django_based/uncloud/uncloud/settings.py b/uncloud_django_based/uncloud/uncloud/settings.py
new file mode 100644
index 0000000..5b4744d
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud/settings.py
@@ -0,0 +1,176 @@
+"""
+Django settings for uncloud project.
+
+Generated by 'django-admin startproject' using Django 3.0.3.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/3.0/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/3.0/ref/settings/
+"""
+
+import os
+import ldap
+
+# Uncommitted file with secrets
+import uncloud.secrets
+
+from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
+
+# Uncommitted file with local settings i.e logging
+try:
+ from uncloud.local_settings import LOGGING, DATABASES
+except ModuleNotFoundError:
+ LOGGING = {}
+ # https://docs.djangoproject.com/en/3.0/ref/settings/#databases
+ DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.postgresql',
+ 'NAME': uncloud.secrets.POSTGRESQL_DB_NAME,
+ }
+ }
+
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = uncloud.secrets.SECRET_KEY
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ 'django_extensions',
+ 'rest_framework',
+ 'uncloud',
+ 'uncloud_pay',
+ 'uncloud_auth',
+ 'uncloud_storage',
+ 'uncloud_vm',
+ 'ungleich_service',
+ 'opennebula'
+]
+
+MIDDLEWARE = [
+ 'django.middleware.security.SecurityMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'uncloud.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.template.context_processors.debug',
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = 'uncloud.wsgi.application'
+
+
+# Password validation
+# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+
+################################################################################
+# AUTH/LDAP
+
+AUTH_LDAP_SERVER_URI = uncloud.secrets.LDAP_SERVER_URI
+
+AUTH_LDAP_USER_ATTR_MAP = {
+ "first_name": "givenName",
+ "last_name": "sn",
+ "email": "mail"
+}
+
+
+AUTH_LDAP_BIND_DN = uncloud.secrets.LDAP_ADMIN_DN
+AUTH_LDAP_BIND_PASSWORD = uncloud.secrets.LDAP_ADMIN_PASSWORD
+
+AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=%(user)s)")
+
+
+################################################################################
+# AUTH/Django
+AUTHENTICATION_BACKENDS = [
+ "django_auth_ldap.backend.LDAPBackend",
+ "django.contrib.auth.backends.ModelBackend"
+]
+
+AUTH_USER_MODEL = 'uncloud_auth.User'
+
+
+################################################################################
+# AUTH/REST
+REST_FRAMEWORK = {
+ 'DEFAULT_AUTHENTICATION_CLASSES': [
+ 'rest_framework.authentication.BasicAuthentication',
+ 'rest_framework.authentication.SessionAuthentication',
+ ]
+}
+
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/3.0/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/3.0/howto/static-files/
+
+STATIC_URL = '/static/'
diff --git a/uncloud_django_based/uncloud/uncloud/urls.py b/uncloud_django_based/uncloud/uncloud/urls.py
new file mode 100644
index 0000000..a848dff
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud/urls.py
@@ -0,0 +1,66 @@
+"""uncloud URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/3.0/topics/http/urls/
+Examples:
+Function views
+ 1. Add an import: from my_app import views
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
+Class-based views
+ 1. Add an import: from other_app.views import Home
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
+Including another URLconf
+ 1. Import the include() function: from django.urls import include, path
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
+"""
+from django.contrib import admin
+from django.urls import path, include
+
+from rest_framework import routers
+
+from uncloud_vm import views as vmviews
+from uncloud_pay import views as payviews
+from ungleich_service import views as serviceviews
+from opennebula import views as oneviews
+from uncloud_auth import views as authviews
+
+router = routers.DefaultRouter()
+
+# VM
+router.register(r'vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct')
+router.register(r'vm/diskimage', vmviews.VMDiskImageProductViewSet, basename='vmdiskimageproduct')
+router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct')
+router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct')
+
+# creates VM from os image
+#router.register(r'vm/ipv6onlyvm', vmviews.VMProductViewSet, basename='vmproduct')
+# ... AND adds IPv4 mapping
+#router.register(r'vm/dualstackvm', vmviews.VMProductViewSet, basename='vmproduct')
+
+# Services
+router.register(r'service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct')
+
+# Pay
+router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-method')
+router.register(r'bill', payviews.BillViewSet, basename='bill')
+router.register(r'order', payviews.OrderViewSet, basename='order')
+router.register(r'payment', payviews.PaymentViewSet, basename='payment')
+router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-methods')
+
+
+# admin/staff urls
+router.register(r'admin/bill', payviews.AdminBillViewSet, basename='admin/bill')
+router.register(r'admin/payment', payviews.AdminPaymentViewSet, basename='admin/payment')
+router.register(r'admin/order', payviews.AdminOrderViewSet, basename='admin/order')
+router.register(r'admin/vmhost', vmviews.VMHostViewSet)
+router.register(r'admin/vmcluster', vmviews.VMClusterViewSet)
+router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula')
+
+# User/Account
+router.register(r'user', authviews.UserViewSet, basename='user')
+
+
+urlpatterns = [
+ path('', include(router.urls)),
+ path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) # for login to REST API
+]
diff --git a/uncloud_django_based/uncloud/uncloud/wsgi.py b/uncloud_django_based/uncloud/uncloud/wsgi.py
new file mode 100644
index 0000000..c4a07b8
--- /dev/null
+++ b/uncloud_django_based/uncloud/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_django_based/uncloud/uncloud_auth/__init__.py b/uncloud_django_based/uncloud/uncloud_auth/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_django_based/uncloud/uncloud_auth/admin.py b/uncloud_django_based/uncloud/uncloud_auth/admin.py
new file mode 100644
index 0000000..f91be8f
--- /dev/null
+++ b/uncloud_django_based/uncloud/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_django_based/uncloud/uncloud_auth/apps.py b/uncloud_django_based/uncloud/uncloud_auth/apps.py
new file mode 100644
index 0000000..c16bd7a
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_auth/apps.py
@@ -0,0 +1,4 @@
+from django.apps import AppConfig
+
+class AuthConfig(AppConfig):
+ name = 'uncloud_auth'
diff --git a/uncloud_django_based/uncloud/uncloud_auth/migrations/0001_initial.py b/uncloud_django_based/uncloud/uncloud_auth/migrations/0001_initial.py
new file mode 100644
index 0000000..a1f8d00
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_auth/migrations/0001_initial.py
@@ -0,0 +1,44 @@
+# Generated by Django 3.0.3 on 2020-03-03 16:49
+
+import django.contrib.auth.models
+import django.contrib.auth.validators
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('auth', '0011_update_proxy_permissions'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='User',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('password', models.CharField(max_length=128, verbose_name='password')),
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+ ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
+ ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+ ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
+ ],
+ options={
+ 'verbose_name': 'user',
+ 'verbose_name_plural': 'users',
+ 'abstract': False,
+ },
+ managers=[
+ ('objects', django.contrib.auth.models.UserManager()),
+ ],
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py b/uncloud_django_based/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py
new file mode 100644
index 0000000..ad2654f
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.0.3 on 2020-03-18 13:43
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_auth', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='amount',
+ field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]),
+ ),
+ migrations.AddField(
+ model_name='user',
+ name='maximum_credit',
+ field=models.FloatField(default=0),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py b/uncloud_django_based/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py
new file mode 100644
index 0000000..31b1717
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.3 on 2020-03-18 13:45
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_auth', '0002_auto_20200318_1343'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='user',
+ name='amount',
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='maximum_credit',
+ field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]),
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/uncloud_auth/migrations/__init__.py b/uncloud_django_based/uncloud/uncloud_auth/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_django_based/uncloud/uncloud_auth/models.py b/uncloud_django_based/uncloud/uncloud_auth/models.py
new file mode 100644
index 0000000..c3a0912
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_auth/models.py
@@ -0,0 +1,23 @@
+from django.contrib.auth.models import AbstractUser
+from django.db import models
+from django.core.validators import MinValueValidator
+
+from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
+
+from uncloud_pay.models import get_balance_for_user
+
+class User(AbstractUser):
+ """
+ We use the standard user and add a maximum credit that is allowed
+ to be accumulated. After that we need to have warnings, cancellation, etc.
+ """
+
+ maximum_credit = models.DecimalField(
+ default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS,
+ validators=[MinValueValidator(0)])
+
+ @property
+ def balance(self):
+ return get_balance_for_user(self)
diff --git a/uncloud_django_based/uncloud/uncloud_auth/serializers.py b/uncloud_django_based/uncloud/uncloud_auth/serializers.py
new file mode 100644
index 0000000..de369c3
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_auth/serializers.py
@@ -0,0 +1,15 @@
+from django.contrib.auth import get_user_model
+from rest_framework import serializers
+
+from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
+
+class UserSerializer(serializers.ModelSerializer):
+
+ class Meta:
+ model = get_user_model()
+ fields = ['username', 'email', 'balance', 'maximum_credit' ]
+
+
+
+ balance = serializers.DecimalField(max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS)
diff --git a/uncloud_django_based/uncloud/uncloud_auth/views.py b/uncloud_django_based/uncloud/uncloud_auth/views.py
new file mode 100644
index 0000000..2f78e1f
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_auth/views.py
@@ -0,0 +1,17 @@
+from rest_framework import viewsets, permissions, status
+from .serializers import *
+
+class UserViewSet(viewsets.ReadOnlyModelViewSet):
+ serializer_class = UserSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = get_user_model().objects.all()
+ else:
+ # This is a bit stupid: we have a user, we create a queryset by
+ # matching on the username. But I don't know a "nicer" way.
+ # Nico, 2020-03-18
+ obj = get_user_model().objects.filter(username=self.request.user.username)
+
+ return obj
diff --git a/uncloud_django_based/uncloud/uncloud_net/__init__.py b/uncloud_django_based/uncloud/uncloud_net/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_django_based/uncloud/uncloud_net/admin.py b/uncloud_django_based/uncloud/uncloud_net/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_net/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/uncloud_django_based/uncloud/uncloud_net/apps.py b/uncloud_django_based/uncloud/uncloud_net/apps.py
new file mode 100644
index 0000000..489beb1
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_net/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class UncloudNetConfig(AppConfig):
+ name = 'uncloud_net'
diff --git a/uncloud_django_based/uncloud/uncloud_net/models.py b/uncloud_django_based/uncloud/uncloud_net/models.py
new file mode 100644
index 0000000..6d0c742
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_net/models.py
@@ -0,0 +1,4 @@
+from django.db import models
+
+class MACAdress(models.Model):
+ prefix = 0x420000000000
diff --git a/uncloud_django_based/uncloud/uncloud_net/tests.py b/uncloud_django_based/uncloud/uncloud_net/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_net/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/uncloud_django_based/uncloud/uncloud_net/views.py b/uncloud_django_based/uncloud/uncloud_net/views.py
new file mode 100644
index 0000000..91ea44a
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_net/views.py
@@ -0,0 +1,3 @@
+from django.shortcuts import render
+
+# Create your views here.
diff --git a/uncloud_django_based/uncloud/uncloud_pay/__init__.py b/uncloud_django_based/uncloud/uncloud_pay/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_django_based/uncloud/uncloud_pay/admin.py b/uncloud_django_based/uncloud/uncloud_pay/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_pay/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/uncloud_django_based/uncloud/uncloud_pay/apps.py b/uncloud_django_based/uncloud/uncloud_pay/apps.py
new file mode 100644
index 0000000..051ffb4
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_pay/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class UncloudPayConfig(AppConfig):
+ name = 'uncloud_pay'
diff --git a/uncloud_django_based/uncloud/uncloud_pay/helpers.py b/uncloud_django_based/uncloud/uncloud_pay/helpers.py
new file mode 100644
index 0000000..f791564
--- /dev/null
+++ b/uncloud_django_based/uncloud/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_django_based/uncloud/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud_django_based/uncloud/uncloud_pay/management/commands/charge-negative-balance.py
new file mode 100644
index 0000000..24d53bf
--- /dev/null
+++ b/uncloud_django_based/uncloud/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
+
+from datetime import timedelta
+from django.utils import timezone
+
+class Command(BaseCommand):
+ help = 'Generate bills and charge customers if necessary.'
+
+ def add_arguments(self, parser):
+ pass
+
+ def handle(self, *args, **options):
+ users = User.objects.all()
+ print("Processing {} users.".format(users.count()))
+ for user in users:
+ balance = get_balance_for(user)
+ if balance < 0:
+ print("User {} has negative balance ({}), charging.".format(user.username, balance))
+ payment_method = PaymentMethod.get_primary_for(user)
+ if payment_method != None:
+ amount_to_be_charged = abs(balance)
+ charge_ok = payment_method.charge(amount_to_be_charged)
+ if not charge_ok:
+ print("ERR: charging {} with method {} failed"
+ .format(user.username, payment_method.uuid)
+ )
+ else:
+ print("ERR: no payment method registered for {}".format(user.username))
+ print("=> Done.")
diff --git a/uncloud_django_based/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud_django_based/uncloud/uncloud_pay/management/commands/generate-bills.py
new file mode 100644
index 0000000..5bd4519
--- /dev/null
+++ b/uncloud_django_based/uncloud/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_django_based/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py b/uncloud_django_based/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py
new file mode 100644
index 0000000..595fbc2
--- /dev/null
+++ b/uncloud_django_based/uncloud/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_django_based/uncloud/uncloud_pay/migrations/0001_initial.py b/uncloud_django_based/uncloud/uncloud_pay/migrations/0001_initial.py
new file mode 100644
index 0000000..89fa586
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_pay/migrations/0001_initial.py
@@ -0,0 +1,85 @@
+# Generated by Django 3.0.3 on 2020-03-05 10:17
+
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('uncloud_auth', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Bill',
+ fields=[
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('creation_date', models.DateTimeField(auto_now_add=True)),
+ ('starting_date', models.DateTimeField()),
+ ('ending_date', models.DateTimeField()),
+ ('due_date', models.DateField()),
+ ('valid', models.BooleanField(default=True)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Order',
+ fields=[
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('creation_date', models.DateTimeField(auto_now_add=True)),
+ ('starting_date', models.DateTimeField(auto_now_add=True)),
+ ('ending_date', models.DateTimeField(blank=True, null=True)),
+ ('recurring_period', models.CharField(choices=[('ONCE', 'Onetime'), ('YEAR', 'Per Year'), ('MONTH', 'Per Month'), ('MINUTE', 'Per Minute'), ('DAY', 'Per Day'), ('HOUR', 'Per Hour'), ('SECOND', 'Per Second')], default='MONTH', max_length=32)),
+ ('bill', models.ManyToManyField(blank=True, editable=False, to='uncloud_pay.Bill')),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='StripeCustomer',
+ fields=[
+ ('owner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
+ ('stripe_id', models.CharField(max_length=32)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Payment',
+ fields=[
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('amount', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
+ ('source', models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256)),
+ ('timestamp', models.DateTimeField(auto_now_add=True)),
+ ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='OrderRecord',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('one_time_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
+ ('recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
+ ('description', models.TextField()),
+ ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='PaymentMethod',
+ fields=[
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('source', models.CharField(choices=[('stripe', 'Stripe'), ('unknown', 'Unknown')], default='stripe', max_length=256)),
+ ('description', models.TextField()),
+ ('primary', models.BooleanField(default=True)),
+ ('stripe_card_id', models.CharField(blank=True, max_length=32, null=True)),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'unique_together': {('owner', 'primary')},
+ },
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/uncloud_pay/migrations/__init__.py b/uncloud_django_based/uncloud/uncloud_pay/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_django_based/uncloud/uncloud_pay/models.py b/uncloud_django_based/uncloud/uncloud_pay/models.py
new file mode 100644
index 0000000..945187b
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_pay/models.py
@@ -0,0 +1,466 @@
+from django.db import models
+from django.db.models import Q
+from django.contrib.auth import get_user_model
+from django.core.validators import MinValueValidator
+from django.utils.translation import gettext_lazy as _
+from django.utils import timezone
+from django.dispatch import receiver
+from django.core.exceptions import ObjectDoesNotExist
+import django.db.models.signals as signals
+
+import uuid
+from functools import reduce
+from math import ceil
+from datetime import timedelta
+from calendar import monthrange
+
+from decimal import Decimal
+
+import uncloud_pay.stripe
+from uncloud_pay.helpers import beginning_of_month, end_of_month
+from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
+from uncloud.models import UncloudModel, UncloudStatus
+
+
+# Used to generate bill due dates.
+BILL_PAYMENT_DELAY=timedelta(days=10)
+
+# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
+class RecurringPeriod(models.TextChoices):
+ ONE_TIME = 'ONCE', _('Onetime')
+ PER_YEAR = 'YEAR', _('Per Year')
+ PER_MONTH = 'MONTH', _('Per Month')
+ PER_MINUTE = 'MINUTE', _('Per Minute')
+ PER_DAY = 'DAY', _('Per Day')
+ PER_HOUR = 'HOUR', _('Per Hour')
+ PER_SECOND = 'SECOND', _('Per Second')
+
+
+
+def get_balance_for_user(user):
+ bills = reduce(
+ lambda acc, entry: acc + entry.total,
+ Bill.objects.filter(owner=user),
+ 0)
+ payments = reduce(
+ lambda acc, entry: acc + entry.amount,
+ Payment.objects.filter(owner=user),
+ 0)
+ return payments - bills
+
+class StripeCustomer(models.Model):
+ owner = models.OneToOneField( get_user_model(),
+ primary_key=True,
+ on_delete=models.CASCADE)
+ stripe_id = models.CharField(max_length=32)
+
+###
+# Payments and Payment Methods.
+
+class Payment(models.Model):
+ uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE)
+
+ amount = models.DecimalField(
+ default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS,
+ validators=[MinValueValidator(0)])
+
+ source = models.CharField(max_length=256,
+ choices = (
+ ('wire', 'Wire Transfer'),
+ ('stripe', 'Stripe'),
+ ('voucher', 'Voucher'),
+ ('referral', 'Referral'),
+ ('unknown', 'Unknown')
+ ),
+ default='unknown')
+ timestamp = models.DateTimeField(editable=False, auto_now_add=True)
+
+ # WIP prepaid and service activation logic by fnux.
+ ## We override save() in order to active products awaiting payment.
+ #def save(self, *args, **kwargs):
+ # # TODO: only run activation logic on creation, not on update.
+ # unpaid_bills_before_payment = Bill.get_unpaid_for(self.owner)
+ # super(Payment, self).save(*args, **kwargs) # Save payment in DB.
+ # unpaid_bills_after_payment = Bill.get_unpaid_for(self.owner)
+
+ # newly_paid_bills = list(
+ # set(unpaid_bills_before_payment) - set(unpaid_bills_after_payment))
+ # for bill in newly_paid_bills:
+ # bill.activate_orders()
+
+
+class PaymentMethod(models.Model):
+ uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE,
+ editable=False)
+ source = models.CharField(max_length=256,
+ choices = (
+ ('stripe', 'Stripe'),
+ ('unknown', 'Unknown'),
+ ),
+ default='stripe')
+ description = models.TextField()
+ primary = models.BooleanField(default=True)
+
+ # Only used for "Stripe" source
+ stripe_card_id = models.CharField(max_length=32, blank=True, null=True)
+
+ @property
+ def stripe_card_last4(self):
+ if self.source == 'stripe':
+ card_request = uncloud_pay.stripe.get_card(
+ StripeCustomer.objects.get(owner=self.owner).stripe_id,
+ self.stripe_card_id)
+ if card_request['error'] == None:
+ return card_request['response_object']['last4']
+ else:
+ return None
+ else:
+ return None
+
+
+ def charge(self, amount):
+ if amount > 0: # Make sure we don't charge negative amount by errors...
+ if self.source == 'stripe':
+ stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id
+ charge_request = uncloud_pay.stripe.charge_customer(amount, stripe_customer, self.stripe_card_id)
+ if charge_request['error'] == None:
+ payment = Payment(owner=self.owner, source=self.source, amount=amount)
+ payment.save() # TODO: Check return status
+
+ return payment
+ else:
+ raise Exception('Stripe error: {}'.format(charge_request['error']))
+ else:
+ raise Exception('This payment method is unsupported/cannot be charged.')
+ else:
+ raise Exception('Cannot charge negative amount.')
+
+
+ def get_primary_for(user):
+ methods = PaymentMethod.objects.filter(owner=user)
+ for method in methods:
+ # Do we want to do something with non-primary method?
+ if method.primary:
+ return method
+
+ return None
+
+ class Meta:
+ unique_together = [['owner', 'primary']]
+
+###
+# Bills & Payments.
+
+class Bill(models.Model):
+ uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE)
+
+ creation_date = models.DateTimeField(auto_now_add=True)
+ starting_date = models.DateTimeField()
+ ending_date = models.DateTimeField()
+ due_date = models.DateField()
+
+ valid = models.BooleanField(default=True)
+
+ @property
+ def reference(self):
+ return "{}-{}".format(
+ self.owner.username,
+ self.creation_date.strftime("%Y-%m-%d-%H%M"))
+
+ @property
+ def records(self):
+ bill_records = []
+ orders = Order.objects.filter(bill=self)
+ for order in orders:
+ for order_record in order.records:
+ bill_record = BillRecord(self, order_record)
+ bill_records.append(bill_record)
+
+ return bill_records
+
+ @property
+ def total(self):
+ return reduce(lambda acc, record: acc + record.amount, self.records, 0)
+
+ @property
+ def final(self):
+ # A bill is final when its ending date is passed.
+ return self.ending_date < timezone.now()
+
+ @staticmethod
+ def generate_for(year, month, user):
+ # /!\ We exclusively work on the specified year and month.
+
+ # Default values for next bill (if any). Only saved at the end of
+ # this method, if relevant.
+ next_bill = Bill(owner=user,
+ starting_date=beginning_of_month(year, month),
+ ending_date=end_of_month(year, month),
+ creation_date=timezone.now(),
+ due_date=timezone.now() + BILL_PAYMENT_DELAY)
+
+ # Select all orders active on the request period.
+ orders = Order.objects.filter(
+ Q(ending_date__gt=next_bill.starting_date) | Q(ending_date__isnull=True),
+ owner=user)
+
+ # Check if there is already a bill covering the order and period pair:
+ # * Get latest bill by ending_date: previous_bill.ending_date
+ # * If previous_bill.ending_date is before next_bill.ending_date, a new
+ # bill has to be generated.
+ unpaid_orders = []
+ for order in orders:
+ try:
+ previous_bill = order.bill.latest('ending_date')
+ except ObjectDoesNotExist:
+ previous_bill = None
+
+ if previous_bill == None or previous_bill.ending_date < next_bill.ending_date:
+ unpaid_orders.append(order)
+
+ # Commit next_bill if it there are 'unpaid' orders.
+ if len(unpaid_orders) > 0:
+ next_bill.save()
+
+ # It is not possible to register many-to-many relationship before
+ # the two end-objects are saved in database.
+ for order in unpaid_orders:
+ order.bill.add(next_bill)
+
+ # TODO: use logger.
+ print("Generated bill {} (amount: {}) for user {}."
+ .format(next_bill.uuid, next_bill.total, user))
+
+ return next_bill
+
+ # Return None if no bill was created.
+ return None
+
+ @staticmethod
+ def get_unpaid_for(user):
+ balance = get_balance_for(user)
+ unpaid_bills = []
+ # No unpaid bill if balance is positive.
+ if balance >= 0:
+ return []
+ else:
+ bills = Bill.objects.filter(
+ owner=user,
+ due_date__lt=timezone.now()
+ ).order_by('-creation_date')
+
+ # Amount to be paid by the customer.
+ unpaid_balance = abs(balance)
+ for bill in bills:
+ if unpaid_balance < 0:
+ break
+
+ unpaid_balance -= bill.amount
+ unpaid_bills.append(bill)
+
+ return unpaid_bills
+
+ @staticmethod
+ def get_overdue_for(user):
+ unpaid_bills = Bill.get_unpaid_for(user)
+ return list(filter(lambda bill: bill.due_date > timezone.now(), unpaid_bills))
+
+class BillRecord():
+ """
+ Entry of a bill, dynamically generated from order records.
+ """
+
+ def __init__(self, bill, order_record):
+ self.bill = bill
+ self.order = order_record.order
+ self.recurring_price = order_record.recurring_price
+ self.recurring_period = order_record.recurring_period
+ self.description = order_record.description
+
+ if self.order.starting_date > self.bill.starting_date:
+ self.one_time_price = order_record.one_time_price
+ else:
+ self.one_time_price = 0
+
+ @property
+ def recurring_count(self):
+ # Compute billing delta.
+ billed_until = self.bill.ending_date
+ if self.order.ending_date != None and self.order.ending_date < self.order.ending_date:
+ billed_until = self.order.ending_date
+
+ billed_from = self.bill.starting_date
+ if self.order.starting_date > self.bill.starting_date:
+ billed_from = self.order.starting_date
+
+ if billed_from > billed_until:
+ # TODO: think about and check edges cases. This should not be
+ # possible.
+ raise Exception('Impossible billing delta!')
+
+ billed_delta = billed_until - billed_from
+
+ # TODO: refactor this thing?
+ # TODO: weekly
+ # TODO: yearly
+ if self.recurring_period == RecurringPeriod.PER_MONTH:
+ days = ceil(billed_delta / timedelta(days=1))
+
+ # XXX: we assume monthly bills for now.
+ if (self.bill.starting_date.year != self.bill.starting_date.year or
+ self.bill.starting_date.month != self.bill.ending_date.month):
+ raise Exception('Bill {} covers more than one month. Cannot bill PER_MONTH.'.
+ format(self.bill.uuid))
+
+ # XXX: minumal length of monthly order is to be enforced somewhere else.
+ (_, days_in_month) = monthrange(
+ self.bill.starting_date.year,
+ self.bill.starting_date.month)
+ return Decimal(days / days_in_month)
+ elif self.recurring_period == RecurringPeriod.PER_DAY:
+ days = ceil(billed_delta / timedelta(days=1))
+ return Decimal(days)
+ elif self.recurring_period == RecurringPeriod.PER_HOUR:
+ hours = ceil(billed_delta / timedelta(hours=1))
+ return Decimal(hours)
+ elif self.recurring_period == RecurringPeriod.PER_SECOND:
+ seconds = ceil(billed_delta / timedelta(seconds=1))
+ return Decimal(seconds)
+ elif self.recurring_period == RecurringPeriod.ONE_TIME:
+ return Decimal(0)
+ else:
+ raise Exception('Unsupported recurring period: {}.'.
+ format(record.recurring_period))
+
+ @property
+ def amount(self):
+ return self.recurring_price * self.recurring_count + self.one_time_price
+
+###
+# Orders.
+
+# Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
+# bills. Do **NOT** mutate then!
+class Order(models.Model):
+ uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE,
+ editable=False)
+
+ # TODO: enforce ending_date - starting_date to be larger than recurring_period.
+ creation_date = models.DateTimeField(auto_now_add=True)
+ starting_date = models.DateTimeField(auto_now_add=True)
+ ending_date = models.DateTimeField(blank=True,
+ null=True)
+
+ bill = models.ManyToManyField(Bill,
+ editable=False,
+ blank=True)
+
+ recurring_period = models.CharField(max_length=32,
+ choices = RecurringPeriod.choices,
+ default = RecurringPeriod.PER_MONTH)
+
+ @property
+ def records(self):
+ return OrderRecord.objects.filter(order=self)
+
+ @property
+ def one_time_price(self):
+ return reduce(lambda acc, record: acc + record.one_time_price, self.records, 0)
+
+ @property
+ def recurring_price(self):
+ return reduce(lambda acc, record: acc + record.recurring_price, self.records, 0)
+
+ def add_record(self, one_time_price, recurring_price, description):
+ OrderRecord.objects.create(order=self,
+ one_time_price=one_time_price,
+ recurring_price=recurring_price,
+ description=description)
+
+
+class OrderRecord(models.Model):
+ """
+ Order records store billing informations for products: the actual product
+ might be mutated and/or moved to another order but we do not want to loose
+ the details of old orders.
+
+ Used as source of trust to dynamically generate bill entries.
+ """
+
+ order = models.ForeignKey(Order, on_delete=models.CASCADE)
+ one_time_price = models.DecimalField(default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS,
+ validators=[MinValueValidator(0)])
+ recurring_price = models.DecimalField(default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS,
+ validators=[MinValueValidator(0)])
+
+ description = models.TextField()
+
+
+ @property
+ def recurring_period(self):
+ return self.order.recurring_period
+
+ @property
+ def starting_date(self):
+ return self.order.starting_date
+
+ @property
+ def ending_date(self):
+ return self.order.ending_date
+
+
+###
+# Products
+
+# Abstract (= no database representation) class used as parent for products
+# (e.g. uncloud_vm.models.VMProduct).
+class Product(UncloudModel):
+ uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE,
+ editable=False)
+
+ description = ""
+
+ status = models.CharField(max_length=32,
+ choices=UncloudStatus.choices,
+ default=UncloudStatus.PENDING)
+
+ order = models.ForeignKey(Order,
+ on_delete=models.CASCADE,
+ editable=False,
+ null=True)
+
+ @property
+ def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH):
+ pass # To be implemented in child.
+
+ @property
+ def one_time_price(self):
+ return 0
+
+ @property
+ def recurring_period(self):
+ return self.order.recurring_period
+
+ @staticmethod
+ def allowed_recurring_periods():
+ return RecurringPeriod.choices
+
+ class Meta:
+ abstract = True
diff --git a/uncloud_django_based/uncloud/uncloud_pay/serializers.py b/uncloud_django_based/uncloud/uncloud_pay/serializers.py
new file mode 100644
index 0000000..a0a8635
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_pay/serializers.py
@@ -0,0 +1,73 @@
+from django.contrib.auth import get_user_model
+from rest_framework import serializers
+from .models import *
+
+###
+# Payments and Payment Methods.
+
+class PaymentSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Payment
+ fields = ['owner', 'amount', 'source', 'timestamp']
+
+class PaymentMethodSerializer(serializers.ModelSerializer):
+ stripe_card_last4 = serializers.IntegerField()
+
+ class Meta:
+ model = PaymentMethod
+ fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4']
+
+class ChargePaymentMethodSerializer(serializers.Serializer):
+ amount = serializers.DecimalField(max_digits=10, decimal_places=2)
+
+class CreditCardSerializer(serializers.Serializer):
+ number = serializers.IntegerField()
+ exp_month = serializers.IntegerField()
+ exp_year = serializers.IntegerField()
+ cvc = serializers.IntegerField()
+
+class CreatePaymentMethodSerializer(serializers.ModelSerializer):
+ credit_card = CreditCardSerializer()
+
+ class Meta:
+ model = PaymentMethod
+ fields = ['source', 'description', 'primary', 'credit_card']
+
+###
+# Orders & Products.
+
+class OrderRecordSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = OrderRecord
+ fields = ['one_time_price', 'recurring_price', 'description']
+
+
+class OrderSerializer(serializers.ModelSerializer):
+ records = OrderRecordSerializer(many=True, read_only=True)
+ class Meta:
+ model = Order
+ fields = ['uuid', 'creation_date', 'starting_date', 'ending_date',
+ 'bill', 'recurring_period', 'records', 'recurring_price', 'one_time_price']
+
+
+###
+# Bills
+
+# TODO: remove magic numbers for decimal fields
+class BillRecordSerializer(serializers.Serializer):
+ order = serializers.HyperlinkedRelatedField(
+ view_name='order-detail',
+ read_only=True)
+ description = serializers.CharField()
+ recurring_period = serializers.CharField()
+ recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2)
+ recurring_count = serializers.DecimalField(max_digits=10, decimal_places=2)
+ one_time_price = serializers.DecimalField(max_digits=10, decimal_places=2)
+ amount = serializers.DecimalField(max_digits=10, decimal_places=2)
+
+class BillSerializer(serializers.ModelSerializer):
+ records = BillRecordSerializer(many=True, read_only=True)
+ class Meta:
+ model = Bill
+ fields = ['reference', 'owner', 'total', 'due_date', 'creation_date',
+ 'starting_date', 'ending_date', 'records', 'final']
diff --git a/uncloud_django_based/uncloud/uncloud_pay/stripe.py b/uncloud_django_based/uncloud/uncloud_pay/stripe.py
new file mode 100644
index 0000000..4f28d94
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_pay/stripe.py
@@ -0,0 +1,133 @@
+import stripe
+import stripe.error
+import logging
+
+from django.core.exceptions import ObjectDoesNotExist
+import uncloud_pay.models
+
+import uncloud.secrets
+
+# Static stripe configuration used below.
+CURRENCY = 'chf'
+
+stripe.api_key = uncloud.secrets.STRIPE_KEY
+
+# Helper (decorator) used to catch errors raised by stripe logic.
+def handle_stripe_error(f):
+ def handle_problems(*args, **kwargs):
+ response = {
+ 'paid': False,
+ 'response_object': None,
+ 'error': None
+ }
+
+ common_message = "Currently it is not possible to make payments."
+ try:
+ response_object = f(*args, **kwargs)
+ response = {
+ 'response_object': response_object,
+ 'error': None
+ }
+ return response
+ except stripe.error.CardError as e:
+ # Since it's a decline, stripe.error.CardError will be caught
+ body = e.json_body
+ err = body['error']
+ response.update({'error': err['message']})
+ logging.error(str(e))
+ return response
+ except stripe.error.RateLimitError:
+ response.update(
+ {'error': "Too many requests made to the API too quickly"})
+ return response
+ except stripe.error.InvalidRequestError as e:
+ logging.error(str(e))
+ response.update({'error': "Invalid parameters"})
+ return response
+ except stripe.error.AuthenticationError as e:
+ # Authentication with Stripe's API failed
+ # (maybe you changed API keys recently)
+ logging.error(str(e))
+ response.update({'error': common_message})
+ return response
+ except stripe.error.APIConnectionError as e:
+ logging.error(str(e))
+ response.update({'error': common_message})
+ return response
+ except stripe.error.StripeError as e:
+ # maybe send email
+ logging.error(str(e))
+ response.update({'error': common_message})
+ return response
+ except Exception as e:
+ # maybe send email
+ logging.error(str(e))
+ response.update({'error': common_message})
+ return response
+
+ return handle_problems
+
+# Convenience CC container, also used for serialization.
+class CreditCard():
+ number = None
+ exp_year = None
+ exp_month = None
+ cvc = None
+
+ def __init__(self, number, exp_month, exp_year, cvc):
+ self.number=number
+ self.exp_year = exp_year
+ self.exp_month = exp_month
+ self.cvc = cvc
+
+# Actual Stripe logic.
+
+def get_customer_id_for(user):
+ try:
+ # .get() raise if there is no matching entry.
+ return uncloud_pay.models.StripeCustomer.objects.get(owner=user).stripe_id
+ except ObjectDoesNotExist:
+ # No entry yet - making a new one.
+ customer_request = create_customer(user.username, user.email)
+ if customer_request['error'] == None:
+ mapping = uncloud_pay.models.StripeCustomer.objects.create(
+ owner=user,
+ stripe_id=customer_request['response_object']['id']
+ )
+ return mapping.stripe_id
+ else:
+ return None
+
+@handle_stripe_error
+def create_card(customer_id, credit_card):
+ return stripe.Customer.create_source(
+ customer_id,
+ card={
+ 'number': credit_card.number,
+ 'exp_month': credit_card.exp_month,
+ 'exp_year': credit_card.exp_year,
+ 'cvc': credit_card.cvc
+ })
+
+@handle_stripe_error
+def get_card(customer_id, card_id):
+ return stripe.Customer.retrieve_source(customer_id, card_id)
+
+@handle_stripe_error
+def charge_customer(amount, customer_id, card_id):
+ # Amount is in CHF but stripes requires smallest possible unit.
+ # See https://stripe.com/docs/api/charges/create
+ adjusted_amount = int(amount * 100)
+ return stripe.Charge.create(
+ amount=adjusted_amount,
+ currency=CURRENCY,
+ customer=customer_id,
+ source=card_id)
+
+@handle_stripe_error
+def create_customer(name, email):
+ return stripe.Customer.create(name=name, email=email)
+
+@handle_stripe_error
+def get_customer(customer_id):
+ return stripe.Customer.retrieve(customer_id)
diff --git a/uncloud_django_based/uncloud/uncloud_pay/tests.py b/uncloud_django_based/uncloud/uncloud_pay/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_pay/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/uncloud_django_based/uncloud/uncloud_pay/views.py b/uncloud_django_based/uncloud/uncloud_pay/views.py
new file mode 100644
index 0000000..e86a464
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_pay/views.py
@@ -0,0 +1,150 @@
+from django.shortcuts import render
+from django.db import transaction
+from django.contrib.auth import get_user_model
+from rest_framework import viewsets, permissions, status
+from rest_framework.response import Response
+from rest_framework.decorators import action
+
+import json
+
+from .models import *
+from .serializers import *
+from datetime import datetime
+import uncloud_pay.stripe as uncloud_stripe
+
+###
+# Standard user views:
+
+class BalanceViewSet(viewsets.ViewSet):
+ # here we return a number
+ # number = sum(payments) - sum(bills)
+
+ #bills = Bill.objects.filter(owner=self.request.user)
+ #payments = Payment.objects.filter(owner=self.request.user)
+
+ # sum_paid = sum([ amount for amount payments..,. ]) # you get the picture
+ # sum_to_be_paid = sum([ amount for amount bills..,. ]) # you get the picture
+ pass
+
+
+class BillViewSet(viewsets.ReadOnlyModelViewSet):
+ serializer_class = BillSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return Bill.objects.filter(owner=self.request.user)
+
+class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
+ serializer_class = PaymentSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return Payment.objects.filter(owner=self.request.user)
+
+class OrderViewSet(viewsets.ReadOnlyModelViewSet):
+ serializer_class = OrderSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return Order.objects.filter(owner=self.request.user)
+
+
+class PaymentMethodViewSet(viewsets.ModelViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_serializer_class(self):
+ if self.action == 'create':
+ return CreatePaymentMethodSerializer
+ elif self.action == 'charge':
+ return ChargePaymentMethodSerializer
+ else:
+ return PaymentMethodSerializer
+
+
+ def get_queryset(self):
+ return PaymentMethod.objects.filter(owner=self.request.user)
+
+ # XXX: Handling of errors is far from great down there.
+ @transaction.atomic
+ def create(self, request):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ # Retrieve Stripe customer ID for user.
+ customer_id = uncloud_stripe.get_customer_id_for(request.user)
+ if customer_id == None:
+ return Response(
+ {'error': 'Could not resolve customer stripe ID.'},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+ # Register card under stripe customer.
+ credit_card = uncloud_stripe.CreditCard(**serializer.validated_data.pop('credit_card'))
+ card_request = uncloud_stripe.create_card(customer_id, credit_card)
+ if card_request['error']:
+ return Response({'stripe_error': card_request['error']}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+ card_id = card_request['response_object']['id']
+
+ # Save payment method locally.
+ serializer.validated_data['stripe_card_id'] = card_request['response_object']['id']
+ payment_method = PaymentMethod.objects.create(owner=request.user, **serializer.validated_data)
+
+ # We do not want to return the credit card details sent with the POST
+ # request.
+ output_serializer = PaymentMethodSerializer(payment_method)
+ return Response(output_serializer.data)
+
+ @action(detail=True, methods=['post'])
+ def charge(self, request, pk=None):
+ payment_method = self.get_object()
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ amount = serializer.validated_data['amount']
+ try:
+ payment = payment_method.charge(amount)
+ output_serializer = PaymentSerializer(payment)
+ return Response(output_serializer.data)
+ except Exception as e:
+ return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+###
+# Admin views.
+
+class AdminPaymentViewSet(viewsets.ModelViewSet):
+ serializer_class = PaymentSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return Payment.objects.all()
+
+ def create(self, request):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ serializer.save(timestamp=datetime.now())
+
+ headers = self.get_success_headers(serializer.data)
+ return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+
+class AdminBillViewSet(viewsets.ModelViewSet):
+ serializer_class = BillSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return Bill.objects.all()
+
+ def unpaid(self, request):
+ return Bill.objects.filter(owner=self.request.user, paid=False)
+
+ def create(self, request):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ serializer.save(creation_date=datetime.now())
+
+ headers = self.get_success_headers(serializer.data)
+ return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+
+class AdminOrderViewSet(viewsets.ModelViewSet):
+ serializer_class = OrderSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ return Order.objects.all()
diff --git a/uncloud_django_based/uncloud/uncloud_storage/__init__.py b/uncloud_django_based/uncloud/uncloud_storage/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_django_based/uncloud/uncloud_storage/admin.py b/uncloud_django_based/uncloud/uncloud_storage/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_storage/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/uncloud_django_based/uncloud/uncloud_storage/apps.py b/uncloud_django_based/uncloud/uncloud_storage/apps.py
new file mode 100644
index 0000000..38b2301
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_storage/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class UncloudStorageConfig(AppConfig):
+ name = 'uncloud_storage'
diff --git a/uncloud_django_based/uncloud/uncloud_storage/models.py b/uncloud_django_based/uncloud/uncloud_storage/models.py
new file mode 100644
index 0000000..0dac5c2
--- /dev/null
+++ b/uncloud_django_based/uncloud/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_django_based/uncloud/uncloud_storage/tests.py b/uncloud_django_based/uncloud/uncloud_storage/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_storage/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/uncloud_django_based/uncloud/uncloud_storage/views.py b/uncloud_django_based/uncloud/uncloud_storage/views.py
new file mode 100644
index 0000000..91ea44a
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_storage/views.py
@@ -0,0 +1,3 @@
+from django.shortcuts import render
+
+# Create your views here.
diff --git a/uncloud_django_based/uncloud/uncloud_vm/__init__.py b/uncloud_django_based/uncloud/uncloud_vm/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_django_based/uncloud/uncloud_vm/admin.py b/uncloud_django_based/uncloud/uncloud_vm/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/uncloud_django_based/uncloud/uncloud_vm/apps.py b/uncloud_django_based/uncloud/uncloud_vm/apps.py
new file mode 100644
index 0000000..c5e94a5
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class UncloudVmConfig(AppConfig):
+ name = 'uncloud_vm'
diff --git a/uncloud_django_based/uncloud/uncloud_vm/management/commands/vm.py b/uncloud_django_based/uncloud/uncloud_vm/management/commands/vm.py
new file mode 100644
index 0000000..667c5ad
--- /dev/null
+++ b/uncloud_django_based/uncloud/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_django_based/uncloud/uncloud_vm/migrations/0001_initial.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0001_initial.py
new file mode 100644
index 0000000..f9f40d8
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/migrations/0001_initial.py
@@ -0,0 +1,108 @@
+# Generated by Django 3.0.3 on 2020-03-05 10:34
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('uncloud_pay', '0001_initial'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='VMDiskImageProduct',
+ fields=[
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=256)),
+ ('is_os_image', models.BooleanField(default=False)),
+ ('is_public', models.BooleanField(default=False)),
+ ('size_in_gb', models.FloatField(blank=True, null=True)),
+ ('import_url', models.URLField(blank=True, null=True)),
+ ('image_source', models.CharField(max_length=128, null=True)),
+ ('image_source_type', models.CharField(max_length=128, null=True)),
+ ('storage_class', models.CharField(choices=[('hdd', 'HDD'), ('ssd', 'SSD')], default='ssd', max_length=32)),
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('creating', 'Creating'), ('active', 'Active'), ('disabled', 'Disabled'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32)),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VMHost',
+ fields=[
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('hostname', models.CharField(max_length=253, unique=True)),
+ ('physical_cores', models.IntegerField(default=0)),
+ ('usable_cores', models.IntegerField(default=0)),
+ ('usable_ram_in_gb', models.FloatField(default=0)),
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('creating', 'Creating'), ('active', 'Active'), ('disabled', 'Disabled'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32)),
+ ('vms', models.TextField(default='')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VMProduct',
+ fields=[
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted')], default='PENDING', max_length=32)),
+ ('name', models.CharField(max_length=32)),
+ ('cores', models.IntegerField()),
+ ('ram_in_gb', models.FloatField()),
+ ('vmid', models.IntegerField(null=True)),
+ ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('vmhost', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMHost')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='VMWithOSProduct',
+ fields=[
+ ('vmproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.VMProduct')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ bases=('uncloud_vm.vmproduct',),
+ ),
+ migrations.CreateModel(
+ name='VMSnapshotProduct',
+ fields=[
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted')], default='PENDING', max_length=32)),
+ ('gb_ssd', models.FloatField(editable=False)),
+ ('gb_hdd', models.FloatField(editable=False)),
+ ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='VMNetworkCard',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('mac_address', models.BigIntegerField()),
+ ('ip_address', models.GenericIPAddressField(blank=True, null=True)),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='VMDiskProduct',
+ fields=[
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('size_in_gb', models.FloatField(blank=True)),
+ ('image', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMDiskImageProduct')),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')),
+ ],
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py
new file mode 100644
index 0000000..2711b33
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.3 on 2020-03-05 13:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_vm', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='vmdiskimageproduct',
+ name='storage_class',
+ field=models.CharField(choices=[('HDD', 'HDD'), ('SSD', 'SSD')], default='SSD', max_length=32),
+ ),
+ migrations.AlterField(
+ model_name='vmproduct',
+ name='name',
+ field=models.CharField(blank=True, max_length=32, null=True),
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py
new file mode 100644
index 0000000..70ee863
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.0.3 on 2020-03-05 13:58
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_vm', '0002_auto_20200305_1321'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='vmhost',
+ name='vms',
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py
new file mode 100644
index 0000000..5f44b57
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.0.3 on 2020-03-17 14:40
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_vm', '0003_remove_vmhost_vms'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='vmproduct',
+ name='vmid',
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py
new file mode 100644
index 0000000..3799e6a
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py
@@ -0,0 +1,50 @@
+# Generated by Django 3.0.3 on 2020-03-21 10:58
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_vm', '0004_remove_vmproduct_vmid'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vmdiskimageproduct',
+ name='extra_data',
+ field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name='vmdiskproduct',
+ name='extra_data',
+ field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name='vmhost',
+ name='extra_data',
+ field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name='vmproduct',
+ name='extra_data',
+ field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True),
+ ),
+ migrations.AddField(
+ model_name='vmsnapshotproduct',
+ name='extra_data',
+ field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True),
+ ),
+ migrations.AlterField(
+ model_name='vmdiskproduct',
+ name='vm',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='disks', to='uncloud_vm.VMProduct'),
+ ),
+ migrations.AlterField(
+ model_name='vmsnapshotproduct',
+ name='vm',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='uncloud_vm.VMProduct'),
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py
new file mode 100644
index 0000000..7726c9b
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py
@@ -0,0 +1,57 @@
+# Generated by Django 3.0.3 on 2020-03-22 17:58
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_vm', '0005_auto_20200321_1058'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='VMCluster',
+ fields=[
+ ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)),
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=128, unique=True)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.AlterField(
+ model_name='vmdiskimageproduct',
+ name='is_public',
+ field=models.BooleanField(default=False, editable=False),
+ ),
+ migrations.AlterField(
+ model_name='vmdiskimageproduct',
+ name='status',
+ field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32),
+ ),
+ migrations.AlterField(
+ model_name='vmhost',
+ name='status',
+ field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32),
+ ),
+ migrations.AlterField(
+ model_name='vmproduct',
+ name='status',
+ field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32),
+ ),
+ migrations.AlterField(
+ model_name='vmsnapshotproduct',
+ name='status',
+ field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32),
+ ),
+ migrations.AddField(
+ model_name='vmproduct',
+ name='vmcluster',
+ field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMCluster'),
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py
new file mode 100644
index 0000000..6766dd7
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.0.3 on 2020-03-22 18:09
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_vm', '0006_auto_20200322_1758'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vmhost',
+ name='vmcluster',
+ field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMCluster'),
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/uncloud_vm/migrations/__init__.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_django_based/uncloud/uncloud_vm/models.py b/uncloud_django_based/uncloud/uncloud_vm/models.py
new file mode 100644
index 0000000..3b2c46b
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/models.py
@@ -0,0 +1,197 @@
+import uuid
+
+from django.db import models
+from django.contrib.auth import get_user_model
+
+
+# Uncomment if you override model's clean method
+# from django.core.exceptions import ValidationError
+
+from uncloud_pay.models import Product, RecurringPeriod
+from uncloud.models import UncloudModel, UncloudStatus
+
+import uncloud_pay.models as pay_models
+import uncloud_storage.models
+
+class VMCluster(UncloudModel):
+ uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ name = models.CharField(max_length=128, unique=True)
+
+
+class VMHost(UncloudModel):
+ uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+
+ # 253 is the maximum DNS name length
+ hostname = models.CharField(max_length=253, unique=True)
+
+ vmcluster = models.ForeignKey(
+ VMCluster, on_delete=models.CASCADE, editable=False, blank=True, null=True
+ )
+
+ # indirectly gives a maximum number of cores / VM - f.i. 32
+ physical_cores = models.IntegerField(default=0)
+
+ # determines the maximum usable cores - f.i. 320 if you overbook by a factor of 10
+ usable_cores = models.IntegerField(default=0)
+
+ # ram that can be used of the server
+ usable_ram_in_gb = models.FloatField(default=0)
+
+ status = models.CharField(
+ max_length=32, choices=UncloudStatus.choices, default=UncloudStatus.PENDING
+ )
+
+ @property
+ def vms(self):
+ return VMProduct.objects.filter(vmhost=self)
+
+ @property
+ def used_ram_in_gb(self):
+ return sum([vm.ram_in_gb for vm in VMProduct.objects.filter(vmhost=self)])
+
+ @property
+ def available_ram_in_gb(self):
+ return self.usable_ram_in_gb - self.used_ram_in_gb
+
+ @property
+ def available_cores(self):
+ return self.usable_cores - sum([vm.cores for vm in self.vms ])
+
+
+class VMProduct(Product):
+ vmhost = models.ForeignKey(
+ VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True
+ )
+
+ vmcluster = models.ForeignKey(
+ VMCluster, on_delete=models.CASCADE, editable=False, blank=True, null=True
+ )
+
+ # VM-specific. The name is only intended for customers: it's a pain to
+ # remember IDs (speaking from experience as ungleich customer)!
+ name = models.CharField(max_length=32, blank=True, null=True)
+ cores = models.IntegerField()
+ ram_in_gb = models.FloatField()
+
+
+ def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH):
+ # TODO: move magic numbers in variables
+ if recurring_period == RecurringPeriod.PER_MONTH:
+ return self.cores * 3 + self.ram_in_gb * 4
+ elif recurring_period == RecurringPeriod.PER_HOUR:
+ return self.cores * 4.0/(30 * 24) + self.ram_in_gb * 4.5/(30* 24)
+ else:
+ raise Exception('Invalid recurring period for VM Product pricing.')
+
+ def __str__(self):
+ return "VM {} ({}): {} cores {} gb ram".format(self.uuid,
+ self.name,
+ self.cores,
+ self.ram_in_gb)
+ @property
+ def description(self):
+ return "Virtual machine '{}': {} core(s), {}GB memory".format(
+ self.name, self.cores, self.ram_in_gb)
+
+ @staticmethod
+ def allowed_recurring_periods():
+ return list(filter(
+ lambda pair: pair[0] in [RecurringPeriod.PER_MONTH, RecurringPeriod.PER_HOUR],
+ RecurringPeriod.choices))
+
+class VMWithOSProduct(VMProduct):
+ pass
+
+
+class VMDiskImageProduct(UncloudModel):
+ """
+ Images are used for cloning/linking.
+
+ They are the base for images.
+
+ """
+
+ uuid = models.UUIDField(
+ primary_key=True, default=uuid.uuid4, editable=False
+ )
+ owner = models.ForeignKey(
+ get_user_model(), on_delete=models.CASCADE, editable=False
+ )
+
+ name = models.CharField(max_length=256)
+ is_os_image = models.BooleanField(default=False)
+ is_public = models.BooleanField(default=False, editable=False) # only allow admins to set this
+
+ size_in_gb = models.FloatField(null=True, blank=True)
+ import_url = models.URLField(null=True, blank=True)
+ image_source = models.CharField(max_length=128, null=True)
+ image_source_type = models.CharField(max_length=128, null=True)
+
+ storage_class = models.CharField(max_length=32,
+ choices = uncloud_storage.models.StorageClass.choices,
+ default = uncloud_storage.models.StorageClass.SSD)
+
+ status = models.CharField(
+ max_length=32, choices=UncloudStatus.choices, default=UncloudStatus.PENDING
+ )
+
+ def __str__(self):
+ return "VMDiskImage {} ({}): {} gb".format(self.uuid,
+ self.name,
+ self.size_in_gb)
+
+
+
+class VMDiskProduct(UncloudModel):
+ """
+ The VMDiskProduct is attached to a VM.
+
+ It is based on a VMDiskImageProduct that will be used as a basis.
+
+ It can be enlarged, but not shrinked compared to the VMDiskImageProduct.
+ """
+
+ uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE,
+ editable=False)
+
+ vm = models.ForeignKey(VMProduct,
+ related_name='disks',
+ on_delete=models.CASCADE)
+ image = models.ForeignKey(VMDiskImageProduct, on_delete=models.CASCADE)
+
+ size_in_gb = models.FloatField(blank=True)
+
+ # Sample code for clean method
+
+ # Ensures that a VMDiskProduct can only be created from a VMDiskImageProduct
+ # that is in status 'active'
+
+ # def clean(self):
+ # if self.image.status != 'active':
+ # raise ValidationError({
+ # 'image': 'VM Disk must be created from an active disk image.'
+ # })
+
+ def save(self, *args, **kwargs):
+ self.full_clean()
+ super().save(*args, **kwargs)
+
+
+class VMNetworkCard(models.Model):
+ vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE)
+
+ mac_address = models.BigIntegerField()
+
+ ip_address = models.GenericIPAddressField(blank=True,
+ null=True)
+
+
+class VMSnapshotProduct(Product):
+ gb_ssd = models.FloatField(editable=False)
+ gb_hdd = models.FloatField(editable=False)
+
+ vm = models.ForeignKey(VMProduct,
+ related_name='snapshots',
+ on_delete=models.CASCADE)
diff --git a/uncloud_django_based/uncloud/uncloud_vm/serializers.py b/uncloud_django_based/uncloud/uncloud_vm/serializers.py
new file mode 100644
index 0000000..c0cca48
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/serializers.py
@@ -0,0 +1,104 @@
+from django.contrib.auth import get_user_model
+
+from rest_framework import serializers
+
+from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct, VMCluster
+from uncloud_pay.models import RecurringPeriod
+
+GB_SSD_PER_DAY=0.012
+GB_HDD_PER_DAY=0.0006
+
+GB_SSD_PER_DAY=0.012
+GB_HDD_PER_DAY=0.0006
+
+
+class VMHostSerializer(serializers.HyperlinkedModelSerializer):
+ vms = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
+
+ class Meta:
+ model = VMHost
+ fields = '__all__'
+ read_only_fields = [ 'vms' ]
+
+class VMClusterSerializer(serializers.HyperlinkedModelSerializer):
+ class Meta:
+ model = VMCluster
+ fields = '__all__'
+
+
+class VMDiskProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskProduct
+ fields = '__all__'
+
+class VMDiskImageProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMDiskImageProduct
+ fields = '__all__'
+
+
+
+class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer):
+ """
+ Create an interface similar to standard DCL
+ """
+
+ # Custom field used at creation (= ordering) only.
+ recurring_period = serializers.ChoiceField(
+ choices=VMProduct.allowed_recurring_periods())
+
+ os_disk_uuid = serializers.UUIDField()
+ # os_disk_size =
+
+ class Meta:
+ model = VMProduct
+
+class ManagedVMProductSerializer(serializers.ModelSerializer):
+ """
+ Managed VM serializer used in ungleich_service app.
+ """
+ class Meta:
+ model = VMProduct
+ fields = [ 'cores', 'ram_in_gb']
+
+class VMSnapshotProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMSnapshotProduct
+ fields = '__all__'
+
+
+ # verify that vm.owner == user.request
+ def validate_vm(self, value):
+ if not value.owner == self.context['request'].user:
+ raise serializers.ValidationError("VM {} not found for owner {}.".format(value,
+ self.context['request'].user))
+ disks = VMDiskProduct.objects.filter(vm=value)
+
+ if len(disks) == 0:
+ raise serializers.ValidationError("VM {} does not have any disks, cannot snapshot".format(value.uuid))
+
+ return value
+
+ pricing = {}
+ pricing['per_gb_ssd'] = 0.012
+ pricing['per_gb_hdd'] = 0.0006
+ pricing['recurring_period'] = 'per_day'
+
+class VMProductSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = VMProduct
+ fields = ['uuid', 'order', 'owner', 'status', 'name',
+ 'cores', 'ram_in_gb', 'recurring_period',
+ 'snapshots', 'disks',
+ 'extra_data' ]
+ read_only_fields = ['uuid', 'order', 'owner', 'status' ]
+
+ # Custom field used at creation (= ordering) only.
+ recurring_period = serializers.ChoiceField(
+ choices=VMProduct.allowed_recurring_periods())
+
+ snapshots = VMSnapshotProductSerializer(many=True,
+ read_only=True)
+
+ disks = VMDiskProductSerializer(many=True,
+ read_only=True)
diff --git a/uncloud_django_based/uncloud/uncloud_vm/tests.py b/uncloud_django_based/uncloud/uncloud_vm/tests.py
new file mode 100644
index 0000000..8d7994f
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/tests.py
@@ -0,0 +1,112 @@
+import datetime
+
+import parsedatetime
+
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from django.utils import timezone
+from django.core.exceptions import ValidationError
+
+from uncloud_vm.models import VMDiskImageProduct, VMDiskProduct, VMProduct, VMHost
+from uncloud_pay.models import Order
+
+User = get_user_model()
+cal = parsedatetime.Calendar()
+
+
+# If you want to check the test database using some GUI/cli tool
+# then use the following connecting parameters
+
+# host: localhost
+# database: test_uncloud
+# user: root
+# password:
+# port: 5432
+
+class VMTestCase(TestCase):
+ @classmethod
+ def setUpClass(cls):
+ # Setup vm host
+ cls.vm_host, created = VMHost.objects.get_or_create(
+ hostname='serverx.placey.ungleich.ch', physical_cores=32, usable_cores=320,
+ usable_ram_in_gb=512.0, status='active'
+ )
+ super().setUpClass()
+
+ def setUp(self) -> None:
+ # Setup two users as it is common to test with different user
+ self.user = User.objects.create_user(
+ username='testuser', email='test@test.com', first_name='Test', last_name='User'
+ )
+ self.user2 = User.objects.create_user(
+ username='Meow', email='meow123@test.com', first_name='Meow', last_name='Cat'
+ )
+ super().setUp()
+
+ def create_sample_vm(self, owner):
+ one_month_later, parse_status = cal.parse("1 month later")
+ return VMProduct.objects.create(
+ vmhost=self.vm_host, cores=2, ram_in_gb=4, owner=owner,
+ order=Order.objects.create(
+ owner=owner,
+ creation_date=datetime.datetime.now(tz=timezone.utc),
+ starting_date=datetime.datetime.now(tz=timezone.utc),
+ ending_date=datetime.datetime(*one_month_later[:6], tzinfo=timezone.utc),
+ recurring_price=4.0, one_time_price=5.0, recurring_period='per_month'
+ )
+ )
+
+ def test_disk_product(self):
+ """Ensures that a VMDiskProduct can only be created from a VMDiskImageProduct
+ that is in status 'active'"""
+
+ vm = self.create_sample_vm(owner=self.user)
+
+ pending_disk_image = VMDiskImageProduct.objects.create(
+ owner=self.user, name='pending_disk_image', is_os_image=True, is_public=True, size_in_gb=10,
+ status='pending'
+ )
+ try:
+ vm_disk_product = VMDiskProduct.objects.create(
+ owner=self.user, vm=vm, image=pending_disk_image, size_in_gb=10
+ )
+ except ValidationError:
+ vm_disk_product = None
+
+ self.assertIsNone(
+ vm_disk_product,
+ msg='VMDiskProduct created with disk image whose status is not active.'
+ )
+
+ def test_vm_disk_product_creation(self):
+ """Ensure that a user can only create a VMDiskProduct for an existing VM"""
+
+ disk_image = VMDiskImageProduct.objects.create(
+ owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10,
+ status='active'
+ )
+
+ with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for non-existing VM'):
+ # Create VMProduct object but don't save it in database
+ vm = VMProduct()
+
+ vm_disk_product = VMDiskProduct.objects.create(
+ owner=self.user, vm=vm, image=disk_image, size_in_gb=10
+ )
+
+ def test_vm_disk_product_creation_for_someone_else(self):
+ """Ensure that a user can only create a VMDiskProduct for his/her own VM"""
+
+ # Create a VM which is ownership of self.user2
+ someone_else_vm = self.create_sample_vm(owner=self.user2)
+
+ # 'self.user' would try to create a VMDiskProduct for 'user2's VM
+ with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for someone else VM.'):
+ vm_disk_product = VMDiskProduct.objects.create(
+ owner=self.user, vm=someone_else_vm,
+ size_in_gb=10,
+ image=VMDiskImageProduct.objects.create(
+ owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10,
+ status='active'
+ )
+ )
diff --git a/uncloud_django_based/uncloud/uncloud_vm/views.py b/uncloud_django_based/uncloud/uncloud_vm/views.py
new file mode 100644
index 0000000..0672904
--- /dev/null
+++ b/uncloud_django_based/uncloud/uncloud_vm/views.py
@@ -0,0 +1,224 @@
+from django.db import transaction
+from django.shortcuts import render
+
+from django.contrib.auth.models import User
+from django.shortcuts import get_object_or_404
+
+from rest_framework import viewsets, permissions
+from rest_framework.response import Response
+from rest_framework.exceptions import ValidationError
+
+from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct, VMCluster
+from uncloud_pay.models import Order
+
+from .serializers import (VMHostSerializer, VMProductSerializer,
+ VMSnapshotProductSerializer, VMDiskImageProductSerializer,
+ VMDiskProductSerializer, DCLVMProductSerializer,
+ VMClusterSerializer)
+from uncloud_pay.helpers import ProductViewSet
+
+
+import datetime
+
+class VMHostViewSet(viewsets.ModelViewSet):
+ serializer_class = VMHostSerializer
+ queryset = VMHost.objects.all()
+ permission_classes = [permissions.IsAdminUser]
+
+class VMClusterViewSet(viewsets.ModelViewSet):
+ serializer_class = VMClusterSerializer
+ queryset = VMCluster.objects.all()
+ permission_classes = [permissions.IsAdminUser]
+
+class VMDiskImageProductViewSet(viewsets.ModelViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMDiskImageProductSerializer
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMDiskImageProduct.objects.all()
+ else:
+ obj = VMDiskImageProduct.objects.filter(owner=self.request.user) | VMDiskImageProduct.objects.filter(is_public=True)
+
+ return obj
+
+
+ def create(self, request):
+ serializer = VMDiskImageProductSerializer(data=request.data, context={'request': request})
+ serializer.is_valid(raise_exception=True)
+
+ # did not specify size NOR import url?
+ if not serializer.validated_data['size_in_gb']:
+ if not serializer.validated_data['import_url']:
+ raise ValidationError(detail={ 'error_mesage': 'Specify either import_url or size_in_gb' })
+
+ serializer.save(owner=request.user)
+ return Response(serializer.data)
+
+
+class VMDiskImageProductPublicViewSet(viewsets.ReadOnlyModelViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMDiskImageProductSerializer
+
+ def get_queryset(self):
+ return VMDiskImageProduct.objects.filter(is_public=True)
+
+class VMDiskProductViewSet(viewsets.ModelViewSet):
+ """
+ Let a user modify their own VMDisks
+ """
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMDiskProductSerializer
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMDiskProduct.objects.all()
+ else:
+ obj = VMDiskProduct.objects.filter(owner=self.request.user)
+
+ return obj
+
+ def create(self, request):
+ serializer = VMDiskProductSerializer(data=request.data, context={'request': request})
+ serializer.is_valid(raise_exception=True)
+
+ # get disk size from image, if not specified
+ if not 'size_in_gb' in serializer.validated_data:
+ size_in_gb = serializer.validated_data['image'].size_in_gb
+ else:
+ size_in_gb = serializer.validated_data['size_in_gb']
+
+ if size_in_gb < serializer.validated_data['image'].size_in_gb:
+ raise ValidationError(detail={ 'error_mesage': 'Size is smaller than original image' })
+
+
+ serializer.save(owner=request.user, size_in_gb=size_in_gb)
+ return Response(serializer.data)
+
+
+
+class VMProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMProductSerializer
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMProduct.objects.all()
+ else:
+ obj = VMProduct.objects.filter(owner=self.request.user)
+
+ return obj
+
+ # Use a database transaction so that we do not get half-created structure
+ # if something goes wrong.
+ @transaction.atomic
+ def create(self, request):
+ # Extract serializer data.
+ serializer = VMProductSerializer(data=request.data, context={'request': request})
+ serializer.is_valid(raise_exception=True)
+ order_recurring_period = serializer.validated_data.pop("recurring_period")
+
+ # Create base order.
+ order = Order.objects.create(
+ recurring_period=order_recurring_period,
+ owner=request.user
+ )
+ order.save()
+
+ # Create VM.
+ vm = serializer.save(owner=request.user, order=order)
+
+ # Add Product record to order (VM is mutable, allows to keep history in order).
+ # XXX: Move this to some kind of on_create hook in parent Product class?
+ order.add_record(vm.one_time_price,
+ vm.recurring_price(order.recurring_period), vm.description)
+
+ return Response(serializer.data)
+
+
+class VMSnapshotProductViewSet(viewsets.ModelViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = VMSnapshotProductSerializer
+
+ def get_queryset(self):
+ if self.request.user.is_superuser:
+ obj = VMSnapshotProduct.objects.all()
+ else:
+ obj = VMSnapshotProduct.objects.filter(owner=self.request.user)
+
+ return obj
+
+ def create(self, request):
+ serializer = VMSnapshotProductSerializer(data=request.data, context={'request': request})
+
+ # This verifies that the VM belongs to the request user
+ serializer.is_valid(raise_exception=True)
+
+ vm = vm=serializer.validated_data['vm']
+ disks = VMDiskProduct.objects.filter(vm=vm)
+ ssds_size = sum([d.size_in_gb for d in disks if d.image.storage_class == 'ssd'])
+ hdds_size = sum([d.size_in_gb for d in disks if d.image.storage_class == 'hdd'])
+
+ recurring_price = serializer.pricing['per_gb_ssd'] * ssds_size + serializer.pricing['per_gb_hdd'] * hdds_size
+ recurring_period = serializer.pricing['recurring_period']
+
+ # Create order
+ now = datetime.datetime.now()
+ order = Order(owner=request.user,
+ recurring_period=recurring_period)
+ order.save()
+ order.add_record(one_time_price=0,
+ recurring_price=recurring_price,
+ description="Snapshot of VM {} from {}".format(vm, now))
+
+ serializer.save(owner=request.user,
+ order=order,
+ gb_ssd=ssds_size,
+ gb_hdd=hdds_size)
+
+ return Response(serializer.data)
+
+
+
+# Also create:
+# - /dcl/available_os
+# Basically a view of public and my disk images
+# -
+class DCLCreateVMProductViewSet(ProductViewSet):
+ """
+ This view resembles the way how DCL VMs are created by default.
+
+ The user chooses an OS, os disk size, ram, cpu and whether or not to have a mapped IPv4 address
+ """
+
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = DCLVMProductSerializer
+
+ def get_queryset(self):
+ return VMProduct.objects.filter(owner=self.request.user)
+
+ # Use a database transaction so that we do not get half-created structure
+ # if something goes wrong.
+ @transaction.atomic
+ def create(self, request):
+ # Extract serializer data.
+ serializer = VMProductSerializer(data=request.data, context={'request': request})
+ serializer.is_valid(raise_exception=True)
+ order_recurring_period = serializer.validated_data.pop("recurring_period")
+
+ # Create base order.
+ order = Order.objects.create(
+ recurring_period=order_recurring_period,
+ owner=request.user
+ )
+ order.save()
+
+ # Create VM.
+ vm = serializer.save(owner=request.user, order=order)
+
+ # Add Product record to order (VM is mutable, allows to keep history in order).
+ # XXX: Move this to some kind of on_create hook in parent Product class?
+ order.add_record(vm.one_time_price,
+ vm.recurring_price(order.recurring_period), vm.description)
+
+ return Response(serializer.data)
diff --git a/uncloud_django_based/uncloud/ungleich_service/__init__.py b/uncloud_django_based/uncloud/ungleich_service/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_django_based/uncloud/ungleich_service/admin.py b/uncloud_django_based/uncloud/ungleich_service/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/uncloud_django_based/uncloud/ungleich_service/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/uncloud_django_based/uncloud/ungleich_service/apps.py b/uncloud_django_based/uncloud/ungleich_service/apps.py
new file mode 100644
index 0000000..184e181
--- /dev/null
+++ b/uncloud_django_based/uncloud/ungleich_service/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class UngleichServiceConfig(AppConfig):
+ name = 'ungleich_service'
diff --git a/uncloud_django_based/uncloud/ungleich_service/migrations/0001_initial.py b/uncloud_django_based/uncloud/ungleich_service/migrations/0001_initial.py
new file mode 100644
index 0000000..5b843c8
--- /dev/null
+++ b/uncloud_django_based/uncloud/ungleich_service/migrations/0001_initial.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.0.3 on 2020-03-17 11:45
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('uncloud_vm', '0003_remove_vmhost_vms'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('uncloud_pay', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='MatrixServiceProduct',
+ fields=[
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted')], default='PENDING', max_length=32)),
+ ('domain', models.CharField(default='domain.tld', max_length=255)),
+ ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py b/uncloud_django_based/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py
new file mode 100644
index 0000000..f755ddb
--- /dev/null
+++ b/uncloud_django_based/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.0.3 on 2020-03-21 10:58
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ungleich_service', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='matrixserviceproduct',
+ name='extra_data',
+ field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True),
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py b/uncloud_django_based/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py
new file mode 100644
index 0000000..73dbd6a
--- /dev/null
+++ b/uncloud_django_based/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.3 on 2020-03-22 17:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ungleich_service', '0002_matrixserviceproduct_extra_data'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='matrixserviceproduct',
+ name='status',
+ field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32),
+ ),
+ ]
diff --git a/uncloud_django_based/uncloud/ungleich_service/migrations/__init__.py b/uncloud_django_based/uncloud/ungleich_service/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud_django_based/uncloud/ungleich_service/models.py b/uncloud_django_based/uncloud/ungleich_service/models.py
new file mode 100644
index 0000000..9d6a8ac
--- /dev/null
+++ b/uncloud_django_based/uncloud/ungleich_service/models.py
@@ -0,0 +1,32 @@
+import uuid
+
+from django.db import models
+from uncloud_pay.models import Product, RecurringPeriod
+from uncloud_vm.models import VMProduct
+
+class MatrixServiceProduct(Product):
+ monthly_managment_fee = 20
+
+ description = "Managed Matrix HomeServer"
+
+ # Specific to Matrix-as-a-Service
+ vm = models.ForeignKey(
+ VMProduct, on_delete=models.CASCADE
+ )
+ domain = models.CharField(max_length=255, default='domain.tld')
+
+ def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH):
+ if recurring_period == RecurringPeriod.PER_MONTH:
+ return self.monthly_managment_fee
+ else:
+ raise Exception('Invalid recurring period for VM Product pricing.')
+
+ @staticmethod
+ def allowed_recurring_periods():
+ return list(filter(
+ lambda pair: pair[0] in [RecurringPeriod.PER_MONTH],
+ RecurringPeriod.choices))
+
+ @property
+ def one_time_price(self):
+ return 30
diff --git a/uncloud_django_based/uncloud/ungleich_service/serializers.py b/uncloud_django_based/uncloud/ungleich_service/serializers.py
new file mode 100644
index 0000000..b4038b7
--- /dev/null
+++ b/uncloud_django_based/uncloud/ungleich_service/serializers.py
@@ -0,0 +1,17 @@
+from rest_framework import serializers
+from .models import MatrixServiceProduct
+from uncloud_vm.serializers import ManagedVMProductSerializer
+from uncloud_vm.models import VMProduct
+from uncloud_pay.models import RecurringPeriod
+
+class MatrixServiceProductSerializer(serializers.ModelSerializer):
+ vm = ManagedVMProductSerializer()
+
+ # Custom field used at creation (= ordering) only.
+ recurring_period = serializers.ChoiceField(
+ choices=MatrixServiceProduct.allowed_recurring_periods())
+
+ class Meta:
+ model = MatrixServiceProduct
+ fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain', 'recurring_period']
+ read_only_fields = ['uuid', 'order', 'owner', 'status']
diff --git a/uncloud_django_based/uncloud/ungleich_service/tests.py b/uncloud_django_based/uncloud/ungleich_service/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/uncloud_django_based/uncloud/ungleich_service/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/uncloud_django_based/uncloud/ungleich_service/views.py b/uncloud_django_based/uncloud/ungleich_service/views.py
new file mode 100644
index 0000000..47c15e2
--- /dev/null
+++ b/uncloud_django_based/uncloud/ungleich_service/views.py
@@ -0,0 +1,61 @@
+from rest_framework import viewsets, permissions
+from rest_framework.response import Response
+from django.db import transaction
+
+from .models import MatrixServiceProduct
+from .serializers import MatrixServiceProductSerializer
+
+from uncloud_pay.helpers import ProductViewSet
+from uncloud_pay.models import Order
+from uncloud_vm.models import VMProduct
+
+class MatrixServiceProductViewSet(ProductViewSet):
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = MatrixServiceProductSerializer
+
+ def get_queryset(self):
+ return MatrixServiceProduct.objects.filter(owner=self.request.user)
+
+ @transaction.atomic
+ def create(self, request):
+ # Extract serializer data.
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ order_recurring_period = serializer.validated_data.pop("recurring_period")
+
+ # Create base order.
+ order = Order.objects.create(
+ recurring_period=order_recurring_period,
+ owner=request.user
+ )
+ order.save()
+
+ # Create unerderlying VM.
+ # TODO: move this logic to a method for use with other
+ # products.
+ vm_data = serializer.validated_data.pop('vm')
+ vm_data['owner'] = request.user
+ vm_data['order'] = order
+ vm = VMProduct.objects.create(**vm_data)
+
+ # XXX: Move this to some kind of on_create hook in parent
+ # Product class?
+ order.add_record(
+ vm.one_time_price,
+ vm.recurring_price(order.recurring_period),
+ vm.description)
+
+ # Create service.
+ service = serializer.save(
+ order=order,
+ owner=self.request.user,
+ vm=vm)
+
+ # XXX: Move this to some kind of on_create hook in parent
+ # Product class?
+ order.add_record(
+ service.one_time_price,
+ service.recurring_price(order.recurring_period),
+ service.description)
+
+ return Response(serializer.data)
diff --git a/uncloud_django_based/vat_rates.csv b/uncloud_django_based/vat_rates.csv
new file mode 100644
index 0000000..17bdb99
--- /dev/null
+++ b/uncloud_django_based/vat_rates.csv
@@ -0,0 +1,325 @@
+start_date,stop_date,territory_codes,currency_code,rate,rate_type,description
+2011-01-04,,AI,XCD,0,standard,Anguilla (British overseas territory) is exempted of VAT.
+1984-01-01,,AT,EUR,0.2,standard,Austria (member state) standard VAT rate.
+1976-01-01,1984-01-01,AT,EUR,0.18,standard,
+1973-01-01,1976-01-01,AT,EUR,0.16,standard,
+1984-01-01,,"AT-6691
+DE-87491",EUR,0.19,standard,Jungholz (Austrian town) special VAT rate.
+1984-01-01,,"AT-6991
+AT-6992
+AT-6993
+DE-87567
+DE-87568
+DE-87569",EUR,0.19,standard,Mittelberg (Austrian town) special VAT rate.
+1996-01-01,,BE,EUR,0.21,standard,Belgium (member state) standard VAT rate.
+1994-01-01,1996-01-01,BE,EUR,0.205,standard,
+1992-04-01,1994-01-01,BE,EUR,0.195,standard,
+1983-01-01,1992-04-01,BE,EUR,0.19,standard,
+1981-07-01,1983-01-01,BE,EUR,0.17,standard,
+1978-07-01,1981-07-01,BE,EUR,0.16,standard,
+1971-07-01,1978-07-01,BE,EUR,0.18,standard,
+1999-01-01,,BG,BGN,0.2,standard,Bulgaria (member state) standard VAT rate.
+1996-07-01,1999-01-01,BG,BGN,0.22,standard,
+1994-04-01,1996-07-01,BG,BGN,0.18,standard,
+2011-01-04,,BM,BMD,0,standard,Bermuda (British overseas territory) is exempted of VAT.
+2014-01-13,,"CY
+GB-BFPO 57
+GB-BFPO 58
+GB-BFPO 59
+UK-BFPO 57
+UK-BFPO 58
+UK-BFPO 59",EUR,0.19,standard,"Cyprus (member state) standard VAT rate.
+Akrotiri and Dhekelia (British overseas territory) is subjected to Cyprus' standard VAT rate."
+2013-01-14,2014-01-13,CY,EUR,0.18,standard,
+2012-03-01,2013-01-14,CY,EUR,0.17,standard,
+2003-01-01,2012-03-01,CY,EUR,0.15,standard,
+2002-07-01,2003-01-01,CY,EUR,0.13,standard,
+2000-07-01,2002-07-01,CY,EUR,0.1,standard,
+1993-10-01,2000-07-01,CY,EUR,0.08,standard,
+1992-07-01,1993-10-01,CY,EUR,0.05,standard,
+2013-01-01,,CZ,CZK,0.21,standard,Czech Republic (member state) standard VAT rate.
+2010-01-01,2013-01-01,CZ,CZK,0.2,standard,
+2004-05-01,2010-01-01,CZ,CZK,0.19,standard,
+1995-01-01,2004-05-01,CZ,CZK,0.22,standard,
+1993-01-01,1995-01-01,CZ,CZK,0.23,standard,
+2007-01-01,,DE,EUR,0.19,standard,Germany (member state) standard VAT rate.
+1998-04-01,2007-01-01,DE,EUR,0.16,standard,
+1993-01-01,1998-04-01,DE,EUR,0.15,standard,
+1983-07-01,1993-01-01,DE,EUR,0.14,standard,
+1979-07-01,1983-07-01,DE,EUR,0.13,standard,
+1978-01-01,1979-07-01,DE,EUR,0.12,standard,
+1968-07-01,1978-01-01,DE,EUR,0.11,standard,
+1968-01-01,1968-07-01,DE,EUR,0.1,standard,
+2007-01-01,,DE-27498,EUR,0,standard,Heligoland (German island) is exempted of VAT.
+2007-01-01,,"DE-78266
+CH-8238",EUR,0,standard,Busingen am Hochrhein (German territory) is exempted of VAT.
+1992-01-01,,DK,DKK,0.25,standard,Denmark (member state) standard VAT rate.
+1980-06-30,1992-01-01,DK,DKK,0.22,standard,
+1978-10-30,1980-06-30,DK,DKK,0.2025,standard,
+1977-10-03,1978-10-30,DK,DKK,0.18,standard,
+1970-06-29,1977-10-03,DK,DKK,0.15,standard,
+1968-04-01,1970-06-29,DK,DKK,0.125,standard,
+1967-07-03,1968-04-01,DK,DKK,0.1,standard,
+2009-07-01,,EE,EUR,0.2,standard,Estonia (member state) standard VAT rate.
+1993-01-01,2009-07-01,EE,EUR,0.18,standard,
+1991-01-01,1993-01-01,EE,EUR,0.1,standard,
+2016-06-01,,"GR
+EL",EUR,0.24,standard,Greece (member state) standard VAT rate.
+2010-07-01,2016-06-01,"GR
+EL",EUR,0.23,standard,
+2010-03-15,2010-07-01,"GR
+EL",EUR,0.21,standard,
+2005-04-01,2010-03-15,"GR
+EL",EUR,0.19,standard,
+1990-04-28,2005-04-01,"GR
+EL",EUR,0.18,standard,
+1988-01-01,1990-04-28,"GR
+EL",EUR,0.16,standard,
+1987-01-01,1988-01-01,"GR
+EL",EUR,0.18,standard,
+2012-09-01,,ES,EUR,0.21,standard,Spain (member state) standard VAT rate.
+2010-07-01,2012-09-01,ES,EUR,0.18,standard,
+1995-01-01,2010-07-01,ES,EUR,0.16,standard,
+1992-08-01,1995-01-01,ES,EUR,0.15,standard,
+1992-01-01,1992-08-01,ES,EUR,0.13,standard,
+1986-01-01,1992-01-01,ES,EUR,0.12,standard,
+2012-09-01,,"ES-CN
+ES-GC
+ES-TF
+IC",EUR,0,standard,Canary Islands (Spanish autonomous community) is exempted of VAT.
+2012-09-01,,"ES-ML
+ES-CE
+EA",EUR,0,standard,Ceuta and Melilla (Spanish autonomous cities) is exempted of VAT.
+2013-01-01,,FI,EUR,0.24,standard,Finland (member state) standard VAT rate.
+2010-07-01,2013-01-01,FI,EUR,0.23,standard,
+1994-06-01,2010-07-01,FI,EUR,0.22,standard,
+2013-01-01,,"FI-01
+AX",EUR,0,standard,Aland Islands (Finish autonomous region) is exempted of VAT.
+2011-01-04,,FK,FKP,0,standard,Falkland Islands (British overseas territory) is exempted of VAT.
+1992-01-01,,FO,DKK,0,standard,Faroe Islands (Danish autonomous country) is exempted of VAT.
+2014-01-01,,"FR
+MC",EUR,0.2,standard,"France (member state) standard VAT rate.
+Monaco (sovereign city-state) is member of the EU VAT area and subjected to France's standard VAT rate."
+2000-04-01,2014-01-01,"FR
+MC",EUR,0.196,standard,
+1995-08-01,2000-04-01,"FR
+MC",EUR,0.206,standard,
+1982-07-01,1995-08-01,"FR
+MC",EUR,0.186,standard,
+1977-01-01,1982-07-01,"FR
+MC",EUR,0.176,standard,
+1973-01-01,1977-01-01,"FR
+MC",EUR,0.2,standard,
+1970-01-01,1973-01-01,"FR
+MC",EUR,0.23,standard,
+1968-12-01,1970-01-01,"FR
+MC",EUR,0.19,standard,
+1968-01-01,1968-12-01,"FR
+MC",EUR,0.1666,standard,
+2014-01-01,,"FR-BL
+BL",EUR,0,standard,Saint Barthelemy (French overseas collectivity) is exempted of VAT.
+2014-01-01,,"FR-GF
+GF",EUR,0,standard,Guiana (French overseas department) is exempted of VAT.
+2014-01-01,,"FR-GP
+GP",EUR,0.085,standard,Guadeloupe (French overseas department) special VAT rate.
+2014-01-01,,"FR-MF
+MF",EUR,0,standard,Saint Martin (French overseas collectivity) is subjected to France's standard VAT rate.
+2014-01-01,,"FR-MQ
+MQ",EUR,0.085,standard,Martinique (French overseas department) special VAT rate.
+2014-01-01,,"FR-NC
+NC",XPF,0,standard,New Caledonia (French special collectivity) is exempted of VAT.
+2014-01-01,,"FR-PF
+PF",XPF,0,standard,French Polynesia (French overseas collectivity) is exempted of VAT.
+2014-01-01,,"FR-PM
+PM",EUR,0,standard,Saint Pierre and Miquelon (French overseas collectivity) is exempted of VAT.
+2014-01-01,,"FR-RE
+RE",EUR,0.085,standard,Reunion (French overseas department) special VAT rate.
+2014-01-01,,"FR-TF
+TF",EUR,0,standard,French Southern and Antarctic Lands (French overseas territory) is exempted of VAT.
+2014-01-01,,"FR-WF
+WF",XPF,0,standard,Wallis and Futuna (French overseas collectivity) is exempted of VAT.
+2014-01-01,,"FR-YT
+YT",EUR,0,standard,Mayotte (French overseas department) is exempted of VAT.
+2011-01-04,,GG,GBP,0,standard,Guernsey (British Crown dependency) is exempted of VAT.
+2011-01-04,,GI,GIP,0,standard,Gibraltar (British overseas territory) is exempted of VAT.
+1992-01-01,,GL,DKK,0,standard,Greenland (Danish autonomous country) is exempted of VAT.
+2010-07-01,2016-06-01,"GR-34007
+EL-34007",EUR,0.16,standard,Skyros (Greek island) special VAT rate.
+2010-07-01,2016-06-01,"GR-37002
+GR-37003
+GR-37005
+EL-37002
+EL-37003
+EL-37005",EUR,0.16,standard,Northern Sporades (Greek islands) special VAT rate.
+2010-07-01,2016-06-01,"GR-64004
+EL-64004",EUR,0.16,standard,Thasos (Greek island) special VAT rate.
+2010-07-01,2016-06-01,"GR-68002
+EL-68002",EUR,0.16,standard,Samothrace (Greek island) special VAT rate.
+2010-07-01,,"GR-69
+EL-69",EUR,0,standard,Mount Athos (Greek self-governed part) is exempted of VAT.
+2010-07-01,2016-06-01,"GR-81
+EL-81",EUR,0.16,standard,Dodecanese (Greek department) special VAT rate.
+2010-07-01,2016-06-01,"GR-82
+EL-82",EUR,0.16,standard,Cyclades (Greek department) special VAT rate.
+2010-07-01,2016-06-01,"GR-83
+EL-83",EUR,0.16,standard,Lesbos (Greek department) special VAT rate.
+2010-07-01,2016-06-01,"GR-84
+EL-84",EUR,0.16,standard,Samos (Greek department) special VAT rate.
+2010-07-01,2016-06-01,"GR-85
+EL-85",EUR,0.16,standard,Chios (Greek department) special VAT rate.
+2011-01-04,,GS,GBP,0,standard,South Georgia and the South Sandwich Islands (British overseas territory) is exempted of VAT.
+2012-03-01,,HR,HRK,0.25,standard,Croatia (member state) standard VAT rate.
+2009-08-01,2012-03-01,HR,HRK,0.23,standard,
+1998-08-01,2009-08-01,HR,HRK,0.22,standard,
+2012-01-01,,HU,HUF,0.27,standard,Hungary (member state) standard VAT rate.
+2009-07-01,2012-01-01,HU,HUF,0.25,standard,
+2006-01-01,2009-07-01,HU,HUF,0.2,standard,
+1988-01-01,2006-01-01,HU,HUF,0.25,standard,
+2012-01-01,,IE,EUR,0.23,standard,Republic of Ireland (member state) standard VAT rate.
+2010-01-01,2012-01-01,IE,EUR,0.21,standard,
+2008-12-01,2010-01-01,IE,EUR,0.215,standard,
+2002-03-01,2008-12-01,IE,EUR,0.21,standard,
+2001-01-01,2002-03-01,IE,EUR,0.2,standard,
+1991-03-01,2001-01-01,IE,EUR,0.21,standard,
+1990-03-01,1991-03-01,IE,EUR,0.23,standard,
+1986-03-01,1990-03-01,IE,EUR,0.25,standard,
+1983-05-01,1986-03-01,IE,EUR,0.23,standard,
+1983-03-01,1983-05-01,IE,EUR,0.35,standard,
+1982-05-01,1983-03-01,IE,EUR,0.3,standard,
+1980-05-01,1982-05-01,IE,EUR,0.25,standard,
+1976-03-01,1980-05-01,IE,EUR,0.2,standard,
+1973-09-03,1976-03-01,IE,EUR,0.195,standard,
+1972-11-01,1973-09-03,IE,EUR,0.1637,standard,
+2011-01-04,,IO,GBP,0,standard,British Indian Ocean Territory (British overseas territory) is exempted of VAT.
+2013-10-01,,IT,EUR,0.22,standard,Italy (member state) standard VAT rate.
+2011-09-17,2013-10-01,IT,EUR,0.21,standard,
+1997-10-01,2011-09-17,IT,EUR,0.2,standard,
+1988-08-01,1997-10-01,IT,EUR,0.19,standard,
+1982-08-05,1988-08-01,IT,EUR,0.18,standard,
+1981-01-01,1982-08-05,IT,EUR,0.15,standard,
+1980-11-01,1981-01-01,IT,EUR,0.14,standard,
+1980-07-03,1980-11-01,IT,EUR,0.15,standard,
+1977-02-08,1980-07-03,IT,EUR,0.14,standard,
+1973-01-01,1977-02-08,IT,EUR,0.12,standard,
+2013-10-01,,"IT-22060
+CH-6911",CHF,0,standard,Campione (Italian town) is exempted of VAT.
+2013-10-01,,IT-23030,EUR,0,standard,Livigno (Italian town) is exempted of VAT.
+2011-01-04,,JE,GBP,0,standard,Jersey (British Crown dependency) is exempted of VAT.
+2011-01-04,,KY,KYD,0,standard,Cayman Islands (British overseas territory) is exempted of VAT.
+2009-09-01,,LT,EUR,0.21,standard,Lithuania (member state) standard VAT rate.
+2009-01-01,2009-09-01,LT,EUR,0.19,standard,
+1994-05-01,2009-01-01,LT,EUR,0.18,standard,
+2015-01-01,,LU,EUR,0.17,standard,Luxembourg (member state) standard VAT rate.
+1992-01-01,2015-01-01,LU,EUR,0.15,standard,
+1983-07-01,1992-01-01,LU,EUR,0.12,standard,
+1971-01-01,1983-07-01,LU,EUR,0.1,standard,
+1970-01-01,1971-01-01,LU,EUR,0.8,standard,
+2012-07-01,,LV,EUR,0.21,standard,Latvia (member state) standard VAT rate.
+2011-01-01,2012-07-01,LV,EUR,0.22,standard,
+2009-01-01,2011-01-01,LV,EUR,0.21,standard,
+1995-05-01,2009-01-01,LV,EUR,0.18,standard,
+2011-01-04,,MS,XCD,0,standard,Montserrat (British overseas territory) is exempted of VAT.
+2004-01-01,,MT,EUR,0.18,standard,Malta (member state) standard VAT rate.
+1995-01-01,2004-01-01,MT,EUR,0.15,standard,
+2012-10-01,,NL,EUR,0.21,standard,Netherlands (member state) standard VAT rate.
+2001-01-01,2012-10-01,NL,EUR,0.19,standard,
+1992-10-01,2001-01-01,NL,EUR,0.175,standard,
+1989-01-01,1992-10-01,NL,EUR,0.185,standard,
+1986-10-01,1989-01-01,NL,EUR,0.2,standard,
+1984-01-01,1986-10-01,NL,EUR,0.19,standard,
+1976-01-01,1984-01-01,NL,EUR,0.18,standard,
+1973-01-01,1976-01-01,NL,EUR,0.16,standard,
+1971-01-01,1973-01-01,NL,EUR,0.14,standard,
+1969-01-01,1971-01-01,NL,EUR,0.12,standard,
+2012-10-01,,"NL-AW
+AW",AWG,0,standard,Aruba (Dutch country) are exempted of VAT.
+2012-10-01,,"NL-CW
+NL-SX
+CW
+SX",ANG,0,standard,Curacao and Sint Maarten (Dutch countries) are exempted of VAT.
+2012-10-01,,"NL-BQ1
+NL-BQ2
+NL-BQ3
+BQ
+BQ-BO
+BQ-SA
+BQ-SE",USD,0,standard,"Bonaire, Saba and Sint Eustatius (Dutch special municipalities) are exempted of VAT."
+2011-01-01,,PL,PLN,0.23,standard,Poland (member state) standard VAT rate.
+1993-01-08,2011-01-01,PL,PLN,0.22,standard,
+2011-01-04,,PN,NZD,0,standard,Pitcairn Islands (British overseas territory) is exempted of VAT.
+2011-01-01,,PT,EUR,0.23,standard,Portugal (member state) standard VAT rate.
+2010-07-01,2011-01-01,PT,EUR,0.21,standard,
+2008-07-01,2010-07-01,PT,EUR,0.2,standard,
+2005-07-01,2008-07-01,PT,EUR,0.21,standard,
+2002-06-05,2005-07-01,PT,EUR,0.19,standard,
+1995-01-01,2002-06-05,PT,EUR,0.17,standard,
+1992-03-24,1995-01-01,PT,EUR,0.16,standard,
+1988-02-01,1992-03-24,PT,EUR,0.17,standard,
+1986-01-01,1988-02-01,PT,EUR,0.16,standard,
+2011-01-01,,PT-20,EUR,0.18,standard,Azores (Portuguese autonomous region) special VAT rate.
+2011-01-01,,PT-30,EUR,0.22,standard,Madeira (Portuguese autonomous region) special VAT rate.
+2017-01-01,,RO,RON,0.19,standard,Romania (member state) standard VAT rate.
+2016-01-01,2017-01-01,RO,RON,0.2,standard,Romania (member state) standard VAT rate.
+2010-07-01,2016-01-01,RO,RON,0.24,standard,
+2000-01-01,2010-07-01,RO,RON,0.19,standard,
+1998-02-01,2000-01-01,RO,RON,0.22,standard,
+1993-07-01,1998-02-01,RO,RON,0.18,standard,
+1990-07-01,,SE,SEK,0.25,standard,Sweden (member state) standard VAT rate.
+1983-01-01,1990-07-01,SE,SEK,0.2346,standard,
+1981-11-16,1983-01-01,SE,SEK,0.2151,standard,
+1980-09-08,1981-11-16,SE,SEK,0.2346,standard,
+1977-06-01,1980-09-08,SE,SEK,0.2063,standard,
+1971-01-01,1977-06-01,SE,SEK,0.1765,standard,
+1969-01-01,1971-01-01,SE,SEK,0.1111,standard,
+2011-01-04,,"AC
+SH
+SH-AC
+SH-HL",SHP,0,standard,Ascension and Saint Helena (British overseas territory) is exempted of VAT.
+2011-01-04,,"TA
+SH-TA",GBP,0,standard,Tristan da Cunha (British oversea territory) is exempted of VAT.
+2013-07-01,,SI,EUR,0.22,standard,Slovenia (member state) standard VAT rate.
+2002-01-01,2013-07-01,SI,EUR,0.2,standard,
+1999-07-01,2002-01-01,SI,EUR,0.19,standard,
+2011-01-01,,SK,EUR,0.2,standard,Slovakia (member state) standard VAT rate.
+2004-01-01,2011-01-01,SK,EUR,0.19,standard,
+2003-01-01,2004-01-01,SK,EUR,0.2,standard,
+1996-01-01,2003-01-01,SK,EUR,0.23,standard,
+1993-08-01,1996-01-01,SK,EUR,0.25,standard,
+1993-01-01,1993-08-01,SK,EUR,0.23,standard,
+2011-01-04,,TC,USD,0,standard,Turks and Caicos Islands (British overseas territory) is exempted of VAT.
+2011-01-04,,"GB
+UK
+IM",GBP,0.2,standard,"United Kingdom (member state) standard VAT rate.
+Isle of Man (British self-governing dependency) is member of the EU VAT area and subjected to UK's standard VAT rate."
+2010-01-01,2011-01-04,"GB
+UK
+IM",GBP,0.175,standard,
+2008-12-01,2010-01-01,"GB
+UK
+IM",GBP,0.15,standard,
+1991-04-01,2008-12-01,"GB
+UK
+IM",GBP,0.175,standard,
+1979-06-18,1991-04-01,"GB
+UK
+IM",GBP,0.15,standard,
+1974-07-29,1979-06-18,"GB
+UK
+IM",GBP,0.08,standard,
+1973-04-01,1974-07-29,"GB
+UK
+IM",GBP,0.1,standard,
+2011-01-04,,VG,USD,0,standard,British Virgin Islands (British overseas territory) is exempted of VAT.
+2014-01-01,,CP,EUR,0,standard,Clipperton Island (French overseas possession) is exempted of VAT.
+2019-11-15,,CH,CHF,0.077,standard,Switzerland standard VAT (added manually)
+2019-11-15,,MC,EUR,0.196,standard,Monaco standard VAT (added manually)
+2019-11-15,,FR,EUR,0.2,standard,France standard VAT (added manually)
+2019-11-15,,GR,EUR,0.24,standard,Greece standard VAT (added manually)
+2019-11-15,,GB,EUR,0.2,standard,UK standard VAT (added manually)
+2019-12-17,,AD,EUR,0.045,standard,Andorra standard VAT (added manually)
+2019-12-17,,TK,EUR,0.18,standard,Turkey standard VAT (added manually)
+2019-12-17,,IS,EUR,0.24,standard,Iceland standard VAT (added manually)
+2019-12-17,,FX,EUR,0.20,standard,France metropolitan standard VAT (added manually)
+2020-01-04,,CY,EUR,0.19,standard,Cyprus standard VAT (added manually)
+2019-01-04,,IL,EUR,0.23,standard,Ireland standard VAT (added manually)
+2019-01-04,,LI,EUR,0.077,standard,Liechtenstein standard VAT (added manually)