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=< + + +
+ AbstractBaseUser +
+ >] + 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=< + + +
+ PermissionsMixin +
+ >] + 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)