Merge remote-tracking branch 'meowpaylocal/master'
This commit is contained in:
commit
fa0ca2d9c1
|
@ -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()
|
|
@ -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)
|
|
@ -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
|
||||||
|
```
|
|
@ -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')
|
||||||
|
)
|
|
@ -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)
|
|
@ -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
|
|
@ -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]))
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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'))
|
|
@ -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()
|
|
@ -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
|
|
@ -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~~
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
||||||
|
* TODO register CC
|
||||||
|
* TODO list products
|
||||||
|
* ahmed
|
||||||
|
** schemas
|
||||||
|
*** field: is_valid? - used by schemas
|
||||||
|
*** definition of a "schema"
|
|
@ -0,0 +1,4 @@
|
||||||
|
db.sqlite3
|
||||||
|
uncloud/secrets.py
|
||||||
|
debug.log
|
||||||
|
uncloud/local_settings.py
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||||
|
```
|
|
@ -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 |
|
@ -0,0 +1,3 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class OpennebulaConfig(AppConfig):
|
||||||
|
name = 'opennebula'
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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,
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
|
|
@ -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' ]
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
|
@ -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
|
|
@ -0,0 +1,13 @@
|
||||||
|
django
|
||||||
|
djangorestframework
|
||||||
|
django-auth-ldap
|
||||||
|
stripe
|
||||||
|
xmltodict
|
||||||
|
psycopg2
|
||||||
|
|
||||||
|
parsedatetime
|
||||||
|
|
||||||
|
# Follow are for creating graph models
|
||||||
|
pyparsing
|
||||||
|
pydot
|
||||||
|
django-extensions
|
|
@ -0,0 +1 @@
|
||||||
|
secrets.py
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Define DecimalField properties, used to represent amounts of money.
|
||||||
|
# Used in pay and auth
|
||||||
|
AMOUNT_MAX_DIGITS=10
|
||||||
|
AMOUNT_DECIMALS=2
|
|
@ -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()
|
|
@ -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 =
|
|
@ -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
|
|
@ -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"
|
|
@ -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/'
|
|
@ -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
|
||||||
|
]
|
|
@ -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()
|
|
@ -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)
|
|
@ -0,0 +1,4 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class AuthConfig(AppConfig):
|
||||||
|
name = 'uncloud_auth'
|
|
@ -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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -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,
|
||||||
|
),
|
||||||
|
]
|
|
@ -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)]),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UncloudNetConfig(AppConfig):
|
||||||
|
name = 'uncloud_net'
|
|
@ -0,0 +1,4 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
class MACAdress(models.Model):
|
||||||
|
prefix = 0x420000000000
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UncloudPayConfig(AppConfig):
|
||||||
|
name = 'uncloud_pay'
|
|
@ -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
|
|
@ -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.")
|
|
@ -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.")
|
|
@ -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.")
|
|
@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
|
|
@ -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']
|
|
@ -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)
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
|
@ -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()
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UncloudStorageConfig(AppConfig):
|
||||||
|
name = 'uncloud_storage'
|
|
@ -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')
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UncloudVmConfig(AppConfig):
|
||||||
|
name = 'uncloud_vm'
|
|
@ -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")
|
|
@ -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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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',
|
||||||
|
),
|
||||||
|
]
|
|
@ -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',
|
||||||
|
),
|
||||||
|
]
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
Loading…
Reference in New Issue