Merge remote-tracking branch 'meowpaylocal/master'

This commit is contained in:
Nico Schottelius 2020-04-02 19:31:57 +02:00
commit fa0ca2d9c1
117 changed files with 7133 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
* TODO register CC
* TODO list products
* ahmed
** schemas
*** field: is_valid? - used by schemas
*** definition of a "schema"

View File

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

View File

@ -0,0 +1,9 @@
## Introduction
This document describes how to create a product and use it.
A product (like a VMSnapshotproduct) creates an order when ordered.
The "order" is used to combine products together.
Sub-products or related products link to the same order.
Each product has one (?) orderrecord

View File

@ -0,0 +1,82 @@
## Introduction
This article describes how models relate to each other and what the
design ideas are. It is meant to prevent us from double implementing
something or changing something that is already solved.
## Products
A product is something someone can order. We might have "low level"
products that need to be composed (= higher degree of flexibility, but
more amount of details necessary) and "composed products" that present
some defaults or select other products automatically (f.i. a "dual
stack VM" can be a VM + a disk + an IPv4 address).
## Bills
Bills represent active orders of a month. Bills can be shown during a
month but only become definitive at the end of the month.
## Orders
When customer X order a (set) of product, it generates an order for billing
purposes. The ordered products point to that order and register an Order Record
at creation.
Orders and Order Records are assumed immutable => they are used to generate
bills and should not be mutated. If a product is updated (e.g. adding RAM to
VM), a new order should be generated.
The order MUST NOT be deleted when a product is deleted, as it is used for
billing (including past bills).
### Order record
Used to store billing details of a product at creation: will stay there even if
the product change (e.g. new pricing, updated) and act as some kind of archive.
Used to generate bills.
## Payment Methods
Users/customers can register payment methods.
## Sample flows / products
### A VM snapshot
A VM snapshot creates a snapshot of all disks attached to a VM to be
able to rollback the VM to a previous state.
Creating a VM snapshot (-product) creates a related order. Deleting a
VMSnapshotproduct sets the order to deleted.
### Object Storage
(tbd by Balazs)
### A "raw" VM
(tbd by Ahmed)
### An IPv6 only VM
(tbd by Ahmed)
### A dual stack VM
(tbd by Ahmed)
### A managed service (e.g. Matrix-as-a-Service)
Customer orders service with:
* Service-specific configuration: e.g. domain name for matrix
* VM configuration:
- CPU
- Memory
- Disk (soon)
It creates a new Order with two products/records:
* Service itself (= management)
* Underlying VM

View File

@ -0,0 +1,95 @@
## Install
### OS package requirements
Alpine:
```
apk add openldap-dev postgresql-dev
```
Debian/Devuan:
```
apt install postgresql-server-dev-all
```
### Python requirements
If you prefer using a venv, use:
```
python -m venv venv
. ./venv/bin/activate
```
Then install the requirements
```
pip install -r requirements.txt
```
### Database requirements
Due to the use of the JSONField, postgresql is required.
First create a role to be used:
```
postgres=# create role nico login;
```
Then create the database owner by the new role:
```
postgres=# create database uncloud owner nico;
```
Installing the postgresql service is os dependent, but some hints:
* Alpine: `apk add postgresql-server && rc-update add postgresql && rc-service postgresql start`
* Debian/Devuan: `apt install postgresql`
After postresql is started, apply the migrations:
```
python manage.py migrate
```
### Secrets
cp `uncloud/secrets_sample.py` to `uncloud/secrets.py` and replace the
sample values with real values.
## Flows / Orders
### Creating a VMHost
### Creating a VM
* Create a VMHost
* Create a VM on a VMHost
### Creating a VM Snapshot
## Working Beta APIs
These APIs can be used for internal testing.
### URL Overview
```
http -a nicoschottelius:$(pass ungleich.ch/nico.schottelius@ungleich.ch) http://localhost:8000
```
### Snapshotting
```
http -a nicoschottelius:$(pass ungleich.ch/nico.schottelius@ungleich.ch) http://localhost:8000/vm/snapshot/ vm_uuid=$(uuidgen)
```

View File

@ -0,0 +1,21 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,193 @@
import sys
from datetime import datetime
from django.core.management.base import BaseCommand
from django.utils import timezone
from django.contrib.auth import get_user_model
from opennebula.models import VM as VMModel
from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct
from uncloud_pay.models import Order
import logging
log = logging.getLogger(__name__)
def convert_mac_to_int(mac_address: str):
# Remove octet connecting characters
mac_address = mac_address.replace(':', '')
mac_address = mac_address.replace('.', '')
mac_address = mac_address.replace('-', '')
mac_address = mac_address.replace(' ', '')
# Parse the resulting number as hexadecimal
mac_address = int(mac_address, base=16)
return mac_address
def get_vm_price(core, ram, ssd_size, hdd_size, n_of_ipv4, n_of_ipv6):
total = 3 * core + 4 * ram + (3.5 * ssd_size/10.) + (1.5 * hdd_size/100.) + 8 * n_of_ipv4 + 0 * n_of_ipv6
# TODO: Find some reason about the following magical subtraction.
total -= 8
return total
def create_nics(one_vm, vm_product):
for nic in one_vm.nics:
mac_address = convert_mac_to_int(nic.get('MAC'))
ip_address = nic.get('IP', None) or nic.get('IP6_GLOBAL', None)
VMNetworkCard.objects.update_or_create(
mac_address=mac_address, vm=vm_product, defaults={'ip_address': ip_address}
)
def sync_disk_and_image(one_vm, vm_product, disk_owner):
"""
a) Check all opennebula disk if they are in the uncloud VM, if not add
b) Check all uncloud disks and remove them if they are not in the opennebula VM
"""
vmdisknum = 0
one_disks_extra_data = []
for disk in one_vm.disks:
vmowner = one_vm.owner
name = disk.get('image')
vmdisknum += 1
log.info("Checking disk {} for VM {}".format(name, one_vm))
is_os_image, is_public, status = True, False, 'active'
image_size_in_gb = disk.get('image_size_in_gb')
disk_size_in_gb = disk.get('size_in_gb')
storage_class = disk.get('storage_class')
image_source = disk.get('source')
image_source_type = disk.get('source_type')
image, _ = VMDiskImageProduct.objects.update_or_create(
name=name,
defaults={
'owner': disk_owner,
'is_os_image': is_os_image,
'is_public': is_public,
'size_in_gb': image_size_in_gb,
'storage_class': storage_class,
'image_source': image_source,
'image_source_type': image_source_type,
'status': status
}
)
# identify vmdisk from opennebula - primary mapping key
extra_data = {
'opennebula_vm': one_vm.vmid,
'opennebula_size_in_gb': disk_size_in_gb,
'opennebula_source': disk.get('opennebula_source'),
'opennebula_disk_num': vmdisknum
}
# Save for comparing later
one_disks_extra_data.append(extra_data)
try:
vm_disk = VMDiskProduct.objects.get(extra_data=extra_data)
except VMDiskProduct.DoesNotExist:
vm_disk = VMDiskProduct.objects.create(
owner=vmowner,
vm=vm_product,
image=image,
size_in_gb=disk_size_in_gb,
extra_data=extra_data
)
# Now remove all disks that are not in above extra_data list
for disk in VMDiskProduct.objects.filter(vm=vm_product):
extra_data = disk.extra_data
if not extra_data in one_disks_extra_data:
log.info("Removing disk {} from VM {}".format(disk, vm_product))
disk.delete()
disks = [ disk.extra_data for disk in VMDiskProduct.objects.filter(vm=vm_product) ]
log.info("VM {} has disks: {}".format(vm_product, disks))
class Command(BaseCommand):
help = 'Migrate Opennebula VM to regular (uncloud) vm'
def add_arguments(self, parser):
parser.add_argument('--disk-owner', required=True, help="The user who owns the the opennebula disks")
def handle(self, *args, **options):
log.debug("{} {}".format(args, options))
disk_owner = get_user_model().objects.get(username=options['disk_owner'])
for one_vm in VMModel.objects.all():
if not one_vm.last_host:
log.warning("No VMHost for VM {} - VM might be on hold - skipping".format(one_vm.vmid))
continue
try:
vmhost = VMHost.objects.get(hostname=one_vm.last_host)
except VMHost.DoesNotExist:
log.error("VMHost {} does not exist, aborting".format(one_vm.last_host))
raise
cores = one_vm.cores
ram_in_gb = one_vm.ram_in_gb
owner = one_vm.owner
status = 'active'
ssd_size = sum([ disk['size_in_gb'] for disk in one_vm.disks if disk['pool_name'] in ['ssd', 'one'] ])
hdd_size = sum([ disk['size_in_gb'] for disk in one_vm.disks if disk['pool_name'] in ['hdd'] ])
# List of IPv4 addresses and Global IPv6 addresses
ipv4, ipv6 = one_vm.ips
# TODO: Insert actual/real creation_date, starting_date, ending_date
# instead of pseudo one we are putting currently
creation_date = starting_date = datetime.now(tz=timezone.utc)
# Price calculation based on datacenterlight.ch
one_time_price = 0
recurring_period = 'per_month'
recurring_price = get_vm_price(cores, ram_in_gb,
ssd_size, hdd_size,
len(ipv4), len(ipv6))
try:
vm_product = VMProduct.objects.get(extra_data__opennebula_id=one_vm.vmid)
except VMProduct.DoesNotExist:
order = Order.objects.create(
owner=owner,
creation_date=creation_date,
starting_date=starting_date
)
vm_product = VMProduct(
extra_data={ 'opennebula_id': one_vm.vmid },
name=one_vm.uncloud_name,
order=order
)
# we don't use update_or_create, as filtering by json AND setting json
# at the same time does not work
vm_product.vmhost = vmhost
vm_product.owner = owner
vm_product.cores = cores
vm_product.ram_in_gb = ram_in_gb
vm_product.status = status
vm_product.save()
# Create VMNetworkCards
create_nics(one_vm, vm_product)
# Create VMDiskImageProduct and VMDiskProduct
sync_disk_and_image(one_vm, vm_product, disk_owner=disk_owner)

View File

@ -0,0 +1,28 @@
# Generated by Django 3.0.3 on 2020-02-23 17:12
from django.conf import settings
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='VM',
fields=[
('vmid', models.IntegerField(primary_key=True, serialize=False)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('data', django.contrib.postgres.fields.jsonb.JSONField()),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 3.0.3 on 2020-02-25 13:35
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('opennebula', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='vm',
name='uuid',
),
migrations.RemoveField(
model_name='vm',
name='vmid',
),
migrations.AddField(
model_name='vm',
name='id',
field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, unique=True),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.0.3 on 2020-02-25 14:28
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('opennebula', '0002_auto_20200225_1335'),
]
operations = [
migrations.AlterField(
model_name='vm',
name='id',
field=models.CharField(default=uuid.uuid4, max_length=64, primary_key=True, serialize=False, unique=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.0.3 on 2020-02-25 18:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('opennebula', '0003_auto_20200225_1428'),
]
operations = [
migrations.RemoveField(
model_name='vm',
name='id',
),
migrations.AddField(
model_name='vm',
name='vmid',
field=models.IntegerField(default=42, primary_key=True, serialize=False),
preserve_default=False,
),
]

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
from rest_framework import viewsets, permissions
from .models import VM
from .serializers import OpenNebulaVMSerializer
class VMViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated]
serializer_class = OpenNebulaVMSerializer
def get_queryset(self):
if self.request.user.is_superuser:
obj = VM.objects.all()
else:
obj = VM.objects.filter(owner=self.request.user)
return obj

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
"""
ASGI config for uncloud project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings')
application = get_asgi_application()

View File

@ -0,0 +1,28 @@
import sys
from datetime import datetime
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from opennebula.models import VM as VMModel
from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct, VMCluster
import logging
log = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'General uncloud commands'
def add_arguments(self, parser):
parser.add_argument('--bootstrap', action='store_true', help='Bootstrap a typical uncloud installation')
def handle(self, *args, **options):
if options['bootstrap']:
self.bootstrap()
def bootstrap(self):
default_cluster = VMCluster.objects.get_or_create(name="default")
# local_host =

View File

@ -0,0 +1,35 @@
from django.db import models
from django.contrib.postgres.fields import JSONField
from django.utils.translation import gettext_lazy as _
class UncloudModel(models.Model):
"""
This class extends the standard model with an
extra_data field that can be used to include public,
but internal information.
For instance if you migrate from an existing virtualisation
framework to uncloud.
The extra_data attribute should be considered a hack and whenever
data is necessary for running uncloud, it should **not** be stored
in there.
"""
extra_data = JSONField(editable=False, blank=True, null=True)
class Meta:
abstract = True
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
class UncloudStatus(models.TextChoices):
PENDING = 'PENDING', _('Pending')
AWAITING_PAYMENT = 'AWAITING_PAYMENT', _('Awaiting payment')
BEING_CREATED = 'BEING_CREATED', _('Being created')
SCHEDULED = 'SCHEDULED', _('Scheduled') # resource selected, waiting for dispatching
ACTIVE = 'ACTIVE', _('Active')
MODIFYING = 'MODIFYING', _('Modifying') # Resource is being changed
DELETED = 'DELETED', _('Deleted') # Resource has been deleted
DISABLED = 'DISABLED', _('Disabled') # Is usable, but cannot be used for new things
UNUSABLE = 'UNUSABLE', _('Unusable'), # Has some kind of error

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
"""
WSGI config for uncloud project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings')
application = get_wsgi_application()

View File

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

View File

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

View File

@ -0,0 +1,44 @@
# Generated by Django 3.0.3 on 2020-03-03 16:49
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0011_update_proxy_permissions'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 3.0.3 on 2020-03-18 13:43
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_auth', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='amount',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='user',
name='maximum_credit',
field=models.FloatField(default=0),
preserve_default=False,
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.0.3 on 2020-03-18 13:45
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_auth', '0002_auto_20200318_1343'),
]
operations = [
migrations.RemoveField(
model_name='user',
name='amount',
),
migrations.AlterField(
model_name='user',
name='maximum_credit',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

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

View File

@ -0,0 +1,15 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = get_user_model()
fields = ['username', 'email', 'balance', 'maximum_credit' ]
balance = serializers.DecimalField(max_digits=AMOUNT_MAX_DIGITS,
decimal_places=AMOUNT_DECIMALS)

View File

@ -0,0 +1,17 @@
from rest_framework import viewsets, permissions, status
from .serializers import *
class UserViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
if self.request.user.is_superuser:
obj = get_user_model().objects.all()
else:
# This is a bit stupid: we have a user, we create a queryset by
# matching on the username. But I don't know a "nicer" way.
# Nico, 2020-03-18
obj = get_user_model().objects.filter(username=self.request.user.username)
return obj

View File

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

View File

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

View File

@ -0,0 +1,4 @@
from django.db import models
class MACAdress(models.Model):
prefix = 0x420000000000

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,31 @@
from django.core.management.base import BaseCommand
from uncloud_auth.models import User
from uncloud_pay.models import Order, Bill, PaymentMethod, get_balance_for
from datetime import timedelta
from django.utils import timezone
class Command(BaseCommand):
help = 'Generate bills and charge customers if necessary.'
def add_arguments(self, parser):
pass
def handle(self, *args, **options):
users = User.objects.all()
print("Processing {} users.".format(users.count()))
for user in users:
balance = get_balance_for(user)
if balance < 0:
print("User {} has negative balance ({}), charging.".format(user.username, balance))
payment_method = PaymentMethod.get_primary_for(user)
if payment_method != None:
amount_to_be_charged = abs(balance)
charge_ok = payment_method.charge(amount_to_be_charged)
if not charge_ok:
print("ERR: charging {} with method {} failed"
.format(user.username, payment_method.uuid)
)
else:
print("ERR: no payment method registered for {}".format(user.username))
print("=> Done.")

View File

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

View File

@ -0,0 +1,23 @@
from django.core.management.base import BaseCommand
from uncloud_auth.models import User
from uncloud_pay.models import Bill
from datetime import timedelta
from django.utils import timezone
class Command(BaseCommand):
help = 'Take action on overdue bills.'
def add_arguments(self, parser):
pass
def handle(self, *args, **options):
users = User.objects.all()
print("Processing {} users.".format(users.count()))
for user in users:
for bill in Bill.get_overdue_for(user):
print("/!\ Overdue bill for {}, {} with amount {}"
.format(user.username, bill.uuid, bill.amount))
# TODO: take action?
print("=> Done.")

View File

@ -0,0 +1,85 @@
# Generated by Django 3.0.3 on 2020-03-05 10:17
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_auth', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Bill',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('creation_date', models.DateTimeField(auto_now_add=True)),
('starting_date', models.DateTimeField()),
('ending_date', models.DateTimeField()),
('due_date', models.DateField()),
('valid', models.BooleanField(default=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Order',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('creation_date', models.DateTimeField(auto_now_add=True)),
('starting_date', models.DateTimeField(auto_now_add=True)),
('ending_date', models.DateTimeField(blank=True, null=True)),
('recurring_period', models.CharField(choices=[('ONCE', 'Onetime'), ('YEAR', 'Per Year'), ('MONTH', 'Per Month'), ('MINUTE', 'Per Minute'), ('DAY', 'Per Day'), ('HOUR', 'Per Hour'), ('SECOND', 'Per Second')], default='MONTH', max_length=32)),
('bill', models.ManyToManyField(blank=True, editable=False, to='uncloud_pay.Bill')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='StripeCustomer',
fields=[
('owner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
('stripe_id', models.CharField(max_length=32)),
],
),
migrations.CreateModel(
name='Payment',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('amount', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('source', models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='OrderRecord',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('one_time_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('description', models.TextField()),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
],
),
migrations.CreateModel(
name='PaymentMethod',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('source', models.CharField(choices=[('stripe', 'Stripe'), ('unknown', 'Unknown')], default='stripe', max_length=256)),
('description', models.TextField()),
('primary', models.BooleanField(default=True)),
('stripe_card_id', models.CharField(blank=True, max_length=32, null=True)),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('owner', 'primary')},
},
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,119 @@
import json
import uncloud.secrets as secrets
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from uncloud_vm.models import VMSnapshotProduct, VMProduct, VMHost
from datetime import datetime
class Command(BaseCommand):
help = 'Select VM Host for VMs'
def add_arguments(self, parser):
parser.add_argument('--this-hostname', required=True)
parser.add_argument('--this-cluster', required=True)
parser.add_argument('--create-vm-snapshots', action='store_true')
parser.add_argument('--schedule-vms', action='store_true')
parser.add_argument('--start-vms', action='store_true')
def handle(self, *args, **options):
for cmd in [ 'create_vm_snapshots', 'schedule_vms', 'start_vms' ]:
if options[cmd]:
f = getattr(self, cmd)
f(args, options)
def schedule_vms(self, *args, **options):
for pending_vm in VMProduct.objects.filter(status='PENDING'):
cores_needed = pending_vm.cores
ram_needed = pending_vm.ram_in_gb
# Database filtering
possible_vmhosts = VMHost.objects.filter(physical_cores__gte=cores_needed)
# Logical filtering
possible_vmhosts = [ vmhost for vmhost in possible_vmhosts
if vmhost.available_cores >=cores_needed
and vmhost.available_ram_in_gb >= ram_needed ]
if not possible_vmhosts:
log.error("No suitable Host found - cannot schedule VM {}".format(pending_vm))
continue
vmhost = possible_vmhosts[0]
pending_vm.vmhost = vmhost
pending_vm.status = 'SCHEDULED'
pending_vm.save()
print("Scheduled VM {} on VMHOST {}".format(pending_vm, pending_vm.vmhost))
print(self)
def start_vms(self, *args, **options):
vmhost = VMHost.objects.get(hostname=options['this_hostname'])
if not vmhost:
raise Exception("No vmhost {} exists".format(options['vmhostname']))
# not active? done here
if not vmhost.status = 'ACTIVE':
return
vms_to_start = VMProduct.objects.filter(vmhost=vmhost,
status='SCHEDULED')
for vm in vms_to_start:
""" run qemu:
check if VM is not already active / qemu running
prepare / create the Qemu arguments
"""
print("Starting VM {}".format(VM))
def check_vms(self, *args, **options):
"""
Check if all VMs that are supposed to run are running
"""
def modify_vms(self, *args, **options):
"""
Check all VMs that are requested to be modified and restart them
"""
def create_vm_snapshots(self, *args, **options):
this_cluster = VMCluster(option['this_cluster'])
for snapshot in VMSnapshotProduct.objects.filter(status='PENDING',
cluster=this_cluster):
if not snapshot.extra_data:
snapshot.extra_data = {}
# TODO: implement locking here
if 'creating_hostname' in snapshot.extra_data:
pass
snapshot.extra_data['creating_hostname'] = options['this_hostname']
snapshot.extra_data['creating_start'] = str(datetime.now())
snapshot.save()
# something on the line of:
# for disk im vm.disks:
# rbd snap create pool/image-name@snapshot name
# snapshot.extra_data['snapshots']
# register the snapshot names in extra_data (?)
print(snapshot)
def check_health(self, *args, **options):
pending_vms = VMProduct.objects.filter(status='PENDING')
vmhosts = VMHost.objects.filter(status='active')
# 1. Check that all active hosts reported back N seconds ago
# 2. Check that no VM is running on a dead host
# 3. Migrate VMs if necessary
# 4. Check that no VMs have been pending for longer than Y seconds
# If VM snapshots exist without a VM -> notify user (?)
print("Nothing is good, you should implement me")

View File

@ -0,0 +1,108 @@
# Generated by Django 3.0.3 on 2020-03-05 10:34
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('uncloud_pay', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='VMDiskImageProduct',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=256)),
('is_os_image', models.BooleanField(default=False)),
('is_public', models.BooleanField(default=False)),
('size_in_gb', models.FloatField(blank=True, null=True)),
('import_url', models.URLField(blank=True, null=True)),
('image_source', models.CharField(max_length=128, null=True)),
('image_source_type', models.CharField(max_length=128, null=True)),
('storage_class', models.CharField(choices=[('hdd', 'HDD'), ('ssd', 'SSD')], default='ssd', max_length=32)),
('status', models.CharField(choices=[('pending', 'Pending'), ('creating', 'Creating'), ('active', 'Active'), ('disabled', 'Disabled'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32)),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='VMHost',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('hostname', models.CharField(max_length=253, unique=True)),
('physical_cores', models.IntegerField(default=0)),
('usable_cores', models.IntegerField(default=0)),
('usable_ram_in_gb', models.FloatField(default=0)),
('status', models.CharField(choices=[('pending', 'Pending'), ('creating', 'Creating'), ('active', 'Active'), ('disabled', 'Disabled'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32)),
('vms', models.TextField(default='')),
],
),
migrations.CreateModel(
name='VMProduct',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted')], default='PENDING', max_length=32)),
('name', models.CharField(max_length=32)),
('cores', models.IntegerField()),
('ram_in_gb', models.FloatField()),
('vmid', models.IntegerField(null=True)),
('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('vmhost', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMHost')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='VMWithOSProduct',
fields=[
('vmproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.VMProduct')),
],
options={
'abstract': False,
},
bases=('uncloud_vm.vmproduct',),
),
migrations.CreateModel(
name='VMSnapshotProduct',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted')], default='PENDING', max_length=32)),
('gb_ssd', models.FloatField(editable=False)),
('gb_hdd', models.FloatField(editable=False)),
('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='VMNetworkCard',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mac_address', models.BigIntegerField()),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')),
],
),
migrations.CreateModel(
name='VMDiskProduct',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('size_in_gb', models.FloatField(blank=True)),
('image', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMDiskImageProduct')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')),
],
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.0.3 on 2020-03-05 13:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='vmdiskimageproduct',
name='storage_class',
field=models.CharField(choices=[('HDD', 'HDD'), ('SSD', 'SSD')], default='SSD', max_length=32),
),
migrations.AlterField(
model_name='vmproduct',
name='name',
field=models.CharField(blank=True, max_length=32, null=True),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

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