From da54a59ca25799a1bdb3182d7265533a43fcfe12 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 20 Jan 2020 12:30:12 +0500 Subject: [PATCH 001/193] initial commit --- .gitignore | 7 + README.md | 43 +++ config.py | 8 + etcd_wrapper.py | 75 +++++ helper.py | 62 +++++ ldap_manager.py | 64 +++++ products/ipv6-only-django.json | 27 ++ products/ipv6-only-vm.json | 33 +++ products/ipv6-only-vpn.json | 15 + products/membership.json | 15 + schemas.py | 134 +++++++++ stripe_utils.py | 490 +++++++++++++++++++++++++++++++++ ucloud_pay.py | 345 +++++++++++++++++++++++ 13 files changed, 1318 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.py create mode 100644 etcd_wrapper.py create mode 100644 helper.py create mode 100644 ldap_manager.py create mode 100644 products/ipv6-only-django.json create mode 100644 products/ipv6-only-vm.json create mode 100644 products/ipv6-only-vpn.json create mode 100644 products/membership.json create mode 100644 schemas.py create mode 100644 stripe_utils.py create mode 100644 ucloud_pay.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77de841 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea/ +.vscode/ +__pycache__/ + +pay.conf +log.txt +test.py \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b50cf3 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# uncloud-pay + +The pay module for the uncloud + +- uses [etcd3](https://coreos.com/blog/etcd3-a-new-etcd.html) for storage. +- uses [Stripe](https://stripe.com/docs/api) as the payment gateway. +- uses [ldap3](https://github.com/cannatag/ldap3) for ldap authentication. + +## Getting started + +**TODO** + +## Usage + +Currently handles very basic features, such as: + +#### 1. Adding of products +```shell script +http --json http://[::]:5000/product/add email=your_email_here password=your_password_here specs:=@ipv6-only-vm.json +``` + +#### 2. Listing of products +```shell script +http --json http://[::]:5000/product/list +``` + +#### 3. Ordering products +```shell script +http --json http://[::]:5000/product/order email=your_email_here password=your_password_here product_id=5332cb89453d495381e2b2167f32c842 cpu=1 ram=1gb os-disk-space=10gb os=alpine +``` + +#### 4. Listing users orders + +```shell script +http --json GET http://[::]:5000/order/list email=your_email_here password=your_password_here +``` + + +#### 5. 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" email=your_email_here password=your_password_here +``` \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..cecbc97 --- /dev/null +++ b/config.py @@ -0,0 +1,8 @@ +import configparser +from etcd_wrapper import EtcdWrapper + + +config = configparser.ConfigParser() +config.read('pay.conf') + +etcd_client = EtcdWrapper(host=config['etcd']['host'], port=config['etcd']['port']) diff --git a/etcd_wrapper.py b/etcd_wrapper.py new file mode 100644 index 0000000..73e2c3c --- /dev/null +++ b/etcd_wrapper.py @@ -0,0 +1,75 @@ +import etcd3 +import json + +from functools import wraps + +from uncloud import UncloudException +from uncloud.common import logger + + +class EtcdEntry: + def __init__(self, meta_or_key, value, value_in_json=False): + if hasattr(meta_or_key, 'key'): + # if meta has attr 'key' then get it + self.key = meta_or_key.key.decode('utf-8') + else: + # otherwise meta is the 'key' + self.key = meta_or_key + self.value = value.decode('utf-8') + + if value_in_json: + self.value = json.loads(self.value) + + +def readable_errors(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except etcd3.exceptions.ConnectionFailedError: + raise UncloudException('Cannot connect to etcd: is etcd running as configured in uncloud.conf?') + except etcd3.exceptions.ConnectionTimeoutError as err: + raise etcd3.exceptions.ConnectionTimeoutError('etcd connection timeout.') from err + except Exception: + logger.exception('Some etcd error occured. See syslog for details.') + + return wrapper + + +class EtcdWrapper: + @readable_errors + def __init__(self, *args, **kwargs): + self.client = etcd3.client(*args, **kwargs) + + @readable_errors + def get(self, *args, value_in_json=False, **kwargs): + _value, _key = self.client.get(*args, **kwargs) + if _key is None or _value is None: + return None + return EtcdEntry(_key, _value, value_in_json=value_in_json) + + @readable_errors + def put(self, *args, value_in_json=False, **kwargs): + _key, _value = args + if value_in_json: + _value = json.dumps(_value) + + if not isinstance(_key, str): + _key = _key.decode('utf-8') + + return self.client.put(_key, _value, **kwargs) + + @readable_errors + def get_prefix(self, *args, value_in_json=False, raise_exception=True, **kwargs): + event_iterator = self.client.get_prefix(*args, **kwargs) + for e in event_iterator: + yield EtcdEntry(*e[::-1], value_in_json=value_in_json) + + @readable_errors + def watch_prefix(self, key, raise_exception=True, value_in_json=False): + event_iterator, cancel = self.client.watch_prefix(key) + for e in event_iterator: + if hasattr(e, '_event'): + e = e._event + if e.type == e.PUT: + yield EtcdEntry(e.kv.key, e.kv.value, value_in_json=value_in_json) diff --git a/helper.py b/helper.py new file mode 100644 index 0000000..c2000f5 --- /dev/null +++ b/helper.py @@ -0,0 +1,62 @@ +import config +from stripe_utils import StripeUtils + +etcd_client = config.etcd_client + + +def get_plan_id_from_product(product): + plan_id = 'ucloud-v1-' + plan_id += product['name'].strip().replace(' ', '-') + # plan_id += '-' + product['type'] + return plan_id + + +def get_order_id(): + order_id_kv = etcd_client.get('/v1/last_order_id') + if order_id_kv is not None: + order_id = int(order_id_kv.value) + 1 + else: + order_id = 0 + etcd_client.put('/v1/last_order_id', str(order_id)) + return 'OR-{}'.format(order_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(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: + print(p.value['uuid'], usable_id) + return p.value['uuid'] + return None diff --git a/ldap_manager.py b/ldap_manager.py new file mode 100644 index 0000000..f8cfaa3 --- /dev/null +++ b/ldap_manager.py @@ -0,0 +1,64 @@ +import hashlib +import random +import base64 + +from ldap3 import Server, Connection, ObjectDef, Reader, ALL + + +class LdapManager: + def __init__(self, server, admin_dn, admin_password): + self.server = Server(server, get_info=ALL) + self.conn = Connection(server, admin_dn, admin_password, auto_bind=True) + self.person_obj_def = ObjectDef('inetOrgPerson', self.conn) + + def get(self, query=None, search_base='dc=ungleich,dc=ch'): + kwargs = { + 'connection': self.conn, + 'object_def': self.person_obj_def, + 'base': search_base, + } + if query: + kwargs['query'] = query + r = Reader(**kwargs) + return r.search() + + def is_password_valid(self, email, password, **kwargs): + entries = self.get(query='(mail={})'.format(email), **kwargs) + if entries: + password_in_ldap = entries[0].userPassword.value + return self._check_password(password_in_ldap, password) + return False + + @staticmethod + def _check_password(tagged_digest_salt, password): + digest_salt_b64 = tagged_digest_salt[6:] + digest_salt = base64.decodebytes(digest_salt_b64) + digest = digest_salt[:20] + salt = digest_salt[20:] + + sha = hashlib.sha1(password.encode('utf-8')) + sha.update(salt) + + return digest == sha.digest() + + @staticmethod + def ssha_password(password): + """ + Apply the SSHA password hashing scheme to the given *password*. + *password* must be a :class:`bytes` object, containing the utf-8 + encoded password. + + Return a :class:`bytes` object containing ``ascii``-compatible data + which can be used as LDAP value, e.g. after armoring it once more using + base64 or decoding it to unicode from ``ascii``. + """ + SALT_BYTES = 15 + + sha1 = hashlib.sha1() + salt = random.SystemRandom().getrandbits(SALT_BYTES * 8).to_bytes(SALT_BYTES, 'little') + sha1.update(password) + sha1.update(salt) + + digest = sha1.digest() + passwd = b'{SSHA}' + base64.b64encode(digest + salt) + return passwd diff --git a/products/ipv6-only-django.json b/products/ipv6-only-django.json new file mode 100644 index 0000000..b3d8730 --- /dev/null +++ b/products/ipv6-only-django.json @@ -0,0 +1,27 @@ +{ + "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", + "features": { + "cpu": { + "unit": {"value": 1, "type":"int"}, + "price_per_unit_per_period": 3, + "one_time_fee": 0, + "constant": false + }, + "ram": { + "unit": {"value": 1, "type":"int"}, + "price_per_unit_per_period": 4, + "one_time_fee": 0, + "constant": false + }, + "os-disk-space": { + "unit": {"value": 10, "type":"int"}, + "one_time_fee": 0, + "price_per_unit_per_period": 3.5, + "constant": false + } + } +} diff --git a/products/ipv6-only-vm.json b/products/ipv6-only-vm.json new file mode 100644 index 0000000..6b21b26 --- /dev/null +++ b/products/ipv6-only-vm.json @@ -0,0 +1,33 @@ +{ + "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", + "features": { + "cpu": { + "unit": {"value": 1, "type":"int"}, + "price_per_unit_per_period": 3, + "one_time_fee": 0, + "constant": false + }, + "ram": { + "unit": {"value": 1, "type":"int"}, + "price_per_unit_per_period": 4, + "one_time_fee": 0, + "constant": false + }, + "os-disk-space": { + "unit": {"value": 10, "type":"int"}, + "one_time_fee": 0, + "price_per_unit_per_period": 4, + "constant": false + }, + "os": { + "unit": {"value": 1, "type":"str"}, + "one_time_fee": 0, + "price_per_unit_per_period": 0, + "constant": false + } + } +} diff --git a/products/ipv6-only-vpn.json b/products/ipv6-only-vpn.json new file mode 100644 index 0000000..43ed7bd --- /dev/null +++ b/products/ipv6-only-vpn.json @@ -0,0 +1,15 @@ +{ + "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", + "features": { + "vpn": { + "unit": {"value": 1, "type": "int"}, + "price_per_unit_per_period": 10, + "one_time_fee": 0, + "constant": true + } + } +} diff --git a/products/membership.json b/products/membership.json new file mode 100644 index 0000000..14596fa --- /dev/null +++ b/products/membership.json @@ -0,0 +1,15 @@ +{ + "usable-id": "membership", + "active": true, + "name": "Membership", + "description": "Membership to use uncloud-pay", + "recurring_period": "eternity", + "features": { + "membership": { + "unit": {"value": 1, "type":"int"}, + "price_per_unit_per_period": 0, + "one_time_fee": 5, + "constant": true + } + } +} diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..9d0c97f --- /dev/null +++ b/schemas.py @@ -0,0 +1,134 @@ +import logging +import config + +from helper import resolve_product_usable_id + +etcd_client = config.etcd_client + + +class ValidationException(Exception): + """Validation Error""" + + +class Field: + def __init__(self, _name, _type, _value=None, validators=None): + if validators is None: + validators = [] + + assert isinstance(validators, list) + + self.name = _name + self.value = _value + self.type = _type + self.validators = validators + + def is_valid(self): + if not isinstance(self.value, self.type): + try: + self.value = self.type(self.value) + except Exception: + raise ValidationException("Incorrect Type for '{}' field".format(self.name)) + + for validator in self.validators: + validator() + + def __repr__(self): + return self.name + + +class BaseSchema: + def __init__(self): + self.fields = [getattr(self, field) for field in dir(self) if isinstance(getattr(self, field), Field)] + + def validation(self): + # custom validation is optional + return True + + def is_valid(self): + for field in self.fields: + field.is_valid() + + for parent in self.__class__.__bases__: + parent.validation(self) + + self.validation() + + for field in self.fields: + setattr(self, field.name, field.value) + + def return_data(self): + return { + field.name: field.value + for field in self.fields + } + + +def get(dictionary: dict, key: str, return_default=False, default=None): + if dictionary is None: + raise ValidationException('No data provided at all.') + try: + value = dictionary[key] + except KeyError: + if return_default: + return default + raise ValidationException("Missing data for '{}' field.".format(key)) + else: + return value + + +class AddProductSchema(BaseSchema): + def __init__(self, data): + self.email = Field('email', str, get(data, 'email')) + self.password = Field('password', str, get(data, 'password')) + self.specs = Field('specs', dict, get(data, 'specs')) + super().__init__() + + +class UserRegisterPaymentSchema(BaseSchema): + def __init__(self, data): + self.email = Field('email', str, get(data, 'email')) + self.password = Field('password', str, get(data, 'password')) + self.card_number = Field('card_number', str, get(data, 'card_number')) + self.cvc = Field('cvc', str, get(data, 'cvc')) + self.expiry_year = Field('expiry_year', int, get(data, 'expiry_year')) + self.expiry_month = Field('expiry_month', int, get(data, 'expiry_month')) + self.card_holder_name = Field('card_holder_name', str, get(data, 'card_holder_name')) + + super().__init__() + + +class ProductOrderSchema(BaseSchema): + def __init__(self, data): + self.email = Field('email', str, get(data, 'email')) + self.password = Field('password', str, get(data, 'password')) + self.product_id = Field('product_id', str, get(data, 'product_id'), validators=[self.product_id_validation]) + + super().__init__() + + def product_id_validation(self): + product_uuid = resolve_product_usable_id(self.product_id.value, etcd_client) + if product_uuid: + self.product_id.value = product_uuid + else: + raise ValidationException('Invalid Product ID') + + +class OrderListSchema(BaseSchema): + def __init__(self, data): + self.email = Field('email', str, get(data, 'email')) + self.password = Field('password', str, get(data, 'password')) + super().__init__() + +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']), get(data, feature_name)) + + return type('{}Schema'.format(specification['name']), (BaseSchema,), fields) + diff --git a/stripe_utils.py b/stripe_utils.py new file mode 100644 index 0000000..5ffb443 --- /dev/null +++ b/stripe_utils.py @@ -0,0 +1,490 @@ +import json +import re +import stripe +import stripe.error +import logging + +from config import etcd_client as client, config as config + +stripe.api_key = config['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): + self.stripe = stripe + + @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): + if name is None or name.strip() == "": + name = email + customer = self.stripe.Customer.create( + source=token, + description=name, + email=email + ) + 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 = json.loads(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", json.dumps({"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 name: The name of the Stripe plan to be created. + :param stripe_plan_id: The id of the Stripe plan to be + created. Use get_stripe_plan_id_string function to + obtain the name of the plan to be created + :param interval: The interval for subscription {month, year}. Defaults + to month if not provided + :return: The StripePlan object if it exists else creates a + Plan object in Stripe and a local StripePlan and + returns it. Returns None in case of Stripe error + """ + _amount = float(amount) + amount = int(_amount * 100) # stripe amount unit, in cents + all_stripe_plans = self._get_all_stripe_plans() + if stripe_plan_id in all_stripe_plans: + logging.debug("{} plan exists in db.".format(stripe_plan_id)) + else: + logging.debug(("{} plan DOES NOT exist in db. " + "Creating").format(stripe_plan_id)) + try: + plan_obj = self.stripe.Plan.retrieve(id=stripe_plan_id) + logging.debug("{} plan exists in Stripe".format(stripe_plan_id)) + all_stripe_plans.add(stripe_plan_id) + except stripe.error.InvalidRequestError as e: + if "No such plan" in str(e): + logging.debug("Plan {} does not exist in Stripe, Creating") + plan_obj = self.stripe.Plan.create( + amount=amount, + product={'name': product_name}, + interval=interval, + currency=self.CURRENCY, + id=stripe_plan_id) + logging.debug(self.PLAN_EXISTS_ERROR_MSG.format(stripe_plan_id)) + all_stripe_plans.add(stripe_plan_id) + self._save_all_stripe_plans(all_stripe_plans) + return stripe_plan_id + + @handle_stripe_error + def delete_stripe_plan(self, stripe_plan_id): + """ + Deletes the Plan in Stripe and also deletes the local db copy + of the plan if it exists + + :param stripe_plan_id: The stripe plan id that needs to be + deleted + :return: True if the plan was deleted successfully from + Stripe, False otherwise. + """ + return_value = False + try: + plan = self.stripe.Plan.retrieve(stripe_plan_id) + plan.delete() + return_value = True + all_stripe_plans = self._get_all_stripe_plans() + all_stripe_plans.remove(stripe_plan_id) + self._save_all_stripe_plans(all_stripe_plans) + except stripe.error.InvalidRequestError as e: + if self.STRIPE_NO_SUCH_PLAN in str(e): + logging.debug( + self.PLAN_DOES_NOT_EXIST_ERROR_MSG.format(stripe_plan_id)) + return return_value + + @handle_stripe_error + def subscribe_customer_to_plan(self, customer, plans, trial_end=None): + """ + Subscribes the given customer to the list of given plans + + :param customer: The stripe customer identifier + :param plans: A list of stripe plans. + :param trial_end: An integer representing when the Stripe subscription + is supposed to end + Ref: https://stripe.com/docs/api/python#create_subscription-items + e.g. + plans = [ + { + "plan": "dcl-v1-cpu-2-ram-5gb-ssd-10gb", + }, + ] + :return: The subscription StripeObject + """ + + subscription_result = self.stripe.Subscription.create( + customer=customer, items=plans, trial_end=trial_end + ) + return subscription_result + + @handle_stripe_error + def set_subscription_metadata(self, subscription_id, metadata): + subscription = stripe.Subscription.retrieve(subscription_id) + subscription.metadata = metadata + subscription.save() + + @handle_stripe_error + def unsubscribe_customer(self, subscription_id): + """ + Cancels a given subscription + + :param subscription_id: The Stripe subscription id string + :return: + """ + sub = stripe.Subscription.retrieve(subscription_id) + return sub.delete() + + @handle_stripe_error + def make_payment(self, customer, amount, token): + charge = self.stripe.Charge.create( + amount=amount, # in cents + currency=self.CURRENCY, + customer=customer + ) + return charge + + @staticmethod + def get_stripe_plan_id(cpu, ram, ssd, version, app='dcl', hdd=None, + price=None): + """ + Returns the Stripe plan id string of the form + `dcl-v1-cpu-2-ram-5gb-ssd-10gb` based on the input parameters + + :param cpu: The number of cores + :param ram: The size of the RAM in GB + :param ssd: The size of ssd storage in GB + :param hdd: The size of hdd storage in GB + :param version: The version of the Stripe plans + :param app: The application to which the stripe plan belongs + to. By default it is 'dcl' + :param price: The price for this plan + :return: A string of the form `dcl-v1-cpu-2-ram-5gb-ssd-10gb` + """ + dcl_plan_string = 'cpu-{cpu}-ram-{ram}gb-ssd-{ssd}gb'.format(cpu=cpu, + ram=ram, + ssd=ssd) + if hdd is not None: + dcl_plan_string = '{dcl_plan_string}-hdd-{hdd}gb'.format( + dcl_plan_string=dcl_plan_string, hdd=hdd) + stripe_plan_id_string = '{app}-v{version}-{plan}'.format( + app=app, + version=version, + plan=dcl_plan_string + ) + if price is not None: + stripe_plan_id_string_with_price = '{}-{}chf'.format( + stripe_plan_id_string, + round(price, 2) + ) + return stripe_plan_id_string_with_price + else: + return stripe_plan_id_string + + @staticmethod + def get_vm_config_from_stripe_id(stripe_id): + """ + Given a string like "dcl-v1-cpu-2-ram-5gb-ssd-10gb" return different + configuration params as a dict + + :param stripe_id|str + :return: dict + """ + pattern = re.compile(r'^dcl-v(\d+)-cpu-(\d+)-ram-(\d+\.?\d*)gb-ssd-(\d+)gb-?(\d*\.?\d*)(chf)?$') + match_res = pattern.match(stripe_id) + if match_res is not None: + price = None + try: + price = match_res.group(5) + except IndexError: + logging.debug("Did not find price in {}".format(stripe_id)) + return { + 'version': match_res.group(1), + 'cores': match_res.group(2), + 'ram': match_res.group(3), + 'ssd': match_res.group(4), + 'price': price + } + + @staticmethod + def get_stripe_plan_name(cpu, memory, disk_size, price): + """ + Returns the Stripe plan name + :return: + """ + return "{cpu} Cores, {memory} GB RAM, {disk_size} GB SSD, " \ + "{price} CHF".format( + cpu=cpu, + memory=memory, + disk_size=disk_size, + price=round(price, 2) + ) + + @handle_stripe_error + def set_subscription_meta_data(self, subscription_id, meta_data): + """ + Adds VM metadata to a subscription + :param subscription_id: Stripe identifier for the subscription + :param meta_data: A dict of meta data to be added + :return: + """ + subscription = stripe.Subscription.retrieve(subscription_id) + subscription.metadata = meta_data + subscription.save() diff --git a/ucloud_pay.py b/ucloud_pay.py new file mode 100644 index 0000000..edee113 --- /dev/null +++ b/ucloud_pay.py @@ -0,0 +1,345 @@ +import json +import time +import logging + +from datetime import datetime +from uuid import uuid4 + +from flask import Flask, request +from flask_restful import Resource, Api + +from config import etcd_client as client, config as config +from stripe_utils import StripeUtils +from ldap_manager import LdapManager +from schemas import ( + make_return_message, ValidationException, UserRegisterPaymentSchema, + AddProductSchema, ProductOrderSchema, OrderListSchema, create_schema +) +from helper import ( + get_plan_id_from_product, get_user_friendly_product, get_order_id, +) + +logger = logging.getLogger() +logger.setLevel(logging.DEBUG) +log_formater = logging.Formatter('[%(filename)s:%(lineno)d] %(message)s') + +stream_logger = logging.StreamHandler() +stream_logger.setLevel(logging.DEBUG) +stream_logger.setFormatter(log_formater) + +logger.addHandler(stream_logger) + +app = Flask(__name__) +api = Api(app) +INIT_ORDER_ID = 0 + +ldap_manager = LdapManager(server=config['ldap']['server'], admin_dn=config['ldap']['admin_dn'], + admin_password=config['ldap']['admin_password']) + + +def calculate_charges(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 + + +class ListProducts(Resource): + @staticmethod + def get(): + products = client.get_prefix('/v1/products/', value_in_json=False) + prod_dict = {} + for p in products: + p = json.loads(p.value) + prod_dict[p['usable-id']] = { + 'name': p['name'], + 'description': p['description'], + 'active': p['active'] + } + logger.debug('Products = {}'.format(prod_dict)) + return prod_dict, 200 + + +class AddProduct(Resource): + @staticmethod + def post(): + data = request.json + logger.debug('Got data: {}'.format(str(data))) + + try: + validator = AddProductSchema(data) + validator.is_valid() + except ValidationException as err: + return make_return_message(err, 400) + else: + if ldap_manager.is_password_valid(data['email'], data['password']): + try: + user = ldap_manager.get('(mail={})'.format(data['email']))[0] + user = json.loads(user.entry_to_json()) + uid, ou, *dc = user['dn'].replace('ou=', '').replace('dc=', '').replace('uid=', '').split(',') + except Exception as err: + logger.error(str(err)) + return {'message': 'No such user exists'} + else: + if ou != config['ldap']['internal_user_ou']: + logger.error('User (email=%s) does not have access to create product', validator.email) + return {'message': 'Forbidden'}, 403 + else: + product_uuid = uuid4().hex + product_key = '/v1/products/{}'.format(product_uuid) + product_value = validator.specs + product_value['uuid'] = product_uuid + + logger.debug('Adding product data: {}'.format(str(product_value))) + client.put(product_key, product_value, value_in_json=True) + return {'message': 'Product created'}, 200 + + else: + return {'message': 'Wrong Credentials'}, 403 + + +class UserRegisterPayment(Resource): + @staticmethod + def post(): + data = request.json + logger.debug('Got data: {}'.format(str(data))) + try: + validator = UserRegisterPaymentSchema(data) + validator.is_valid() + except ValidationException as err: + return make_return_message(err, 400) + else: + last4 = data['card_number'].strip()[-4:] + + if ldap_manager.is_password_valid(validator.email, validator.password): + stripe_utils = StripeUtils() + + # Does customer already exist ? + stripe_customer = stripe_utils.get_stripe_customer_from_email(validator.email) + + # Does customer already exist ? + if stripe_customer is not None: + logger.debug('Customer {} exists already'.format(validator.email)) + + # 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(validator.email)) + token_response = stripe_utils.get_token_from_card( + validator.card_number, validator.cvc, validator.expiry_month, + validator.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=validator.card_holder_name, + token=token_response['response_object'].id, + email=validator.email + ) + 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) + else: + return make_return_message('Wrong Credentials', 403) + + +class ProductOrder(Resource): + @staticmethod + def post(): + data = request.json + try: + validator = ProductOrderSchema(data) + validator.is_valid() + except ValidationException as err: + return make_return_message(err, 400) + else: + if ldap_manager.is_password_valid(validator.email, validator.password): + stripe_utils = StripeUtils() + logger.debug('Product ID = {}'.format(validator.product_id)) + + # Validate the given product is ok + product = client.get('/v1/products/{}'.format(validator.product_id), value_in_json=True) + if not product: + return make_return_message('Invalid Product', 400) + + product = product.value + + customer_previous_orders = client.get_prefix( + '/v1/user/{}'.format(validator.email), value_in_json=True + ) + membership = next(filter(lambda o: o.value['product'] == 'membership', customer_previous_orders), None) + if membership is None and data['product_id'] != 'membership': + return make_return_message('Please buy membership first to use this facility') + + logger.debug('Got product {}'.format(product)) + + # Check the user has a payment source added + stripe_customer = stripe_utils.get_stripe_customer_from_email(validator.email) + + if not stripe_customer or len(stripe_customer.sources) == 0: + return make_return_message('Please register 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.return_data() + logger.debug('Tranformed data: {}'.format(transformed_data)) + one_time_charge, recurring_charge = calculate_charges(product, transformed_data) + recurring_charge = int(recurring_charge) + + # 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': get_order_id(), + 'ordered_at': int(time.time()), + 'product': product['usable-id'], + } + client.put('/v1/user/{}/orders'.format(validator.email), order_obj, value_in_json=True) + order_obj['ordered_at'] = datetime.fromtimestamp(order_obj['ordered_at']).strftime('%c') + return make_return_message('Order Successful. Order Details: {}'.format(order_obj)) + else: + logger.error('Could not create plan {}'.format(plan_id)) + + 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': get_order_id(), + 'ordered_at': int(time.time()), + 'product': product['usable-id'], + } + client.put( + '/v1/user/{}/orders'.format(validator.email),order_obj, + value_in_json=True + ) + order_obj['ordered_at'] = datetime.fromtimestamp(order_obj['ordered_at']).strftime('%c') + return {'message': 'Order successful', 'order_details': order_obj}, 200 + else: + return make_return_message('Wrong Credentials', 400) + + +class OrderList(Resource): + @staticmethod + def get(): + data = request.json + try: + validator = OrderListSchema(data) + validator.is_valid() + except ValidationException as err: + return make_return_message(err, 400) + else: + print(validator.email, validator.password) + if not ldap_manager.is_password_valid(validator.email, validator.password): + return {'message': 'Wrong Credentials'}, 403 + + orders = client.get_prefix('/v1/user/{}/orders'.format(validator.email), value_in_json=True) + orders_dict = { + order.value['order_id']: { + 'ordered-at': datetime.fromtimestamp(order.value['ordered_at']).strftime('%c'), + 'product': order.value['product'] + } + for order in orders + } + # for p in orders: + # order_dict = p.value + # order_dict['ordered_at'] = datetime.fromtimestamp( + # order_dict['ordered_at']).strftime('%c') + # order_dict['product'] = order_dict['product']['name'] + # orders_dict[order_dict['order_id']] = order_dict + logger.debug('Orders = {}'.format(orders_dict)) + return orders_dict, 200 + + +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') + + +if __name__ == '__main__': + app.run(host='::', port=config['app']['port'], debug=True) \ No newline at end of file From 1a76d2b5f348222c139db364ad9dad529c3540ce Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 27 Jan 2020 13:40:57 +0500 Subject: [PATCH 002/193] Many more changes --- config.py | 5 +- helper.py | 38 ++-- ldap_manager.py | 13 +- products/ipv6-only-django.json | 1 + products/ipv6-only-vm.json | 1 + products/ipv6-only-vpn.json | 1 + products/ipv6box.json | 16 ++ products/membership.json | 10 +- schemas.py | 208 ++++++++++++------ stripe_utils.py | 5 +- ucloud_pay.py | 371 ++++++++++++++++----------------- 11 files changed, 386 insertions(+), 283 deletions(-) create mode 100644 products/ipv6box.json diff --git a/config.py b/config.py index cecbc97..b951830 100644 --- a/config.py +++ b/config.py @@ -1,8 +1,11 @@ import configparser from etcd_wrapper import EtcdWrapper - +from ldap_manager import LdapManager config = configparser.ConfigParser() config.read('pay.conf') etcd_client = EtcdWrapper(host=config['etcd']['host'], port=config['etcd']['port']) + +ldap_manager = LdapManager(server=config['ldap']['server'], admin_dn=config['ldap']['admin_dn'], + admin_password=config['ldap']['admin_password']) \ No newline at end of file diff --git a/helper.py b/helper.py index c2000f5..d1a5dd4 100644 --- a/helper.py +++ b/helper.py @@ -1,26 +1,14 @@ -import config -from stripe_utils import StripeUtils +import logging -etcd_client = config.etcd_client +from stripe_utils import StripeUtils def get_plan_id_from_product(product): plan_id = 'ucloud-v1-' plan_id += product['name'].strip().replace(' ', '-') - # plan_id += '-' + product['type'] return plan_id -def get_order_id(): - order_id_kv = etcd_client.get('/v1/last_order_id') - if order_id_kv is not None: - order_id = int(order_id_kv.value) + 1 - else: - order_id = 0 - etcd_client.put('/v1/last_order_id', str(order_id)) - return 'OR-{}'.format(order_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) @@ -53,10 +41,26 @@ def get_token(card_number, cvc, exp_month, exp_year): return None -def resolve_product_usable_id(usable_id, etcd_client): +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: - print(p.value['uuid'], usable_id) - return p.value['uuid'] + 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 diff --git a/ldap_manager.py b/ldap_manager.py index f8cfaa3..382afab 100644 --- a/ldap_manager.py +++ b/ldap_manager.py @@ -22,12 +22,17 @@ class LdapManager: r = Reader(**kwargs) return r.search() - def is_password_valid(self, email, password, **kwargs): - entries = self.get(query='(mail={})'.format(email), **kwargs) + def is_password_valid(self, query_value, password, query_key='mail', **kwargs): + entries = self.get(query='({}={})'.format(query_key, query_value), **kwargs) if entries: password_in_ldap = entries[0].userPassword.value - return self._check_password(password_in_ldap, password) - return False + found = self._check_password(password_in_ldap, password) + if not found: + raise Exception('Invalid Password') + else: + return entries[0] + else: + raise ValueError('Such {}={} not found'.format(query_key, query_value)) @staticmethod def _check_password(tagged_digest_salt, password): diff --git a/products/ipv6-only-django.json b/products/ipv6-only-django.json index b3d8730..110027a 100644 --- a/products/ipv6-only-django.json +++ b/products/ipv6-only-django.json @@ -4,6 +4,7 @@ "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"}, diff --git a/products/ipv6-only-vm.json b/products/ipv6-only-vm.json index 6b21b26..d07ad6c 100644 --- a/products/ipv6-only-vm.json +++ b/products/ipv6-only-vm.json @@ -4,6 +4,7 @@ "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"}, diff --git a/products/ipv6-only-vpn.json b/products/ipv6-only-vpn.json index 43ed7bd..38c6201 100644 --- a/products/ipv6-only-vpn.json +++ b/products/ipv6-only-vpn.json @@ -4,6 +4,7 @@ "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"}, diff --git a/products/ipv6box.json b/products/ipv6box.json new file mode 100644 index 0000000..eca11f0 --- /dev/null +++ b/products/ipv6box.json @@ -0,0 +1,16 @@ +{ + "usable-id": "ipv6-box", + "active": true, + "name": "IPv6 Box", + "description": "A ready-to-go IPv6 Box: it creates a VPN to ungleich and distributes IPv6 addresses to all your computers.", + "recurring_period": "eternity", + "quantity": 4, + "features": { + "ipv6-box": { + "unit": {"value": 1, "type":"int"}, + "price_per_unit_per_period": 0, + "one_time_fee": 250, + "constant": true + } + } +} diff --git a/products/membership.json b/products/membership.json index 14596fa..4003330 100644 --- a/products/membership.json +++ b/products/membership.json @@ -3,13 +3,15 @@ "active": true, "name": "Membership", "description": "Membership to use uncloud-pay", - "recurring_period": "eternity", + "recurring_period": "month", + "quantity": "inf", "features": { "membership": { "unit": {"value": 1, "type":"int"}, - "price_per_unit_per_period": 0, - "one_time_fee": 5, + "price_per_unit_per_period": 5, + "one_time_fee": 0, "constant": true } - } + }, + "max_per_user": "1" } diff --git a/schemas.py b/schemas.py index 9d0c97f..c128d19 100644 --- a/schemas.py +++ b/schemas.py @@ -1,7 +1,10 @@ import logging import config +import json +import math -from helper import resolve_product_usable_id +from config import ldap_manager +from helper import resolve_product etcd_client = config.etcd_client @@ -11,26 +14,23 @@ class ValidationException(Exception): class Field: - def __init__(self, _name, _type, _value=None, validators=None): - if validators is None: - validators = [] - - assert isinstance(validators, list) - + def __init__(self, _name, _type, _value=None, validators=None, disable_validation=False): + self.validation_disabled = disable_validation self.name = _name self.value = _value self.type = _type - self.validators = validators + self.validators = validators or [] def is_valid(self): - if not isinstance(self.value, self.type): - try: - self.value = self.type(self.value) - except Exception: - raise ValidationException("Incorrect Type for '{}' field".format(self.name)) + if not self.validation_disabled: + if not isinstance(self.value, self.type): + try: + self.value = self.type(self.value) + except Exception: + raise ValidationException("Incorrect Type for '{}' field".format(self.name)) - for validator in self.validators: - validator() + for validator in self.validators: + validator() def __repr__(self): return self.name @@ -38,86 +38,171 @@ class Field: class BaseSchema: def __init__(self): - self.fields = [getattr(self, field) for field in dir(self) if isinstance(getattr(self, field), Field)] + self.objects = {} def validation(self): # custom validation is optional return True + def get_fields(self): + return [getattr(self, field) for field in dir(self) if isinstance(getattr(self, field), Field)] + def is_valid(self): - for field in self.fields: + for field in self.get_fields(): field.is_valid() - - for parent in self.__class__.__bases__: - parent.validation(self) - self.validation() - for field in self.fields: - setattr(self, field.name, field.value) - - def return_data(self): - return { + def get_cleaned_values(self): + field_kv_dict = { field.name: field.value - for field in self.fields + for field in self.get_fields() } + cleaned_values = field_kv_dict + cleaned_values.update(self.objects) + return cleaned_values -def get(dictionary: dict, key: str, return_default=False, default=None): - if dictionary is None: - raise ValidationException('No data provided at all.') - try: - value = dictionary[key] - except KeyError: - if return_default: - return default - raise ValidationException("Missing data for '{}' field.".format(key)) - else: - return value + def add_schema(self, schema, data, under_field_name=None): + s = schema(data) + s.is_valid() + + base = self + if under_field_name: + # Create a field in self + setattr(self, under_field_name, Field(under_field_name, dict, _value={}, disable_validation=True)) + base = getattr(self, under_field_name) + + for field in s.get_fields(): + if under_field_name: + getattr(base, 'value')[field.name] = field.value + else: + setattr(base, field.name, field) + + self.objects.update(s.objects) + + @staticmethod + def get(dictionary: dict, key: str, return_default=False, default=None): + if dictionary is None: + raise ValidationException('No data provided at all.') + try: + value = dictionary[key] + except KeyError: + if return_default: + return {'_value': default, 'disable_validation': True} + raise ValidationException("Missing data for '{}' field.".format(key)) + else: + return {'_value': value, 'disable_validation': False} class AddProductSchema(BaseSchema): def __init__(self, data): - self.email = Field('email', str, get(data, 'email')) - self.password = Field('password', str, get(data, 'password')) - self.specs = Field('specs', dict, get(data, 'specs')) 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['ldap']['internal_user_ou']: + 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): - self.email = Field('email', str, get(data, 'email')) - self.password = Field('password', str, get(data, 'password')) - self.card_number = Field('card_number', str, get(data, 'card_number')) - self.cvc = Field('cvc', str, get(data, 'cvc')) - self.expiry_year = Field('expiry_year', int, get(data, 'expiry_year')) - self.expiry_month = Field('expiry_month', int, get(data, 'expiry_month')) - self.card_holder_name = Field('card_holder_name', str, get(data, 'card_holder_name')) - 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): - self.email = Field('email', str, get(data, 'email')) - self.password = Field('password', str, get(data, 'password')) - self.product_id = Field('product_id', str, get(data, 'product_id'), validators=[self.product_id_validation]) - 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_uuid = resolve_product_usable_id(self.product_id.value, etcd_client) - if product_uuid: - self.product_id.value = product_uuid + product = resolve_product(self.product_id.value, etcd_client) + if product: + 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('Invalid Product ID') + raise ValidationException('No such product exists.') + + def validation(self): + customer_previous_orders = etcd_client.get_prefix('/v1/user/{}'.format(self.username.value), 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): - self.email = Field('email', str, get(data, 'email')) - self.password = Field('password', str, get(data, 'password')) super().__init__() + self.add_schema(UserCredentialSchema, data) + def make_return_message(err, status_code=200): logging.debug('message: {}'.format(str(err))) @@ -128,7 +213,8 @@ 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']), get(data, feature_name)) + fields[feature_name] = Field( + feature_name, eval(feature_detail['unit']['type']), **BaseSchema.get(data, feature_name) + ) return type('{}Schema'.format(specification['name']), (BaseSchema,), fields) - diff --git a/stripe_utils.py b/stripe_utils.py index 5ffb443..a9803af 100644 --- a/stripe_utils.py +++ b/stripe_utils.py @@ -245,13 +245,14 @@ class StripeUtils(object): return customer @handle_stripe_error - def create_customer(self, token, email, name=None): + 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 + email=email, + address=address ) return customer diff --git a/ucloud_pay.py b/ucloud_pay.py index edee113..e4d105f 100644 --- a/ucloud_pay.py +++ b/ucloud_pay.py @@ -1,5 +1,4 @@ import json -import time import logging from datetime import datetime @@ -10,60 +9,27 @@ from flask_restful import Resource, Api from config import etcd_client as client, config as config from stripe_utils import StripeUtils -from ldap_manager import LdapManager from schemas import ( make_return_message, ValidationException, UserRegisterPaymentSchema, AddProductSchema, ProductOrderSchema, OrderListSchema, create_schema ) -from helper import ( - get_plan_id_from_product, get_user_friendly_product, get_order_id, -) - -logger = logging.getLogger() -logger.setLevel(logging.DEBUG) -log_formater = logging.Formatter('[%(filename)s:%(lineno)d] %(message)s') - -stream_logger = logging.StreamHandler() -stream_logger.setLevel(logging.DEBUG) -stream_logger.setFormatter(log_formater) - -logger.addHandler(stream_logger) - -app = Flask(__name__) -api = Api(app) -INIT_ORDER_ID = 0 - -ldap_manager = LdapManager(server=config['ldap']['server'], admin_dn=config['ldap']['admin_dn'], - admin_password=config['ldap']['admin_password']) - - -def calculate_charges(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 +from helper import get_plan_id_from_product, calculate_charges class ListProducts(Resource): @staticmethod def get(): products = client.get_prefix('/v1/products/', value_in_json=False) + products = [ + product + for product in [json.loads(p.value) for p in products] + if product['active'] + ] prod_dict = {} for p in products: - p = json.loads(p.value) prod_dict[p['usable-id']] = { 'name': p['name'], 'description': p['description'], - 'active': p['active'] } logger.debug('Products = {}'.format(prod_dict)) return prod_dict, 200 @@ -72,174 +38,170 @@ class ListProducts(Resource): class AddProduct(Resource): @staticmethod def post(): - data = request.json - logger.debug('Got data: {}'.format(str(data))) + 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: - if ldap_manager.is_password_valid(data['email'], data['password']): - try: - user = ldap_manager.get('(mail={})'.format(data['email']))[0] - user = json.loads(user.entry_to_json()) - uid, ou, *dc = user['dn'].replace('ou=', '').replace('dc=', '').replace('uid=', '').split(',') - except Exception as err: - logger.error(str(err)) - return {'message': 'No such user exists'} + 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: - if ou != config['ldap']['internal_user_ou']: - logger.error('User (email=%s) does not have access to create product', validator.email) - return {'message': 'Forbidden'}, 403 - else: - product_uuid = uuid4().hex - product_key = '/v1/products/{}'.format(product_uuid) - product_value = validator.specs - product_value['uuid'] = product_uuid - - logger.debug('Adding product data: {}'.format(str(product_value))) - client.put(product_key, product_value, value_in_json=True) - return {'message': 'Product created'}, 200 - + product_uuid = previous_product.pop('uuid') else: - return {'message': 'Wrong Credentials'}, 403 + 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, value_in_json=True) + if not previous_product: + return make_return_message('Product created.') + else: + return make_return_message('Product updated.') class UserRegisterPayment(Resource): @staticmethod def post(): - data = request.json - logger.debug('Got data: {}'.format(str(data))) + 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:] - if ldap_manager.is_password_valid(validator.email, validator.password): - stripe_utils = StripeUtils() + stripe_utils = StripeUtils() - # Does customer already exist ? - stripe_customer = stripe_utils.get_stripe_customer_from_email(validator.email) + # 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(validator.email)) + # 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']) + # 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) + 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(validator.email)) + 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( - validator.card_number, validator.cvc, validator.expiry_month, - validator.expiry_year + 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)) - - # Create stripe customer - stripe_customer_resp = stripe_utils.create_customer( - name=validator.card_holder_name, - token=token_response['response_object'].id, - email=validator.email + resp = stripe_utils.associate_customer_card( + stripe_customer.id, token_response['response_object'].id ) - stripe_customer = stripe_customer_resp['response_object'] - - if stripe_customer: - logger.debug('Created stripe customer {}'.format(stripe_customer.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 card. Contact support', 400) else: return make_return_message('Error with payment gateway. Contact support', 400) + else: + return make_return_message('Error: {}'.format(ce_response['error']), 400) else: - return make_return_message('Wrong Credentials', 403) + # 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.json + 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: - if ldap_manager.is_password_valid(validator.email, validator.password): - stripe_utils = StripeUtils() - logger.debug('Product ID = {}'.format(validator.product_id)) + cleaned_values = validator.get_cleaned_values() + stripe_utils = StripeUtils() - # Validate the given product is ok - product = client.get('/v1/products/{}'.format(validator.product_id), value_in_json=True) - if not product: - return make_return_message('Invalid Product', 400) + product = cleaned_values['product'] - product = product.value + # Check the user has a payment source added + stripe_customer = stripe_utils.get_stripe_customer_from_email(cleaned_values['user']['mail']) - customer_previous_orders = client.get_prefix( - '/v1/user/{}'.format(validator.email), value_in_json=True - ) - membership = next(filter(lambda o: o.value['product'] == 'membership', customer_previous_orders), None) - if membership is None and data['product_id'] != 'membership': - return make_return_message('Please buy membership first to use this facility') + if not stripe_customer or len(stripe_customer.sources) == 0: + return make_return_message('Please register your payment method first.', 400) - logger.debug('Got product {}'.format(product)) + 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) - # Check the user has a payment source added - stripe_customer = stripe_utils.get_stripe_customer_from_email(validator.email) - - if not stripe_customer or len(stripe_customer.sources) == 0: - return make_return_message('Please register 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.return_data() - 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 lock: # Initiate a one-time/subscription based on product type if recurring_charge > 0: logger.debug('Product {} is recurring payment'.format(product['name'])) @@ -262,13 +224,26 @@ class ProductOrder(Resource): ) else: order_obj = { - 'order_id': get_order_id(), - 'ordered_at': int(time.time()), + '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, value_in_json=True + ) + product['quantity'] -= 1 + client.put('/v1/products/{}'.format(product['uuid']), product, value_in_json=True) + + return { + 'message': 'Order Successful.', + **order_obj } - client.put('/v1/user/{}/orders'.format(validator.email), order_obj, value_in_json=True) - order_obj['ordered_at'] = datetime.fromtimestamp(order_obj['ordered_at']).strftime('%c') - return make_return_message('Order Successful. Order Details: {}'.format(order_obj)) else: logger.error('Could not create plan {}'.format(plan_id)) @@ -283,63 +258,71 @@ class ProductOrder(Resource): # 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 - ) + return make_return_message('Error subscribing to plan. Details: {}'.format(msg), 400) order_obj = { - 'order_id': get_order_id(), - 'ordered_at': int(time.time()), + '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(validator.email),order_obj, - value_in_json=True + '/v1/user/{}/orders/{}'.format(cleaned_values['username'], order_obj['order-id']), + order_obj, value_in_json=True ) - order_obj['ordered_at'] = datetime.fromtimestamp(order_obj['ordered_at']).strftime('%c') - return {'message': 'Order successful', 'order_details': order_obj}, 200 - else: - return make_return_message('Wrong Credentials', 400) + product['quantity'] -= 1 + client.put('/v1/products/{}'.format(product['uuid']), product, value_in_json=True) + + return {'message': 'Order successful', **order_obj}, 200 class OrderList(Resource): @staticmethod - def get(): - data = request.json + 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: - print(validator.email, validator.password) - if not ldap_manager.is_password_valid(validator.email, validator.password): - return {'message': 'Wrong Credentials'}, 403 - - orders = client.get_prefix('/v1/user/{}/orders'.format(validator.email), value_in_json=True) + cleaned_values = validator.get_cleaned_values() + orders = client.get_prefix( + '/v1/user/{}/orders'.format(cleaned_values['username']), value_in_json=True + ) orders_dict = { - order.value['order_id']: { - 'ordered-at': datetime.fromtimestamp(order.value['ordered_at']).strftime('%c'), - 'product': order.value['product'] + order.value['order-id']: { + **order.value } for order in orders } - # for p in orders: - # order_dict = p.value - # order_dict['ordered_at'] = datetime.fromtimestamp( - # order_dict['ordered_at']).strftime('%c') - # order_dict['product'] = order_dict['product']['name'] - # orders_dict[order_dict['order_id']] = order_dict logger.debug('Orders = {}'.format(orders_dict)) - return orders_dict, 200 - - -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') + return {'orders': orders_dict}, 200 if __name__ == '__main__': - app.run(host='::', port=config['app']['port'], debug=True) \ No newline at end of file + 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['app']['port'], debug=True) From 200a7672f2d893b30c8ebdac3c49973adf540eb6 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 27 Jan 2020 14:55:26 +0500 Subject: [PATCH 003/193] make value_in_json=True --- etcd_wrapper.py | 16 ++++++++-------- schemas.py | 4 +++- stripe_utils.py | 4 ++-- ucloud_pay.py | 23 ++++++++++------------- 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/etcd_wrapper.py b/etcd_wrapper.py index 73e2c3c..9624677 100644 --- a/etcd_wrapper.py +++ b/etcd_wrapper.py @@ -8,7 +8,7 @@ from uncloud.common import logger class EtcdEntry: - def __init__(self, meta_or_key, value, value_in_json=False): + def __init__(self, meta_or_key, value, value_in_json=True): if hasattr(meta_or_key, 'key'): # if meta has attr 'key' then get it self.key = meta_or_key.key.decode('utf-8') @@ -30,8 +30,8 @@ def readable_errors(func): raise UncloudException('Cannot connect to etcd: is etcd running as configured in uncloud.conf?') except etcd3.exceptions.ConnectionTimeoutError as err: raise etcd3.exceptions.ConnectionTimeoutError('etcd connection timeout.') from err - except Exception: - logger.exception('Some etcd error occured. See syslog for details.') + except Exception as err: + logger.exception('Some etcd error occured. See syslog for details.', err) return wrapper @@ -42,14 +42,14 @@ class EtcdWrapper: self.client = etcd3.client(*args, **kwargs) @readable_errors - def get(self, *args, value_in_json=False, **kwargs): + def get(self, *args, value_in_json=True, **kwargs): _value, _key = self.client.get(*args, **kwargs) if _key is None or _value is None: return None return EtcdEntry(_key, _value, value_in_json=value_in_json) @readable_errors - def put(self, *args, value_in_json=False, **kwargs): + def put(self, *args, value_in_json=True, **kwargs): _key, _value = args if value_in_json: _value = json.dumps(_value) @@ -60,16 +60,16 @@ class EtcdWrapper: return self.client.put(_key, _value, **kwargs) @readable_errors - def get_prefix(self, *args, value_in_json=False, raise_exception=True, **kwargs): + def get_prefix(self, *args, value_in_json=True, **kwargs): event_iterator = self.client.get_prefix(*args, **kwargs) for e in event_iterator: yield EtcdEntry(*e[::-1], value_in_json=value_in_json) @readable_errors - def watch_prefix(self, key, raise_exception=True, value_in_json=False): + def watch_prefix(self, key, value_in_json=True): event_iterator, cancel = self.client.watch_prefix(key) for e in event_iterator: if hasattr(e, '_event'): - e = e._event + e = getattr('e', '_event') if e.type == e.PUT: yield EtcdEntry(e.kv.key, e.kv.value, value_in_json=value_in_json) diff --git a/schemas.py b/schemas.py index c128d19..106b591 100644 --- a/schemas.py +++ b/schemas.py @@ -168,6 +168,7 @@ class ProductOrderSchema(BaseSchema): 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)) @@ -181,7 +182,8 @@ class ProductOrderSchema(BaseSchema): raise ValidationException('No such product exists.') def validation(self): - customer_previous_orders = etcd_client.get_prefix('/v1/user/{}'.format(self.username.value), value_in_json=True) + 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': diff --git a/stripe_utils.py b/stripe_utils.py index a9803af..9474f74 100644 --- a/stripe_utils.py +++ b/stripe_utils.py @@ -272,14 +272,14 @@ class StripeUtils(object): all_stripe_plans = client.get("/v1/stripe_plans") all_stripe_plans_set = set() if all_stripe_plans: - all_stripe_plans_obj = json.loads(all_stripe_plans.value) + 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", json.dumps({"plans": list(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, diff --git a/ucloud_pay.py b/ucloud_pay.py index e4d105f..09c5813 100644 --- a/ucloud_pay.py +++ b/ucloud_pay.py @@ -1,4 +1,3 @@ -import json import logging from datetime import datetime @@ -19,10 +18,10 @@ from helper import get_plan_id_from_product, calculate_charges class ListProducts(Resource): @staticmethod def get(): - products = client.get_prefix('/v1/products/', value_in_json=False) + products = client.get_prefix('/v1/products/') products = [ product - for product in [json.loads(p.value) for p in products] + for product in [p.value for p in products] if product['active'] ] prod_dict = {} @@ -63,7 +62,7 @@ class AddProduct(Resource): product_value['uuid'] = product_uuid logger.debug('Adding product data: {}'.format(str(product_value))) - client.put(product_key, product_value, value_in_json=True) + client.put(product_key, product_value) if not previous_product: return make_return_message('Product created.') else: @@ -201,7 +200,7 @@ class ProductOrder(Resource): ) ) - with client.client.lock('product-order') as lock: + 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'])) @@ -234,11 +233,10 @@ class ProductOrder(Resource): client.put( '/v1/user/{}/orders/{}'.format( cleaned_values['username'], order_obj['order-id'] - ), - order_obj, value_in_json=True + ), order_obj ) product['quantity'] -= 1 - client.put('/v1/products/{}'.format(product['uuid']), product, value_in_json=True) + client.put('/v1/products/{}'.format(product['uuid']), product) return { 'message': 'Order Successful.', @@ -246,6 +244,7 @@ class ProductOrder(Resource): } 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'])) @@ -268,10 +267,10 @@ class ProductOrder(Resource): } client.put( '/v1/user/{}/orders/{}'.format(cleaned_values['username'], order_obj['order-id']), - order_obj, value_in_json=True + order_obj ) product['quantity'] -= 1 - client.put('/v1/products/{}'.format(product['uuid']), product, value_in_json=True) + client.put('/v1/products/{}'.format(product['uuid']), product) return {'message': 'Order successful', **order_obj}, 200 @@ -288,9 +287,7 @@ class OrderList(Resource): return make_return_message(err, 400) else: cleaned_values = validator.get_cleaned_values() - orders = client.get_prefix( - '/v1/user/{}/orders'.format(cleaned_values['username']), value_in_json=True - ) + orders = client.get_prefix('/v1/user/{}/orders'.format(cleaned_values['username'])) orders_dict = { order.value['order-id']: { **order.value From c1f384fb9ab222fe5d12067b82e595c1864ee097 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 15 Feb 2020 09:38:33 +0100 Subject: [PATCH 004/193] so many notes&hacks! --- README-penguinpay.md | 10 +++++ hack.py | 98 ++++++++++++++++++++++++++++++++++++++++++++ notes.org | 1 + requirements.txt | 3 ++ 4 files changed, 112 insertions(+) create mode 100644 README-penguinpay.md create mode 100644 hack.py create mode 100644 notes.org create mode 100644 requirements.txt diff --git a/README-penguinpay.md b/README-penguinpay.md new file mode 100644 index 0000000..769f183 --- /dev/null +++ b/README-penguinpay.md @@ -0,0 +1,10 @@ +## How to place a order with penguin pay + +### Requirements + +* An ungleich account - can be registered for free on + https://account.ungleich.ch +* httpie installed (provides the http command) + +### Get a membership + * diff --git a/hack.py b/hack.py new file mode 100644 index 0000000..c84f9f6 --- /dev/null +++ b/hack.py @@ -0,0 +1,98 @@ +from flask import Flask, request +from flask_restful import Resource, Api +import etcd3 +import json +import logging +from functools import wraps + +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 + + +class Order(Resource): + def __init__(self, config): + self.config = config + + @staticmethod + def post(): + print("{} {}".format(data, config)) + data = request.get_json(silent=True) or {} + + + + +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, '/order', resource_class_args=( config, )) + api.add_resource(Membership, '/membership', resource_class_args=( config, )) + + app.run(host='::', port=5000, debug=True) diff --git a/notes.org b/notes.org new file mode 100644 index 0000000..72e8ffc --- /dev/null +++ b/notes.org @@ -0,0 +1 @@ +* diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..668fb3f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask-restful +ldap3 +etcd3 From aa9548e753bddee41a30517114c79938a1e79873 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 15 Feb 2020 11:15:26 +0100 Subject: [PATCH 005/193] +gitignore Signed-off-by: Nico Schottelius --- .gitignore | 4 +++- README-penguinpay.md | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 77de841..786a584 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ __pycache__/ pay.conf log.txt -test.py \ No newline at end of file +test.py +STRIPE +venv/ diff --git a/README-penguinpay.md b/README-penguinpay.md index 769f183..89f494a 100644 --- a/README-penguinpay.md +++ b/README-penguinpay.md @@ -7,4 +7,3 @@ * httpie installed (provides the http command) ### Get a membership - * From 347843cb247e0c4c3710f65a486c502b994f7be1 Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 19 Feb 2020 10:22:15 +0500 Subject: [PATCH 006/193] Sample config file added + uncloud dependency removed --- etcd_wrapper.py | 14 +++++++------- sample-pay.conf | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 sample-pay.conf diff --git a/etcd_wrapper.py b/etcd_wrapper.py index 9624677..0f55271 100644 --- a/etcd_wrapper.py +++ b/etcd_wrapper.py @@ -1,11 +1,9 @@ import etcd3 import json +import logging from functools import wraps -from uncloud import UncloudException -from uncloud.common import logger - class EtcdEntry: def __init__(self, meta_or_key, value, value_in_json=True): @@ -26,12 +24,14 @@ def readable_errors(func): def wrapper(*args, **kwargs): try: return func(*args, **kwargs) - except etcd3.exceptions.ConnectionFailedError: - raise UncloudException('Cannot connect to etcd: is etcd running as configured in uncloud.conf?') + except etcd3.exceptions.ConnectionFailedError as err: + raise etcd3.exceptions.ConnectionFailedError( + 'Cannot connect to etcd: is etcd running as configured in uncloud.conf?' + ) from err except etcd3.exceptions.ConnectionTimeoutError as err: raise etcd3.exceptions.ConnectionTimeoutError('etcd connection timeout.') from err - except Exception as err: - logger.exception('Some etcd error occured. See syslog for details.', err) + except Exception: + logging.exception('Some etcd error occured. See syslog for details.') return wrapper diff --git a/sample-pay.conf b/sample-pay.conf new file mode 100644 index 0000000..bed5dbe --- /dev/null +++ b/sample-pay.conf @@ -0,0 +1,19 @@ +[etcd] +host = 127.0.0.1 +port = 2379 + +[stripe] +private_key=stripe_private_key + +[app] +port = 5000 + +[ldap] +server = ldap_server_url +admin_dn = ldap_admin_dn +admin_password = ldap_admin_password +customer_dn = ldap_customer_dn +user_dn = ldap_user_dn + +internal_user_ou = users +customer_ou = customer \ No newline at end of file From e37592bdc6c2179c3d2c17951789f6fa5493715b Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 19 Feb 2020 11:59:54 +0500 Subject: [PATCH 007/193] README.md updated and reorganized, Improved error handling for configparser and ldap manager, requirements.txt added --- .gitignore | 3 +-- README.md | 22 ++++++++++++++-------- config.py | 25 +++++++++++++++++++++---- ldap_manager.py | 11 +++++++++-- requirements.txt | 4 ++++ sample-pay.conf | 5 ----- schemas.py | 6 ++---- stripe_utils.py | 12 ++++++++---- ucloud_pay.py | 15 +++++++++++++-- 9 files changed, 72 insertions(+), 31 deletions(-) create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index 77de841..304c492 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,4 @@ __pycache__/ pay.conf -log.txt -test.py \ No newline at end of file +log.txt \ No newline at end of file diff --git a/README.md b/README.md index 1b50cf3..6dae6b9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Currently handles very basic features, such as: #### 1. Adding of products ```shell script -http --json http://[::]:5000/product/add email=your_email_here password=your_password_here specs:=@ipv6-only-vm.json +http --json http://[::]:5000/product/add username=your_username_here password=your_password_here specs:=@ipv6-only-vm.json ``` #### 2. Listing of products @@ -24,20 +24,26 @@ http --json http://[::]:5000/product/add email=your_email_here password=your_pas http --json http://[::]:5000/product/list ``` -#### 3. Ordering products +#### 3. Registering user's payment method (credit card for now using Stripe) + ```shell script -http --json http://[::]:5000/product/order email=your_email_here password=your_password_here product_id=5332cb89453d495381e2b2167f32c842 cpu=1 ram=1gb os-disk-space=10gb os=alpine +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. Listing users orders +#### 4. Ordering products + +First of all, user have to buy the membership first. ```shell script -http --json GET http://[::]:5000/order/list email=your_email_here password=your_password_here +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. Registering user's payment method (credit card for now using Stripe) +#### 5. Listing users orders ```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" email=your_email_here password=your_password_here -``` \ No newline at end of file +http --json POST http://[::]:5000/order/list username=your_username_here password=your_password_here +``` diff --git a/config.py b/config.py index b951830..4d5e16a 100644 --- a/config.py +++ b/config.py @@ -1,11 +1,28 @@ import configparser +import sys +import os + from etcd_wrapper import EtcdWrapper from ldap_manager import LdapManager +config_file = os.environ.get('meow-pay-config-file', default='pay.conf') + config = configparser.ConfigParser() -config.read('pay.conf') -etcd_client = EtcdWrapper(host=config['etcd']['host'], port=config['etcd']['port']) +try: + successfully_read_files = config.read(config_file) +except configparser.Error as err: + sys.exit(err) -ldap_manager = LdapManager(server=config['ldap']['server'], admin_dn=config['ldap']['admin_dn'], - admin_password=config['ldap']['admin_password']) \ No newline at end of file +if not successfully_read_files: + sys.exit(f'Config file {config_file} couldn\'t be read.') + +try: + etcd_client = EtcdWrapper(host=config.get('etcd', 'host'), port=config.get('etcd', 'port')) + + ldap_manager = LdapManager( + server=config.get('ldap', 'server'), admin_dn=config.get('ldap', 'admin_dn'), + admin_password=config.get('ldap', 'admin_password') + ) +except configparser.Error as err: + sys.exit(f'{err} in config file {config_file}.') diff --git a/ldap_manager.py b/ldap_manager.py index 382afab..c0a793f 100644 --- a/ldap_manager.py +++ b/ldap_manager.py @@ -1,14 +1,22 @@ import hashlib import random import base64 +import sys from ldap3 import Server, Connection, ObjectDef, Reader, ALL +from ldap3.core import exceptions + +SALT_BYTES = 15 class LdapManager: def __init__(self, server, admin_dn, admin_password): self.server = Server(server, get_info=ALL) - self.conn = Connection(server, admin_dn, admin_password, auto_bind=True) + try: + self.conn = Connection(server, admin_dn, admin_password, auto_bind=True) + except exceptions.LDAPException as err: + sys.exit(f'LDAP Error: {err}') + self.person_obj_def = ObjectDef('inetOrgPerson', self.conn) def get(self, query=None, search_base='dc=ungleich,dc=ch'): @@ -57,7 +65,6 @@ class LdapManager: which can be used as LDAP value, e.g. after armoring it once more using base64 or decoding it to unicode from ``ascii``. """ - SALT_BYTES = 15 sha1 = hashlib.sha1() salt = random.SystemRandom().getrandbits(SALT_BYTES * 8).to_bytes(SALT_BYTES, 'little') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..843641e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +ldap3 +etcd3 +stripe +flask diff --git a/sample-pay.conf b/sample-pay.conf index bed5dbe..7138838 100644 --- a/sample-pay.conf +++ b/sample-pay.conf @@ -12,8 +12,3 @@ port = 5000 server = ldap_server_url admin_dn = ldap_admin_dn admin_password = ldap_admin_password -customer_dn = ldap_customer_dn -user_dn = ldap_user_dn - -internal_user_ou = users -customer_ou = customer \ No newline at end of file diff --git a/schemas.py b/schemas.py index 106b591..25555f9 100644 --- a/schemas.py +++ b/schemas.py @@ -3,11 +3,9 @@ import config import json import math -from config import ldap_manager +from config import ldap_manager, etcd_client from helper import resolve_product -etcd_client = config.etcd_client - class ValidationException(Exception): """Validation Error""" @@ -105,7 +103,7 @@ class AddProductSchema(BaseSchema): 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['ldap']['internal_user_ou']: + 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) diff --git a/stripe_utils.py b/stripe_utils.py index 9474f74..1004b86 100644 --- a/stripe_utils.py +++ b/stripe_utils.py @@ -1,12 +1,16 @@ -import json import re import stripe import stripe.error import logging +import sys -from config import etcd_client as client, config as config +from configparser import Error as ConfigParserError +from config import etcd_client as client, config as config, config_file -stripe.api_key = config['stripe']['private_key'] +try: + stripe.api_key = config.get('stripe', 'private_key') +except ConfigParserError as err: + sys.exit(f'{err} in config file {config_file}') def handle_stripe_error(f): @@ -291,7 +295,7 @@ class StripeUtils(object): returns the new object. :param amount: The amount in CHF cents - :param name: The name of the Stripe plan to be created. + :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 diff --git a/ucloud_pay.py b/ucloud_pay.py index 09c5813..fc45951 100644 --- a/ucloud_pay.py +++ b/ucloud_pay.py @@ -5,7 +5,7 @@ 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 ( @@ -322,4 +322,15 @@ if __name__ == '__main__': api.add_resource(UserRegisterPayment, '/user/register_payment') api.add_resource(OrderList, '/order/list') - app.run(host='::', port=config['app']['port'], debug=True) + 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 From 5f1f451bc2569a7c7291dfdea112d5dbe541f4e0 Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 19 Feb 2020 13:12:07 +0500 Subject: [PATCH 008/193] Added installation and getting started instructions in README.md --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6dae6b9..0f12883 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,17 @@ The pay module for the uncloud - uses [Stripe](https://stripe.com/docs/api) as the payment gateway. - uses [ldap3](https://github.com/cannatag/ldap3) for ldap authentication. -## Getting started +## Installation -**TODO** +```shell script +pip3 install -r requirements.txt +``` + +## Getting Started + +```shell script +python ucloud_pay.py +``` ## Usage From 7b9a970307e164abcc24ca7aac1f59f96027dfa3 Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 19 Feb 2020 13:12:46 +0500 Subject: [PATCH 009/193] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 0f12883..bd2a663 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,6 @@ python ucloud_pay.py ## Usage -Currently handles very basic features, such as: - #### 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 From 519279ce6ffcf92ab119cd8a184fb6a1e2f9e24b Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 19 Feb 2020 13:13:39 +0500 Subject: [PATCH 010/193] Update README.md --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index bd2a663..fe6a2a3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,6 @@ # uncloud-pay -The pay module for the uncloud - -- uses [etcd3](https://coreos.com/blog/etcd3-a-new-etcd.html) for storage. -- uses [Stripe](https://stripe.com/docs/api) as the payment gateway. -- uses [ldap3](https://github.com/cannatag/ldap3) for ldap authentication. +The generic product/payment system. ## Installation From ce709c3b6f029a5486789b3772cf1d47a315eee2 Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 19 Feb 2020 14:44:19 +0500 Subject: [PATCH 011/193] Add certificates option for etcd --- config.py | 8 ++++++-- sample-pay.conf | 3 +++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/config.py b/config.py index 4d5e16a..4e000c9 100644 --- a/config.py +++ b/config.py @@ -7,7 +7,7 @@ from ldap_manager import LdapManager config_file = os.environ.get('meow-pay-config-file', default='pay.conf') -config = configparser.ConfigParser() +config = configparser.ConfigParser(allow_no_value=True) try: successfully_read_files = config.read(config_file) @@ -18,7 +18,11 @@ if not successfully_read_files: sys.exit(f'Config file {config_file} couldn\'t be read.') try: - etcd_client = EtcdWrapper(host=config.get('etcd', 'host'), port=config.get('etcd', 'port')) + 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'), diff --git a/sample-pay.conf b/sample-pay.conf index 7138838..5d1fe61 100644 --- a/sample-pay.conf +++ b/sample-pay.conf @@ -1,6 +1,9 @@ [etcd] host = 127.0.0.1 port = 2379 +ca_cert +cert_cert +cert_key [stripe] private_key=stripe_private_key From cee92f2e9920d72d046021532ef2475c639c5865 Mon Sep 17 00:00:00 2001 From: meow Date: Thu, 20 Feb 2020 00:12:11 +0500 Subject: [PATCH 012/193] A lot of code moved to ungleich-common --- config.py | 39 ++++++++-------------- etcd_wrapper.py | 75 ----------------------------------------- ldap_manager.py | 76 ------------------------------------------ requirements.txt | 1 + schemas.py | 86 +----------------------------------------------- stripe_utils.py | 9 ++--- 6 files changed, 18 insertions(+), 268 deletions(-) delete mode 100644 etcd_wrapper.py delete mode 100644 ldap_manager.py diff --git a/config.py b/config.py index 4e000c9..d8092d4 100644 --- a/config.py +++ b/config.py @@ -1,32 +1,21 @@ -import configparser -import sys import os -from etcd_wrapper import EtcdWrapper -from ldap_manager import LdapManager +from ungleich_common.etcd_wrapper import EtcdWrapper +from ungleich_common.ldap_manager import LdapManager +from ungleich_common.config_parser import StrictConfigParser config_file = os.environ.get('meow-pay-config-file', default='pay.conf') -config = configparser.ConfigParser(allow_no_value=True) +config = StrictConfigParser(allow_no_value=True) +config.read(config_file) -try: - successfully_read_files = config.read(config_file) -except configparser.Error as err: - sys.exit(err) +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') +) -if not successfully_read_files: - sys.exit(f'Config file {config_file} couldn\'t be read.') - -try: - 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') - ) -except configparser.Error as err: - sys.exit(f'{err} in config file {config_file}.') +ldap_manager = LdapManager( + server=config.get('ldap', 'server'), admin_dn=config.get('ldap', 'admin_dn'), + admin_password=config.get('ldap', 'admin_password') +) diff --git a/etcd_wrapper.py b/etcd_wrapper.py deleted file mode 100644 index 0f55271..0000000 --- a/etcd_wrapper.py +++ /dev/null @@ -1,75 +0,0 @@ -import etcd3 -import json -import logging - -from functools import wraps - - -class EtcdEntry: - def __init__(self, meta_or_key, value, value_in_json=True): - if hasattr(meta_or_key, 'key'): - # if meta has attr 'key' then get it - self.key = meta_or_key.key.decode('utf-8') - else: - # otherwise meta is the 'key' - self.key = meta_or_key - self.value = value.decode('utf-8') - - if value_in_json: - self.value = json.loads(self.value) - - -def readable_errors(func): - @wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except etcd3.exceptions.ConnectionFailedError as err: - raise etcd3.exceptions.ConnectionFailedError( - 'Cannot connect to etcd: is etcd running as configured in uncloud.conf?' - ) from err - except etcd3.exceptions.ConnectionTimeoutError as err: - raise etcd3.exceptions.ConnectionTimeoutError('etcd connection timeout.') from err - except Exception: - logging.exception('Some etcd error occured. See syslog for details.') - - return wrapper - - -class EtcdWrapper: - @readable_errors - def __init__(self, *args, **kwargs): - self.client = etcd3.client(*args, **kwargs) - - @readable_errors - def get(self, *args, value_in_json=True, **kwargs): - _value, _key = self.client.get(*args, **kwargs) - if _key is None or _value is None: - return None - return EtcdEntry(_key, _value, value_in_json=value_in_json) - - @readable_errors - def put(self, *args, value_in_json=True, **kwargs): - _key, _value = args - if value_in_json: - _value = json.dumps(_value) - - if not isinstance(_key, str): - _key = _key.decode('utf-8') - - return self.client.put(_key, _value, **kwargs) - - @readable_errors - def get_prefix(self, *args, value_in_json=True, **kwargs): - event_iterator = self.client.get_prefix(*args, **kwargs) - for e in event_iterator: - yield EtcdEntry(*e[::-1], value_in_json=value_in_json) - - @readable_errors - def watch_prefix(self, key, value_in_json=True): - event_iterator, cancel = self.client.watch_prefix(key) - for e in event_iterator: - if hasattr(e, '_event'): - e = getattr('e', '_event') - if e.type == e.PUT: - yield EtcdEntry(e.kv.key, e.kv.value, value_in_json=value_in_json) diff --git a/ldap_manager.py b/ldap_manager.py deleted file mode 100644 index c0a793f..0000000 --- a/ldap_manager.py +++ /dev/null @@ -1,76 +0,0 @@ -import hashlib -import random -import base64 -import sys - -from ldap3 import Server, Connection, ObjectDef, Reader, ALL -from ldap3.core import exceptions - -SALT_BYTES = 15 - - -class LdapManager: - def __init__(self, server, admin_dn, admin_password): - self.server = Server(server, get_info=ALL) - try: - self.conn = Connection(server, admin_dn, admin_password, auto_bind=True) - except exceptions.LDAPException as err: - sys.exit(f'LDAP Error: {err}') - - self.person_obj_def = ObjectDef('inetOrgPerson', self.conn) - - def get(self, query=None, search_base='dc=ungleich,dc=ch'): - kwargs = { - 'connection': self.conn, - 'object_def': self.person_obj_def, - 'base': search_base, - } - if query: - kwargs['query'] = query - r = Reader(**kwargs) - return r.search() - - def is_password_valid(self, query_value, password, query_key='mail', **kwargs): - entries = self.get(query='({}={})'.format(query_key, query_value), **kwargs) - if entries: - password_in_ldap = entries[0].userPassword.value - found = self._check_password(password_in_ldap, password) - if not found: - raise Exception('Invalid Password') - else: - return entries[0] - else: - raise ValueError('Such {}={} not found'.format(query_key, query_value)) - - @staticmethod - def _check_password(tagged_digest_salt, password): - digest_salt_b64 = tagged_digest_salt[6:] - digest_salt = base64.decodebytes(digest_salt_b64) - digest = digest_salt[:20] - salt = digest_salt[20:] - - sha = hashlib.sha1(password.encode('utf-8')) - sha.update(salt) - - return digest == sha.digest() - - @staticmethod - def ssha_password(password): - """ - Apply the SSHA password hashing scheme to the given *password*. - *password* must be a :class:`bytes` object, containing the utf-8 - encoded password. - - Return a :class:`bytes` object containing ``ascii``-compatible data - which can be used as LDAP value, e.g. after armoring it once more using - base64 or decoding it to unicode from ``ascii``. - """ - - sha1 = hashlib.sha1() - salt = random.SystemRandom().getrandbits(SALT_BYTES * 8).to_bytes(SALT_BYTES, 'little') - sha1.update(password) - sha1.update(salt) - - digest = sha1.digest() - passwd = b'{SSHA}' + base64.b64encode(digest + salt) - return passwd diff --git a/requirements.txt b/requirements.txt index 843641e..6b6c77b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ ldap3 etcd3 stripe flask +git+git://code.ungleich.ch/ahmedbilal/ungleich-common@master#egg=ungleich-common \ No newline at end of file diff --git a/schemas.py b/schemas.py index 25555f9..8285491 100644 --- a/schemas.py +++ b/schemas.py @@ -5,91 +5,7 @@ import math from config import ldap_manager, etcd_client from helper import resolve_product - - -class ValidationException(Exception): - """Validation Error""" - - -class Field: - def __init__(self, _name, _type, _value=None, validators=None, disable_validation=False): - self.validation_disabled = disable_validation - self.name = _name - self.value = _value - self.type = _type - self.validators = validators or [] - - def is_valid(self): - if not self.validation_disabled: - if not isinstance(self.value, self.type): - try: - self.value = self.type(self.value) - except Exception: - raise ValidationException("Incorrect Type for '{}' field".format(self.name)) - - for validator in self.validators: - validator() - - def __repr__(self): - return self.name - - -class BaseSchema: - def __init__(self): - self.objects = {} - - def validation(self): - # custom validation is optional - return True - - def get_fields(self): - return [getattr(self, field) for field in dir(self) if isinstance(getattr(self, field), Field)] - - def is_valid(self): - for field in self.get_fields(): - field.is_valid() - self.validation() - - def get_cleaned_values(self): - field_kv_dict = { - field.name: field.value - for field in self.get_fields() - } - cleaned_values = field_kv_dict - cleaned_values.update(self.objects) - - return cleaned_values - - def add_schema(self, schema, data, under_field_name=None): - s = schema(data) - s.is_valid() - - base = self - if under_field_name: - # Create a field in self - setattr(self, under_field_name, Field(under_field_name, dict, _value={}, disable_validation=True)) - base = getattr(self, under_field_name) - - for field in s.get_fields(): - if under_field_name: - getattr(base, 'value')[field.name] = field.value - else: - setattr(base, field.name, field) - - self.objects.update(s.objects) - - @staticmethod - def get(dictionary: dict, key: str, return_default=False, default=None): - if dictionary is None: - raise ValidationException('No data provided at all.') - try: - value = dictionary[key] - except KeyError: - if return_default: - return {'_value': default, 'disable_validation': True} - raise ValidationException("Missing data for '{}' field.".format(key)) - else: - return {'_value': value, 'disable_validation': False} +from ungleich_common.schemas import BaseSchema, Field, ValidationException class AddProductSchema(BaseSchema): diff --git a/stripe_utils.py b/stripe_utils.py index 1004b86..a125474 100644 --- a/stripe_utils.py +++ b/stripe_utils.py @@ -2,15 +2,10 @@ import re import stripe import stripe.error import logging -import sys -from configparser import Error as ConfigParserError -from config import etcd_client as client, config as config, config_file +from config import etcd_client as client, config as config -try: - stripe.api_key = config.get('stripe', 'private_key') -except ConfigParserError as err: - sys.exit(f'{err} in config file {config_file}') +stripe.api_key = config.get('stripe', 'private_key') def handle_stripe_error(f): From 074efffaa70ca594457e8dd61b041001ad06706d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 20 Feb 2020 09:44:30 +0100 Subject: [PATCH 013/193] ++ hack --- README-penguinpay.md | 35 ++++++++++++++++++++++++++++++++++- config.py | 5 ++++- hack.py | 5 +++++ requirements.txt | 1 + stripe_hack.py | 7 +++++++ stripe_utils.py | 9 +++++---- 6 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 stripe_hack.py diff --git a/README-penguinpay.md b/README-penguinpay.md index 89f494a..3229bc5 100644 --- a/README-penguinpay.md +++ b/README-penguinpay.md @@ -6,4 +6,37 @@ https://account.ungleich.ch * httpie installed (provides the http command) -### Get a membership +## Get a membership + + +## Registering a payment method + +To be able to pay for the membership, you will need to register a +credit card or apply for payment on bill (TO BE IMPLEMENTED). + +### Register credit card + +``` +http POST https://api.ungleich.ch/membership \ + username=nico password=yourpassword \ + cc_number=.. \ + cc_ + +``` + + + +### Request payment via bill + + + + +## Create the membership + + +``` +http POST https://api.ungleich.ch/membership username=nico password=yourpassword + +``` + +## List available products diff --git a/config.py b/config.py index b951830..c3bad9d 100644 --- a/config.py +++ b/config.py @@ -5,7 +5,10 @@ from ldap_manager import LdapManager config = configparser.ConfigParser() config.read('pay.conf') +# Note 2020-02-15: this stuff clearly does not belong here, +# if config.py is used everywhere. + etcd_client = EtcdWrapper(host=config['etcd']['host'], port=config['etcd']['port']) ldap_manager = LdapManager(server=config['ldap']['server'], admin_dn=config['ldap']['admin_dn'], - admin_password=config['ldap']['admin_password']) \ No newline at end of file + admin_password=config['ldap']['admin_password']) diff --git a/hack.py b/hack.py index c84f9f6..cbb9a07 100644 --- a/hack.py +++ b/hack.py @@ -70,6 +70,11 @@ class Membership(Resource): 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): diff --git a/requirements.txt b/requirements.txt index 668fb3f..1fc7b83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ flask-restful ldap3 etcd3 +stripe diff --git a/stripe_hack.py b/stripe_hack.py new file mode 100644 index 0000000..f436c62 --- /dev/null +++ b/stripe_hack.py @@ -0,0 +1,7 @@ +import stripe_utils +import os + + +if __name__ == '__main__': + s = stripe_utils.StripeUtils(os.environ['STRIPE_PRIVATE_KEY']) + print(s.get_stripe_customer_from_email('coder.purple+2002@gmail.com')) diff --git a/stripe_utils.py b/stripe_utils.py index 9474f74..3c68698 100644 --- a/stripe_utils.py +++ b/stripe_utils.py @@ -4,9 +4,9 @@ import stripe import stripe.error import logging -from config import etcd_client as client, config as config - -stripe.api_key = config['stripe']['private_key'] +# FIXME: way too many dependencies in this import +# Most of them are not needed for stripe +#from config import etcd_client as client, config as config def handle_stripe_error(f): @@ -73,8 +73,9 @@ class StripeUtils(object): 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): + 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): From 9c7d458eecfd72de02608de7d92e9dcd3d17a9bc Mon Sep 17 00:00:00 2001 From: meow Date: Thu, 20 Feb 2020 13:57:32 +0500 Subject: [PATCH 014/193] use code from ungleich-common --- config.py | 7 +++---- requirements.txt | 5 ++++- schemas.py | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/config.py b/config.py index d8092d4..c3cd6a6 100644 --- a/config.py +++ b/config.py @@ -1,9 +1,8 @@ import os -from ungleich_common.etcd_wrapper import EtcdWrapper -from ungleich_common.ldap_manager import LdapManager -from ungleich_common.config_parser import StrictConfigParser - +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) diff --git a/requirements.txt b/requirements.txt index 6b6c77b..292cf99 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,7 @@ ldap3 etcd3 stripe flask -git+git://code.ungleich.ch/ahmedbilal/ungleich-common@master#egg=ungleich-common \ No newline at end of file +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 \ No newline at end of file diff --git a/schemas.py b/schemas.py index 8285491..2e3aef7 100644 --- a/schemas.py +++ b/schemas.py @@ -5,7 +5,7 @@ import math from config import ldap_manager, etcd_client from helper import resolve_product -from ungleich_common.schemas import BaseSchema, Field, ValidationException +from ungleich_common.schemas.schemas import BaseSchema, Field, ValidationException class AddProductSchema(BaseSchema): From 00b35e0567de86360fe214c2b302a0d74fdfa8c0 Mon Sep 17 00:00:00 2001 From: meow Date: Thu, 20 Feb 2020 14:04:53 +0500 Subject: [PATCH 015/193] cleaned requirements.txt --- config.py | 1 + requirements.txt | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/config.py b/config.py index c3cd6a6..16804af 100644 --- a/config.py +++ b/config.py @@ -3,6 +3,7 @@ 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) diff --git a/requirements.txt b/requirements.txt index 292cf99..6b1ec6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,3 @@ -ldap3 -etcd3 stripe flask git+https://code.ungleich.ch/ahmedbilal/ungleich-common/#egg=ungleich-common-etcd&subdirectory=etcd From bb18f6b0e93841ee80a54f136f7ffb0d9337cfcb Mon Sep 17 00:00:00 2001 From: meow Date: Thu, 20 Feb 2020 14:08:39 +0500 Subject: [PATCH 016/193] Flask-RESTful added in requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 6b1ec6b..cb4f2a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ 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 From 8c353f277cb8158ec94b850bf8989a95bbfb9643 Mon Sep 17 00:00:00 2001 From: meow Date: Thu, 20 Feb 2020 15:23:15 +0500 Subject: [PATCH 017/193] is_order_valid added in helper.py --- helper.py | 21 +++++++++++++++++++++ requirements.txt | 1 + 2 files changed, 22 insertions(+) diff --git a/helper.py b/helper.py index d1a5dd4..65a5155 100644 --- a/helper.py +++ b/helper.py @@ -1,5 +1,8 @@ import logging +import parsedatetime + +from datetime import datetime from stripe_utils import StripeUtils @@ -64,3 +67,21 @@ def calculate_charges(specification, data): feature_detail['unit']['value'] ) return one_time_charge, recurring_charge + + +def is_order_valid(order_timestamp, renewal_period): + """ + Sample Code Usage + + >> current_datetime, status = cal.parse('Now') + >> current_datetime = datetime(*current_datetime[:6]) + + >> print('Is order valid: ', is_order_valid(current_datetime, '1 month')) + >> True + """ + cal = parsedatetime.Calendar() + + renewal_datetime, status = cal.parse(renewal_period) + renewal_datetime = datetime(*renewal_datetime[:6]) + + return order_timestamp <= renewal_datetime diff --git a/requirements.txt b/requirements.txt index cb4f2a8..0f5d0d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +done stripe flask Flask-RESTful From 0a1ccadda2feb6b55a8d434da187d852739453b7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 20 Feb 2020 11:56:47 +0100 Subject: [PATCH 018/193] +ldaptest --- ldaptest.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 ldaptest.py diff --git a/ldaptest.py b/ldaptest.py new file mode 100644 index 0000000..f28fcf6 --- /dev/null +++ b/ldaptest.py @@ -0,0 +1,8 @@ +import ldap3 +from ldap3 import Server, Connection, ObjectDef, Reader, ALL +import os + +server = Server("ldaps://ldap1.ungleich.ch") +conn = Connection(server, 'cn=Nico Schottelius,ou=users,dc=ungleich,dc=ch', os.environ['PW'], auto_bind=True) + +print(conn) From 13292db39e6e28f6a957e42ea890b3fb46615d40 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 20 Feb 2020 11:57:03 +0100 Subject: [PATCH 019/193] +old notes --- notes-nico.org | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 notes-nico.org diff --git a/notes-nico.org b/notes-nico.org new file mode 100644 index 0000000..9e88215 --- /dev/null +++ b/notes-nico.org @@ -0,0 +1,13 @@ +* 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 From 315aaded4148a08f9bf33069f5f7156aaee852fd Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 20 Feb 2020 16:05:58 +0100 Subject: [PATCH 020/193] Focus on creating a VPN as a first test case --- README.md | 7 +++- hack.py => hack-a-vpn.py | 81 +++++++++++++++++++++++++++++++++++++++- ldaptest.py | 25 +++++++++++-- 3 files changed, 106 insertions(+), 7 deletions(-) rename hack.py => hack-a-vpn.py (53%) diff --git a/README.md b/README.md index 1b50cf3..72199ca 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,10 @@ The pay module for the uncloud - uses [Stripe](https://stripe.com/docs/api) as the payment gateway. - uses [ldap3](https://github.com/cannatag/ldap3) for ldap authentication. -## Getting started + +## Getting started as a user + + **TODO** @@ -40,4 +43,4 @@ http --json GET http://[::]:5000/order/list email=your_email_here password=your_ ```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" email=your_email_here password=your_password_here -``` \ No newline at end of file +``` diff --git a/hack.py b/hack-a-vpn.py similarity index 53% rename from hack.py rename to hack-a-vpn.py index cbb9a07..0956cd5 100644 --- a/hack.py +++ b/hack-a-vpn.py @@ -82,8 +82,84 @@ class Order(Resource): @staticmethod def post(): - print("{} {}".format(data, config)) 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 + } + + ] + } + ) + + + @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 + @@ -97,7 +173,8 @@ if __name__ == '__main__': config['ldap_url']="ldaps://ldap1.ungleich.ch" api = Api(app) - api.add_resource(Order, '/order', resource_class_args=( config, )) + api.add_resource(Order, '/orders', resource_class_args=( config, )) + api.add_resource(Product, '/products', resource_class_args=( config, )) api.add_resource(Membership, '/membership', resource_class_args=( config, )) app.run(host='::', port=5000, debug=True) diff --git a/ldaptest.py b/ldaptest.py index f28fcf6..eb5a5be 100644 --- a/ldaptest.py +++ b/ldaptest.py @@ -1,8 +1,27 @@ import ldap3 from ldap3 import Server, Connection, ObjectDef, Reader, ALL import os +import sys -server = Server("ldaps://ldap1.ungleich.ch") -conn = Connection(server, 'cn=Nico Schottelius,ou=users,dc=ungleich,dc=ch', os.environ['PW'], auto_bind=True) +def is_valid_ldap_user(username, password): + server = Server("ldaps://ldap1.ungleich.ch") + is_valid = False -print(conn) + 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])) From e472d20ae007db5cb9de0a2718a642d5bd35f8d9 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 20 Feb 2020 16:52:50 +0100 Subject: [PATCH 021/193] hacking uncloud v202002 Signed-off-by: Nico Schottelius --- nicohack202002/uncloud/api/__init__.py | 0 nicohack202002/uncloud/api/admin.py | 3 + nicohack202002/uncloud/api/apps.py | 5 + .../uncloud/api/migrations/__init__.py | 0 nicohack202002/uncloud/api/models.py | 3 + nicohack202002/uncloud/api/tests.py | 3 + nicohack202002/uncloud/api/views.py | 3 + nicohack202002/uncloud/manage.py | 21 +++ nicohack202002/uncloud/uncloud/__init__.py | 0 nicohack202002/uncloud/uncloud/asgi.py | 16 +++ nicohack202002/uncloud/uncloud/settings.py | 120 ++++++++++++++++++ nicohack202002/uncloud/uncloud/urls.py | 21 +++ nicohack202002/uncloud/uncloud/wsgi.py | 16 +++ 13 files changed, 211 insertions(+) create mode 100644 nicohack202002/uncloud/api/__init__.py create mode 100644 nicohack202002/uncloud/api/admin.py create mode 100644 nicohack202002/uncloud/api/apps.py create mode 100644 nicohack202002/uncloud/api/migrations/__init__.py create mode 100644 nicohack202002/uncloud/api/models.py create mode 100644 nicohack202002/uncloud/api/tests.py create mode 100644 nicohack202002/uncloud/api/views.py create mode 100755 nicohack202002/uncloud/manage.py create mode 100644 nicohack202002/uncloud/uncloud/__init__.py create mode 100644 nicohack202002/uncloud/uncloud/asgi.py create mode 100644 nicohack202002/uncloud/uncloud/settings.py create mode 100644 nicohack202002/uncloud/uncloud/urls.py create mode 100644 nicohack202002/uncloud/uncloud/wsgi.py diff --git a/nicohack202002/uncloud/api/__init__.py b/nicohack202002/uncloud/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nicohack202002/uncloud/api/admin.py b/nicohack202002/uncloud/api/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/nicohack202002/uncloud/api/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/nicohack202002/uncloud/api/apps.py b/nicohack202002/uncloud/api/apps.py new file mode 100644 index 0000000..d87006d --- /dev/null +++ b/nicohack202002/uncloud/api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = 'api' diff --git a/nicohack202002/uncloud/api/migrations/__init__.py b/nicohack202002/uncloud/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nicohack202002/uncloud/api/models.py b/nicohack202002/uncloud/api/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/nicohack202002/uncloud/api/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/nicohack202002/uncloud/api/tests.py b/nicohack202002/uncloud/api/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/nicohack202002/uncloud/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/nicohack202002/uncloud/api/views.py b/nicohack202002/uncloud/api/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/nicohack202002/uncloud/api/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/nicohack202002/uncloud/manage.py b/nicohack202002/uncloud/manage.py new file mode 100755 index 0000000..b050590 --- /dev/null +++ b/nicohack202002/uncloud/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/nicohack202002/uncloud/uncloud/__init__.py b/nicohack202002/uncloud/uncloud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nicohack202002/uncloud/uncloud/asgi.py b/nicohack202002/uncloud/uncloud/asgi.py new file mode 100644 index 0000000..2b5a7a3 --- /dev/null +++ b/nicohack202002/uncloud/uncloud/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for uncloud project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings') + +application = get_asgi_application() diff --git a/nicohack202002/uncloud/uncloud/settings.py b/nicohack202002/uncloud/uncloud/settings.py new file mode 100644 index 0000000..97dcf1e --- /dev/null +++ b/nicohack202002/uncloud/uncloud/settings.py @@ -0,0 +1,120 @@ +""" +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 + +# 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 = 'dx$iqt=lc&yrp^!z5$ay^%g5lhx1y3bcu=jg(jx0yj0ogkfqvf' + +# 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', +] + +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' + + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# 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', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/nicohack202002/uncloud/uncloud/urls.py b/nicohack202002/uncloud/uncloud/urls.py new file mode 100644 index 0000000..7b82bc9 --- /dev/null +++ b/nicohack202002/uncloud/uncloud/urls.py @@ -0,0 +1,21 @@ +"""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 + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/nicohack202002/uncloud/uncloud/wsgi.py b/nicohack202002/uncloud/uncloud/wsgi.py new file mode 100644 index 0000000..c4a07b8 --- /dev/null +++ b/nicohack202002/uncloud/uncloud/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for uncloud project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings') + +application = get_wsgi_application() From 254429db55f50cd4621d21e9c64eb6694a5d6d91 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 20 Feb 2020 16:55:01 +0100 Subject: [PATCH 022/193] .gitignore & more --- hack-a-vpn.py | 33 +++++++++++++++++++++++++++++++ hack.org | 0 nicohack202002/uncloud/.gitignore | 1 + notes-nico.org | 10 ++++++++++ ucloud_pay.py | 4 +++- 5 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 hack.org create mode 100644 nicohack202002/uncloud/.gitignore diff --git a/hack-a-vpn.py b/hack-a-vpn.py index 0956cd5..e6bfb43 100644 --- a/hack-a-vpn.py +++ b/hack-a-vpn.py @@ -5,6 +5,23 @@ 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): @@ -147,6 +164,22 @@ See https://redmine.ungleich.ch/issues/7747? ] } ) + 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 diff --git a/hack.org b/hack.org new file mode 100644 index 0000000..e69de29 diff --git a/nicohack202002/uncloud/.gitignore b/nicohack202002/uncloud/.gitignore new file mode 100644 index 0000000..49ef255 --- /dev/null +++ b/nicohack202002/uncloud/.gitignore @@ -0,0 +1 @@ +db.sqlite3 diff --git a/notes-nico.org b/notes-nico.org index 9e88215..e2b8cac 100644 --- a/notes-nico.org +++ b/notes-nico.org @@ -1,3 +1,13 @@ +* python requirements (nicohack202002) + django djangorestframework +* 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 diff --git a/ucloud_pay.py b/ucloud_pay.py index 09c5813..f2c9e01 100644 --- a/ucloud_pay.py +++ b/ucloud_pay.py @@ -33,7 +33,6 @@ class ListProducts(Resource): logger.debug('Products = {}'.format(prod_dict)) return prod_dict, 200 - class AddProduct(Resource): @staticmethod def post(): @@ -68,6 +67,9 @@ class AddProduct(Resource): else: return make_return_message('Product updated.') +################################################################################ +# Nico-ok-marker + class UserRegisterPayment(Resource): @staticmethod From 9fd445e9478ef2a30106905d6fabd47abaf9663a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 20 Feb 2020 18:58:07 +0100 Subject: [PATCH 023/193] add ldap support + tutorial example --- nicohack202002/uncloud/api/serializers.py | 14 +++++++++++++ nicohack202002/uncloud/api/views.py | 20 ++++++++++++++++++ nicohack202002/uncloud/uncloud/settings.py | 24 ++++++++++++++++++++++ nicohack202002/uncloud/uncloud/urls.py | 19 +++++++++++++++-- 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 nicohack202002/uncloud/api/serializers.py diff --git a/nicohack202002/uncloud/api/serializers.py b/nicohack202002/uncloud/api/serializers.py new file mode 100644 index 0000000..f5a5a92 --- /dev/null +++ b/nicohack202002/uncloud/api/serializers.py @@ -0,0 +1,14 @@ +from django.contrib.auth.models import User, Group +from rest_framework import serializers + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = User + fields = ['url', 'username', 'email', 'groups'] + + +class GroupSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Group + fields = ['url', 'name'] diff --git a/nicohack202002/uncloud/api/views.py b/nicohack202002/uncloud/api/views.py index 91ea44a..d7f3dae 100644 --- a/nicohack202002/uncloud/api/views.py +++ b/nicohack202002/uncloud/api/views.py @@ -1,3 +1,23 @@ from django.shortcuts import render # Create your views here. + +from django.contrib.auth.models import User, Group +from rest_framework import viewsets +from api.serializers import UserSerializer, GroupSerializer + + +class UserViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows users to be viewed or edited. + """ + queryset = User.objects.all().order_by('-date_joined') + serializer_class = UserSerializer + + +class GroupViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows groups to be viewed or edited. + """ + queryset = Group.objects.all() + serializer_class = GroupSerializer diff --git a/nicohack202002/uncloud/uncloud/settings.py b/nicohack202002/uncloud/uncloud/settings.py index 97dcf1e..7def11a 100644 --- a/nicohack202002/uncloud/uncloud/settings.py +++ b/nicohack202002/uncloud/uncloud/settings.py @@ -37,6 +37,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework' ] MIDDLEWARE = [ @@ -99,6 +100,29 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +# LDAP +import ldap +from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion + +AUTHENTICATION_BACKENDS = ["django_auth_ldap.backend.LDAPBackend"] +AUTH_LDAP_SERVER_URI = "ldaps://ldap1.ungleich.ch,ldaps://ldap2.ungleich.ch" + +AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=customer,dc=ungleich,dc=ch" + +AUTH_LDAP_USER_SEARCH = LDAPSearch( + "ou=customer,dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=%(user)s)" +) + +################################################################################ +# 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/ diff --git a/nicohack202002/uncloud/uncloud/urls.py b/nicohack202002/uncloud/uncloud/urls.py index 7b82bc9..e52fd35 100644 --- a/nicohack202002/uncloud/uncloud/urls.py +++ b/nicohack202002/uncloud/uncloud/urls.py @@ -14,8 +14,23 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include +from rest_framework import routers +from api import views + +router = routers.DefaultRouter() +router.register(r'users', views.UserViewSet) +router.register(r'groups', views.GroupViewSet) + +# Wire up our API using automatic URL routing. +# Additionally, we include login URLs for the browsable API. urlpatterns = [ - path('admin/', admin.site.urls), + path('', include(router.urls)), + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) ] + +#urlpatterns = [ +# path('admin/', admin.site.urls), +# path('api/', include('api.urls')), +#] From f8182e00e845b29ae92fbf3ae482a22e30f9f7d4 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 20 Feb 2020 19:38:30 +0100 Subject: [PATCH 024/193] import secrets --- nicohack202002/uncloud/uncloud/.gitignore | 1 + nicohack202002/uncloud/uncloud/settings.py | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 nicohack202002/uncloud/uncloud/.gitignore diff --git a/nicohack202002/uncloud/uncloud/.gitignore b/nicohack202002/uncloud/uncloud/.gitignore new file mode 100644 index 0000000..ef418f5 --- /dev/null +++ b/nicohack202002/uncloud/uncloud/.gitignore @@ -0,0 +1 @@ +secrets.py diff --git a/nicohack202002/uncloud/uncloud/settings.py b/nicohack202002/uncloud/uncloud/settings.py index 7def11a..91bcf47 100644 --- a/nicohack202002/uncloud/uncloud/settings.py +++ b/nicohack202002/uncloud/uncloud/settings.py @@ -142,3 +142,7 @@ USE_TZ = True # https://docs.djangoproject.com/en/3.0/howto/static-files/ STATIC_URL = '/static/' + + +# Uncommitted file +import uncloud.secrets From 118c66799c5629778bab6ce5c685a5d3acd3fe46 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 20 Feb 2020 19:38:43 +0100 Subject: [PATCH 025/193] ++views/permissions --- nicohack202002/uncloud/api/models.py | 3 +++ nicohack202002/uncloud/api/views.py | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/nicohack202002/uncloud/api/models.py b/nicohack202002/uncloud/api/models.py index 71a8362..7288ecf 100644 --- a/nicohack202002/uncloud/api/models.py +++ b/nicohack202002/uncloud/api/models.py @@ -1,3 +1,6 @@ from django.db import models # Create your models here. + +class CreditCard(models.Model): + pass diff --git a/nicohack202002/uncloud/api/views.py b/nicohack202002/uncloud/api/views.py index d7f3dae..c9b1e57 100644 --- a/nicohack202002/uncloud/api/views.py +++ b/nicohack202002/uncloud/api/views.py @@ -3,17 +3,30 @@ from django.shortcuts import render # Create your views here. from django.contrib.auth.models import User, Group -from rest_framework import viewsets +from rest_framework import viewsets, permissions + from api.serializers import UserSerializer, GroupSerializer +class CreditCardViewSet(viewsets.ModelViewSet): + + """ + API endpoint that allows credit cards to be listed + """ + queryset = User.objects.all().order_by('-date_joined') + serializer_class = UserSerializer + + permission_classes = [permissions.IsAuthenticated] + class UserViewSet(viewsets.ModelViewSet): + """ API endpoint that allows users to be viewed or edited. """ queryset = User.objects.all().order_by('-date_joined') serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] class GroupViewSet(viewsets.ModelViewSet): """ @@ -21,3 +34,5 @@ class GroupViewSet(viewsets.ModelViewSet): """ queryset = Group.objects.all() serializer_class = GroupSerializer + + permission_classes = [permissions.IsAuthenticated] From c45635505927da610deaba4e0b6a7de573502a48 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 21 Feb 2020 10:41:22 +0100 Subject: [PATCH 026/193] begin to introduce product Signed-off-by: Nico Schottelius --- hack.org | 0 nicohack202002/uncloud/api/models.py | 6 -- nicohack202002/uncloud/uncloud/settings.py | 6 +- nicohack202002/uncloud/uncloud/stripe.py | 55 +++++++++++++++++++ nicohack202002/uncloud/uncloud/urls.py | 2 +- .../uncloud/{api => uncloud_api}/__init__.py | 0 .../uncloud/{api => uncloud_api}/admin.py | 0 .../uncloud/{api => uncloud_api}/apps.py | 2 +- .../uncloud_api/migrations/0001_initial.py | 34 ++++++++++++ .../migrations/__init__.py | 0 nicohack202002/uncloud/uncloud_api/models.py | 30 ++++++++++ .../{api => uncloud_api}/serializers.py | 0 .../uncloud/{api => uncloud_api}/tests.py | 0 .../uncloud/{api => uncloud_api}/views.py | 2 +- notes-nico.org | 21 ++++++- 15 files changed, 147 insertions(+), 11 deletions(-) delete mode 100644 hack.org delete mode 100644 nicohack202002/uncloud/api/models.py create mode 100644 nicohack202002/uncloud/uncloud/stripe.py rename nicohack202002/uncloud/{api => uncloud_api}/__init__.py (100%) rename nicohack202002/uncloud/{api => uncloud_api}/admin.py (100%) rename nicohack202002/uncloud/{api => uncloud_api}/apps.py (71%) create mode 100644 nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py rename nicohack202002/uncloud/{api => uncloud_api}/migrations/__init__.py (100%) create mode 100644 nicohack202002/uncloud/uncloud_api/models.py rename nicohack202002/uncloud/{api => uncloud_api}/serializers.py (100%) rename nicohack202002/uncloud/{api => uncloud_api}/tests.py (100%) rename nicohack202002/uncloud/{api => uncloud_api}/views.py (94%) diff --git a/hack.org b/hack.org deleted file mode 100644 index e69de29..0000000 diff --git a/nicohack202002/uncloud/api/models.py b/nicohack202002/uncloud/api/models.py deleted file mode 100644 index 7288ecf..0000000 --- a/nicohack202002/uncloud/api/models.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.db import models - -# Create your models here. - -class CreditCard(models.Model): - pass diff --git a/nicohack202002/uncloud/uncloud/settings.py b/nicohack202002/uncloud/uncloud/settings.py index 91bcf47..d6cbb0e 100644 --- a/nicohack202002/uncloud/uncloud/settings.py +++ b/nicohack202002/uncloud/uncloud/settings.py @@ -37,7 +37,8 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'rest_framework' + 'rest_framework', + 'uncloud_api' ] MIDDLEWARE = [ @@ -146,3 +147,6 @@ STATIC_URL = '/static/' # Uncommitted file import uncloud.secrets + +import stripe +stripe.api_key = uncloud.secrets.STRIPE_KEY diff --git a/nicohack202002/uncloud/uncloud/stripe.py b/nicohack202002/uncloud/uncloud/stripe.py new file mode 100644 index 0000000..ce35fd9 --- /dev/null +++ b/nicohack202002/uncloud/uncloud/stripe.py @@ -0,0 +1,55 @@ +import stripe + +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 diff --git a/nicohack202002/uncloud/uncloud/urls.py b/nicohack202002/uncloud/uncloud/urls.py index e52fd35..e0a0b61 100644 --- a/nicohack202002/uncloud/uncloud/urls.py +++ b/nicohack202002/uncloud/uncloud/urls.py @@ -17,7 +17,7 @@ from django.contrib import admin from django.urls import path, include from rest_framework import routers -from api import views +from uncloud_api import views router = routers.DefaultRouter() router.register(r'users', views.UserViewSet) diff --git a/nicohack202002/uncloud/api/__init__.py b/nicohack202002/uncloud/uncloud_api/__init__.py similarity index 100% rename from nicohack202002/uncloud/api/__init__.py rename to nicohack202002/uncloud/uncloud_api/__init__.py diff --git a/nicohack202002/uncloud/api/admin.py b/nicohack202002/uncloud/uncloud_api/admin.py similarity index 100% rename from nicohack202002/uncloud/api/admin.py rename to nicohack202002/uncloud/uncloud_api/admin.py diff --git a/nicohack202002/uncloud/api/apps.py b/nicohack202002/uncloud/uncloud_api/apps.py similarity index 71% rename from nicohack202002/uncloud/api/apps.py rename to nicohack202002/uncloud/uncloud_api/apps.py index d87006d..6830fa2 100644 --- a/nicohack202002/uncloud/api/apps.py +++ b/nicohack202002/uncloud/uncloud_api/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class ApiConfig(AppConfig): - name = 'api' + name = 'uncloud_api' diff --git a/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py b/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py new file mode 100644 index 0000000..7248a66 --- /dev/null +++ b/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 3.0.3 on 2020-02-21 09:40 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Product', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=256)), + ('recurring_period', models.CharField(choices=[('per_year', 'Per Year'), ('per_month', 'Per Month'), ('per_week', 'Per Week'), ('per_day', 'Per Day'), ('per_hour', 'Per Hour'), ('not_recurring', 'Not recurring')], default='not_recurring', max_length=256)), + ], + ), + migrations.CreateModel( + name='Feature', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=256)), + ('recurring_price', models.FloatField(default=0)), + ('one_time_price', models.FloatField()), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_api.Product')), + ], + ), + ] diff --git a/nicohack202002/uncloud/api/migrations/__init__.py b/nicohack202002/uncloud/uncloud_api/migrations/__init__.py similarity index 100% rename from nicohack202002/uncloud/api/migrations/__init__.py rename to nicohack202002/uncloud/uncloud_api/migrations/__init__.py diff --git a/nicohack202002/uncloud/uncloud_api/models.py b/nicohack202002/uncloud/uncloud_api/models.py new file mode 100644 index 0000000..2dca8ea --- /dev/null +++ b/nicohack202002/uncloud/uncloud_api/models.py @@ -0,0 +1,30 @@ +from django.db import models +import uuid + +class Product(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=256) + + recurring_period = models.CharField(max_length=256, + choices = ( + ("per_year", "Per Year"), + ("per_month", "Per Month"), + ("per_week", "Per Week"), + ("per_day", "Per Day"), + ("per_hour", "Per Hour"), + ("not_recurring", "Not recurring") + ), + default="not_recurring" + ) + + + + +class Feature(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=256) + + recurring_price = models.FloatField(default=0) + one_time_price = models.FloatField() + + product = models.ForeignKey(Product, on_delete=models.CASCADE) diff --git a/nicohack202002/uncloud/api/serializers.py b/nicohack202002/uncloud/uncloud_api/serializers.py similarity index 100% rename from nicohack202002/uncloud/api/serializers.py rename to nicohack202002/uncloud/uncloud_api/serializers.py diff --git a/nicohack202002/uncloud/api/tests.py b/nicohack202002/uncloud/uncloud_api/tests.py similarity index 100% rename from nicohack202002/uncloud/api/tests.py rename to nicohack202002/uncloud/uncloud_api/tests.py diff --git a/nicohack202002/uncloud/api/views.py b/nicohack202002/uncloud/uncloud_api/views.py similarity index 94% rename from nicohack202002/uncloud/api/views.py rename to nicohack202002/uncloud/uncloud_api/views.py index c9b1e57..9310d8b 100644 --- a/nicohack202002/uncloud/api/views.py +++ b/nicohack202002/uncloud/uncloud_api/views.py @@ -5,7 +5,7 @@ from django.shortcuts import render from django.contrib.auth.models import User, Group from rest_framework import viewsets, permissions -from api.serializers import UserSerializer, GroupSerializer +from .serializers import UserSerializer, GroupSerializer class CreditCardViewSet(viewsets.ModelViewSet): diff --git a/notes-nico.org b/notes-nico.org index e2b8cac..21102f9 100644 --- a/notes-nico.org +++ b/notes-nico.org @@ -1,5 +1,21 @@ +* snapshot feature +** product: vm-snapshot +* 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 djangorestframework django-auth-ldap stripe +* os package requirements (alpine) + openldap-dev * VPN case ** put on /orders with uuid ** register cc @@ -21,3 +37,6 @@ *** 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 From 2cda4dd57b359ed1ae01eac267d1813997011620 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 21 Feb 2020 11:32:41 +0100 Subject: [PATCH 027/193] [auth] add customer user model Best practice See https://docs.djangoproject.com/en/3.0/topics/auth/customizing/#using-a-custom-user-model-when-starting-a-project --- nicohack202002/uncloud/uncloud/settings.py | 19 +++++++++-- nicohack202002/uncloud/uncloud/urls.py | 1 + nicohack202002/uncloud/uncloud_api/admin.py | 5 ++- .../uncloud_api/migrations/0001_initial.py | 34 ------------------- nicohack202002/uncloud/uncloud_api/models.py | 30 +++++++++++++++- nicohack202002/uncloud/uncloud_api/views.py | 9 +++-- .../migrations => uncloud_auth}/__init__.py | 0 nicohack202002/uncloud/uncloud_auth/admin.py | 5 +++ nicohack202002/uncloud/uncloud_auth/models.py | 4 +++ 9 files changed, 63 insertions(+), 44 deletions(-) delete mode 100644 nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py rename nicohack202002/uncloud/{uncloud_api/migrations => uncloud_auth}/__init__.py (100%) create mode 100644 nicohack202002/uncloud/uncloud_auth/admin.py create mode 100644 nicohack202002/uncloud/uncloud_auth/models.py diff --git a/nicohack202002/uncloud/uncloud/settings.py b/nicohack202002/uncloud/uncloud/settings.py index d6cbb0e..be38f8f 100644 --- a/nicohack202002/uncloud/uncloud/settings.py +++ b/nicohack202002/uncloud/uncloud/settings.py @@ -38,7 +38,8 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', - 'uncloud_api' + 'uncloud_api', + 'uncloud_auth' ] MIDDLEWARE = [ @@ -101,11 +102,13 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] -# LDAP +################################################################################ +# AUTH/LDAP + import ldap from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion -AUTHENTICATION_BACKENDS = ["django_auth_ldap.backend.LDAPBackend"] + AUTH_LDAP_SERVER_URI = "ldaps://ldap1.ungleich.ch,ldaps://ldap2.ungleich.ch" AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=customer,dc=ungleich,dc=ch" @@ -114,6 +117,16 @@ AUTH_LDAP_USER_SEARCH = LDAPSearch( "ou=customer,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 = { diff --git a/nicohack202002/uncloud/uncloud/urls.py b/nicohack202002/uncloud/uncloud/urls.py index e0a0b61..cb50432 100644 --- a/nicohack202002/uncloud/uncloud/urls.py +++ b/nicohack202002/uncloud/uncloud/urls.py @@ -27,6 +27,7 @@ router.register(r'groups', views.GroupViewSet) # Additionally, we include login URLs for the browsable API. urlpatterns = [ path('', include(router.urls)), + path('admin/', admin.site.urls), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) ] diff --git a/nicohack202002/uncloud/uncloud_api/admin.py b/nicohack202002/uncloud/uncloud_api/admin.py index 8c38f3f..f9f5589 100644 --- a/nicohack202002/uncloud/uncloud_api/admin.py +++ b/nicohack202002/uncloud/uncloud_api/admin.py @@ -1,3 +1,6 @@ from django.contrib import admin -# Register your models here. +from .models import Product, Feature + +admin.site.register(Product) +admin.site.register(Feature) diff --git a/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py b/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py deleted file mode 100644 index 7248a66..0000000 --- a/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-21 09:40 - -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Product', - fields=[ - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('name', models.CharField(max_length=256)), - ('recurring_period', models.CharField(choices=[('per_year', 'Per Year'), ('per_month', 'Per Month'), ('per_week', 'Per Week'), ('per_day', 'Per Day'), ('per_hour', 'Per Hour'), ('not_recurring', 'Not recurring')], default='not_recurring', max_length=256)), - ], - ), - migrations.CreateModel( - name='Feature', - fields=[ - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('name', models.CharField(max_length=256)), - ('recurring_price', models.FloatField(default=0)), - ('one_time_price', models.FloatField()), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_api.Product')), - ], - ), - ] diff --git a/nicohack202002/uncloud/uncloud_api/models.py b/nicohack202002/uncloud/uncloud_api/models.py index 2dca8ea..9d4291a 100644 --- a/nicohack202002/uncloud/uncloud_api/models.py +++ b/nicohack202002/uncloud/uncloud_api/models.py @@ -1,6 +1,10 @@ -from django.db import models import uuid +from django.db import models +from django.contrib.auth import get_user_model + + + class Product(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) name = models.CharField(max_length=256) @@ -17,6 +21,8 @@ class Product(models.Model): default="not_recurring" ) + def __str__(self): + return "{}".format(self.name) @@ -28,3 +34,25 @@ class Feature(models.Model): one_time_price = models.FloatField() product = models.ForeignKey(Product, on_delete=models.CASCADE) + + def __str__(self): + return "'{}' - '{}'".format(self.product, self.name) + + +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) + + product = models.ForeignKey(Product, + on_delete=models.CASCADE) + + +class OrderReference(models.Model): + """ + An order can references another product / relate to it. + This model is used for the relation + """ + + pass diff --git a/nicohack202002/uncloud/uncloud_api/views.py b/nicohack202002/uncloud/uncloud_api/views.py index 9310d8b..88e0543 100644 --- a/nicohack202002/uncloud/uncloud_api/views.py +++ b/nicohack202002/uncloud/uncloud_api/views.py @@ -1,8 +1,7 @@ from django.shortcuts import render +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group -# Create your views here. - -from django.contrib.auth.models import User, Group from rest_framework import viewsets, permissions from .serializers import UserSerializer, GroupSerializer @@ -12,7 +11,7 @@ class CreditCardViewSet(viewsets.ModelViewSet): """ API endpoint that allows credit cards to be listed """ - queryset = User.objects.all().order_by('-date_joined') + queryset = get_user_model().objects.all().order_by('-date_joined') serializer_class = UserSerializer permission_classes = [permissions.IsAuthenticated] @@ -23,7 +22,7 @@ class UserViewSet(viewsets.ModelViewSet): """ API endpoint that allows users to be viewed or edited. """ - queryset = User.objects.all().order_by('-date_joined') + queryset = get_user_model().objects.all().order_by('-date_joined') serializer_class = UserSerializer permission_classes = [permissions.IsAuthenticated] diff --git a/nicohack202002/uncloud/uncloud_api/migrations/__init__.py b/nicohack202002/uncloud/uncloud_auth/__init__.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/migrations/__init__.py rename to nicohack202002/uncloud/uncloud_auth/__init__.py diff --git a/nicohack202002/uncloud/uncloud_auth/admin.py b/nicohack202002/uncloud/uncloud_auth/admin.py new file mode 100644 index 0000000..f91be8f --- /dev/null +++ b/nicohack202002/uncloud/uncloud_auth/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from .models import User + +admin.site.register(User, UserAdmin) diff --git a/nicohack202002/uncloud/uncloud_auth/models.py b/nicohack202002/uncloud/uncloud_auth/models.py new file mode 100644 index 0000000..4c9c171 --- /dev/null +++ b/nicohack202002/uncloud/uncloud_auth/models.py @@ -0,0 +1,4 @@ +from django.contrib.auth.models import AbstractUser + +class User(AbstractUser): + pass From 6ba224638a1cbb24b4d1950d3b85008d5d3ca6a9 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 21 Feb 2020 11:42:54 +0100 Subject: [PATCH 028/193] fix migrations / custom user late introduce Signed-off-by: Nico Schottelius --- .../uncloud_api/migrations/0001_initial.py | 50 +++++++++++++++++++ .../uncloud_api/migrations/__init__.py | 0 .../uncloud_auth/migrations/0001_initial.py | 44 ++++++++++++++++ .../uncloud_auth/migrations/__init__.py | 0 4 files changed, 94 insertions(+) create mode 100644 nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py create mode 100644 nicohack202002/uncloud/uncloud_api/migrations/__init__.py create mode 100644 nicohack202002/uncloud/uncloud_auth/migrations/0001_initial.py create mode 100644 nicohack202002/uncloud/uncloud_auth/migrations/__init__.py diff --git a/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py b/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py new file mode 100644 index 0000000..33be28d --- /dev/null +++ b/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 3.0.3 on 2020-02-21 10:42 + +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 = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OrderReference', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), + migrations.CreateModel( + name='Product', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=256)), + ('recurring_period', models.CharField(choices=[('per_year', 'Per Year'), ('per_month', 'Per Month'), ('per_week', 'Per Week'), ('per_day', 'Per Day'), ('per_hour', 'Per Hour'), ('not_recurring', 'Not recurring')], default='not_recurring', max_length=256)), + ], + ), + migrations.CreateModel( + name='Order', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_api.Product')), + ], + ), + migrations.CreateModel( + name='Feature', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=256)), + ('recurring_price', models.FloatField(default=0)), + ('one_time_price', models.FloatField()), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_api.Product')), + ], + ), + ] diff --git a/nicohack202002/uncloud/uncloud_api/migrations/__init__.py b/nicohack202002/uncloud/uncloud_api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nicohack202002/uncloud/uncloud_auth/migrations/0001_initial.py b/nicohack202002/uncloud/uncloud_auth/migrations/0001_initial.py new file mode 100644 index 0000000..267adf2 --- /dev/null +++ b/nicohack202002/uncloud/uncloud_auth/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 3.0.3 on 2020-02-21 10:41 + +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/nicohack202002/uncloud/uncloud_auth/migrations/__init__.py b/nicohack202002/uncloud/uncloud_auth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 From a5695ffa488c9666fb63496d5949a6e83fe4f9e5 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 21 Feb 2020 11:43:17 +0100 Subject: [PATCH 029/193] two more related user problems Signed-off-by: Nico Schottelius --- nicohack202002/uncloud/uncloud_api/serializers.py | 6 ++++-- nicohack202002/uncloud/uncloud_auth/apps.py | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 nicohack202002/uncloud/uncloud_auth/apps.py diff --git a/nicohack202002/uncloud/uncloud_api/serializers.py b/nicohack202002/uncloud/uncloud_api/serializers.py index f5a5a92..57532f2 100644 --- a/nicohack202002/uncloud/uncloud_api/serializers.py +++ b/nicohack202002/uncloud/uncloud_api/serializers.py @@ -1,10 +1,12 @@ -from django.contrib.auth.models import User, Group +from django.contrib.auth.models import Group +from django.contrib.auth import get_user_model + from rest_framework import serializers class UserSerializer(serializers.HyperlinkedModelSerializer): class Meta: - model = User + model = get_user_model() fields = ['url', 'username', 'email', 'groups'] diff --git a/nicohack202002/uncloud/uncloud_auth/apps.py b/nicohack202002/uncloud/uncloud_auth/apps.py new file mode 100644 index 0000000..c16bd7a --- /dev/null +++ b/nicohack202002/uncloud/uncloud_auth/apps.py @@ -0,0 +1,4 @@ +from django.apps import AppConfig + +class AuthConfig(AppConfig): + name = 'uncloud_auth' From 0708a1e1fdfa24ac419a2e2fb7a7a8a54607ca62 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 21 Feb 2020 15:05:17 +0100 Subject: [PATCH 030/193] add requirements.txt --- nicohack202002/uncloud/requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 nicohack202002/uncloud/requirements.txt diff --git a/nicohack202002/uncloud/requirements.txt b/nicohack202002/uncloud/requirements.txt new file mode 100644 index 0000000..d81b59b --- /dev/null +++ b/nicohack202002/uncloud/requirements.txt @@ -0,0 +1,4 @@ +django +djangorestframework +django-auth-ldap +stripe From d61a7e670f562af0ce0d858715b4bf14997c05e3 Mon Sep 17 00:00:00 2001 From: meow Date: Fri, 21 Feb 2020 20:33:37 +0500 Subject: [PATCH 031/193] opennebula vm sync/query application added --- nicohack202002/uncloud/opennebula/__init__.py | 0 nicohack202002/uncloud/opennebula/admin.py | 3 ++ nicohack202002/uncloud/opennebula/apps.py | 5 +++ .../opennebula/management/commands/syncvm.py | 40 +++++++++++++++++++ .../opennebula/migrations/0001_initial.py | 23 +++++++++++ .../migrations/0002_auto_20200221_1024.py | 23 +++++++++++ .../migrations/0003_auto_20200221_1113.py | 21 ++++++++++ .../uncloud/opennebula/migrations/__init__.py | 0 nicohack202002/uncloud/opennebula/models.py | 8 ++++ .../uncloud/opennebula/serializers.py | 8 ++++ nicohack202002/uncloud/opennebula/tests.py | 3 ++ nicohack202002/uncloud/opennebula/views.py | 14 +++++++ nicohack202002/uncloud/uncloud/settings.py | 11 ++--- nicohack202002/uncloud/uncloud/urls.py | 11 +++-- nicohack202002/uncloud/uncloud_auth/models.py | 1 + requirements.txt | 3 ++ 16 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 nicohack202002/uncloud/opennebula/__init__.py create mode 100644 nicohack202002/uncloud/opennebula/admin.py create mode 100644 nicohack202002/uncloud/opennebula/apps.py create mode 100644 nicohack202002/uncloud/opennebula/management/commands/syncvm.py create mode 100644 nicohack202002/uncloud/opennebula/migrations/0001_initial.py create mode 100644 nicohack202002/uncloud/opennebula/migrations/0002_auto_20200221_1024.py create mode 100644 nicohack202002/uncloud/opennebula/migrations/0003_auto_20200221_1113.py create mode 100644 nicohack202002/uncloud/opennebula/migrations/__init__.py create mode 100644 nicohack202002/uncloud/opennebula/models.py create mode 100644 nicohack202002/uncloud/opennebula/serializers.py create mode 100644 nicohack202002/uncloud/opennebula/tests.py create mode 100644 nicohack202002/uncloud/opennebula/views.py diff --git a/nicohack202002/uncloud/opennebula/__init__.py b/nicohack202002/uncloud/opennebula/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nicohack202002/uncloud/opennebula/admin.py b/nicohack202002/uncloud/opennebula/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/nicohack202002/uncloud/opennebula/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/nicohack202002/uncloud/opennebula/apps.py b/nicohack202002/uncloud/opennebula/apps.py new file mode 100644 index 0000000..0750576 --- /dev/null +++ b/nicohack202002/uncloud/opennebula/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OpennebulaConfig(AppConfig): + name = 'opennebula' diff --git a/nicohack202002/uncloud/opennebula/management/commands/syncvm.py b/nicohack202002/uncloud/opennebula/management/commands/syncvm.py new file mode 100644 index 0000000..5ea451d --- /dev/null +++ b/nicohack202002/uncloud/opennebula/management/commands/syncvm.py @@ -0,0 +1,40 @@ +import os +import json + +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from xmlrpc.client import ServerProxy as RPCClient + +from xmltodict import parse + +from opennebula.models import VM as VMModel + +OCA_SESSION_STRING = os.environ.get('OCASECRETS', '') + + +class Command(BaseCommand): + help = 'Syncronize VM information from OpenNebula' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + with RPCClient('https://opennebula.ungleich.ch:2634/RPC2') as rpc_client: + success, response, *_ = rpc_client.one.vmpool.infoextended( + OCA_SESSION_STRING, -2, -1, -1, -1 + ) + if success: + vms = json.loads(json.dumps(parse(response)))['VM_POOL']['VM'] + for i, vm in enumerate(vms): + vm_id = vm['ID'] + vm_owner = vm['UNAME'] + try: + user = get_user_model().objects.get(username=vm_owner) + except get_user_model().DoesNotExist: + user = get_user_model().objects.create_user(username=vm_owner) + + vm_object = VMModel.objects.create(vmid=vm_id, owner=user, data=vm) + vm_object.save() + else: + print(response) + diff --git a/nicohack202002/uncloud/opennebula/migrations/0001_initial.py b/nicohack202002/uncloud/opennebula/migrations/0001_initial.py new file mode 100644 index 0000000..e2c6a1f --- /dev/null +++ b/nicohack202002/uncloud/opennebula/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-02-21 10:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='VM', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('vmid', models.IntegerField()), + ('owner', models.CharField(max_length=128)), + ('data', models.CharField(max_length=65536)), + ], + ), + ] diff --git a/nicohack202002/uncloud/opennebula/migrations/0002_auto_20200221_1024.py b/nicohack202002/uncloud/opennebula/migrations/0002_auto_20200221_1024.py new file mode 100644 index 0000000..43b7442 --- /dev/null +++ b/nicohack202002/uncloud/opennebula/migrations/0002_auto_20200221_1024.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-02-21 10:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('opennebula', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='vm', + name='data', + field=models.CharField(max_length=65536, null=True), + ), + migrations.AlterField( + model_name='vm', + name='owner', + field=models.CharField(max_length=128, null=True), + ), + ] diff --git a/nicohack202002/uncloud/opennebula/migrations/0003_auto_20200221_1113.py b/nicohack202002/uncloud/opennebula/migrations/0003_auto_20200221_1113.py new file mode 100644 index 0000000..9ccc22e --- /dev/null +++ b/nicohack202002/uncloud/opennebula/migrations/0003_auto_20200221_1113.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.3 on 2020-02-21 11:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('opennebula', '0002_auto_20200221_1024'), + ] + + operations = [ + migrations.AlterField( + model_name='vm', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/nicohack202002/uncloud/opennebula/migrations/__init__.py b/nicohack202002/uncloud/opennebula/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nicohack202002/uncloud/opennebula/models.py b/nicohack202002/uncloud/opennebula/models.py new file mode 100644 index 0000000..cd1a044 --- /dev/null +++ b/nicohack202002/uncloud/opennebula/models.py @@ -0,0 +1,8 @@ +from django.db import models +from django.contrib.auth import get_user_model + + +class VM(models.Model): + vmid = models.IntegerField() + owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + data = models.CharField(max_length=65536, null=True) diff --git a/nicohack202002/uncloud/opennebula/serializers.py b/nicohack202002/uncloud/opennebula/serializers.py new file mode 100644 index 0000000..c84f2ab --- /dev/null +++ b/nicohack202002/uncloud/opennebula/serializers.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from opennebula.models import VM + + +class VMSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VM + fields = ['vmid', 'owner', 'data'] diff --git a/nicohack202002/uncloud/opennebula/tests.py b/nicohack202002/uncloud/opennebula/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/nicohack202002/uncloud/opennebula/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/nicohack202002/uncloud/opennebula/views.py b/nicohack202002/uncloud/opennebula/views.py new file mode 100644 index 0000000..f706815 --- /dev/null +++ b/nicohack202002/uncloud/opennebula/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets, generics +from .models import VM +from .serializers import VMSerializer + + +class VMList(generics.ListAPIView): + queryset = VM.objects.all() + serializer_class = VMSerializer + + +class VMDetail(generics.RetrieveAPIView): + lookup_field = 'vmid' + queryset = VM.objects.all() + serializer_class = VMSerializer diff --git a/nicohack202002/uncloud/uncloud/settings.py b/nicohack202002/uncloud/uncloud/settings.py index be38f8f..1e8f358 100644 --- a/nicohack202002/uncloud/uncloud/settings.py +++ b/nicohack202002/uncloud/uncloud/settings.py @@ -39,7 +39,8 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'rest_framework', 'uncloud_api', - 'uncloud_auth' + 'uncloud_auth', + 'opennebula' ] MIDDLEWARE = [ @@ -159,7 +160,7 @@ STATIC_URL = '/static/' # Uncommitted file -import uncloud.secrets - -import stripe -stripe.api_key = uncloud.secrets.STRIPE_KEY +# import uncloud.secrets +# +# import stripe +# stripe.api_key = uncloud.secrets.STRIPE_KEY diff --git a/nicohack202002/uncloud/uncloud/urls.py b/nicohack202002/uncloud/uncloud/urls.py index cb50432..f5804c9 100644 --- a/nicohack202002/uncloud/uncloud/urls.py +++ b/nicohack202002/uncloud/uncloud/urls.py @@ -19,6 +19,8 @@ from django.urls import path, include from rest_framework import routers from uncloud_api import views +from opennebula import views as oneviews + router = routers.DefaultRouter() router.register(r'users', views.UserViewSet) router.register(r'groups', views.GroupViewSet) @@ -28,10 +30,7 @@ router.register(r'groups', views.GroupViewSet) urlpatterns = [ path('', include(router.urls)), path('admin/', admin.site.urls), - path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + path('vm/list/', oneviews.VMList.as_view(), name='vm_list'), + path('vm/detail//', oneviews.VMDetail.as_view(), name='vm_detail'), ] - -#urlpatterns = [ -# path('admin/', admin.site.urls), -# path('api/', include('api.urls')), -#] diff --git a/nicohack202002/uncloud/uncloud_auth/models.py b/nicohack202002/uncloud/uncloud_auth/models.py index 4c9c171..3d30525 100644 --- a/nicohack202002/uncloud/uncloud_auth/models.py +++ b/nicohack202002/uncloud/uncloud_auth/models.py @@ -1,4 +1,5 @@ from django.contrib.auth.models import AbstractUser + class User(AbstractUser): pass diff --git a/requirements.txt b/requirements.txt index 29c21b4..1abfbed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ +xmltodict +djangorestframework +django done stripe flask From 4df7c761d3040c4a79c274a1ca44855e62c5c480 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 21 Feb 2020 20:51:04 +0100 Subject: [PATCH 032/193] ++stuff --- .../uncloud_api/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/snapshot.py | 29 +++++++++++++++++++ nicohack202002/uncloud/uncloud_api/models.py | 1 + 4 files changed, 30 insertions(+) create mode 100644 nicohack202002/uncloud/uncloud_api/management/__init__.py create mode 100644 nicohack202002/uncloud/uncloud_api/management/commands/__init__.py create mode 100644 nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py diff --git a/nicohack202002/uncloud/uncloud_api/management/__init__.py b/nicohack202002/uncloud/uncloud_api/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nicohack202002/uncloud/uncloud_api/management/commands/__init__.py b/nicohack202002/uncloud/uncloud_api/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py b/nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py new file mode 100644 index 0000000..41d0e38 --- /dev/null +++ b/nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py @@ -0,0 +1,29 @@ +import time +from django.conf import settings +from django.core.management.base import BaseCommand + +from uncloud_api import models + + +class Command(BaseCommand): + args = '' + help = 'VM Snapshot support' + + def add_arguments(self, parser): + parser.add_argument('command', type=str, help='Command') + + def handle(self, *args, **options): + print("Snapshotting") + #getattr(self, options['command'])(**options) + + @classmethod + def monitor(cls, **_): + while True: + try: + tweets = models.Reply.get_target_tweets() + responses = models.Reply.objects.values_list('tweet_id', flat=True) + new_tweets = [x for x in tweets if x.id not in responses] + models.Reply.send(new_tweets) + except TweepError as e: + print(e) + time.sleep(60) diff --git a/nicohack202002/uncloud/uncloud_api/models.py b/nicohack202002/uncloud/uncloud_api/models.py index 9d4291a..06e77ed 100644 --- a/nicohack202002/uncloud/uncloud_api/models.py +++ b/nicohack202002/uncloud/uncloud_api/models.py @@ -49,6 +49,7 @@ class Order(models.Model): on_delete=models.CASCADE) + class OrderReference(models.Model): """ An order can references another product / relate to it. From dc5092be71800ce0f1532830eab9b7588771d913 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 21 Feb 2020 21:41:51 +0100 Subject: [PATCH 033/193] Add sample secrets --- nicohack202002/uncloud/uncloud/secrets_sample.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 nicohack202002/uncloud/uncloud/secrets_sample.py diff --git a/nicohack202002/uncloud/uncloud/secrets_sample.py b/nicohack202002/uncloud/uncloud/secrets_sample.py new file mode 100644 index 0000000..a895bc9 --- /dev/null +++ b/nicohack202002/uncloud/uncloud/secrets_sample.py @@ -0,0 +1,11 @@ + + + + + + + + + + +STRIPE_KEY="" From b1bb6bc314c44ac028ae27ce664caec22c7ff3b2 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 22 Feb 2020 00:22:42 +0100 Subject: [PATCH 034/193] Make products available via getattr --- .../opennebula/management/commands/syncvm.py | 8 ++- nicohack202002/uncloud/requirements.txt | 1 + .../uncloud/uncloud/secrets_sample.py | 8 ++- nicohack202002/uncloud/uncloud/urls.py | 2 + .../uncloud_api/management/commands/hack.py | 26 +++++++++ nicohack202002/uncloud/uncloud_api/models.py | 54 ++++++++++++++++--- .../uncloud/uncloud_api/serializers.py | 3 ++ nicohack202002/uncloud/uncloud_api/views.py | 45 +++++++++++++++- notes-nico.org | 9 ++++ plan.org | 6 +++ 10 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 nicohack202002/uncloud/uncloud_api/management/commands/hack.py create mode 100644 plan.org diff --git a/nicohack202002/uncloud/opennebula/management/commands/syncvm.py b/nicohack202002/uncloud/opennebula/management/commands/syncvm.py index 5ea451d..205b066 100644 --- a/nicohack202002/uncloud/opennebula/management/commands/syncvm.py +++ b/nicohack202002/uncloud/opennebula/management/commands/syncvm.py @@ -9,8 +9,7 @@ from xmltodict import parse from opennebula.models import VM as VMModel -OCA_SESSION_STRING = os.environ.get('OCASECRETS', '') - +import uncloud.secrets class Command(BaseCommand): help = 'Syncronize VM information from OpenNebula' @@ -19,9 +18,9 @@ class Command(BaseCommand): pass def handle(self, *args, **options): - with RPCClient('https://opennebula.ungleich.ch:2634/RPC2') as rpc_client: + with RPCClient(uncloud.secrets.OPENNEBULA_URL) as rpc_client: success, response, *_ = rpc_client.one.vmpool.infoextended( - OCA_SESSION_STRING, -2, -1, -1, -1 + uncloud.secrets.OPENNEBULA_USER_PASS, -2, -1, -1, -1 ) if success: vms = json.loads(json.dumps(parse(response)))['VM_POOL']['VM'] @@ -37,4 +36,3 @@ class Command(BaseCommand): vm_object.save() else: print(response) - diff --git a/nicohack202002/uncloud/requirements.txt b/nicohack202002/uncloud/requirements.txt index d81b59b..11ab309 100644 --- a/nicohack202002/uncloud/requirements.txt +++ b/nicohack202002/uncloud/requirements.txt @@ -2,3 +2,4 @@ django djangorestframework django-auth-ldap stripe +xmltodict diff --git a/nicohack202002/uncloud/uncloud/secrets_sample.py b/nicohack202002/uncloud/uncloud/secrets_sample.py index a895bc9..d145124 100644 --- a/nicohack202002/uncloud/uncloud/secrets_sample.py +++ b/nicohack202002/uncloud/uncloud/secrets_sample.py @@ -7,5 +7,11 @@ - +# 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' diff --git a/nicohack202002/uncloud/uncloud/urls.py b/nicohack202002/uncloud/uncloud/urls.py index f5804c9..c7ce9b6 100644 --- a/nicohack202002/uncloud/uncloud/urls.py +++ b/nicohack202002/uncloud/uncloud/urls.py @@ -30,7 +30,9 @@ router.register(r'groups', views.GroupViewSet) urlpatterns = [ path('', include(router.urls)), path('admin/', admin.site.urls), + path('products/', views.ProductsView.as_view(), name='products'), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), path('vm/list/', oneviews.VMList.as_view(), name='vm_list'), path('vm/detail//', oneviews.VMDetail.as_view(), name='vm_detail'), + ] diff --git a/nicohack202002/uncloud/uncloud_api/management/commands/hack.py b/nicohack202002/uncloud/uncloud_api/management/commands/hack.py new file mode 100644 index 0000000..e129952 --- /dev/null +++ b/nicohack202002/uncloud/uncloud_api/management/commands/hack.py @@ -0,0 +1,26 @@ +import time +from django.conf import settings +from django.core.management.base import BaseCommand + +import uncloud_api.models + +import inspect +import sys +import re + +class Command(BaseCommand): + args = '' + help = 'hacking - only use if you are Nico' + + def add_arguments(self, parser): + parser.add_argument('command', type=str, help='Command') + + def handle(self, *args, **options): + getattr(self, options['command'])(**options) + + @classmethod + def classtest(cls, **_): + clsmembers = inspect.getmembers(sys.modules['uncloud_api.models'], inspect.isclass) + for name, c in clsmembers: + if re.match(r'.+Product$', name): + print("{} -> {}".format(name, c)) diff --git a/nicohack202002/uncloud/uncloud_api/models.py b/nicohack202002/uncloud/uncloud_api/models.py index 06e77ed..6df17c4 100644 --- a/nicohack202002/uncloud/uncloud_api/models.py +++ b/nicohack202002/uncloud/uncloud_api/models.py @@ -3,9 +3,38 @@ import uuid from django.db import models from django.contrib.auth import get_user_model +# Product in DB vs. product in code +# DB: +# - need to define params (+param types) in db -> messy? +# - get /products/ is easy / automatic +# +# code +# - can have serializer/verification of fields easily in DRF +# - can have per product side effects / extra code running +# - might (??) make features easier?? +# - how to setup / query the recurring period (?) +# - could get products list via getattr() + re ...Product() classes +# -> this could include the url for ordering => /order/vm_snapshot (params) +# ---> this would work with urlpatterns +# Combination: create specific product in DB (?) +# - a table per product (?) with 1 entry? + +# Orders +# define state in DB +# select a price from a product => product might change, order stays +# params: +# - the product uuid or name (?) => productuuid +# - the product parameters => for each feature +# + +# logs +# Should have a log = ... => 1:n field for most models! class Product(models.Model): + + description = "" + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) name = models.CharField(max_length=256) @@ -21,10 +50,18 @@ class Product(models.Model): default="not_recurring" ) + # params = [ vmuuid, ... ] + # features -> required as defined + def __str__(self): return "{}".format(self.name) +class VMSnapshotProduct(Product): + # need to setup recurring_periodd + + description = "Create snapshot of a VM" + class Feature(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -35,6 +72,15 @@ class Feature(models.Model): product = models.ForeignKey(Product, on_delete=models.CASCADE) + # params for "cpu": cpu_count -> int + # each feature can only have one parameters + # could call this "value" and set whether it is user usable + # has_value = True/False + # value = string -> int (?) + # value_int + # value_str + # value_float + def __str__(self): return "'{}' - '{}'".format(self.product, self.name) @@ -49,11 +95,5 @@ class Order(models.Model): on_delete=models.CASCADE) - -class OrderReference(models.Model): - """ - An order can references another product / relate to it. - This model is used for the relation - """ - +class VMSnapshotOrder(Order): pass diff --git a/nicohack202002/uncloud/uncloud_api/serializers.py b/nicohack202002/uncloud/uncloud_api/serializers.py index 57532f2..1573bf0 100644 --- a/nicohack202002/uncloud/uncloud_api/serializers.py +++ b/nicohack202002/uncloud/uncloud_api/serializers.py @@ -14,3 +14,6 @@ class GroupSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Group fields = ['url', 'name'] + +class VMSnapshotSerializer(serializers.Serializer): + pass diff --git a/nicohack202002/uncloud/uncloud_api/views.py b/nicohack202002/uncloud/uncloud_api/views.py index 88e0543..8cf76f2 100644 --- a/nicohack202002/uncloud/uncloud_api/views.py +++ b/nicohack202002/uncloud/uncloud_api/views.py @@ -2,9 +2,11 @@ from django.shortcuts import render from django.contrib.auth import get_user_model from django.contrib.auth.models import Group -from rest_framework import viewsets, permissions - +from rest_framework import viewsets, permissions, generics from .serializers import UserSerializer, GroupSerializer +from rest_framework.views import APIView +from rest_framework.response import Response + class CreditCardViewSet(viewsets.ModelViewSet): @@ -35,3 +37,42 @@ class GroupViewSet(viewsets.ModelViewSet): serializer_class = GroupSerializer permission_classes = [permissions.IsAuthenticated] + +class GroupViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows groups to be viewed or edited. + """ + queryset = Group.objects.all() + serializer_class = GroupSerializer + + permission_classes = [permissions.IsAuthenticated] + + +# POST /vm/snapshot/ vmuuid=... => create snapshot, returns snapshot uuid +# GET /vm/snapshot => list +# DEL /vm/snapshot/ => delete +# create-list -> get, post => ListCreateAPIView +# del on other! +class VMSnapshotView(generics.ListCreateAPIView): + #lookup_field = 'uuid' + permission_classes = [permissions.IsAuthenticated] + +import inspect +import sys +import re + +class ProductsView(APIView): + def get(self, request, format=None): + clsmembers = inspect.getmembers(sys.modules['uncloud_api.models'], inspect.isclass) + products = [] + for name, c in clsmembers: + # Include everything that ends in Product, but not Product itself + if re.search(r'.+Product$', name): + products.append({ + 'name': name, + 'description': c.description + } + ) + + + return Response(products) diff --git a/notes-nico.org b/notes-nico.org index 21102f9..93e0c00 100644 --- a/notes-nico.org +++ b/notes-nico.org @@ -1,5 +1,14 @@ * 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=... +``` * steps ** DONE authenticate via ldap CLOSED: [2020-02-20 Thu 19:05] diff --git a/plan.org b/plan.org new file mode 100644 index 0000000..9f172c2 --- /dev/null +++ b/plan.org @@ -0,0 +1,6 @@ +* TODO register CC +* TODO list products +* ahmed +** schemas +*** field: is_valid? - used by schemas +*** definition of a "schema" From 4f4a4be8396316df064f4acd8f61a4dc184e3fb0 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 22 Feb 2020 00:50:06 +0100 Subject: [PATCH 035/193] good night commit - introducing status --- nicohack202002/uncloud/uncloud_api/models.py | 62 ++++++++++++++------ nicohack202002/uncloud/uncloud_api/views.py | 11 +++- 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/nicohack202002/uncloud/uncloud_api/models.py b/nicohack202002/uncloud/uncloud_api/models.py index 6df17c4..fafefe6 100644 --- a/nicohack202002/uncloud/uncloud_api/models.py +++ b/nicohack202002/uncloud/uncloud_api/models.py @@ -32,35 +32,59 @@ from django.contrib.auth import get_user_model # Should have a log = ... => 1:n field for most models! class Product(models.Model): - + # override these fields by default description = "" + recurring_period = "not_recurring" - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=256) - - recurring_period = models.CharField(max_length=256, - choices = ( - ("per_year", "Per Year"), - ("per_month", "Per Month"), - ("per_week", "Per Week"), - ("per_day", "Per Day"), - ("per_hour", "Per Hour"), - ("not_recurring", "Not recurring") - ), - default="not_recurring" - ) - - # params = [ vmuuid, ... ] - # features -> required as defined + status = models.CharField(max_length=256, + choices = ( + ('pending', 'Pending'), + ('being_created', 'Being created'), + ('created_active', 'Created'), + ('deleted', 'Deleted') + ) def __str__(self): return "{}".format(self.name) class VMSnapshotProduct(Product): - # need to setup recurring_periodd + price_per_gb_ssd = 0.35 + price_per_gb_hdd = 1.5/100 + + sample_ssd = 10 + sample_hdd = 100 + + def recurring_price(self): + return 0 + + def one_time_price(self): + return 0 + + @classmethod + def sample_price(cls): + return cls.sample_ssd * cls.price_per_gb_ssd + cls.sample_hdd * cls.price_per_gb_hdd description = "Create snapshot of a VM" + recurring_period = "monthly" + + @classmethod + def pricing_model(cls): + return """ +Pricing is on monthly basis and storage prices are equivalent to the storage +price in the VM. + +Price per GB SSD is: {} +Price per GB HDD is: {} + + +Sample price for a VM with {} GB SSD and {} GB HDD VM is: {}. +""".format(cls.price_per_gb_ssd, cls.price_per_gb_hdd, + cls.sample_ssd, cls.sample_hdd, cls.sample_price()) + + gb_ssd = models.FloatField() + gb_hdd = models.FloatField() + class Feature(models.Model): diff --git a/nicohack202002/uncloud/uncloud_api/views.py b/nicohack202002/uncloud/uncloud_api/views.py index 8cf76f2..68963ff 100644 --- a/nicohack202002/uncloud/uncloud_api/views.py +++ b/nicohack202002/uncloud/uncloud_api/views.py @@ -61,16 +61,21 @@ import inspect import sys import re +# Next: create /order/ urls +# Next: strip off "Product" at the end class ProductsView(APIView): def get(self, request, format=None): clsmembers = inspect.getmembers(sys.modules['uncloud_api.models'], inspect.isclass) products = [] for name, c in clsmembers: # Include everything that ends in Product, but not Product itself - if re.search(r'.+Product$', name): + m = re.match(r'(?P.+)Product$', name) + if m: products.append({ - 'name': name, - 'description': c.description + 'name': m.group('pname'), + 'description': c.description, + 'recurring_period': c.recurring_period, + 'pricing_model': c.pricing_model() } ) From dc34c0ecd48719b5c7cb17c39891ef1e9c179ffb Mon Sep 17 00:00:00 2001 From: Ahmed Bilal <49-ahmedbilal@users.noreply.code.ungleich.ch> Date: Sat, 22 Feb 2020 07:32:52 +0100 Subject: [PATCH 036/193] Merge nico/meow-pay into ahmedbilal/meow-pay --- .../opennebula/management/commands/syncvm.py | 8 +- nicohack202002/uncloud/requirements.txt | 5 + .../uncloud/uncloud/secrets_sample.py | 17 +++ nicohack202002/uncloud/uncloud/urls.py | 2 + .../uncloud_api/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../uncloud_api/management/commands/hack.py | 26 +++++ .../management/commands/snapshot.py | 29 +++++ nicohack202002/uncloud/uncloud_api/models.py | 103 ++++++++++++++---- .../uncloud/uncloud_api/serializers.py | 3 + nicohack202002/uncloud/uncloud_api/views.py | 50 ++++++++- notes-nico.org | 9 ++ plan.org | 6 + 13 files changed, 232 insertions(+), 26 deletions(-) create mode 100644 nicohack202002/uncloud/requirements.txt create mode 100644 nicohack202002/uncloud/uncloud/secrets_sample.py create mode 100644 nicohack202002/uncloud/uncloud_api/management/__init__.py create mode 100644 nicohack202002/uncloud/uncloud_api/management/commands/__init__.py create mode 100644 nicohack202002/uncloud/uncloud_api/management/commands/hack.py create mode 100644 nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py create mode 100644 plan.org diff --git a/nicohack202002/uncloud/opennebula/management/commands/syncvm.py b/nicohack202002/uncloud/opennebula/management/commands/syncvm.py index 5ea451d..205b066 100644 --- a/nicohack202002/uncloud/opennebula/management/commands/syncvm.py +++ b/nicohack202002/uncloud/opennebula/management/commands/syncvm.py @@ -9,8 +9,7 @@ from xmltodict import parse from opennebula.models import VM as VMModel -OCA_SESSION_STRING = os.environ.get('OCASECRETS', '') - +import uncloud.secrets class Command(BaseCommand): help = 'Syncronize VM information from OpenNebula' @@ -19,9 +18,9 @@ class Command(BaseCommand): pass def handle(self, *args, **options): - with RPCClient('https://opennebula.ungleich.ch:2634/RPC2') as rpc_client: + with RPCClient(uncloud.secrets.OPENNEBULA_URL) as rpc_client: success, response, *_ = rpc_client.one.vmpool.infoextended( - OCA_SESSION_STRING, -2, -1, -1, -1 + uncloud.secrets.OPENNEBULA_USER_PASS, -2, -1, -1, -1 ) if success: vms = json.loads(json.dumps(parse(response)))['VM_POOL']['VM'] @@ -37,4 +36,3 @@ class Command(BaseCommand): vm_object.save() else: print(response) - diff --git a/nicohack202002/uncloud/requirements.txt b/nicohack202002/uncloud/requirements.txt new file mode 100644 index 0000000..11ab309 --- /dev/null +++ b/nicohack202002/uncloud/requirements.txt @@ -0,0 +1,5 @@ +django +djangorestframework +django-auth-ldap +stripe +xmltodict diff --git a/nicohack202002/uncloud/uncloud/secrets_sample.py b/nicohack202002/uncloud/uncloud/secrets_sample.py new file mode 100644 index 0000000..d145124 --- /dev/null +++ b/nicohack202002/uncloud/uncloud/secrets_sample.py @@ -0,0 +1,17 @@ + + + + + + + + + +# 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' diff --git a/nicohack202002/uncloud/uncloud/urls.py b/nicohack202002/uncloud/uncloud/urls.py index f5804c9..c7ce9b6 100644 --- a/nicohack202002/uncloud/uncloud/urls.py +++ b/nicohack202002/uncloud/uncloud/urls.py @@ -30,7 +30,9 @@ router.register(r'groups', views.GroupViewSet) urlpatterns = [ path('', include(router.urls)), path('admin/', admin.site.urls), + path('products/', views.ProductsView.as_view(), name='products'), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), path('vm/list/', oneviews.VMList.as_view(), name='vm_list'), path('vm/detail//', oneviews.VMDetail.as_view(), name='vm_detail'), + ] diff --git a/nicohack202002/uncloud/uncloud_api/management/__init__.py b/nicohack202002/uncloud/uncloud_api/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nicohack202002/uncloud/uncloud_api/management/commands/__init__.py b/nicohack202002/uncloud/uncloud_api/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nicohack202002/uncloud/uncloud_api/management/commands/hack.py b/nicohack202002/uncloud/uncloud_api/management/commands/hack.py new file mode 100644 index 0000000..e129952 --- /dev/null +++ b/nicohack202002/uncloud/uncloud_api/management/commands/hack.py @@ -0,0 +1,26 @@ +import time +from django.conf import settings +from django.core.management.base import BaseCommand + +import uncloud_api.models + +import inspect +import sys +import re + +class Command(BaseCommand): + args = '' + help = 'hacking - only use if you are Nico' + + def add_arguments(self, parser): + parser.add_argument('command', type=str, help='Command') + + def handle(self, *args, **options): + getattr(self, options['command'])(**options) + + @classmethod + def classtest(cls, **_): + clsmembers = inspect.getmembers(sys.modules['uncloud_api.models'], inspect.isclass) + for name, c in clsmembers: + if re.match(r'.+Product$', name): + print("{} -> {}".format(name, c)) diff --git a/nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py b/nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py new file mode 100644 index 0000000..41d0e38 --- /dev/null +++ b/nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py @@ -0,0 +1,29 @@ +import time +from django.conf import settings +from django.core.management.base import BaseCommand + +from uncloud_api import models + + +class Command(BaseCommand): + args = '' + help = 'VM Snapshot support' + + def add_arguments(self, parser): + parser.add_argument('command', type=str, help='Command') + + def handle(self, *args, **options): + print("Snapshotting") + #getattr(self, options['command'])(**options) + + @classmethod + def monitor(cls, **_): + while True: + try: + tweets = models.Reply.get_target_tweets() + responses = models.Reply.objects.values_list('tweet_id', flat=True) + new_tweets = [x for x in tweets if x.id not in responses] + models.Reply.send(new_tweets) + except TweepError as e: + print(e) + time.sleep(60) diff --git a/nicohack202002/uncloud/uncloud_api/models.py b/nicohack202002/uncloud/uncloud_api/models.py index 9d4291a..fafefe6 100644 --- a/nicohack202002/uncloud/uncloud_api/models.py +++ b/nicohack202002/uncloud/uncloud_api/models.py @@ -3,28 +3,89 @@ import uuid from django.db import models from django.contrib.auth import get_user_model +# Product in DB vs. product in code +# DB: +# - need to define params (+param types) in db -> messy? +# - get /products/ is easy / automatic +# +# code +# - can have serializer/verification of fields easily in DRF +# - can have per product side effects / extra code running +# - might (??) make features easier?? +# - how to setup / query the recurring period (?) +# - could get products list via getattr() + re ...Product() classes +# -> this could include the url for ordering => /order/vm_snapshot (params) +# ---> this would work with urlpatterns +# Combination: create specific product in DB (?) +# - a table per product (?) with 1 entry? + +# Orders +# define state in DB +# select a price from a product => product might change, order stays +# params: +# - the product uuid or name (?) => productuuid +# - the product parameters => for each feature +# + +# logs +# Should have a log = ... => 1:n field for most models! class Product(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=256) + # override these fields by default + description = "" + recurring_period = "not_recurring" - recurring_period = models.CharField(max_length=256, - choices = ( - ("per_year", "Per Year"), - ("per_month", "Per Month"), - ("per_week", "Per Week"), - ("per_day", "Per Day"), - ("per_hour", "Per Hour"), - ("not_recurring", "Not recurring") - ), - default="not_recurring" - ) + status = models.CharField(max_length=256, + choices = ( + ('pending', 'Pending'), + ('being_created', 'Being created'), + ('created_active', 'Created'), + ('deleted', 'Deleted') + ) def __str__(self): return "{}".format(self.name) +class VMSnapshotProduct(Product): + price_per_gb_ssd = 0.35 + price_per_gb_hdd = 1.5/100 + + sample_ssd = 10 + sample_hdd = 100 + + def recurring_price(self): + return 0 + + def one_time_price(self): + return 0 + + @classmethod + def sample_price(cls): + return cls.sample_ssd * cls.price_per_gb_ssd + cls.sample_hdd * cls.price_per_gb_hdd + + description = "Create snapshot of a VM" + recurring_period = "monthly" + + @classmethod + def pricing_model(cls): + return """ +Pricing is on monthly basis and storage prices are equivalent to the storage +price in the VM. + +Price per GB SSD is: {} +Price per GB HDD is: {} + + +Sample price for a VM with {} GB SSD and {} GB HDD VM is: {}. +""".format(cls.price_per_gb_ssd, cls.price_per_gb_hdd, + cls.sample_ssd, cls.sample_hdd, cls.sample_price()) + + gb_ssd = models.FloatField() + gb_hdd = models.FloatField() + + class Feature(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -35,6 +96,15 @@ class Feature(models.Model): product = models.ForeignKey(Product, on_delete=models.CASCADE) + # params for "cpu": cpu_count -> int + # each feature can only have one parameters + # could call this "value" and set whether it is user usable + # has_value = True/False + # value = string -> int (?) + # value_int + # value_str + # value_float + def __str__(self): return "'{}' - '{}'".format(self.product, self.name) @@ -49,10 +119,5 @@ class Order(models.Model): on_delete=models.CASCADE) -class OrderReference(models.Model): - """ - An order can references another product / relate to it. - This model is used for the relation - """ - +class VMSnapshotOrder(Order): pass diff --git a/nicohack202002/uncloud/uncloud_api/serializers.py b/nicohack202002/uncloud/uncloud_api/serializers.py index 57532f2..1573bf0 100644 --- a/nicohack202002/uncloud/uncloud_api/serializers.py +++ b/nicohack202002/uncloud/uncloud_api/serializers.py @@ -14,3 +14,6 @@ class GroupSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Group fields = ['url', 'name'] + +class VMSnapshotSerializer(serializers.Serializer): + pass diff --git a/nicohack202002/uncloud/uncloud_api/views.py b/nicohack202002/uncloud/uncloud_api/views.py index 88e0543..68963ff 100644 --- a/nicohack202002/uncloud/uncloud_api/views.py +++ b/nicohack202002/uncloud/uncloud_api/views.py @@ -2,9 +2,11 @@ from django.shortcuts import render from django.contrib.auth import get_user_model from django.contrib.auth.models import Group -from rest_framework import viewsets, permissions - +from rest_framework import viewsets, permissions, generics from .serializers import UserSerializer, GroupSerializer +from rest_framework.views import APIView +from rest_framework.response import Response + class CreditCardViewSet(viewsets.ModelViewSet): @@ -35,3 +37,47 @@ class GroupViewSet(viewsets.ModelViewSet): serializer_class = GroupSerializer permission_classes = [permissions.IsAuthenticated] + +class GroupViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows groups to be viewed or edited. + """ + queryset = Group.objects.all() + serializer_class = GroupSerializer + + permission_classes = [permissions.IsAuthenticated] + + +# POST /vm/snapshot/ vmuuid=... => create snapshot, returns snapshot uuid +# GET /vm/snapshot => list +# DEL /vm/snapshot/ => delete +# create-list -> get, post => ListCreateAPIView +# del on other! +class VMSnapshotView(generics.ListCreateAPIView): + #lookup_field = 'uuid' + permission_classes = [permissions.IsAuthenticated] + +import inspect +import sys +import re + +# Next: create /order/ urls +# Next: strip off "Product" at the end +class ProductsView(APIView): + def get(self, request, format=None): + clsmembers = inspect.getmembers(sys.modules['uncloud_api.models'], inspect.isclass) + products = [] + for name, c in clsmembers: + # Include everything that ends in Product, but not Product itself + m = re.match(r'(?P.+)Product$', name) + if m: + products.append({ + 'name': m.group('pname'), + 'description': c.description, + 'recurring_period': c.recurring_period, + 'pricing_model': c.pricing_model() + } + ) + + + return Response(products) diff --git a/notes-nico.org b/notes-nico.org index 21102f9..93e0c00 100644 --- a/notes-nico.org +++ b/notes-nico.org @@ -1,5 +1,14 @@ * 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=... +``` * steps ** DONE authenticate via ldap CLOSED: [2020-02-20 Thu 19:05] diff --git a/plan.org b/plan.org new file mode 100644 index 0000000..9f172c2 --- /dev/null +++ b/plan.org @@ -0,0 +1,6 @@ +* TODO register CC +* TODO list products +* ahmed +** schemas +*** field: is_valid? - used by schemas +*** definition of a "schema" From 5f28e9630cd02d7c82ec1c27a7c9075b5f361f2f Mon Sep 17 00:00:00 2001 From: meow Date: Sat, 22 Feb 2020 11:36:18 +0500 Subject: [PATCH 037/193] Remove unneccessary requirements from {repo_root}/requirements.txt + uncloud/secret_sample.py minor changes --- nicohack202002/uncloud/uncloud/secrets_sample.py | 15 +++------------ requirements.txt | 4 ---- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/nicohack202002/uncloud/uncloud/secrets_sample.py b/nicohack202002/uncloud/uncloud/secrets_sample.py index d145124..e094e2d 100644 --- a/nicohack202002/uncloud/uncloud/secrets_sample.py +++ b/nicohack202002/uncloud/uncloud/secrets_sample.py @@ -1,17 +1,8 @@ - - - - - - - - - # Live/test key from stripe -STRIPE_KEY="" +STRIPE_KEY = '' # XML-RPC interface of opennebula -OPENNEBULA_URL='https://opennebula.ungleich.ch:2634/RPC2' +OPENNEBULA_URL = 'https://opennebula.ungleich.ch:2634/RPC2' # user:pass for accessing opennebula -OPENNEBULA_USER_PASS='user:password' +OPENNEBULA_USER_PASS = 'user:password' diff --git a/requirements.txt b/requirements.txt index 1abfbed..0b758ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,3 @@ -xmltodict -djangorestframework -django -done stripe flask Flask-RESTful From 71a764ce1ea1ab6b0aaa3ac35669896a0b47a591 Mon Sep 17 00:00:00 2001 From: meow Date: Sat, 22 Feb 2020 15:49:00 +0500 Subject: [PATCH 038/193] Move vm/{detail,list} under opennebula/vm/{detail,list} and make it admin accessible only + Created vm/list that list currently authenticated user's VMs --- nicohack202002/uncloud/.gitignore | 2 + .../opennebula/management/commands/syncvm.py | 2 +- .../migrations/0004_auto_20200222_0713.py | 23 +++++++++ nicohack202002/uncloud/opennebula/models.py | 3 ++ .../uncloud/opennebula/serializers.py | 2 +- nicohack202002/uncloud/opennebula/views.py | 51 +++++++++++++++++-- .../uncloud/uncloud/secrets_sample.py | 4 ++ nicohack202002/uncloud/uncloud/settings.py | 47 +++++++++++++---- nicohack202002/uncloud/uncloud/urls.py | 6 +-- .../migrations/0002_auto_20200222_0719.py | 46 +++++++++++++++++ nicohack202002/uncloud/uncloud_api/models.py | 20 ++++---- notes-abk.md | 11 ++++ notes.org | 1 - 13 files changed, 190 insertions(+), 28 deletions(-) create mode 100644 nicohack202002/uncloud/opennebula/migrations/0004_auto_20200222_0713.py create mode 100644 nicohack202002/uncloud/uncloud_api/migrations/0002_auto_20200222_0719.py create mode 100644 notes-abk.md delete mode 100644 notes.org diff --git a/nicohack202002/uncloud/.gitignore b/nicohack202002/uncloud/.gitignore index 49ef255..4ade18f 100644 --- a/nicohack202002/uncloud/.gitignore +++ b/nicohack202002/uncloud/.gitignore @@ -1 +1,3 @@ db.sqlite3 +uncloud/secrets.py +debug.log \ No newline at end of file diff --git a/nicohack202002/uncloud/opennebula/management/commands/syncvm.py b/nicohack202002/uncloud/opennebula/management/commands/syncvm.py index 205b066..e68a4a4 100644 --- a/nicohack202002/uncloud/opennebula/management/commands/syncvm.py +++ b/nicohack202002/uncloud/opennebula/management/commands/syncvm.py @@ -31,7 +31,7 @@ class Command(BaseCommand): user = get_user_model().objects.get(username=vm_owner) except get_user_model().DoesNotExist: user = get_user_model().objects.create_user(username=vm_owner) - + vm = json.dumps(vm, ensure_ascii=True) vm_object = VMModel.objects.create(vmid=vm_id, owner=user, data=vm) vm_object.save() else: diff --git a/nicohack202002/uncloud/opennebula/migrations/0004_auto_20200222_0713.py b/nicohack202002/uncloud/opennebula/migrations/0004_auto_20200222_0713.py new file mode 100644 index 0000000..a298c06 --- /dev/null +++ b/nicohack202002/uncloud/opennebula/migrations/0004_auto_20200222_0713.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-02-22 07:13 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('opennebula', '0003_auto_20200221_1113'), + ] + + operations = [ + migrations.RemoveField( + model_name='vm', + name='id', + ), + migrations.AddField( + model_name='vm', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + ] diff --git a/nicohack202002/uncloud/opennebula/models.py b/nicohack202002/uncloud/opennebula/models.py index cd1a044..915862a 100644 --- a/nicohack202002/uncloud/opennebula/models.py +++ b/nicohack202002/uncloud/opennebula/models.py @@ -1,8 +1,11 @@ +import uuid + from django.db import models from django.contrib.auth import get_user_model class VM(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) vmid = models.IntegerField() owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) data = models.CharField(max_length=65536, null=True) diff --git a/nicohack202002/uncloud/opennebula/serializers.py b/nicohack202002/uncloud/opennebula/serializers.py index c84f2ab..ac40725 100644 --- a/nicohack202002/uncloud/opennebula/serializers.py +++ b/nicohack202002/uncloud/opennebula/serializers.py @@ -5,4 +5,4 @@ from opennebula.models import VM class VMSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VM - fields = ['vmid', 'owner', 'data'] + fields = ['uuid', 'vmid', 'owner', 'data'] diff --git a/nicohack202002/uncloud/opennebula/views.py b/nicohack202002/uncloud/opennebula/views.py index f706815..1030101 100644 --- a/nicohack202002/uncloud/opennebula/views.py +++ b/nicohack202002/uncloud/opennebula/views.py @@ -1,14 +1,59 @@ -from rest_framework import viewsets, generics +import json + +from rest_framework import generics +from rest_framework.authentication import SessionAuthentication, BasicAuthentication +from rest_framework.permissions import IsAuthenticated, IsAdminUser + from .models import VM from .serializers import VMSerializer - class VMList(generics.ListAPIView): + authentication_classes = [SessionAuthentication, BasicAuthentication] + permission_classes = [IsAuthenticated, IsAdminUser] queryset = VM.objects.all() serializer_class = VMSerializer class VMDetail(generics.RetrieveAPIView): - lookup_field = 'vmid' + authentication_classes = [SessionAuthentication, BasicAuthentication] + permission_classes = [IsAuthenticated, IsAdminUser] + lookup_field = 'uuid' queryset = VM.objects.all() serializer_class = VMSerializer + + +class UserVMList(generics.ListAPIView): + authentication_classes = [SessionAuthentication, BasicAuthentication] + permission_classes = [IsAuthenticated] + serializer_class = VMSerializer + + def get_queryset(self): + user_email = self.request.user.ldap_user.attrs.data['mail'] + vms = [] + for mail in user_email: + vms += VM.objects.filter(owner__username=mail) + + for vm in vms: + data = json.loads(vm.data) + vm_template = data['TEMPLATE'] + vm.data = { + 'cpu': vm_template['VCPU'], + 'ram': vm_template['MEMORY'], + 'nic': vm_template['NIC'], + 'disks': vm_template['DISK'] + } + + return vms + +####################################### +# Following for quick experimentation # +####################################### + +# from django.http import HttpResponse +# +# def test(request): +# user_email = request.user.ldap_user.attrs.data['mail'] +# vms = [] +# for mail in user_email: +# vms += VM.objects.filter(owner__username=mail) +# return HttpResponse("Hello World") diff --git a/nicohack202002/uncloud/uncloud/secrets_sample.py b/nicohack202002/uncloud/uncloud/secrets_sample.py index e094e2d..f4c89ac 100644 --- a/nicohack202002/uncloud/uncloud/secrets_sample.py +++ b/nicohack202002/uncloud/uncloud/secrets_sample.py @@ -6,3 +6,7 @@ OPENNEBULA_URL = 'https://opennebula.ungleich.ch:2634/RPC2' # user:pass for accessing opennebula OPENNEBULA_USER_PASS = 'user:password' + +AUTH_LDAP_BIND_DN = 'something' + +AUTH_LDAP_BIND_PASSWORD = r'somepass' diff --git a/nicohack202002/uncloud/uncloud/settings.py b/nicohack202002/uncloud/uncloud/settings.py index 1e8f358..edd7c19 100644 --- a/nicohack202002/uncloud/uncloud/settings.py +++ b/nicohack202002/uncloud/uncloud/settings.py @@ -12,6 +12,15 @@ https://docs.djangoproject.com/en/3.0/ref/settings/ import os +import stripe +import ldap + +import uncloud.secrets as secrets + +from django_auth_ldap.config import LDAPSearch + + + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -106,14 +115,14 @@ AUTH_PASSWORD_VALIDATORS = [ ################################################################################ # AUTH/LDAP -import ldap -from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion - - AUTH_LDAP_SERVER_URI = "ldaps://ldap1.ungleich.ch,ldaps://ldap2.ungleich.ch" AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=customer,dc=ungleich,dc=ch" +AUTH_LDAP_BIND_DN = secrets.AUTH_LDAP_BIND_DN + +AUTH_LDAP_BIND_PASSWORD = secrets.AUTH_LDAP_BIND_PASSWORD + AUTH_LDAP_USER_SEARCH = LDAPSearch( "ou=customer,dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=%(user)s)" ) @@ -132,7 +141,6 @@ AUTH_USER_MODEL = 'uncloud_auth.User' # AUTH/REST REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.SessionAuthentication', ] } @@ -158,9 +166,28 @@ USE_TZ = True STATIC_URL = '/static/' +stripe.api_key = secrets.STRIPE_KEY -# Uncommitted file -# import uncloud.secrets -# -# import stripe -# stripe.api_key = uncloud.secrets.STRIPE_KEY +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'file': { + 'level': 'DEBUG', + 'class': 'logging.FileHandler', + 'filename': 'debug.log', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['file'], + 'level': 'DEBUG', + 'propagate': True, + }, + 'django_auth_ldap': { + 'handlers': ['file'], + 'level': 'DEBUG', + 'propagate': True + } + }, +} diff --git a/nicohack202002/uncloud/uncloud/urls.py b/nicohack202002/uncloud/uncloud/urls.py index c7ce9b6..cd8c333 100644 --- a/nicohack202002/uncloud/uncloud/urls.py +++ b/nicohack202002/uncloud/uncloud/urls.py @@ -32,7 +32,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('products/', views.ProductsView.as_view(), name='products'), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), - path('vm/list/', oneviews.VMList.as_view(), name='vm_list'), - path('vm/detail//', oneviews.VMDetail.as_view(), name='vm_detail'), - + path('opennebula/vm/list/', oneviews.VMList.as_view(), name='vm_list'), + path('opennebula/vm/detail//', oneviews.VMDetail.as_view(), name='vm_detail'), + path('vm/list/', oneviews.UserVMList.as_view(), name='user_vm_list'), ] diff --git a/nicohack202002/uncloud/uncloud_api/migrations/0002_auto_20200222_0719.py b/nicohack202002/uncloud/uncloud_api/migrations/0002_auto_20200222_0719.py new file mode 100644 index 0000000..a52eade --- /dev/null +++ b/nicohack202002/uncloud/uncloud_api/migrations/0002_auto_20200222_0719.py @@ -0,0 +1,46 @@ +# Generated by Django 3.0.3 on 2020-02-22 07:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_api', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='VMSnapshotOrder', + fields=[ + ('order_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_api.Order')), + ], + bases=('uncloud_api.order',), + ), + migrations.CreateModel( + name='VMSnapshotProduct', + fields=[ + ('product_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_api.Product')), + ('gb_ssd', models.FloatField()), + ('gb_hdd', models.FloatField()), + ], + bases=('uncloud_api.product',), + ), + migrations.DeleteModel( + name='OrderReference', + ), + migrations.RemoveField( + model_name='product', + name='name', + ), + migrations.RemoveField( + model_name='product', + name='recurring_period', + ), + migrations.AddField( + model_name='product', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('created_active', 'Created'), ('deleted', 'Deleted')], default='pending', max_length=256), + ), + ] diff --git a/nicohack202002/uncloud/uncloud_api/models.py b/nicohack202002/uncloud/uncloud_api/models.py index fafefe6..7eaec7b 100644 --- a/nicohack202002/uncloud/uncloud_api/models.py +++ b/nicohack202002/uncloud/uncloud_api/models.py @@ -31,18 +31,21 @@ from django.contrib.auth import get_user_model # logs # Should have a log = ... => 1:n field for most models! + class Product(models.Model): # override these fields by default description = "" recurring_period = "not_recurring" - - status = models.CharField(max_length=256, - choices = ( - ('pending', 'Pending'), - ('being_created', 'Being created'), - ('created_active', 'Created'), - ('deleted', 'Deleted') - ) + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + status = models.CharField( + max_length=256, choices=( + ('pending', 'Pending'), + ('being_created', 'Being created'), + ('created_active', 'Created'), + ('deleted', 'Deleted') + ), + default='pending' + ) def __str__(self): return "{}".format(self.name) @@ -86,7 +89,6 @@ Sample price for a VM with {} GB SSD and {} GB HDD VM is: {}. gb_hdd = models.FloatField() - class Feature(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) name = models.CharField(max_length=256) diff --git a/notes-abk.md b/notes-abk.md new file mode 100644 index 0000000..6d5c223 --- /dev/null +++ b/notes-abk.md @@ -0,0 +1,11 @@ +## TODO 2020-02-22 + +* ~~move the current rest api to /opennebula~~ +* ~~make the /opennebula api only accessible by an admin account~~ +* ~~create a new filtered api on /vm/list that~~ + * ~~a) requires authentication~~ + * ~~b) only shows the VMs of the current user~~ +* ~~the new api should not contain all details, but: cpus (as read by the vcpu field), ram, ips, disks~~ +* ~~also make a (random) uuid the primary key for VMs - everything in this uncloud hack will use uuids as the id~~ +* ~~still expose the opennebula id as opennebula_id~~ +* ~~note put all secrets/configs into uncloud.secrets - I added a sample file into the repo~~ diff --git a/notes.org b/notes.org deleted file mode 100644 index 72e8ffc..0000000 --- a/notes.org +++ /dev/null @@ -1 +0,0 @@ -* From 26449d31590c28b730b1371b6afe2936d9c7722f Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 09:18:16 +0100 Subject: [PATCH 039/193] ++snapshot ideas --- .../uncloud/uncloud_api/management/commands/snapshot.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py b/nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py index 41d0e38..1a021aa 100644 --- a/nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py +++ b/nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py @@ -16,6 +16,13 @@ class Command(BaseCommand): print("Snapshotting") #getattr(self, options['command'])(**options) + + def get_disks_of_vm(self, vmuuid): + """ Returns the disks used by a VM in the format + ( ceph_name, size ) + """ + pass + @classmethod def monitor(cls, **_): while True: From ce0da4b827ff06238f2e5ace73bf1246024fd868 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 09:44:55 +0100 Subject: [PATCH 040/193] + bracket --- nicohack202002/uncloud/uncloud_api/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nicohack202002/uncloud/uncloud_api/models.py b/nicohack202002/uncloud/uncloud_api/models.py index fafefe6..e4292dc 100644 --- a/nicohack202002/uncloud/uncloud_api/models.py +++ b/nicohack202002/uncloud/uncloud_api/models.py @@ -43,6 +43,7 @@ class Product(models.Model): ('created_active', 'Created'), ('deleted', 'Deleted') ) + ) def __str__(self): return "{}".format(self.name) From 7f821b4d5a374053b66d7616d286edbc2ccb7af2 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 10:31:28 +0100 Subject: [PATCH 041/193] add readme --- nicohack202002/uncloud/README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 nicohack202002/uncloud/README.md diff --git a/nicohack202002/uncloud/README.md b/nicohack202002/uncloud/README.md new file mode 100644 index 0000000..eca82d4 --- /dev/null +++ b/nicohack202002/uncloud/README.md @@ -0,0 +1,24 @@ +## Install + +### OS package requirements + +Alpine: + +``` +apk add openldap-dev +``` + +### 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 +``` From 581865460b16448641e78bb90d950960ece20786 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 11:41:51 +0100 Subject: [PATCH 042/193] Mess with migrations --- .../opennebula/migrations/0001_initial.py | 13 ++++--- .../migrations/0002_auto_20200221_1024.py | 23 ------------ .../migrations/0003_auto_20200221_1113.py | 21 ----------- .../uncloud_api/migrations/0001_initial.py | 35 +++++-------------- .../uncloud_auth/migrations/0001_initial.py | 2 +- 5 files changed, 17 insertions(+), 77 deletions(-) delete mode 100644 nicohack202002/uncloud/opennebula/migrations/0002_auto_20200221_1024.py delete mode 100644 nicohack202002/uncloud/opennebula/migrations/0003_auto_20200221_1113.py diff --git a/nicohack202002/uncloud/opennebula/migrations/0001_initial.py b/nicohack202002/uncloud/opennebula/migrations/0001_initial.py index e2c6a1f..f1d3d6b 100644 --- a/nicohack202002/uncloud/opennebula/migrations/0001_initial.py +++ b/nicohack202002/uncloud/opennebula/migrations/0001_initial.py @@ -1,6 +1,9 @@ -# Generated by Django 3.0.3 on 2020-02-21 10:22 +# Generated by Django 3.0.3 on 2020-02-23 10:02 +from django.conf import settings +import django.contrib.postgres.fields.jsonb from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -8,16 +11,16 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='VM', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('vmid', models.IntegerField()), - ('owner', models.CharField(max_length=128)), - ('data', models.CharField(max_length=65536)), + ('vmid', models.IntegerField(primary_key=True, serialize=False)), + ('data', django.contrib.postgres.fields.jsonb.JSONField()), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), ] diff --git a/nicohack202002/uncloud/opennebula/migrations/0002_auto_20200221_1024.py b/nicohack202002/uncloud/opennebula/migrations/0002_auto_20200221_1024.py deleted file mode 100644 index 43b7442..0000000 --- a/nicohack202002/uncloud/opennebula/migrations/0002_auto_20200221_1024.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-21 10:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('opennebula', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='vm', - name='data', - field=models.CharField(max_length=65536, null=True), - ), - migrations.AlterField( - model_name='vm', - name='owner', - field=models.CharField(max_length=128, null=True), - ), - ] diff --git a/nicohack202002/uncloud/opennebula/migrations/0003_auto_20200221_1113.py b/nicohack202002/uncloud/opennebula/migrations/0003_auto_20200221_1113.py deleted file mode 100644 index 9ccc22e..0000000 --- a/nicohack202002/uncloud/opennebula/migrations/0003_auto_20200221_1113.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-21 11:13 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('opennebula', '0002_auto_20200221_1024'), - ] - - operations = [ - migrations.AlterField( - model_name='vm', - name='owner', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py b/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py index 33be28d..d8d9630 100644 --- a/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py +++ b/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-02-21 10:42 +# Generated by Django 3.0.3 on 2020-02-23 10:16 from django.conf import settings from django.db import migrations, models @@ -16,35 +16,16 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='OrderReference', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ], - ), - migrations.CreateModel( - name='Product', - fields=[ - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('name', models.CharField(max_length=256)), - ('recurring_period', models.CharField(choices=[('per_year', 'Per Year'), ('per_month', 'Per Month'), ('per_week', 'Per Week'), ('per_day', 'Per Day'), ('per_hour', 'Per Hour'), ('not_recurring', 'Not recurring')], default='not_recurring', max_length=256)), - ], - ), - migrations.CreateModel( - name='Order', + name='VMSnapshotProduct', fields=[ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('created_active', 'Created'), ('deleted', 'Deleted')], default='pending', max_length=256)), + ('gb_ssd', models.FloatField()), + ('gb_hdd', models.FloatField()), ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_api.Product')), - ], - ), - migrations.CreateModel( - name='Feature', - fields=[ - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('name', models.CharField(max_length=256)), - ('recurring_price', models.FloatField(default=0)), - ('one_time_price', models.FloatField()), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_api.Product')), ], + options={ + 'abstract': False, + }, ), ] diff --git a/nicohack202002/uncloud/uncloud_auth/migrations/0001_initial.py b/nicohack202002/uncloud/uncloud_auth/migrations/0001_initial.py index 267adf2..a3ade55 100644 --- a/nicohack202002/uncloud/uncloud_auth/migrations/0001_initial.py +++ b/nicohack202002/uncloud/uncloud_auth/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-02-21 10:41 +# Generated by Django 3.0.3 on 2020-02-23 10:02 import django.contrib.auth.models import django.contrib.auth.validators From f588691f0d9bebf08e775334fddeceeb1b695fa7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 11:42:03 +0100 Subject: [PATCH 043/193] [opennebula] add json, add helper functions --- .../opennebula/management/commands/syncvm.py | 8 ++++-- nicohack202002/uncloud/opennebula/models.py | 28 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/nicohack202002/uncloud/opennebula/management/commands/syncvm.py b/nicohack202002/uncloud/opennebula/management/commands/syncvm.py index 205b066..136e145 100644 --- a/nicohack202002/uncloud/opennebula/management/commands/syncvm.py +++ b/nicohack202002/uncloud/opennebula/management/commands/syncvm.py @@ -32,7 +32,11 @@ class Command(BaseCommand): except get_user_model().DoesNotExist: user = get_user_model().objects.create_user(username=vm_owner) - vm_object = VMModel.objects.create(vmid=vm_id, owner=user, data=vm) - vm_object.save() + VMModel.objects.update_or_create( + defaults= { 'data': vm, + 'owner': user }, + vmid=vm_id + ) + else: print(response) diff --git a/nicohack202002/uncloud/opennebula/models.py b/nicohack202002/uncloud/opennebula/models.py index cd1a044..babba26 100644 --- a/nicohack202002/uncloud/opennebula/models.py +++ b/nicohack202002/uncloud/opennebula/models.py @@ -1,8 +1,32 @@ from django.db import models from django.contrib.auth import get_user_model +from django.contrib.postgres.fields import JSONField class VM(models.Model): - vmid = models.IntegerField() + vmid = models.IntegerField(primary_key=True) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) - data = models.CharField(max_length=65536, null=True) + data = JSONField() + + + def cores(self): + return self.data['TEMPLATE']['VCPU'] + + def ram_in_gb(self): + return (int(self.data['TEMPLATE']['MEMORY'])/1024.) + + 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. + """ + + if not 'DISK' in self.data['TEMPLATE']['DISK']: + return [] + elif type(self.data['TEMPLATE']['DISK']) is dict: + return [ self.data['TEMPLATE']['DISK'] ] + else: + return self.data['TEMPLATE']['DISK'] From fc4ec7b0f8438b46b382303b6da6ad9b850613a5 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 11:42:15 +0100 Subject: [PATCH 044/193] update readme + api --- nicohack202002/uncloud/README.md | 25 +++++++++++++++- nicohack202002/uncloud/requirements.txt | 1 + .../uncloud/uncloud/secrets_sample.py | 11 ++----- nicohack202002/uncloud/uncloud/settings.py | 27 ++++++++--------- nicohack202002/uncloud/uncloud_api/admin.py | 4 +-- nicohack202002/uncloud/uncloud_api/models.py | 30 ++++++++++++------- 6 files changed, 61 insertions(+), 37 deletions(-) diff --git a/nicohack202002/uncloud/README.md b/nicohack202002/uncloud/README.md index eca82d4..9db1c5c 100644 --- a/nicohack202002/uncloud/README.md +++ b/nicohack202002/uncloud/README.md @@ -5,7 +5,7 @@ Alpine: ``` -apk add openldap-dev +apk add openldap-dev postgresql-dev ``` ### Python requirements @@ -22,3 +22,26 @@ 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; +``` + + + +### Secrets + +cp `uncloud/secrets_sample.py` to `uncloud/secrets.py` and replace the +sample values with real values. diff --git a/nicohack202002/uncloud/requirements.txt b/nicohack202002/uncloud/requirements.txt index 11ab309..1b4e05b 100644 --- a/nicohack202002/uncloud/requirements.txt +++ b/nicohack202002/uncloud/requirements.txt @@ -3,3 +3,4 @@ djangorestframework django-auth-ldap stripe xmltodict +psycopg2 diff --git a/nicohack202002/uncloud/uncloud/secrets_sample.py b/nicohack202002/uncloud/uncloud/secrets_sample.py index d145124..b578a8b 100644 --- a/nicohack202002/uncloud/uncloud/secrets_sample.py +++ b/nicohack202002/uncloud/uncloud/secrets_sample.py @@ -1,12 +1,3 @@ - - - - - - - - - # Live/test key from stripe STRIPE_KEY="" @@ -15,3 +6,5 @@ OPENNEBULA_URL='https://opennebula.ungleich.ch:2634/RPC2' # user:pass for accessing opennebula OPENNEBULA_USER_PASS='user:password' + +POSTGRESQL_DB_NAME="uncloud" diff --git a/nicohack202002/uncloud/uncloud/settings.py b/nicohack202002/uncloud/uncloud/settings.py index 1e8f358..0e08750 100644 --- a/nicohack202002/uncloud/uncloud/settings.py +++ b/nicohack202002/uncloud/uncloud/settings.py @@ -74,15 +74,6 @@ TEMPLATES = [ WSGI_APPLICATION = 'uncloud.wsgi.application' -# Database -# https://docs.djangoproject.com/en/3.0/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } -} # Password validation @@ -159,8 +150,16 @@ USE_TZ = True STATIC_URL = '/static/' -# Uncommitted file -# import uncloud.secrets -# -# import stripe -# stripe.api_key = uncloud.secrets.STRIPE_KEY +# Uncommitted file with secrets +import uncloud.secrets + + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': uncloud.secrets.POSTGRESQL_DB_NAME, + } +} diff --git a/nicohack202002/uncloud/uncloud_api/admin.py b/nicohack202002/uncloud/uncloud_api/admin.py index f9f5589..d242668 100644 --- a/nicohack202002/uncloud/uncloud_api/admin.py +++ b/nicohack202002/uncloud/uncloud_api/admin.py @@ -2,5 +2,5 @@ from django.contrib import admin from .models import Product, Feature -admin.site.register(Product) -admin.site.register(Feature) +#admin.site.register(Product) +#admin.site.register(Feature) diff --git a/nicohack202002/uncloud/uncloud_api/models.py b/nicohack202002/uncloud/uncloud_api/models.py index e4292dc..11a7560 100644 --- a/nicohack202002/uncloud/uncloud_api/models.py +++ b/nicohack202002/uncloud/uncloud_api/models.py @@ -32,7 +32,12 @@ from django.contrib.auth import get_user_model # Should have a log = ... => 1:n field for most models! class Product(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE) + # override these fields by default + description = "" recurring_period = "not_recurring" @@ -42,9 +47,13 @@ class Product(models.Model): ('being_created', 'Being created'), ('created_active', 'Created'), ('deleted', 'Deleted') - ) + ), + default='pending' ) + class Meta: + abstract = True + def __str__(self): return "{}".format(self.name) @@ -106,19 +115,18 @@ class Feature(models.Model): # value_str # value_float + class Meta: + abstract = True + def __str__(self): return "'{}' - '{}'".format(self.product, self.name) -class Order(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) +# 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) +# owner = models.ForeignKey(get_user_model(), +# on_delete=models.CASCADE) - product = models.ForeignKey(Product, - on_delete=models.CASCADE) - - -class VMSnapshotOrder(Order): - pass +# product = models.ForeignKey(Product, +# on_delete=models.CASCADE) From f8c29aa1d63073b2e05dafbc89d2edcea9acfc09 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 11:55:57 +0100 Subject: [PATCH 045/193] add uuid() to opennebula VM --- .../opennebula/migrations/0002_vm_uuid.py | 19 +++++++++++++++++++ nicohack202002/uncloud/opennebula/models.py | 7 ++++++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 nicohack202002/uncloud/opennebula/migrations/0002_vm_uuid.py diff --git a/nicohack202002/uncloud/opennebula/migrations/0002_vm_uuid.py b/nicohack202002/uncloud/opennebula/migrations/0002_vm_uuid.py new file mode 100644 index 0000000..595fd05 --- /dev/null +++ b/nicohack202002/uncloud/opennebula/migrations/0002_vm_uuid.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-02-23 10:55 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('opennebula', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='vm', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + ] diff --git a/nicohack202002/uncloud/opennebula/models.py b/nicohack202002/uncloud/opennebula/models.py index babba26..0f93b78 100644 --- a/nicohack202002/uncloud/opennebula/models.py +++ b/nicohack202002/uncloud/opennebula/models.py @@ -1,3 +1,4 @@ +import uuid from django.db import models from django.contrib.auth import get_user_model @@ -5,16 +6,20 @@ from django.contrib.postgres.fields import JSONField class VM(models.Model): vmid = models.IntegerField(primary_key=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) data = JSONField() + @property def cores(self): - return self.data['TEMPLATE']['VCPU'] + 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. From 1d1ae6fb3e113583f3926bd4f54306979497429f Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 11:59:09 +0100 Subject: [PATCH 046/193] Force uniqueness on uuid --- .../migrations/0003_auto_20200223_1058.py | 19 +++++++++++++++++++ nicohack202002/uncloud/opennebula/models.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 nicohack202002/uncloud/opennebula/migrations/0003_auto_20200223_1058.py diff --git a/nicohack202002/uncloud/opennebula/migrations/0003_auto_20200223_1058.py b/nicohack202002/uncloud/opennebula/migrations/0003_auto_20200223_1058.py new file mode 100644 index 0000000..d2173da --- /dev/null +++ b/nicohack202002/uncloud/opennebula/migrations/0003_auto_20200223_1058.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-02-23 10:58 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('opennebula', '0002_vm_uuid'), + ] + + operations = [ + migrations.AlterField( + model_name='vm', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ] diff --git a/nicohack202002/uncloud/opennebula/models.py b/nicohack202002/uncloud/opennebula/models.py index 0f93b78..ff0e49c 100644 --- a/nicohack202002/uncloud/opennebula/models.py +++ b/nicohack202002/uncloud/opennebula/models.py @@ -6,7 +6,7 @@ from django.contrib.postgres.fields import JSONField class VM(models.Model): vmid = models.IntegerField(primary_key=True) - uuid = models.UUIDField(default=uuid.uuid4, editable=False) + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) data = JSONField() From 94633d6cc8ec579573632d150294e5ccd3e7ba8b Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 14:07:37 +0100 Subject: [PATCH 047/193] move uncloud a layer up Signed-off-by: Nico Schottelius --- README-penguinpay.md | 42 ---------------- README.md => meow-payv1/README.md | 0 config.py => meow-payv1/config.py | 0 hack-a-vpn.py => meow-payv1/hack-a-vpn.py | 0 helper.py => meow-payv1/helper.py | 0 ldaptest.py => meow-payv1/ldaptest.py | 0 .../products}/ipv6-only-django.json | 0 .../products}/ipv6-only-vm.json | 0 .../products}/ipv6-only-vpn.json | 0 .../products}/ipv6box.json | 0 .../products}/membership.json | 0 .../requirements.txt | 0 sample-pay.conf => meow-payv1/sample-pay.conf | 0 schemas.py => meow-payv1/schemas.py | 0 stripe_hack.py => meow-payv1/stripe_hack.py | 0 stripe_utils.py => meow-payv1/stripe_utils.py | 0 ucloud_pay.py => meow-payv1/ucloud_pay.py | 0 notes-nico.org | 49 +++++++++++++++++++ notes.org | 1 - plan.org | 6 --- .../uncloud => uncloud}/.gitignore | 0 {nicohack202002/uncloud => uncloud}/README.md | 0 {nicohack202002/uncloud => uncloud}/manage.py | 0 .../opennebula/__init__.py | 0 .../uncloud => uncloud}/opennebula/admin.py | 0 .../uncloud => uncloud}/opennebula/apps.py | 0 .../opennebula/management/commands/syncvm.py | 0 .../opennebula/migrations/0001_initial.py | 0 .../opennebula/migrations/0002_vm_uuid.py | 0 .../migrations/0003_auto_20200223_1058.py | 0 .../opennebula/migrations/__init__.py | 0 .../uncloud => uncloud}/opennebula/models.py | 2 +- .../opennebula/serializers.py | 0 .../uncloud => uncloud}/opennebula/tests.py | 0 .../uncloud => uncloud}/opennebula/views.py | 2 + .../uncloud => uncloud}/requirements.txt | 0 .../uncloud => uncloud}/uncloud/.gitignore | 0 .../uncloud => uncloud}/uncloud/__init__.py | 0 .../uncloud => uncloud}/uncloud/asgi.py | 0 .../uncloud/secrets_sample.py | 0 .../uncloud => uncloud}/uncloud/settings.py | 0 .../uncloud => uncloud}/uncloud/stripe.py | 0 .../uncloud => uncloud}/uncloud/urls.py | 0 .../uncloud => uncloud}/uncloud/wsgi.py | 0 .../uncloud_api/__init__.py | 0 .../uncloud => uncloud}/uncloud_api/admin.py | 0 .../uncloud => uncloud}/uncloud_api/apps.py | 0 .../uncloud_api/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../uncloud_api/management/commands/hack.py | 0 .../management/commands/snapshot.py | 0 .../uncloud_api/migrations/0001_initial.py | 0 .../uncloud_api/migrations/__init__.py | 0 .../uncloud => uncloud}/uncloud_api/models.py | 0 .../uncloud_api/serializers.py | 0 .../uncloud => uncloud}/uncloud_api/tests.py | 0 .../uncloud => uncloud}/uncloud_api/views.py | 0 .../uncloud_auth/__init__.py | 0 .../uncloud => uncloud}/uncloud_auth/admin.py | 0 .../uncloud => uncloud}/uncloud_auth/apps.py | 0 .../uncloud_auth/migrations/0001_initial.py | 0 .../uncloud_auth/migrations/__init__.py | 0 .../uncloud_auth/models.py | 0 uncloud/uncloud_vm/__init__.py | 0 uncloud/uncloud_vm/admin.py | 3 ++ uncloud/uncloud_vm/apps.py | 5 ++ uncloud/uncloud_vm/migrations/__init__.py | 0 uncloud/uncloud_vm/models.py | 12 +++++ uncloud/uncloud_vm/tests.py | 3 ++ uncloud/uncloud_vm/views.py | 24 +++++++++ 70 files changed, 99 insertions(+), 50 deletions(-) delete mode 100644 README-penguinpay.md rename README.md => meow-payv1/README.md (100%) rename config.py => meow-payv1/config.py (100%) rename hack-a-vpn.py => meow-payv1/hack-a-vpn.py (100%) rename helper.py => meow-payv1/helper.py (100%) rename ldaptest.py => meow-payv1/ldaptest.py (100%) rename {products => meow-payv1/products}/ipv6-only-django.json (100%) rename {products => meow-payv1/products}/ipv6-only-vm.json (100%) rename {products => meow-payv1/products}/ipv6-only-vpn.json (100%) rename {products => meow-payv1/products}/ipv6box.json (100%) rename {products => meow-payv1/products}/membership.json (100%) rename requirements.txt => meow-payv1/requirements.txt (100%) rename sample-pay.conf => meow-payv1/sample-pay.conf (100%) rename schemas.py => meow-payv1/schemas.py (100%) rename stripe_hack.py => meow-payv1/stripe_hack.py (100%) rename stripe_utils.py => meow-payv1/stripe_utils.py (100%) rename ucloud_pay.py => meow-payv1/ucloud_pay.py (100%) delete mode 100644 notes.org delete mode 100644 plan.org rename {nicohack202002/uncloud => uncloud}/.gitignore (100%) rename {nicohack202002/uncloud => uncloud}/README.md (100%) rename {nicohack202002/uncloud => uncloud}/manage.py (100%) rename {nicohack202002/uncloud => uncloud}/opennebula/__init__.py (100%) rename {nicohack202002/uncloud => uncloud}/opennebula/admin.py (100%) rename {nicohack202002/uncloud => uncloud}/opennebula/apps.py (100%) rename {nicohack202002/uncloud => uncloud}/opennebula/management/commands/syncvm.py (100%) rename {nicohack202002/uncloud => uncloud}/opennebula/migrations/0001_initial.py (100%) rename {nicohack202002/uncloud => uncloud}/opennebula/migrations/0002_vm_uuid.py (100%) rename {nicohack202002/uncloud => uncloud}/opennebula/migrations/0003_auto_20200223_1058.py (100%) rename {nicohack202002/uncloud => uncloud}/opennebula/migrations/__init__.py (100%) rename {nicohack202002/uncloud => uncloud}/opennebula/models.py (98%) rename {nicohack202002/uncloud => uncloud}/opennebula/serializers.py (100%) rename {nicohack202002/uncloud => uncloud}/opennebula/tests.py (100%) rename {nicohack202002/uncloud => uncloud}/opennebula/views.py (95%) rename {nicohack202002/uncloud => uncloud}/requirements.txt (100%) rename {nicohack202002/uncloud => uncloud}/uncloud/.gitignore (100%) rename {nicohack202002/uncloud => uncloud}/uncloud/__init__.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud/asgi.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud/secrets_sample.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud/settings.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud/stripe.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud/urls.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud/wsgi.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/__init__.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/admin.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/apps.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/management/__init__.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/management/commands/__init__.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/management/commands/hack.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/management/commands/snapshot.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/migrations/0001_initial.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/migrations/__init__.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/models.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/serializers.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/tests.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_api/views.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_auth/__init__.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_auth/admin.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_auth/apps.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_auth/migrations/0001_initial.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_auth/migrations/__init__.py (100%) rename {nicohack202002/uncloud => uncloud}/uncloud_auth/models.py (100%) create mode 100644 uncloud/uncloud_vm/__init__.py create mode 100644 uncloud/uncloud_vm/admin.py create mode 100644 uncloud/uncloud_vm/apps.py create mode 100644 uncloud/uncloud_vm/migrations/__init__.py create mode 100644 uncloud/uncloud_vm/models.py create mode 100644 uncloud/uncloud_vm/tests.py create mode 100644 uncloud/uncloud_vm/views.py diff --git a/README-penguinpay.md b/README-penguinpay.md deleted file mode 100644 index 3229bc5..0000000 --- a/README-penguinpay.md +++ /dev/null @@ -1,42 +0,0 @@ -## How to place a order with penguin pay - -### Requirements - -* An ungleich account - can be registered for free on - https://account.ungleich.ch -* httpie installed (provides the http command) - -## Get a membership - - -## Registering a payment method - -To be able to pay for the membership, you will need to register a -credit card or apply for payment on bill (TO BE IMPLEMENTED). - -### Register credit card - -``` -http POST https://api.ungleich.ch/membership \ - username=nico password=yourpassword \ - cc_number=.. \ - cc_ - -``` - - - -### Request payment via bill - - - - -## Create the membership - - -``` -http POST https://api.ungleich.ch/membership username=nico password=yourpassword - -``` - -## List available products diff --git a/README.md b/meow-payv1/README.md similarity index 100% rename from README.md rename to meow-payv1/README.md diff --git a/config.py b/meow-payv1/config.py similarity index 100% rename from config.py rename to meow-payv1/config.py diff --git a/hack-a-vpn.py b/meow-payv1/hack-a-vpn.py similarity index 100% rename from hack-a-vpn.py rename to meow-payv1/hack-a-vpn.py diff --git a/helper.py b/meow-payv1/helper.py similarity index 100% rename from helper.py rename to meow-payv1/helper.py diff --git a/ldaptest.py b/meow-payv1/ldaptest.py similarity index 100% rename from ldaptest.py rename to meow-payv1/ldaptest.py diff --git a/products/ipv6-only-django.json b/meow-payv1/products/ipv6-only-django.json similarity index 100% rename from products/ipv6-only-django.json rename to meow-payv1/products/ipv6-only-django.json diff --git a/products/ipv6-only-vm.json b/meow-payv1/products/ipv6-only-vm.json similarity index 100% rename from products/ipv6-only-vm.json rename to meow-payv1/products/ipv6-only-vm.json diff --git a/products/ipv6-only-vpn.json b/meow-payv1/products/ipv6-only-vpn.json similarity index 100% rename from products/ipv6-only-vpn.json rename to meow-payv1/products/ipv6-only-vpn.json diff --git a/products/ipv6box.json b/meow-payv1/products/ipv6box.json similarity index 100% rename from products/ipv6box.json rename to meow-payv1/products/ipv6box.json diff --git a/products/membership.json b/meow-payv1/products/membership.json similarity index 100% rename from products/membership.json rename to meow-payv1/products/membership.json diff --git a/requirements.txt b/meow-payv1/requirements.txt similarity index 100% rename from requirements.txt rename to meow-payv1/requirements.txt diff --git a/sample-pay.conf b/meow-payv1/sample-pay.conf similarity index 100% rename from sample-pay.conf rename to meow-payv1/sample-pay.conf diff --git a/schemas.py b/meow-payv1/schemas.py similarity index 100% rename from schemas.py rename to meow-payv1/schemas.py diff --git a/stripe_hack.py b/meow-payv1/stripe_hack.py similarity index 100% rename from stripe_hack.py rename to meow-payv1/stripe_hack.py diff --git a/stripe_utils.py b/meow-payv1/stripe_utils.py similarity index 100% rename from stripe_utils.py rename to meow-payv1/stripe_utils.py diff --git a/ucloud_pay.py b/meow-payv1/ucloud_pay.py similarity index 100% rename from ucloud_pay.py rename to meow-payv1/ucloud_pay.py diff --git a/notes-nico.org b/notes-nico.org index 93e0c00..03c1b97 100644 --- a/notes-nico.org +++ b/notes-nico.org @@ -49,3 +49,52 @@ password=... * Django rest framework ** viewset: .list and .create ** view: .get .post +* TODO register CC +* TODO list products +* ahmed +** schemas +*** field: is_valid? - used by schemas +*** definition of a "schema" +* penguin pay +## How to place a order with penguin pay + +### Requirements + +* An ungleich account - can be registered for free on + https://account.ungleich.ch +* httpie installed (provides the http command) + +## Get a membership + + +## Registering a payment method + +To be able to pay for the membership, you will need to register a +credit card or apply for payment on bill (TO BE IMPLEMENTED). + +### Register credit card + +``` +http POST https://api.ungleich.ch/membership \ + username=nico password=yourpassword \ + cc_number=.. \ + cc_ + +``` + + + +### Request payment via bill + + + + +## Create the membership + + +``` +http POST https://api.ungleich.ch/membership username=nico password=yourpassword + +``` + +## List available products diff --git a/notes.org b/notes.org deleted file mode 100644 index 72e8ffc..0000000 --- a/notes.org +++ /dev/null @@ -1 +0,0 @@ -* diff --git a/plan.org b/plan.org deleted file mode 100644 index 9f172c2..0000000 --- a/plan.org +++ /dev/null @@ -1,6 +0,0 @@ -* TODO register CC -* TODO list products -* ahmed -** schemas -*** field: is_valid? - used by schemas -*** definition of a "schema" diff --git a/nicohack202002/uncloud/.gitignore b/uncloud/.gitignore similarity index 100% rename from nicohack202002/uncloud/.gitignore rename to uncloud/.gitignore diff --git a/nicohack202002/uncloud/README.md b/uncloud/README.md similarity index 100% rename from nicohack202002/uncloud/README.md rename to uncloud/README.md diff --git a/nicohack202002/uncloud/manage.py b/uncloud/manage.py similarity index 100% rename from nicohack202002/uncloud/manage.py rename to uncloud/manage.py diff --git a/nicohack202002/uncloud/opennebula/__init__.py b/uncloud/opennebula/__init__.py similarity index 100% rename from nicohack202002/uncloud/opennebula/__init__.py rename to uncloud/opennebula/__init__.py diff --git a/nicohack202002/uncloud/opennebula/admin.py b/uncloud/opennebula/admin.py similarity index 100% rename from nicohack202002/uncloud/opennebula/admin.py rename to uncloud/opennebula/admin.py diff --git a/nicohack202002/uncloud/opennebula/apps.py b/uncloud/opennebula/apps.py similarity index 100% rename from nicohack202002/uncloud/opennebula/apps.py rename to uncloud/opennebula/apps.py diff --git a/nicohack202002/uncloud/opennebula/management/commands/syncvm.py b/uncloud/opennebula/management/commands/syncvm.py similarity index 100% rename from nicohack202002/uncloud/opennebula/management/commands/syncvm.py rename to uncloud/opennebula/management/commands/syncvm.py diff --git a/nicohack202002/uncloud/opennebula/migrations/0001_initial.py b/uncloud/opennebula/migrations/0001_initial.py similarity index 100% rename from nicohack202002/uncloud/opennebula/migrations/0001_initial.py rename to uncloud/opennebula/migrations/0001_initial.py diff --git a/nicohack202002/uncloud/opennebula/migrations/0002_vm_uuid.py b/uncloud/opennebula/migrations/0002_vm_uuid.py similarity index 100% rename from nicohack202002/uncloud/opennebula/migrations/0002_vm_uuid.py rename to uncloud/opennebula/migrations/0002_vm_uuid.py diff --git a/nicohack202002/uncloud/opennebula/migrations/0003_auto_20200223_1058.py b/uncloud/opennebula/migrations/0003_auto_20200223_1058.py similarity index 100% rename from nicohack202002/uncloud/opennebula/migrations/0003_auto_20200223_1058.py rename to uncloud/opennebula/migrations/0003_auto_20200223_1058.py diff --git a/nicohack202002/uncloud/opennebula/migrations/__init__.py b/uncloud/opennebula/migrations/__init__.py similarity index 100% rename from nicohack202002/uncloud/opennebula/migrations/__init__.py rename to uncloud/opennebula/migrations/__init__.py diff --git a/nicohack202002/uncloud/opennebula/models.py b/uncloud/opennebula/models.py similarity index 98% rename from nicohack202002/uncloud/opennebula/models.py rename to uncloud/opennebula/models.py index ff0e49c..6dbc576 100644 --- a/nicohack202002/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -15,7 +15,7 @@ class VM(models.Model): def cores(self): return int(self.data['TEMPLATE']['VCPU']) - @property + @propertyx def ram_in_gb(self): return (int(self.data['TEMPLATE']['MEMORY'])/1024.) diff --git a/nicohack202002/uncloud/opennebula/serializers.py b/uncloud/opennebula/serializers.py similarity index 100% rename from nicohack202002/uncloud/opennebula/serializers.py rename to uncloud/opennebula/serializers.py diff --git a/nicohack202002/uncloud/opennebula/tests.py b/uncloud/opennebula/tests.py similarity index 100% rename from nicohack202002/uncloud/opennebula/tests.py rename to uncloud/opennebula/tests.py diff --git a/nicohack202002/uncloud/opennebula/views.py b/uncloud/opennebula/views.py similarity index 95% rename from nicohack202002/uncloud/opennebula/views.py rename to uncloud/opennebula/views.py index f706815..7f2b537 100644 --- a/nicohack202002/uncloud/opennebula/views.py +++ b/uncloud/opennebula/views.py @@ -12,3 +12,5 @@ class VMDetail(generics.RetrieveAPIView): lookup_field = 'vmid' queryset = VM.objects.all() serializer_class = VMSerializer + +class VMViewSet( diff --git a/nicohack202002/uncloud/requirements.txt b/uncloud/requirements.txt similarity index 100% rename from nicohack202002/uncloud/requirements.txt rename to uncloud/requirements.txt diff --git a/nicohack202002/uncloud/uncloud/.gitignore b/uncloud/uncloud/.gitignore similarity index 100% rename from nicohack202002/uncloud/uncloud/.gitignore rename to uncloud/uncloud/.gitignore diff --git a/nicohack202002/uncloud/uncloud/__init__.py b/uncloud/uncloud/__init__.py similarity index 100% rename from nicohack202002/uncloud/uncloud/__init__.py rename to uncloud/uncloud/__init__.py diff --git a/nicohack202002/uncloud/uncloud/asgi.py b/uncloud/uncloud/asgi.py similarity index 100% rename from nicohack202002/uncloud/uncloud/asgi.py rename to uncloud/uncloud/asgi.py diff --git a/nicohack202002/uncloud/uncloud/secrets_sample.py b/uncloud/uncloud/secrets_sample.py similarity index 100% rename from nicohack202002/uncloud/uncloud/secrets_sample.py rename to uncloud/uncloud/secrets_sample.py diff --git a/nicohack202002/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py similarity index 100% rename from nicohack202002/uncloud/uncloud/settings.py rename to uncloud/uncloud/settings.py diff --git a/nicohack202002/uncloud/uncloud/stripe.py b/uncloud/uncloud/stripe.py similarity index 100% rename from nicohack202002/uncloud/uncloud/stripe.py rename to uncloud/uncloud/stripe.py diff --git a/nicohack202002/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py similarity index 100% rename from nicohack202002/uncloud/uncloud/urls.py rename to uncloud/uncloud/urls.py diff --git a/nicohack202002/uncloud/uncloud/wsgi.py b/uncloud/uncloud/wsgi.py similarity index 100% rename from nicohack202002/uncloud/uncloud/wsgi.py rename to uncloud/uncloud/wsgi.py diff --git a/nicohack202002/uncloud/uncloud_api/__init__.py b/uncloud/uncloud_api/__init__.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/__init__.py rename to uncloud/uncloud_api/__init__.py diff --git a/nicohack202002/uncloud/uncloud_api/admin.py b/uncloud/uncloud_api/admin.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/admin.py rename to uncloud/uncloud_api/admin.py diff --git a/nicohack202002/uncloud/uncloud_api/apps.py b/uncloud/uncloud_api/apps.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/apps.py rename to uncloud/uncloud_api/apps.py diff --git a/nicohack202002/uncloud/uncloud_api/management/__init__.py b/uncloud/uncloud_api/management/__init__.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/management/__init__.py rename to uncloud/uncloud_api/management/__init__.py diff --git a/nicohack202002/uncloud/uncloud_api/management/commands/__init__.py b/uncloud/uncloud_api/management/commands/__init__.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/management/commands/__init__.py rename to uncloud/uncloud_api/management/commands/__init__.py diff --git a/nicohack202002/uncloud/uncloud_api/management/commands/hack.py b/uncloud/uncloud_api/management/commands/hack.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/management/commands/hack.py rename to uncloud/uncloud_api/management/commands/hack.py diff --git a/nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py b/uncloud/uncloud_api/management/commands/snapshot.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/management/commands/snapshot.py rename to uncloud/uncloud_api/management/commands/snapshot.py diff --git a/nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py b/uncloud/uncloud_api/migrations/0001_initial.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/migrations/0001_initial.py rename to uncloud/uncloud_api/migrations/0001_initial.py diff --git a/nicohack202002/uncloud/uncloud_api/migrations/__init__.py b/uncloud/uncloud_api/migrations/__init__.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/migrations/__init__.py rename to uncloud/uncloud_api/migrations/__init__.py diff --git a/nicohack202002/uncloud/uncloud_api/models.py b/uncloud/uncloud_api/models.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/models.py rename to uncloud/uncloud_api/models.py diff --git a/nicohack202002/uncloud/uncloud_api/serializers.py b/uncloud/uncloud_api/serializers.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/serializers.py rename to uncloud/uncloud_api/serializers.py diff --git a/nicohack202002/uncloud/uncloud_api/tests.py b/uncloud/uncloud_api/tests.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/tests.py rename to uncloud/uncloud_api/tests.py diff --git a/nicohack202002/uncloud/uncloud_api/views.py b/uncloud/uncloud_api/views.py similarity index 100% rename from nicohack202002/uncloud/uncloud_api/views.py rename to uncloud/uncloud_api/views.py diff --git a/nicohack202002/uncloud/uncloud_auth/__init__.py b/uncloud/uncloud_auth/__init__.py similarity index 100% rename from nicohack202002/uncloud/uncloud_auth/__init__.py rename to uncloud/uncloud_auth/__init__.py diff --git a/nicohack202002/uncloud/uncloud_auth/admin.py b/uncloud/uncloud_auth/admin.py similarity index 100% rename from nicohack202002/uncloud/uncloud_auth/admin.py rename to uncloud/uncloud_auth/admin.py diff --git a/nicohack202002/uncloud/uncloud_auth/apps.py b/uncloud/uncloud_auth/apps.py similarity index 100% rename from nicohack202002/uncloud/uncloud_auth/apps.py rename to uncloud/uncloud_auth/apps.py diff --git a/nicohack202002/uncloud/uncloud_auth/migrations/0001_initial.py b/uncloud/uncloud_auth/migrations/0001_initial.py similarity index 100% rename from nicohack202002/uncloud/uncloud_auth/migrations/0001_initial.py rename to uncloud/uncloud_auth/migrations/0001_initial.py diff --git a/nicohack202002/uncloud/uncloud_auth/migrations/__init__.py b/uncloud/uncloud_auth/migrations/__init__.py similarity index 100% rename from nicohack202002/uncloud/uncloud_auth/migrations/__init__.py rename to uncloud/uncloud_auth/migrations/__init__.py diff --git a/nicohack202002/uncloud/uncloud_auth/models.py b/uncloud/uncloud_auth/models.py similarity index 100% rename from nicohack202002/uncloud/uncloud_auth/models.py rename to uncloud/uncloud_auth/models.py diff --git a/uncloud/uncloud_vm/__init__.py b/uncloud/uncloud_vm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/uncloud_vm/admin.py b/uncloud/uncloud_vm/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/uncloud/uncloud_vm/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/uncloud/uncloud_vm/apps.py b/uncloud/uncloud_vm/apps.py new file mode 100644 index 0000000..c5e94a5 --- /dev/null +++ b/uncloud/uncloud_vm/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UncloudVmConfig(AppConfig): + name = 'uncloud_vm' diff --git a/uncloud/uncloud_vm/migrations/__init__.py b/uncloud/uncloud_vm/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py new file mode 100644 index 0000000..b1aab40 --- /dev/null +++ b/uncloud/uncloud_vm/models.py @@ -0,0 +1,12 @@ +from django.db import models + + +class VM(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + + cores = models.IntegerField() + ram = models.FloatField() + + +class VMDisk(models.Model): diff --git a/uncloud/uncloud_vm/tests.py b/uncloud/uncloud_vm/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/uncloud/uncloud_vm/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py new file mode 100644 index 0000000..aa5855c --- /dev/null +++ b/uncloud/uncloud_vm/views.py @@ -0,0 +1,24 @@ +from django.shortcuts import render + + +from django.contrib.auth.models import User +from django.shortcuts import get_object_or_404 +from myapps.serializers import UserSerializer +from rest_framework import viewsets +from rest_framework.response import Response + +from opennebula.models import VM as OpenNebulaVM + +class VMViewSet(viewsets.ViewSet): + def list(self, request): + queryset = User.objects.all() + serializer = UserSerializer(queryset, many=True) + return Response(serializer.data) + + def retrieve(self, request, pk=None): + queryset = User.objects.all() + user = get_object_or_404(queryset, pk=pk) + serializer = UserSerializer(user) + return Response(serializer.data) + + permission_classes = [permissions.IsAuthenticated] From cee45b5227c6c0067c633e3ee8075f61a67ec59a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 15:09:58 +0100 Subject: [PATCH 048/193] -typo --- uncloud/opennebula/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/opennebula/models.py b/uncloud/opennebula/models.py index 6dbc576..ff0e49c 100644 --- a/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -15,7 +15,7 @@ class VM(models.Model): def cores(self): return int(self.data['TEMPLATE']['VCPU']) - @propertyx + @property def ram_in_gb(self): return (int(self.data['TEMPLATE']['MEMORY'])/1024.) From 7b09f0a373a8b190c3f6b2e825ccca4c8748ca74 Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 23 Feb 2020 19:18:51 +0500 Subject: [PATCH 049/193] abk-hacks added --- abk-hacks.py | 55 +++++++++ vat_rates.csv | 325 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 abk-hacks.py create mode 100644 vat_rates.csv diff --git a/abk-hacks.py b/abk-hacks.py new file mode 100644 index 0000000..abc63d3 --- /dev/null +++ b/abk-hacks.py @@ -0,0 +1,55 @@ +""" +investigate into a simple python function that maps an ldap user to a vat percentage. Basically you need to +lookup the customer address, check if she is a business/registered tax number and if not apply the local +vat +""" + +import iso3166 +import datetime + +from csv import DictReader + + +def get_vat(street_address, city, postal_code, country, vat_number=None): + vat = { + 'Austria': [ + {'period': '1984-01-01/', 'rate': 0.2}, + {'period': '1976-01-01/1984-01-01', 'rate': 0.18}, + {'period': '1973-01-01/1976-01-01', 'rate': 0.16}, + ] + } + return iso3166.countries.get(country) + + # return iso3166.countries_by_name[country] + + +def main(): + # vat = get_vat( + # street_address='82 Nasheman-e-Iqbal near Wapda Town', + # city='Lahore', + # postal_code=53700, + # country='Pakistan', + # ) + # print(vat) + vat_rates = {} + with open('vat_rates.csv', newline='') as csvfile: + reader = DictReader(csvfile) + for row in reader: + territory_codes = row['territory_codes'].split('\n') + for code in territory_codes: + if code not in vat_rates: + vat_rates[code] = {} + + start_date = row['start_date'] + stop_data = row['stop_date'] + time_period = f'{start_date}|{stop_data}' + r = row.copy() + del r['start_date'] + del r['stop_date'] + del r['territory_codes'] + vat_rates[code][time_period] = r + print(vat_rates) + + +if __name__ == '__main__': + main() diff --git a/vat_rates.csv b/vat_rates.csv new file mode 100644 index 0000000..17bdb99 --- /dev/null +++ b/vat_rates.csv @@ -0,0 +1,325 @@ +start_date,stop_date,territory_codes,currency_code,rate,rate_type,description +2011-01-04,,AI,XCD,0,standard,Anguilla (British overseas territory) is exempted of VAT. +1984-01-01,,AT,EUR,0.2,standard,Austria (member state) standard VAT rate. +1976-01-01,1984-01-01,AT,EUR,0.18,standard, +1973-01-01,1976-01-01,AT,EUR,0.16,standard, +1984-01-01,,"AT-6691 +DE-87491",EUR,0.19,standard,Jungholz (Austrian town) special VAT rate. +1984-01-01,,"AT-6991 +AT-6992 +AT-6993 +DE-87567 +DE-87568 +DE-87569",EUR,0.19,standard,Mittelberg (Austrian town) special VAT rate. +1996-01-01,,BE,EUR,0.21,standard,Belgium (member state) standard VAT rate. +1994-01-01,1996-01-01,BE,EUR,0.205,standard, +1992-04-01,1994-01-01,BE,EUR,0.195,standard, +1983-01-01,1992-04-01,BE,EUR,0.19,standard, +1981-07-01,1983-01-01,BE,EUR,0.17,standard, +1978-07-01,1981-07-01,BE,EUR,0.16,standard, +1971-07-01,1978-07-01,BE,EUR,0.18,standard, +1999-01-01,,BG,BGN,0.2,standard,Bulgaria (member state) standard VAT rate. +1996-07-01,1999-01-01,BG,BGN,0.22,standard, +1994-04-01,1996-07-01,BG,BGN,0.18,standard, +2011-01-04,,BM,BMD,0,standard,Bermuda (British overseas territory) is exempted of VAT. +2014-01-13,,"CY +GB-BFPO 57 +GB-BFPO 58 +GB-BFPO 59 +UK-BFPO 57 +UK-BFPO 58 +UK-BFPO 59",EUR,0.19,standard,"Cyprus (member state) standard VAT rate. +Akrotiri and Dhekelia (British overseas territory) is subjected to Cyprus' standard VAT rate." +2013-01-14,2014-01-13,CY,EUR,0.18,standard, +2012-03-01,2013-01-14,CY,EUR,0.17,standard, +2003-01-01,2012-03-01,CY,EUR,0.15,standard, +2002-07-01,2003-01-01,CY,EUR,0.13,standard, +2000-07-01,2002-07-01,CY,EUR,0.1,standard, +1993-10-01,2000-07-01,CY,EUR,0.08,standard, +1992-07-01,1993-10-01,CY,EUR,0.05,standard, +2013-01-01,,CZ,CZK,0.21,standard,Czech Republic (member state) standard VAT rate. +2010-01-01,2013-01-01,CZ,CZK,0.2,standard, +2004-05-01,2010-01-01,CZ,CZK,0.19,standard, +1995-01-01,2004-05-01,CZ,CZK,0.22,standard, +1993-01-01,1995-01-01,CZ,CZK,0.23,standard, +2007-01-01,,DE,EUR,0.19,standard,Germany (member state) standard VAT rate. +1998-04-01,2007-01-01,DE,EUR,0.16,standard, +1993-01-01,1998-04-01,DE,EUR,0.15,standard, +1983-07-01,1993-01-01,DE,EUR,0.14,standard, +1979-07-01,1983-07-01,DE,EUR,0.13,standard, +1978-01-01,1979-07-01,DE,EUR,0.12,standard, +1968-07-01,1978-01-01,DE,EUR,0.11,standard, +1968-01-01,1968-07-01,DE,EUR,0.1,standard, +2007-01-01,,DE-27498,EUR,0,standard,Heligoland (German island) is exempted of VAT. +2007-01-01,,"DE-78266 +CH-8238",EUR,0,standard,Busingen am Hochrhein (German territory) is exempted of VAT. +1992-01-01,,DK,DKK,0.25,standard,Denmark (member state) standard VAT rate. +1980-06-30,1992-01-01,DK,DKK,0.22,standard, +1978-10-30,1980-06-30,DK,DKK,0.2025,standard, +1977-10-03,1978-10-30,DK,DKK,0.18,standard, +1970-06-29,1977-10-03,DK,DKK,0.15,standard, +1968-04-01,1970-06-29,DK,DKK,0.125,standard, +1967-07-03,1968-04-01,DK,DKK,0.1,standard, +2009-07-01,,EE,EUR,0.2,standard,Estonia (member state) standard VAT rate. +1993-01-01,2009-07-01,EE,EUR,0.18,standard, +1991-01-01,1993-01-01,EE,EUR,0.1,standard, +2016-06-01,,"GR +EL",EUR,0.24,standard,Greece (member state) standard VAT rate. +2010-07-01,2016-06-01,"GR +EL",EUR,0.23,standard, +2010-03-15,2010-07-01,"GR +EL",EUR,0.21,standard, +2005-04-01,2010-03-15,"GR +EL",EUR,0.19,standard, +1990-04-28,2005-04-01,"GR +EL",EUR,0.18,standard, +1988-01-01,1990-04-28,"GR +EL",EUR,0.16,standard, +1987-01-01,1988-01-01,"GR +EL",EUR,0.18,standard, +2012-09-01,,ES,EUR,0.21,standard,Spain (member state) standard VAT rate. +2010-07-01,2012-09-01,ES,EUR,0.18,standard, +1995-01-01,2010-07-01,ES,EUR,0.16,standard, +1992-08-01,1995-01-01,ES,EUR,0.15,standard, +1992-01-01,1992-08-01,ES,EUR,0.13,standard, +1986-01-01,1992-01-01,ES,EUR,0.12,standard, +2012-09-01,,"ES-CN +ES-GC +ES-TF +IC",EUR,0,standard,Canary Islands (Spanish autonomous community) is exempted of VAT. +2012-09-01,,"ES-ML +ES-CE +EA",EUR,0,standard,Ceuta and Melilla (Spanish autonomous cities) is exempted of VAT. +2013-01-01,,FI,EUR,0.24,standard,Finland (member state) standard VAT rate. +2010-07-01,2013-01-01,FI,EUR,0.23,standard, +1994-06-01,2010-07-01,FI,EUR,0.22,standard, +2013-01-01,,"FI-01 +AX",EUR,0,standard,Aland Islands (Finish autonomous region) is exempted of VAT. +2011-01-04,,FK,FKP,0,standard,Falkland Islands (British overseas territory) is exempted of VAT. +1992-01-01,,FO,DKK,0,standard,Faroe Islands (Danish autonomous country) is exempted of VAT. +2014-01-01,,"FR +MC",EUR,0.2,standard,"France (member state) standard VAT rate. +Monaco (sovereign city-state) is member of the EU VAT area and subjected to France's standard VAT rate." +2000-04-01,2014-01-01,"FR +MC",EUR,0.196,standard, +1995-08-01,2000-04-01,"FR +MC",EUR,0.206,standard, +1982-07-01,1995-08-01,"FR +MC",EUR,0.186,standard, +1977-01-01,1982-07-01,"FR +MC",EUR,0.176,standard, +1973-01-01,1977-01-01,"FR +MC",EUR,0.2,standard, +1970-01-01,1973-01-01,"FR +MC",EUR,0.23,standard, +1968-12-01,1970-01-01,"FR +MC",EUR,0.19,standard, +1968-01-01,1968-12-01,"FR +MC",EUR,0.1666,standard, +2014-01-01,,"FR-BL +BL",EUR,0,standard,Saint Barthelemy (French overseas collectivity) is exempted of VAT. +2014-01-01,,"FR-GF +GF",EUR,0,standard,Guiana (French overseas department) is exempted of VAT. +2014-01-01,,"FR-GP +GP",EUR,0.085,standard,Guadeloupe (French overseas department) special VAT rate. +2014-01-01,,"FR-MF +MF",EUR,0,standard,Saint Martin (French overseas collectivity) is subjected to France's standard VAT rate. +2014-01-01,,"FR-MQ +MQ",EUR,0.085,standard,Martinique (French overseas department) special VAT rate. +2014-01-01,,"FR-NC +NC",XPF,0,standard,New Caledonia (French special collectivity) is exempted of VAT. +2014-01-01,,"FR-PF +PF",XPF,0,standard,French Polynesia (French overseas collectivity) is exempted of VAT. +2014-01-01,,"FR-PM +PM",EUR,0,standard,Saint Pierre and Miquelon (French overseas collectivity) is exempted of VAT. +2014-01-01,,"FR-RE +RE",EUR,0.085,standard,Reunion (French overseas department) special VAT rate. +2014-01-01,,"FR-TF +TF",EUR,0,standard,French Southern and Antarctic Lands (French overseas territory) is exempted of VAT. +2014-01-01,,"FR-WF +WF",XPF,0,standard,Wallis and Futuna (French overseas collectivity) is exempted of VAT. +2014-01-01,,"FR-YT +YT",EUR,0,standard,Mayotte (French overseas department) is exempted of VAT. +2011-01-04,,GG,GBP,0,standard,Guernsey (British Crown dependency) is exempted of VAT. +2011-01-04,,GI,GIP,0,standard,Gibraltar (British overseas territory) is exempted of VAT. +1992-01-01,,GL,DKK,0,standard,Greenland (Danish autonomous country) is exempted of VAT. +2010-07-01,2016-06-01,"GR-34007 +EL-34007",EUR,0.16,standard,Skyros (Greek island) special VAT rate. +2010-07-01,2016-06-01,"GR-37002 +GR-37003 +GR-37005 +EL-37002 +EL-37003 +EL-37005",EUR,0.16,standard,Northern Sporades (Greek islands) special VAT rate. +2010-07-01,2016-06-01,"GR-64004 +EL-64004",EUR,0.16,standard,Thasos (Greek island) special VAT rate. +2010-07-01,2016-06-01,"GR-68002 +EL-68002",EUR,0.16,standard,Samothrace (Greek island) special VAT rate. +2010-07-01,,"GR-69 +EL-69",EUR,0,standard,Mount Athos (Greek self-governed part) is exempted of VAT. +2010-07-01,2016-06-01,"GR-81 +EL-81",EUR,0.16,standard,Dodecanese (Greek department) special VAT rate. +2010-07-01,2016-06-01,"GR-82 +EL-82",EUR,0.16,standard,Cyclades (Greek department) special VAT rate. +2010-07-01,2016-06-01,"GR-83 +EL-83",EUR,0.16,standard,Lesbos (Greek department) special VAT rate. +2010-07-01,2016-06-01,"GR-84 +EL-84",EUR,0.16,standard,Samos (Greek department) special VAT rate. +2010-07-01,2016-06-01,"GR-85 +EL-85",EUR,0.16,standard,Chios (Greek department) special VAT rate. +2011-01-04,,GS,GBP,0,standard,South Georgia and the South Sandwich Islands (British overseas territory) is exempted of VAT. +2012-03-01,,HR,HRK,0.25,standard,Croatia (member state) standard VAT rate. +2009-08-01,2012-03-01,HR,HRK,0.23,standard, +1998-08-01,2009-08-01,HR,HRK,0.22,standard, +2012-01-01,,HU,HUF,0.27,standard,Hungary (member state) standard VAT rate. +2009-07-01,2012-01-01,HU,HUF,0.25,standard, +2006-01-01,2009-07-01,HU,HUF,0.2,standard, +1988-01-01,2006-01-01,HU,HUF,0.25,standard, +2012-01-01,,IE,EUR,0.23,standard,Republic of Ireland (member state) standard VAT rate. +2010-01-01,2012-01-01,IE,EUR,0.21,standard, +2008-12-01,2010-01-01,IE,EUR,0.215,standard, +2002-03-01,2008-12-01,IE,EUR,0.21,standard, +2001-01-01,2002-03-01,IE,EUR,0.2,standard, +1991-03-01,2001-01-01,IE,EUR,0.21,standard, +1990-03-01,1991-03-01,IE,EUR,0.23,standard, +1986-03-01,1990-03-01,IE,EUR,0.25,standard, +1983-05-01,1986-03-01,IE,EUR,0.23,standard, +1983-03-01,1983-05-01,IE,EUR,0.35,standard, +1982-05-01,1983-03-01,IE,EUR,0.3,standard, +1980-05-01,1982-05-01,IE,EUR,0.25,standard, +1976-03-01,1980-05-01,IE,EUR,0.2,standard, +1973-09-03,1976-03-01,IE,EUR,0.195,standard, +1972-11-01,1973-09-03,IE,EUR,0.1637,standard, +2011-01-04,,IO,GBP,0,standard,British Indian Ocean Territory (British overseas territory) is exempted of VAT. +2013-10-01,,IT,EUR,0.22,standard,Italy (member state) standard VAT rate. +2011-09-17,2013-10-01,IT,EUR,0.21,standard, +1997-10-01,2011-09-17,IT,EUR,0.2,standard, +1988-08-01,1997-10-01,IT,EUR,0.19,standard, +1982-08-05,1988-08-01,IT,EUR,0.18,standard, +1981-01-01,1982-08-05,IT,EUR,0.15,standard, +1980-11-01,1981-01-01,IT,EUR,0.14,standard, +1980-07-03,1980-11-01,IT,EUR,0.15,standard, +1977-02-08,1980-07-03,IT,EUR,0.14,standard, +1973-01-01,1977-02-08,IT,EUR,0.12,standard, +2013-10-01,,"IT-22060 +CH-6911",CHF,0,standard,Campione (Italian town) is exempted of VAT. +2013-10-01,,IT-23030,EUR,0,standard,Livigno (Italian town) is exempted of VAT. +2011-01-04,,JE,GBP,0,standard,Jersey (British Crown dependency) is exempted of VAT. +2011-01-04,,KY,KYD,0,standard,Cayman Islands (British overseas territory) is exempted of VAT. +2009-09-01,,LT,EUR,0.21,standard,Lithuania (member state) standard VAT rate. +2009-01-01,2009-09-01,LT,EUR,0.19,standard, +1994-05-01,2009-01-01,LT,EUR,0.18,standard, +2015-01-01,,LU,EUR,0.17,standard,Luxembourg (member state) standard VAT rate. +1992-01-01,2015-01-01,LU,EUR,0.15,standard, +1983-07-01,1992-01-01,LU,EUR,0.12,standard, +1971-01-01,1983-07-01,LU,EUR,0.1,standard, +1970-01-01,1971-01-01,LU,EUR,0.8,standard, +2012-07-01,,LV,EUR,0.21,standard,Latvia (member state) standard VAT rate. +2011-01-01,2012-07-01,LV,EUR,0.22,standard, +2009-01-01,2011-01-01,LV,EUR,0.21,standard, +1995-05-01,2009-01-01,LV,EUR,0.18,standard, +2011-01-04,,MS,XCD,0,standard,Montserrat (British overseas territory) is exempted of VAT. +2004-01-01,,MT,EUR,0.18,standard,Malta (member state) standard VAT rate. +1995-01-01,2004-01-01,MT,EUR,0.15,standard, +2012-10-01,,NL,EUR,0.21,standard,Netherlands (member state) standard VAT rate. +2001-01-01,2012-10-01,NL,EUR,0.19,standard, +1992-10-01,2001-01-01,NL,EUR,0.175,standard, +1989-01-01,1992-10-01,NL,EUR,0.185,standard, +1986-10-01,1989-01-01,NL,EUR,0.2,standard, +1984-01-01,1986-10-01,NL,EUR,0.19,standard, +1976-01-01,1984-01-01,NL,EUR,0.18,standard, +1973-01-01,1976-01-01,NL,EUR,0.16,standard, +1971-01-01,1973-01-01,NL,EUR,0.14,standard, +1969-01-01,1971-01-01,NL,EUR,0.12,standard, +2012-10-01,,"NL-AW +AW",AWG,0,standard,Aruba (Dutch country) are exempted of VAT. +2012-10-01,,"NL-CW +NL-SX +CW +SX",ANG,0,standard,Curacao and Sint Maarten (Dutch countries) are exempted of VAT. +2012-10-01,,"NL-BQ1 +NL-BQ2 +NL-BQ3 +BQ +BQ-BO +BQ-SA +BQ-SE",USD,0,standard,"Bonaire, Saba and Sint Eustatius (Dutch special municipalities) are exempted of VAT." +2011-01-01,,PL,PLN,0.23,standard,Poland (member state) standard VAT rate. +1993-01-08,2011-01-01,PL,PLN,0.22,standard, +2011-01-04,,PN,NZD,0,standard,Pitcairn Islands (British overseas territory) is exempted of VAT. +2011-01-01,,PT,EUR,0.23,standard,Portugal (member state) standard VAT rate. +2010-07-01,2011-01-01,PT,EUR,0.21,standard, +2008-07-01,2010-07-01,PT,EUR,0.2,standard, +2005-07-01,2008-07-01,PT,EUR,0.21,standard, +2002-06-05,2005-07-01,PT,EUR,0.19,standard, +1995-01-01,2002-06-05,PT,EUR,0.17,standard, +1992-03-24,1995-01-01,PT,EUR,0.16,standard, +1988-02-01,1992-03-24,PT,EUR,0.17,standard, +1986-01-01,1988-02-01,PT,EUR,0.16,standard, +2011-01-01,,PT-20,EUR,0.18,standard,Azores (Portuguese autonomous region) special VAT rate. +2011-01-01,,PT-30,EUR,0.22,standard,Madeira (Portuguese autonomous region) special VAT rate. +2017-01-01,,RO,RON,0.19,standard,Romania (member state) standard VAT rate. +2016-01-01,2017-01-01,RO,RON,0.2,standard,Romania (member state) standard VAT rate. +2010-07-01,2016-01-01,RO,RON,0.24,standard, +2000-01-01,2010-07-01,RO,RON,0.19,standard, +1998-02-01,2000-01-01,RO,RON,0.22,standard, +1993-07-01,1998-02-01,RO,RON,0.18,standard, +1990-07-01,,SE,SEK,0.25,standard,Sweden (member state) standard VAT rate. +1983-01-01,1990-07-01,SE,SEK,0.2346,standard, +1981-11-16,1983-01-01,SE,SEK,0.2151,standard, +1980-09-08,1981-11-16,SE,SEK,0.2346,standard, +1977-06-01,1980-09-08,SE,SEK,0.2063,standard, +1971-01-01,1977-06-01,SE,SEK,0.1765,standard, +1969-01-01,1971-01-01,SE,SEK,0.1111,standard, +2011-01-04,,"AC +SH +SH-AC +SH-HL",SHP,0,standard,Ascension and Saint Helena (British overseas territory) is exempted of VAT. +2011-01-04,,"TA +SH-TA",GBP,0,standard,Tristan da Cunha (British oversea territory) is exempted of VAT. +2013-07-01,,SI,EUR,0.22,standard,Slovenia (member state) standard VAT rate. +2002-01-01,2013-07-01,SI,EUR,0.2,standard, +1999-07-01,2002-01-01,SI,EUR,0.19,standard, +2011-01-01,,SK,EUR,0.2,standard,Slovakia (member state) standard VAT rate. +2004-01-01,2011-01-01,SK,EUR,0.19,standard, +2003-01-01,2004-01-01,SK,EUR,0.2,standard, +1996-01-01,2003-01-01,SK,EUR,0.23,standard, +1993-08-01,1996-01-01,SK,EUR,0.25,standard, +1993-01-01,1993-08-01,SK,EUR,0.23,standard, +2011-01-04,,TC,USD,0,standard,Turks and Caicos Islands (British overseas territory) is exempted of VAT. +2011-01-04,,"GB +UK +IM",GBP,0.2,standard,"United Kingdom (member state) standard VAT rate. +Isle of Man (British self-governing dependency) is member of the EU VAT area and subjected to UK's standard VAT rate." +2010-01-01,2011-01-04,"GB +UK +IM",GBP,0.175,standard, +2008-12-01,2010-01-01,"GB +UK +IM",GBP,0.15,standard, +1991-04-01,2008-12-01,"GB +UK +IM",GBP,0.175,standard, +1979-06-18,1991-04-01,"GB +UK +IM",GBP,0.15,standard, +1974-07-29,1979-06-18,"GB +UK +IM",GBP,0.08,standard, +1973-04-01,1974-07-29,"GB +UK +IM",GBP,0.1,standard, +2011-01-04,,VG,USD,0,standard,British Virgin Islands (British overseas territory) is exempted of VAT. +2014-01-01,,CP,EUR,0,standard,Clipperton Island (French overseas possession) is exempted of VAT. +2019-11-15,,CH,CHF,0.077,standard,Switzerland standard VAT (added manually) +2019-11-15,,MC,EUR,0.196,standard,Monaco standard VAT (added manually) +2019-11-15,,FR,EUR,0.2,standard,France standard VAT (added manually) +2019-11-15,,GR,EUR,0.24,standard,Greece standard VAT (added manually) +2019-11-15,,GB,EUR,0.2,standard,UK standard VAT (added manually) +2019-12-17,,AD,EUR,0.045,standard,Andorra standard VAT (added manually) +2019-12-17,,TK,EUR,0.18,standard,Turkey standard VAT (added manually) +2019-12-17,,IS,EUR,0.24,standard,Iceland standard VAT (added manually) +2019-12-17,,FX,EUR,0.20,standard,France metropolitan standard VAT (added manually) +2020-01-04,,CY,EUR,0.19,standard,Cyprus standard VAT (added manually) +2019-01-04,,IL,EUR,0.23,standard,Ireland standard VAT (added manually) +2019-01-04,,LI,EUR,0.077,standard,Liechtenstein standard VAT (added manually) From e2b5b5d102aa64736833148ddc670d837404fd6d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 15:33:26 +0100 Subject: [PATCH 050/193] opennebula -> router --- uncloud/opennebula/models.py | 25 +++++++++++++++++++------ uncloud/opennebula/serializers.py | 6 ++++++ uncloud/opennebula/views.py | 20 +++++++++++++------- uncloud/uncloud/urls.py | 8 +++++--- 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/uncloud/opennebula/models.py b/uncloud/opennebula/models.py index ff0e49c..0b0f307 100644 --- a/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -29,9 +29,22 @@ class VM(models.Model): If there are multiple disks, we have a list of dictionaries in the database. """ - if not 'DISK' in self.data['TEMPLATE']['DISK']: - return [] - elif type(self.data['TEMPLATE']['DISK']) is dict: - return [ self.data['TEMPLATE']['DISK'] ] - else: - return self.data['TEMPLATE']['DISK'] + 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'], + } + for d in disks + ] + + return disks diff --git a/uncloud/opennebula/serializers.py b/uncloud/opennebula/serializers.py index c84f2ab..30bd20a 100644 --- a/uncloud/opennebula/serializers.py +++ b/uncloud/opennebula/serializers.py @@ -6,3 +6,9 @@ class VMSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VM fields = ['vmid', 'owner', 'data'] + + +class OpenNebulaVMSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VM + fields = ['vmid', 'owner', 'cores', 'ram_in_gb', 'disks' ] diff --git a/uncloud/opennebula/views.py b/uncloud/opennebula/views.py index 7f2b537..5505b32 100644 --- a/uncloud/opennebula/views.py +++ b/uncloud/opennebula/views.py @@ -1,16 +1,22 @@ -from rest_framework import viewsets, generics +from rest_framework import viewsets, generics, permissions from .models import VM -from .serializers import VMSerializer +from .serializers import VMSerializer, OpenNebulaVMSerializer -class VMList(generics.ListAPIView): +#class VMList(generics.ListAPIView): +# queryset = VM.objects.all() +# serializer_class = VMSerializer + + +class RawVMViewSet(viewsets.ModelViewSet): +# lookup_field = 'vmid' queryset = VM.objects.all() serializer_class = VMSerializer + permission_classes = [permissions.IsAuthenticated] -class VMDetail(generics.RetrieveAPIView): - lookup_field = 'vmid' +class VMViewSet(viewsets.ModelViewSet): queryset = VM.objects.all() - serializer_class = VMSerializer + serializer_class = OpenNebulaVMSerializer -class VMViewSet( + permission_classes = [permissions.IsAuthenticated] diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index c7ce9b6..0291b7f 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -24,6 +24,8 @@ from opennebula import views as oneviews router = routers.DefaultRouter() router.register(r'users', views.UserViewSet) router.register(r'groups', views.GroupViewSet) +router.register(r'opennebula', oneviews.VMViewSet) +router.register(r'opennebula_raw', oneviews.RawVMViewSet) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. @@ -31,8 +33,8 @@ urlpatterns = [ path('', include(router.urls)), path('admin/', admin.site.urls), path('products/', views.ProductsView.as_view(), name='products'), - path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), - path('vm/list/', oneviews.VMList.as_view(), name='vm_list'), - path('vm/detail//', oneviews.VMDetail.as_view(), name='vm_detail'), + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) +# path('vm/list/', oneviews.VMList.as_view(), name='vm_list'), +# path('vm/detail//', oneviews.VMDetail.as_view(), name='vm_detail'), ] From edbfb7964e8e9830afa2d59ebc7c78e31b6ee004 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 16:52:30 +0100 Subject: [PATCH 051/193] [ldap] bind with admin to get attributes --- uncloud/opennebula/views.py | 27 ++++++++++++++++----------- uncloud/uncloud/secrets_sample.py | 8 ++++++++ uncloud/uncloud/settings.py | 26 +++++++++++++++++++------- uncloud/uncloud/urls.py | 5 +---- 4 files changed, 44 insertions(+), 22 deletions(-) diff --git a/uncloud/opennebula/views.py b/uncloud/opennebula/views.py index 5505b32..0d9a334 100644 --- a/uncloud/opennebula/views.py +++ b/uncloud/opennebula/views.py @@ -1,22 +1,27 @@ from rest_framework import viewsets, generics, permissions +from rest_framework.response import Response + +from django.contrib.auth import get_user_model + from .models import VM from .serializers import VMSerializer, OpenNebulaVMSerializer - -#class VMList(generics.ListAPIView): -# queryset = VM.objects.all() -# serializer_class = VMSerializer - - class RawVMViewSet(viewsets.ModelViewSet): -# lookup_field = 'vmid' queryset = VM.objects.all() serializer_class = VMSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAdminUser] class VMViewSet(viewsets.ModelViewSet): - queryset = VM.objects.all() - serializer_class = OpenNebulaVMSerializer - permission_classes = [permissions.IsAuthenticated] + + def list(self, request): + queryset = VM.objects.filter(owner=request.user) + serializer = OpenNebulaVMSerializer(queryset, many=True) + return Response(serializer.data) + + def retrieve(self, request, pk=None): + queryset = VM.objects.filter(owner=request.user) + user = get_object_or_404(queryset, pk=pk) + serializer = OpenNebulaVMSerializer(queryset) + return Response(serializer.data) diff --git a/uncloud/uncloud/secrets_sample.py b/uncloud/uncloud/secrets_sample.py index b578a8b..8c4516c 100644 --- a/uncloud/uncloud/secrets_sample.py +++ b/uncloud/uncloud/secrets_sample.py @@ -8,3 +8,11 @@ OPENNEBULA_URL='https://opennebula.ungleich.ch:2634/RPC2' 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 = "" + +SECRET_KEY="dx$iqt=lc&yrp^!z5$ay^%g5lhx1y3bcu=jg(jx0yj0ogkfqvf" diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 0e08750..fc95a86 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -12,6 +12,10 @@ https://docs.djangoproject.com/en/3.0/ref/settings/ import os +# Uncommitted file with secrets +import uncloud.secrets + + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -20,7 +24,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'dx$iqt=lc&yrp^!z5$ay^%g5lhx1y3bcu=jg(jx0yj0ogkfqvf' +SECRET_KEY = uncloud.secrets.SECRET_KEY # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -100,15 +104,25 @@ AUTH_PASSWORD_VALIDATORS = [ import ldap from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion +AUTH_LDAP_SERVER_URI = uncloud.secrets.LDAP_SERVER_URI -AUTH_LDAP_SERVER_URI = "ldaps://ldap1.ungleich.ch,ldaps://ldap2.ungleich.ch" - -AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=customer,dc=ungleich,dc=ch" +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( - "ou=customer,dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=%(user)s)" + "dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=%(user)s)" ) +#AUTH_LDAP_BIND_AS_AUTHENTICATING_USER=True +#AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=customer,dc=ungleich,dc=ch" + + + ################################################################################ # AUTH/Django AUTHENTICATION_BACKENDS = [ @@ -150,8 +164,6 @@ USE_TZ = True STATIC_URL = '/static/' -# Uncommitted file with secrets -import uncloud.secrets # Database diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 0291b7f..a01ef66 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -24,7 +24,7 @@ from opennebula import views as oneviews router = routers.DefaultRouter() router.register(r'users', views.UserViewSet) router.register(r'groups', views.GroupViewSet) -router.register(r'opennebula', oneviews.VMViewSet) +router.register(r'opennebula', oneviews.VMViewSet, basename='opennebula') router.register(r'opennebula_raw', oneviews.RawVMViewSet) # Wire up our API using automatic URL routing. @@ -34,7 +34,4 @@ urlpatterns = [ path('admin/', admin.site.urls), path('products/', views.ProductsView.as_view(), name='products'), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) -# path('vm/list/', oneviews.VMList.as_view(), name='vm_list'), -# path('vm/detail//', oneviews.VMDetail.as_view(), name='vm_detail'), - ] From fa4d7a1d70d608c2a43c812f08e2b99ec057a18a Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 23 Feb 2020 21:00:18 +0500 Subject: [PATCH 052/193] opennebula_hacks added i.e create one user and chown of vm --- abkhack/opennebula_hacks.py | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 abkhack/opennebula_hacks.py diff --git a/abkhack/opennebula_hacks.py b/abkhack/opennebula_hacks.py new file mode 100644 index 0000000..c0bbaf8 --- /dev/null +++ b/abkhack/opennebula_hacks.py @@ -0,0 +1,46 @@ +import importlib +import sys +import os + +from os.path import join as join_path +from xmlrpc.client import ServerProxy as RPCClient + +root = os.path.dirname(os.getcwd()) +sys.path.append(join_path(root, 'uncloud')) +secrets = importlib.import_module('uncloud.secrets') + + +class OpenNebula: + def __init__(self, url, session_string): + self.session_string = session_string + self.client = RPCClient(secrets.OPENNEBULA_URL) + + def create_user(self, username, password, authentication_driver='', group_id=None): + # https://docs.opennebula.org/5.10/integration/system_interfaces/api.html#one-user-allocate + + if group_id is None: + group_id = [] + + return self.client.one.user.allocate( + self.session_string, + username, + password, + authentication_driver, + group_id + ) + + def chmod(self, vm_id, user_id=-1, group_id=-1): + # https://docs.opennebula.org/5.10/integration/system_interfaces/api.html#one-vm-chown + + return self.client.one.vm.chown(self.session_string, vm_id, user_id, group_id) + + +one = OpenNebula(secrets.OPENNEBULA_URL, secrets.OPENNEBULA_USER_PASS) + +# Create User in OpenNebula +# success, response, *_ = one.create_user(username='meow12345', password='hello_world') +# print(success, response) + +# Change owner of a VM +# success, response, *_ = one.chmod(vm_id=25589, user_id=706) +# print(success, response) From 46921c43ad1956a70c8377e589791302b64005b9 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 17:11:05 +0100 Subject: [PATCH 053/193] update ldap, update syncvm --- uncloud/opennebula/management/commands/syncvm.py | 10 +++++++--- uncloud/opennebula/views.py | 2 +- uncloud/uncloud/settings.py | 6 +----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/uncloud/opennebula/management/commands/syncvm.py b/uncloud/opennebula/management/commands/syncvm.py index 136e145..795d53a 100644 --- a/uncloud/opennebula/management/commands/syncvm.py +++ b/uncloud/opennebula/management/commands/syncvm.py @@ -26,11 +26,14 @@ class Command(BaseCommand): vms = json.loads(json.dumps(parse(response)))['VM_POOL']['VM'] for i, vm in enumerate(vms): vm_id = vm['ID'] - vm_owner = vm['UNAME'] + vm_owner_email = vm['UNAME'] + try: - user = get_user_model().objects.get(username=vm_owner) + user = get_user_model().objects.get(email=vm_owner_email) except get_user_model().DoesNotExist: - user = get_user_model().objects.create_user(username=vm_owner) + print("Skipping VM import for unknown user with email: {}".format(vm_owner_email)) + continue + # user = get_user_model().objects.create_user(username=vm_owner) VMModel.objects.update_or_create( defaults= { 'data': vm, @@ -40,3 +43,4 @@ class Command(BaseCommand): else: print(response) + print(uncloud.secrets.OPENNEBULA_USER_PASS) diff --git a/uncloud/opennebula/views.py b/uncloud/opennebula/views.py index 0d9a334..29fdb64 100644 --- a/uncloud/opennebula/views.py +++ b/uncloud/opennebula/views.py @@ -17,7 +17,7 @@ class VMViewSet(viewsets.ModelViewSet): def list(self, request): queryset = VM.objects.filter(owner=request.user) - serializer = OpenNebulaVMSerializer(queryset, many=True) + serializer = OpenNebulaVMSerializer(queryset, many=True, context={'request': request}) return Response(serializer.data) def retrieve(self, request, pk=None): diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index fc95a86..2267be2 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -102,7 +102,7 @@ AUTH_PASSWORD_VALIDATORS = [ # AUTH/LDAP import ldap -from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion +from django_auth_ldap.config import LDAPSearch AUTH_LDAP_SERVER_URI = uncloud.secrets.LDAP_SERVER_URI @@ -118,10 +118,6 @@ AUTH_LDAP_USER_SEARCH = LDAPSearch( "dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=%(user)s)" ) -#AUTH_LDAP_BIND_AS_AUTHENTICATING_USER=True -#AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=customer,dc=ungleich,dc=ch" - - ################################################################################ # AUTH/Django From 8c6e4eee00a20a249c993c949741878e59d845fd Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 17:20:28 +0100 Subject: [PATCH 054/193] -- merge conflict --- uncloud/uncloud/settings.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index b2fc7ef..5ce8e92 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -111,14 +111,7 @@ AUTH_PASSWORD_VALIDATORS = [ ################################################################################ # AUTH/LDAP -<<<<<<< HEAD -import ldap -from django_auth_ldap.config import LDAPSearch - AUTH_LDAP_SERVER_URI = uncloud.secrets.LDAP_SERVER_URI -======= -AUTH_LDAP_SERVER_URI = "ldaps://ldap1.ungleich.ch,ldaps://ldap2.ungleich.ch" ->>>>>>> ahmed/master AUTH_LDAP_USER_ATTR_MAP = { "first_name": "givenName", @@ -126,15 +119,10 @@ AUTH_LDAP_USER_ATTR_MAP = { "email": "mail" } -<<<<<<< HEAD + AUTH_LDAP_BIND_DN = uncloud.secrets.LDAP_ADMIN_DN AUTH_LDAP_BIND_PASSWORD = uncloud.secrets.LDAP_ADMIN_PASSWORD -======= -AUTH_LDAP_BIND_DN = secrets.AUTH_LDAP_BIND_DN -AUTH_LDAP_BIND_PASSWORD = secrets.AUTH_LDAP_BIND_PASSWORD - ->>>>>>> ahmed/master AUTH_LDAP_USER_SEARCH = LDAPSearch( "dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=%(user)s)" ) From b3e505d37cc1f267c6ee7752fb85f8aa440be5be Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 23 Feb 2020 21:34:22 +0500 Subject: [PATCH 055/193] migration fix --- uncloud/opennebula/migrations/0004_auto_20200222_0713.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/opennebula/migrations/0004_auto_20200222_0713.py b/uncloud/opennebula/migrations/0004_auto_20200222_0713.py index a298c06..89913cb 100644 --- a/uncloud/opennebula/migrations/0004_auto_20200222_0713.py +++ b/uncloud/opennebula/migrations/0004_auto_20200222_0713.py @@ -7,7 +7,7 @@ import uuid class Migration(migrations.Migration): dependencies = [ - ('opennebula', '0003_auto_20200221_1113'), + ('opennebula', '0003_auto_20200223_1058'), ] operations = [ From 734c4062456e8a14b869159c3fef0183afac39aa Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 17:43:06 +0100 Subject: [PATCH 056/193] Extend uncloud VM models --- uncloud/uncloud_vm/models.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index b1aab40..bba01c5 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -1,12 +1,41 @@ from django.db import models +class VMHost(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + # 253 is the maximum DNS name length + hostname = models.CharField(max_length=253) + + # indirectly gives a maximum number of cores / VM - f.i. 32 + physical_cores = models.IntegerField() + + # determines the maximum usable cores - f.i. 320 if you overbook by a factor of 10 + usable_cores = models.IntegerField() + + # ram that can be used of the server + usable_ram_in_gb = models.FloatField() + + class VM(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) cores = models.IntegerField() - ram = models.FloatField() + ram_in_gb = models.FloatField() + + vmhost = models.ForeignKey(VMHost, on_delete=models.CASCADE) class VMDisk(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + vm = models.ForeignKey(VM, on_delete=models.CASCADE) + size_in_gb = models.FloatField() + + storage_class = models.CharField(max_length=32, + choices = ( + ('hdd', 'HDD'), + ('ssd', 'SSD'), + ), + default='ssd' + ) From 50df7050d688ca28c40f97edafa758cf5a711c56 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 17:46:30 +0100 Subject: [PATCH 057/193] vmhost: add status field --- uncloud/uncloud_vm/models.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index bba01c5..faf61b0 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -17,6 +17,16 @@ class VMHost(models.Model): usable_ram_in_gb = models.FloatField() + status = models.CharField(max_length=32, + choices = ( + ('pending', 'Pending'), + ('active', 'Active'), + ('unusable', 'Unusable'), + ), + default='pending' + ) + + class VM(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) From 15b0fe3dc9b9cee90a3aac530cad281e9766c160 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 23 Feb 2020 18:11:14 +0100 Subject: [PATCH 058/193] fix migrations the ugly way Signed-off-by: Nico Schottelius --- uncloud/opennebula/migrations/0001_initial.py | 4 +- uncloud/opennebula/migrations/0002_vm_uuid.py | 19 -------- .../migrations/0003_auto_20200223_1058.py | 19 -------- .../migrations/0004_auto_20200222_0713.py | 23 ---------- .../uncloud_api/migrations/0001_initial.py | 4 +- .../migrations/0002_auto_20200222_0719.py | 46 ------------------- uncloud/uncloud_api/models.py | 7 ++- .../uncloud_auth/migrations/0001_initial.py | 2 +- uncloud/uncloud_vm/migrations/__init__.py | 0 9 files changed, 12 insertions(+), 112 deletions(-) delete mode 100644 uncloud/opennebula/migrations/0002_vm_uuid.py delete mode 100644 uncloud/opennebula/migrations/0003_auto_20200223_1058.py delete mode 100644 uncloud/opennebula/migrations/0004_auto_20200222_0713.py delete mode 100644 uncloud/uncloud_api/migrations/0002_auto_20200222_0719.py delete mode 100644 uncloud/uncloud_vm/migrations/__init__.py diff --git a/uncloud/opennebula/migrations/0001_initial.py b/uncloud/opennebula/migrations/0001_initial.py index f1d3d6b..7fa9154 100644 --- a/uncloud/opennebula/migrations/0001_initial.py +++ b/uncloud/opennebula/migrations/0001_initial.py @@ -1,9 +1,10 @@ -# Generated by Django 3.0.3 on 2020-02-23 10:02 +# Generated by Django 3.0.3 on 2020-02-23 17:08 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): @@ -19,6 +20,7 @@ class Migration(migrations.Migration): name='VM', fields=[ ('vmid', models.IntegerField(primary_key=True, serialize=False)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('data', django.contrib.postgres.fields.jsonb.JSONField()), ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], diff --git a/uncloud/opennebula/migrations/0002_vm_uuid.py b/uncloud/opennebula/migrations/0002_vm_uuid.py deleted file mode 100644 index 595fd05..0000000 --- a/uncloud/opennebula/migrations/0002_vm_uuid.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-23 10:55 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('opennebula', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='vm', - name='uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False), - ), - ] diff --git a/uncloud/opennebula/migrations/0003_auto_20200223_1058.py b/uncloud/opennebula/migrations/0003_auto_20200223_1058.py deleted file mode 100644 index d2173da..0000000 --- a/uncloud/opennebula/migrations/0003_auto_20200223_1058.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-23 10:58 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('opennebula', '0002_vm_uuid'), - ] - - operations = [ - migrations.AlterField( - model_name='vm', - name='uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), - ), - ] diff --git a/uncloud/opennebula/migrations/0004_auto_20200222_0713.py b/uncloud/opennebula/migrations/0004_auto_20200222_0713.py deleted file mode 100644 index a298c06..0000000 --- a/uncloud/opennebula/migrations/0004_auto_20200222_0713.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-22 07:13 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('opennebula', '0003_auto_20200221_1113'), - ] - - operations = [ - migrations.RemoveField( - model_name='vm', - name='id', - ), - migrations.AddField( - model_name='vm', - name='uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), - ), - ] diff --git a/uncloud/uncloud_api/migrations/0001_initial.py b/uncloud/uncloud_api/migrations/0001_initial.py index d8d9630..cc3944c 100644 --- a/uncloud/uncloud_api/migrations/0001_initial.py +++ b/uncloud/uncloud_api/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-02-23 10:16 +# Generated by Django 3.0.3 on 2020-02-23 17:09 from django.conf import settings from django.db import migrations, models @@ -19,7 +19,7 @@ class Migration(migrations.Migration): name='VMSnapshotProduct', fields=[ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('created_active', 'Created'), ('deleted', 'Deleted')], default='pending', max_length=256)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), ('gb_ssd', models.FloatField()), ('gb_hdd', models.FloatField()), ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), diff --git a/uncloud/uncloud_api/migrations/0002_auto_20200222_0719.py b/uncloud/uncloud_api/migrations/0002_auto_20200222_0719.py deleted file mode 100644 index a52eade..0000000 --- a/uncloud/uncloud_api/migrations/0002_auto_20200222_0719.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-22 07:19 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_api', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='VMSnapshotOrder', - fields=[ - ('order_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_api.Order')), - ], - bases=('uncloud_api.order',), - ), - migrations.CreateModel( - name='VMSnapshotProduct', - fields=[ - ('product_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_api.Product')), - ('gb_ssd', models.FloatField()), - ('gb_hdd', models.FloatField()), - ], - bases=('uncloud_api.product',), - ), - migrations.DeleteModel( - name='OrderReference', - ), - migrations.RemoveField( - model_name='product', - name='name', - ), - migrations.RemoveField( - model_name='product', - name='recurring_period', - ), - migrations.AddField( - model_name='product', - name='status', - field=models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('created_active', 'Created'), ('deleted', 'Deleted')], default='pending', max_length=256), - ), - ] diff --git a/uncloud/uncloud_api/models.py b/uncloud/uncloud_api/models.py index 11a7560..1540e69 100644 --- a/uncloud/uncloud_api/models.py +++ b/uncloud/uncloud_api/models.py @@ -45,12 +45,17 @@ class Product(models.Model): choices = ( ('pending', 'Pending'), ('being_created', 'Being created'), - ('created_active', 'Created'), + ('active', 'Active'), ('deleted', 'Deleted') ), default='pending' ) + recurring_price = models.FloatField() + one_time_price = models.FloatField() + + + class Meta: abstract = True diff --git a/uncloud/uncloud_auth/migrations/0001_initial.py b/uncloud/uncloud_auth/migrations/0001_initial.py index a3ade55..73072a5 100644 --- a/uncloud/uncloud_auth/migrations/0001_initial.py +++ b/uncloud/uncloud_auth/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-02-23 10:02 +# Generated by Django 3.0.3 on 2020-02-23 17:08 import django.contrib.auth.models import django.contrib.auth.validators diff --git a/uncloud/uncloud_vm/migrations/__init__.py b/uncloud/uncloud_vm/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 From 739bd7252612ccbb9f3f72183084effd0e743099 Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 23 Feb 2020 23:00:42 +0500 Subject: [PATCH 059/193] Migration fixed + opennebula/views.py fixed --- .../opennebula/management/commands/syncvm.py | 6 ++- uncloud/opennebula/migrations/0001_initial.py | 4 +- uncloud/opennebula/migrations/0002_vm_uuid.py | 19 -------- .../migrations/0003_auto_20200223_1058.py | 19 -------- .../migrations/0004_auto_20200222_0713.py | 23 ---------- uncloud/opennebula/views.py | 16 +++---- .../uncloud_api/migrations/0001_initial.py | 2 +- .../migrations/0002_auto_20200222_0719.py | 46 ------------------- .../uncloud_auth/migrations/0001_initial.py | 2 +- uncloud/uncloud_vm/migrations/__init__.py | 0 10 files changed, 17 insertions(+), 120 deletions(-) delete mode 100644 uncloud/opennebula/migrations/0002_vm_uuid.py delete mode 100644 uncloud/opennebula/migrations/0003_auto_20200223_1058.py delete mode 100644 uncloud/opennebula/migrations/0004_auto_20200222_0713.py delete mode 100644 uncloud/uncloud_api/migrations/0002_auto_20200222_0719.py delete mode 100644 uncloud/uncloud_vm/migrations/__init__.py diff --git a/uncloud/opennebula/management/commands/syncvm.py b/uncloud/opennebula/management/commands/syncvm.py index 795d53a..f5f80b1 100644 --- a/uncloud/opennebula/management/commands/syncvm.py +++ b/uncloud/opennebula/management/commands/syncvm.py @@ -11,6 +11,7 @@ from opennebula.models import VM as VMModel import uncloud.secrets + class Command(BaseCommand): help = 'Syncronize VM information from OpenNebula' @@ -24,6 +25,7 @@ class Command(BaseCommand): ) if success: vms = json.loads(json.dumps(parse(response)))['VM_POOL']['VM'] + unknown_user_with_email = set() for i, vm in enumerate(vms): vm_id = vm['ID'] vm_owner_email = vm['UNAME'] @@ -31,7 +33,7 @@ class Command(BaseCommand): try: user = get_user_model().objects.get(email=vm_owner_email) except get_user_model().DoesNotExist: - print("Skipping VM import for unknown user with email: {}".format(vm_owner_email)) + unknown_user_with_email.add(vm_owner_email) continue # user = get_user_model().objects.create_user(username=vm_owner) @@ -40,7 +42,7 @@ class Command(BaseCommand): 'owner': user }, vmid=vm_id ) - + print('User with email but no username:', unknown_user_with_email) else: print(response) print(uncloud.secrets.OPENNEBULA_USER_PASS) diff --git a/uncloud/opennebula/migrations/0001_initial.py b/uncloud/opennebula/migrations/0001_initial.py index f1d3d6b..4c0527a 100644 --- a/uncloud/opennebula/migrations/0001_initial.py +++ b/uncloud/opennebula/migrations/0001_initial.py @@ -1,9 +1,10 @@ -# Generated by Django 3.0.3 on 2020-02-23 10:02 +# 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): @@ -19,6 +20,7 @@ class Migration(migrations.Migration): name='VM', fields=[ ('vmid', models.IntegerField(primary_key=True, serialize=False)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('data', django.contrib.postgres.fields.jsonb.JSONField()), ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], diff --git a/uncloud/opennebula/migrations/0002_vm_uuid.py b/uncloud/opennebula/migrations/0002_vm_uuid.py deleted file mode 100644 index 595fd05..0000000 --- a/uncloud/opennebula/migrations/0002_vm_uuid.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-23 10:55 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('opennebula', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='vm', - name='uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False), - ), - ] diff --git a/uncloud/opennebula/migrations/0003_auto_20200223_1058.py b/uncloud/opennebula/migrations/0003_auto_20200223_1058.py deleted file mode 100644 index d2173da..0000000 --- a/uncloud/opennebula/migrations/0003_auto_20200223_1058.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-23 10:58 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('opennebula', '0002_vm_uuid'), - ] - - operations = [ - migrations.AlterField( - model_name='vm', - name='uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), - ), - ] diff --git a/uncloud/opennebula/migrations/0004_auto_20200222_0713.py b/uncloud/opennebula/migrations/0004_auto_20200222_0713.py deleted file mode 100644 index 89913cb..0000000 --- a/uncloud/opennebula/migrations/0004_auto_20200222_0713.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-22 07:13 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('opennebula', '0003_auto_20200223_1058'), - ] - - operations = [ - migrations.RemoveField( - model_name='vm', - name='id', - ), - migrations.AddField( - model_name='vm', - name='uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), - ), - ] diff --git a/uncloud/opennebula/views.py b/uncloud/opennebula/views.py index 29fdb64..5498928 100644 --- a/uncloud/opennebula/views.py +++ b/uncloud/opennebula/views.py @@ -1,11 +1,10 @@ -from rest_framework import viewsets, generics, permissions +from rest_framework import viewsets, permissions from rest_framework.response import Response -from django.contrib.auth import get_user_model - from .models import VM from .serializers import VMSerializer, OpenNebulaVMSerializer + class RawVMViewSet(viewsets.ModelViewSet): queryset = VM.objects.all() serializer_class = VMSerializer @@ -14,14 +13,15 @@ class RawVMViewSet(viewsets.ModelViewSet): class VMViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] + serializer_class = OpenNebulaVMSerializer + + def get_queryset(self): + return VM.objects.filter(owner=self.request.user) def list(self, request): - queryset = VM.objects.filter(owner=request.user) - serializer = OpenNebulaVMSerializer(queryset, many=True, context={'request': request}) + serializer = OpenNebulaVMSerializer(self.queryset, many=True, context={'request': request}) return Response(serializer.data) def retrieve(self, request, pk=None): - queryset = VM.objects.filter(owner=request.user) - user = get_object_or_404(queryset, pk=pk) - serializer = OpenNebulaVMSerializer(queryset) + serializer = OpenNebulaVMSerializer(self.queryset) return Response(serializer.data) diff --git a/uncloud/uncloud_api/migrations/0001_initial.py b/uncloud/uncloud_api/migrations/0001_initial.py index d8d9630..c549a9d 100644 --- a/uncloud/uncloud_api/migrations/0001_initial.py +++ b/uncloud/uncloud_api/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-02-23 10:16 +# Generated by Django 3.0.3 on 2020-02-23 17:12 from django.conf import settings from django.db import migrations, models diff --git a/uncloud/uncloud_api/migrations/0002_auto_20200222_0719.py b/uncloud/uncloud_api/migrations/0002_auto_20200222_0719.py deleted file mode 100644 index a52eade..0000000 --- a/uncloud/uncloud_api/migrations/0002_auto_20200222_0719.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-22 07:19 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_api', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='VMSnapshotOrder', - fields=[ - ('order_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_api.Order')), - ], - bases=('uncloud_api.order',), - ), - migrations.CreateModel( - name='VMSnapshotProduct', - fields=[ - ('product_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_api.Product')), - ('gb_ssd', models.FloatField()), - ('gb_hdd', models.FloatField()), - ], - bases=('uncloud_api.product',), - ), - migrations.DeleteModel( - name='OrderReference', - ), - migrations.RemoveField( - model_name='product', - name='name', - ), - migrations.RemoveField( - model_name='product', - name='recurring_period', - ), - migrations.AddField( - model_name='product', - name='status', - field=models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('created_active', 'Created'), ('deleted', 'Deleted')], default='pending', max_length=256), - ), - ] diff --git a/uncloud/uncloud_auth/migrations/0001_initial.py b/uncloud/uncloud_auth/migrations/0001_initial.py index a3ade55..63885c4 100644 --- a/uncloud/uncloud_auth/migrations/0001_initial.py +++ b/uncloud/uncloud_auth/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-02-23 10:02 +# Generated by Django 3.0.3 on 2020-02-23 17:11 import django.contrib.auth.models import django.contrib.auth.validators diff --git a/uncloud/uncloud_vm/migrations/__init__.py b/uncloud/uncloud_vm/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 From a72bc142a68bcbe9f5a339e723fb3b3db0a5dfcc Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 25 Feb 2020 11:50:49 +0500 Subject: [PATCH 060/193] Fixed issues in opennebula/views.py + syncvm now behaves correctly and print users which are not in ldap as per their email address --- .../opennebula/management/commands/syncvm.py | 54 ++++++++++++------- uncloud/opennebula/views.py | 14 ++--- uncloud/requirements.txt | 1 + 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/uncloud/opennebula/management/commands/syncvm.py b/uncloud/opennebula/management/commands/syncvm.py index f5f80b1..00108f0 100644 --- a/uncloud/opennebula/management/commands/syncvm.py +++ b/uncloud/opennebula/management/commands/syncvm.py @@ -1,15 +1,22 @@ -import os import json +import uncloud.secrets as secrets + + +from xmlrpc.client import ServerProxy as RPCClient + from django.core.management.base import BaseCommand from django.contrib.auth import get_user_model -from xmlrpc.client import ServerProxy as RPCClient - from xmltodict import parse +from ungleich_common.ldap.ldap_manager import LdapManager from opennebula.models import VM as VMModel -import uncloud.secrets + +def find_user_based_on_email(users, email): + for user in users: + if email in user.mail.values: + return user class Command(BaseCommand): @@ -19,30 +26,39 @@ class Command(BaseCommand): pass def handle(self, *args, **options): - with RPCClient(uncloud.secrets.OPENNEBULA_URL) as rpc_client: + ldap_server_uri = secrets.LDAP_SERVER_URI.split(',')[0] + ldap_manager = LdapManager( + server=ldap_server_uri, + admin_dn=secrets.LDAP_ADMIN_DN, + admin_password=secrets.LDAP_ADMIN_PASSWORD, + ) + users = ldap_manager.get('') # Get all users + + with RPCClient(secrets.OPENNEBULA_URL) as rpc_client: success, response, *_ = rpc_client.one.vmpool.infoextended( - uncloud.secrets.OPENNEBULA_USER_PASS, -2, -1, -1, -1 + secrets.OPENNEBULA_USER_PASS, -2, -1, -1, -1 ) if success: vms = json.loads(json.dumps(parse(response)))['VM_POOL']['VM'] unknown_user_with_email = set() - for i, vm in enumerate(vms): + + for vm in vms: vm_id = vm['ID'] vm_owner_email = vm['UNAME'] - try: - user = get_user_model().objects.get(email=vm_owner_email) - except get_user_model().DoesNotExist: + user = find_user_based_on_email(users, vm_owner_email) + if not user: unknown_user_with_email.add(vm_owner_email) - continue - # user = get_user_model().objects.create_user(username=vm_owner) + else: + try: + user_in_db = get_user_model().objects.get(email=vm_owner_email) + except get_user_model().DoesNotExist: + user_in_db = get_user_model().objects.create_user(username=user.uid, email=vm_owner_email) - VMModel.objects.update_or_create( - defaults= { 'data': vm, - 'owner': user }, - vmid=vm_id - ) - print('User with email but no username:', unknown_user_with_email) + VMModel.objects.update_or_create( + defaults={'data': vm, 'owner': user_in_db}, vmid=vm_id + ) + print('User with email but not found in ldap:', unknown_user_with_email) else: print(response) - print(uncloud.secrets.OPENNEBULA_USER_PASS) + print(secrets.OPENNEBULA_USER_PASS) diff --git a/uncloud/opennebula/views.py b/uncloud/opennebula/views.py index 5498928..66269c7 100644 --- a/uncloud/opennebula/views.py +++ b/uncloud/opennebula/views.py @@ -1,5 +1,6 @@ from rest_framework import viewsets, permissions from rest_framework.response import Response +from django.shortcuts import get_object_or_404 from .models import VM from .serializers import VMSerializer, OpenNebulaVMSerializer @@ -11,17 +12,16 @@ class RawVMViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAdminUser] -class VMViewSet(viewsets.ModelViewSet): +class VMViewSet(viewsets.ViewSet): permission_classes = [permissions.IsAuthenticated] - serializer_class = OpenNebulaVMSerializer - - def get_queryset(self): - return VM.objects.filter(owner=self.request.user) def list(self, request): - serializer = OpenNebulaVMSerializer(self.queryset, many=True, context={'request': request}) + queryset = VM.objects.filter(owner=request.user) + serializer = OpenNebulaVMSerializer(queryset, many=True, context={'request': request}) return Response(serializer.data) def retrieve(self, request, pk=None): - serializer = OpenNebulaVMSerializer(self.queryset) + queryset = VM.objects.filter(owner=request.user) + user = get_object_or_404(queryset, pk=pk) + serializer = OpenNebulaVMSerializer(queryset) return Response(serializer.data) diff --git a/uncloud/requirements.txt b/uncloud/requirements.txt index 11ab309..e79f479 100644 --- a/uncloud/requirements.txt +++ b/uncloud/requirements.txt @@ -3,3 +3,4 @@ djangorestframework django-auth-ldap stripe xmltodict +git+https://code.ungleich.ch/ahmedbilal/ungleich-common/#egg=ungleich-common-ldap&subdirectory=ldap From c7252cde5312046492514e6a271aa7eccbbfed24 Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 25 Feb 2020 13:09:54 +0500 Subject: [PATCH 061/193] Introduced local settings in meow-pay/uncloud django app --- uncloud/.gitignore | 3 +- uncloud/uncloud/settings.py | 57 ++++++++++--------------------------- 2 files changed, 17 insertions(+), 43 deletions(-) diff --git a/uncloud/.gitignore b/uncloud/.gitignore index 4ade18f..71202e1 100644 --- a/uncloud/.gitignore +++ b/uncloud/.gitignore @@ -1,3 +1,4 @@ db.sqlite3 uncloud/secrets.py -debug.log \ No newline at end of file +debug.log +uncloud/local_settings.py \ No newline at end of file diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 5ce8e92..e8530e7 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -12,18 +12,26 @@ https://docs.djangoproject.com/en/3.0/ref/settings/ import os +import stripe +import ldap # Uncommitted file with secrets import uncloud.secrets -import stripe -import ldap - -import uncloud.secrets as secrets - from django_auth_ldap.config import LDAPSearch - +# 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, ...) @@ -88,8 +96,6 @@ TEMPLATES = [ WSGI_APPLICATION = 'uncloud.wsgi.application' - - # Password validation # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators @@ -167,37 +173,4 @@ USE_TZ = True STATIC_URL = '/static/' -stripe.api_key = secrets.STRIPE_KEY - -# FIXME: not sure if we really need this -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'file': { - 'level': 'DEBUG', - 'class': 'logging.FileHandler', - 'filename': 'debug.log', - }, - }, - 'loggers': { - 'django': { - 'handlers': ['file'], - 'level': 'DEBUG', - 'propagate': True, - }, - 'django_auth_ldap': { - 'handlers': ['file'], - 'level': 'DEBUG', - 'propagate': True - } - }, -} - -# https://docs.djangoproject.com/en/3.0/ref/settings/#databases -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': uncloud.secrets.POSTGRESQL_DB_NAME, - } -} +stripe.api_key = uncloud.secrets.STRIPE_KEY From cc9e5905eb5bdd6733ca35c6563e6eb2bf54703d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 Feb 2020 14:12:23 +0100 Subject: [PATCH 062/193] update Signed-off-by: Nico Schottelius --- notes-nico.org | 22 +++++++++-------- uncloud/uncloud/settings.py | 46 +++++++++++++++++------------------ uncloud/uncloud/urls.py | 1 - uncloud/uncloud_api/models.py | 16 ++++++++++-- uncloud/uncloud_api/views.py | 35 +++----------------------- 5 files changed, 52 insertions(+), 68 deletions(-) diff --git a/notes-nico.org b/notes-nico.org index 03c1b97..811fbff 100644 --- a/notes-nico.org +++ b/notes-nico.org @@ -9,6 +9,16 @@ 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] @@ -50,16 +60,8 @@ password=... ** viewset: .list and .create ** view: .get .post * TODO register CC -* TODO list products -* ahmed -** schemas -*** field: is_valid? - used by schemas -*** definition of a "schema" -* penguin pay -## How to place a order with penguin pay - -### Requirements - +* 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) diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 5ce8e92..f671dc5 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -170,29 +170,29 @@ STATIC_URL = '/static/' stripe.api_key = secrets.STRIPE_KEY # FIXME: not sure if we really need this -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'file': { - 'level': 'DEBUG', - 'class': 'logging.FileHandler', - 'filename': 'debug.log', - }, - }, - 'loggers': { - 'django': { - 'handlers': ['file'], - 'level': 'DEBUG', - 'propagate': True, - }, - 'django_auth_ldap': { - 'handlers': ['file'], - 'level': 'DEBUG', - 'propagate': True - } - }, -} +# LOGGING = { +# 'version': 1, +# 'disable_existing_loggers': False, +# 'handlers': { +# 'file': { +# 'level': 'DEBUG', +# 'class': 'logging.FileHandler', +# 'filename': 'debug.log', +# }, +# }, +# 'loggers': { +# 'django': { +# 'handlers': ['file'], +# 'level': 'DEBUG', +# 'propagate': True, +# }, +# 'django_auth_ldap': { +# 'handlers': ['file'], +# 'level': 'DEBUG', +# 'propagate': True +# } +# }, +# } # https://docs.djangoproject.com/en/3.0/ref/settings/#databases DATABASES = { diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index a01ef66..60054c4 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -23,7 +23,6 @@ from opennebula import views as oneviews router = routers.DefaultRouter() router.register(r'users', views.UserViewSet) -router.register(r'groups', views.GroupViewSet) router.register(r'opennebula', oneviews.VMViewSet, basename='opennebula') router.register(r'opennebula_raw', oneviews.RawVMViewSet) diff --git a/uncloud/uncloud_api/models.py b/uncloud/uncloud_api/models.py index 1540e69..50857fb 100644 --- a/uncloud/uncloud_api/models.py +++ b/uncloud/uncloud_api/models.py @@ -51,6 +51,7 @@ class Product(models.Model): default='pending' ) + # This is calculated by each product and saved in the DB recurring_price = models.FloatField() one_time_price = models.FloatField() @@ -67,6 +68,15 @@ class VMSnapshotProduct(Product): price_per_gb_ssd = 0.35 price_per_gb_hdd = 1.5/100 + # This we need to get from the VM + gb_ssd = models.FloatField() + gb_hdd = models.FloatField() + + vm_uuid = models.UUIDField() + + # Need to setup recurring_price and one_time_price and recurring period + + sample_ssd = 10 sample_hdd = 100 @@ -97,8 +107,10 @@ Sample price for a VM with {} GB SSD and {} GB HDD VM is: {}. """.format(cls.price_per_gb_ssd, cls.price_per_gb_hdd, cls.sample_ssd, cls.sample_hdd, cls.sample_price()) - gb_ssd = models.FloatField() - gb_hdd = models.FloatField() + + + + diff --git a/uncloud/uncloud_api/views.py b/uncloud/uncloud_api/views.py index 68963ff..c8ffca7 100644 --- a/uncloud/uncloud_api/views.py +++ b/uncloud/uncloud_api/views.py @@ -7,17 +7,9 @@ from .serializers import UserSerializer, GroupSerializer from rest_framework.views import APIView from rest_framework.response import Response - -class CreditCardViewSet(viewsets.ModelViewSet): - - """ - API endpoint that allows credit cards to be listed - """ - queryset = get_user_model().objects.all().order_by('-date_joined') - serializer_class = UserSerializer - - permission_classes = [permissions.IsAuthenticated] - +import inspect +import sys +import re class UserViewSet(viewsets.ModelViewSet): @@ -29,24 +21,6 @@ class UserViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] -class GroupViewSet(viewsets.ModelViewSet): - """ - API endpoint that allows groups to be viewed or edited. - """ - queryset = Group.objects.all() - serializer_class = GroupSerializer - - permission_classes = [permissions.IsAuthenticated] - -class GroupViewSet(viewsets.ModelViewSet): - """ - API endpoint that allows groups to be viewed or edited. - """ - queryset = Group.objects.all() - serializer_class = GroupSerializer - - permission_classes = [permissions.IsAuthenticated] - # POST /vm/snapshot/ vmuuid=... => create snapshot, returns snapshot uuid # GET /vm/snapshot => list @@ -57,9 +31,6 @@ class VMSnapshotView(generics.ListCreateAPIView): #lookup_field = 'uuid' permission_classes = [permissions.IsAuthenticated] -import inspect -import sys -import re # Next: create /order/ urls # Next: strip off "Product" at the end From 7d1c8df84d5262a08157c8cab611017adf9b89e6 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 Feb 2020 14:20:03 +0100 Subject: [PATCH 063/193] ++ postgres requirement --- uncloud/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/uncloud/requirements.txt b/uncloud/requirements.txt index 11ab309..1b4e05b 100644 --- a/uncloud/requirements.txt +++ b/uncloud/requirements.txt @@ -3,3 +3,4 @@ djangorestframework django-auth-ldap stripe xmltodict +psycopg2 From d658b9635dff80764c2edbe1580bdcccbc32d438 Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 25 Feb 2020 21:03:20 +0500 Subject: [PATCH 064/193] Replace (vmid,uuid) with id in VM model + Add last_host and graphics in VM model + Fixed retrieve view in uncloud.opennebula --- .../opennebula/management/commands/syncvm.py | 4 +-- .../migrations/0002_auto_20200225_1335.py | 27 +++++++++++++++++++ .../migrations/0003_auto_20200225_1428.py | 19 +++++++++++++ uncloud/opennebula/models.py | 16 ++++++++--- uncloud/opennebula/serializers.py | 4 +-- uncloud/opennebula/views.py | 4 +-- uncloud/uncloud/settings.py | 1 + 7 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 uncloud/opennebula/migrations/0002_auto_20200225_1335.py create mode 100644 uncloud/opennebula/migrations/0003_auto_20200225_1428.py diff --git a/uncloud/opennebula/management/commands/syncvm.py b/uncloud/opennebula/management/commands/syncvm.py index 00108f0..55844e3 100644 --- a/uncloud/opennebula/management/commands/syncvm.py +++ b/uncloud/opennebula/management/commands/syncvm.py @@ -54,9 +54,9 @@ class Command(BaseCommand): user_in_db = get_user_model().objects.get(email=vm_owner_email) except get_user_model().DoesNotExist: user_in_db = get_user_model().objects.create_user(username=user.uid, email=vm_owner_email) - VMModel.objects.update_or_create( - defaults={'data': vm, 'owner': user_in_db}, vmid=vm_id + id=f'opennebula{vm_id}', + defaults={'data': vm, 'owner': user_in_db} ) print('User with email but not found in ldap:', unknown_user_with_email) else: diff --git a/uncloud/opennebula/migrations/0002_auto_20200225_1335.py b/uncloud/opennebula/migrations/0002_auto_20200225_1335.py new file mode 100644 index 0000000..1554aa6 --- /dev/null +++ b/uncloud/opennebula/migrations/0002_auto_20200225_1335.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.3 on 2020-02-25 13:35 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('opennebula', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='vm', + name='uuid', + ), + migrations.RemoveField( + model_name='vm', + name='vmid', + ), + migrations.AddField( + model_name='vm', + name='id', + field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, unique=True), + ), + ] diff --git a/uncloud/opennebula/migrations/0003_auto_20200225_1428.py b/uncloud/opennebula/migrations/0003_auto_20200225_1428.py new file mode 100644 index 0000000..8bb3d8d --- /dev/null +++ b/uncloud/opennebula/migrations/0003_auto_20200225_1428.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-02-25 14:28 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('opennebula', '0002_auto_20200225_1335'), + ] + + operations = [ + migrations.AlterField( + model_name='vm', + name='id', + field=models.CharField(default=uuid.uuid4, max_length=64, primary_key=True, serialize=False, unique=True), + ), + ] diff --git a/uncloud/opennebula/models.py b/uncloud/opennebula/models.py index 0b0f307..904699d 100644 --- a/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -1,15 +1,17 @@ import uuid from django.db import models from django.contrib.auth import get_user_model - from django.contrib.postgres.fields import JSONField + class VM(models.Model): - vmid = models.IntegerField(primary_key=True) - uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + id = models.CharField(primary_key=True, editable=True, default=uuid.uuid4, unique=True, max_length=64) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) data = JSONField() + def save(self, *args, **kwargs): + self.id = 'opennebula' + str(self.data.get("ID")) + super().save(*args, **kwargs) @property def cores(self): @@ -48,3 +50,11 @@ class VM(models.Model): ] 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', {}) diff --git a/uncloud/opennebula/serializers.py b/uncloud/opennebula/serializers.py index 30bd20a..6bfaf56 100644 --- a/uncloud/opennebula/serializers.py +++ b/uncloud/opennebula/serializers.py @@ -5,10 +5,10 @@ from opennebula.models import VM class VMSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VM - fields = ['vmid', 'owner', 'data'] + fields = ['id', 'owner', 'data'] class OpenNebulaVMSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VM - fields = ['vmid', 'owner', 'cores', 'ram_in_gb', 'disks' ] + fields = ['id', 'owner', 'cores', 'ram_in_gb', 'disks', 'last_host', 'graphics'] diff --git a/uncloud/opennebula/views.py b/uncloud/opennebula/views.py index 66269c7..61ed5a4 100644 --- a/uncloud/opennebula/views.py +++ b/uncloud/opennebula/views.py @@ -22,6 +22,6 @@ class VMViewSet(viewsets.ViewSet): def retrieve(self, request, pk=None): queryset = VM.objects.filter(owner=request.user) - user = get_object_or_404(queryset, pk=pk) - serializer = OpenNebulaVMSerializer(queryset) + vm = get_object_or_404(queryset, pk=pk) + serializer = OpenNebulaVMSerializer(vm, context={'request': request}) return Response(serializer.data) diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index e8530e7..91d2f73 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -148,6 +148,7 @@ AUTH_USER_MODEL = 'uncloud_auth.User' # AUTH/REST REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.SessionAuthentication', ] } From cc3d2f2d427c8ddafc939d98bee09c9437c59713 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 Feb 2020 18:15:22 +0100 Subject: [PATCH 065/193] in-between-commit Signed-off-by: Nico Schottelius --- uncloud/README.md | 3 +++ uncloud/uncloud/settings.py | 1 + 2 files changed, 4 insertions(+) diff --git a/uncloud/README.md b/uncloud/README.md index 9db1c5c..6d5f1c8 100644 --- a/uncloud/README.md +++ b/uncloud/README.md @@ -39,6 +39,9 @@ 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` ### Secrets diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index f671dc5..bdef1df 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -142,6 +142,7 @@ AUTH_USER_MODEL = 'uncloud_auth.User' # AUTH/REST REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.SessionAuthentication', ] } From 446c13b77c12461f3436b33ec993eb9daadf5979 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 Feb 2020 19:23:39 +0100 Subject: [PATCH 066/193] fix/simplify syncvm --- nicohack202002/uncloud/opennebula/models.py | 11 -- nicohack202002/uncloud/opennebula/views.py | 59 --------- nicohack202002/uncloud/uncloud_api/models.py | 125 ------------------ nicohack202002/uncloud/uncloud_api/views.py | 83 ------------ .../opennebula/management/commands/syncvm.py | 37 ++---- .../migrations/0004_auto_20200225_1816.py | 23 ++++ uncloud/opennebula/models.py | 3 +- uncloud/requirements.txt | 1 - uncloud/uncloud/settings.py | 14 +- .../0002_vmsnapshotproduct_vm_uuid.py | 19 +++ uncloud/uncloud_api/models.py | 2 +- 11 files changed, 57 insertions(+), 320 deletions(-) delete mode 100644 nicohack202002/uncloud/opennebula/models.py delete mode 100644 nicohack202002/uncloud/opennebula/views.py delete mode 100644 nicohack202002/uncloud/uncloud_api/models.py delete mode 100644 nicohack202002/uncloud/uncloud_api/views.py create mode 100644 uncloud/opennebula/migrations/0004_auto_20200225_1816.py create mode 100644 uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py diff --git a/nicohack202002/uncloud/opennebula/models.py b/nicohack202002/uncloud/opennebula/models.py deleted file mode 100644 index 915862a..0000000 --- a/nicohack202002/uncloud/opennebula/models.py +++ /dev/null @@ -1,11 +0,0 @@ -import uuid - -from django.db import models -from django.contrib.auth import get_user_model - - -class VM(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - vmid = models.IntegerField() - owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) - data = models.CharField(max_length=65536, null=True) diff --git a/nicohack202002/uncloud/opennebula/views.py b/nicohack202002/uncloud/opennebula/views.py deleted file mode 100644 index 1030101..0000000 --- a/nicohack202002/uncloud/opennebula/views.py +++ /dev/null @@ -1,59 +0,0 @@ -import json - -from rest_framework import generics -from rest_framework.authentication import SessionAuthentication, BasicAuthentication -from rest_framework.permissions import IsAuthenticated, IsAdminUser - -from .models import VM -from .serializers import VMSerializer - -class VMList(generics.ListAPIView): - authentication_classes = [SessionAuthentication, BasicAuthentication] - permission_classes = [IsAuthenticated, IsAdminUser] - queryset = VM.objects.all() - serializer_class = VMSerializer - - -class VMDetail(generics.RetrieveAPIView): - authentication_classes = [SessionAuthentication, BasicAuthentication] - permission_classes = [IsAuthenticated, IsAdminUser] - lookup_field = 'uuid' - queryset = VM.objects.all() - serializer_class = VMSerializer - - -class UserVMList(generics.ListAPIView): - authentication_classes = [SessionAuthentication, BasicAuthentication] - permission_classes = [IsAuthenticated] - serializer_class = VMSerializer - - def get_queryset(self): - user_email = self.request.user.ldap_user.attrs.data['mail'] - vms = [] - for mail in user_email: - vms += VM.objects.filter(owner__username=mail) - - for vm in vms: - data = json.loads(vm.data) - vm_template = data['TEMPLATE'] - vm.data = { - 'cpu': vm_template['VCPU'], - 'ram': vm_template['MEMORY'], - 'nic': vm_template['NIC'], - 'disks': vm_template['DISK'] - } - - return vms - -####################################### -# Following for quick experimentation # -####################################### - -# from django.http import HttpResponse -# -# def test(request): -# user_email = request.user.ldap_user.attrs.data['mail'] -# vms = [] -# for mail in user_email: -# vms += VM.objects.filter(owner__username=mail) -# return HttpResponse("Hello World") diff --git a/nicohack202002/uncloud/uncloud_api/models.py b/nicohack202002/uncloud/uncloud_api/models.py deleted file mode 100644 index 7eaec7b..0000000 --- a/nicohack202002/uncloud/uncloud_api/models.py +++ /dev/null @@ -1,125 +0,0 @@ -import uuid - -from django.db import models -from django.contrib.auth import get_user_model - -# Product in DB vs. product in code -# DB: -# - need to define params (+param types) in db -> messy? -# - get /products/ is easy / automatic -# -# code -# - can have serializer/verification of fields easily in DRF -# - can have per product side effects / extra code running -# - might (??) make features easier?? -# - how to setup / query the recurring period (?) -# - could get products list via getattr() + re ...Product() classes -# -> this could include the url for ordering => /order/vm_snapshot (params) -# ---> this would work with urlpatterns - -# Combination: create specific product in DB (?) -# - a table per product (?) with 1 entry? - -# Orders -# define state in DB -# select a price from a product => product might change, order stays -# params: -# - the product uuid or name (?) => productuuid -# - the product parameters => for each feature -# - -# logs -# Should have a log = ... => 1:n field for most models! - - -class Product(models.Model): - # override these fields by default - description = "" - recurring_period = "not_recurring" - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - status = models.CharField( - max_length=256, choices=( - ('pending', 'Pending'), - ('being_created', 'Being created'), - ('created_active', 'Created'), - ('deleted', 'Deleted') - ), - default='pending' - ) - - def __str__(self): - return "{}".format(self.name) - - -class VMSnapshotProduct(Product): - price_per_gb_ssd = 0.35 - price_per_gb_hdd = 1.5/100 - - sample_ssd = 10 - sample_hdd = 100 - - def recurring_price(self): - return 0 - - def one_time_price(self): - return 0 - - @classmethod - def sample_price(cls): - return cls.sample_ssd * cls.price_per_gb_ssd + cls.sample_hdd * cls.price_per_gb_hdd - - description = "Create snapshot of a VM" - recurring_period = "monthly" - - @classmethod - def pricing_model(cls): - return """ -Pricing is on monthly basis and storage prices are equivalent to the storage -price in the VM. - -Price per GB SSD is: {} -Price per GB HDD is: {} - - -Sample price for a VM with {} GB SSD and {} GB HDD VM is: {}. -""".format(cls.price_per_gb_ssd, cls.price_per_gb_hdd, - cls.sample_ssd, cls.sample_hdd, cls.sample_price()) - - gb_ssd = models.FloatField() - gb_hdd = models.FloatField() - - -class Feature(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=256) - - recurring_price = models.FloatField(default=0) - one_time_price = models.FloatField() - - product = models.ForeignKey(Product, on_delete=models.CASCADE) - - # params for "cpu": cpu_count -> int - # each feature can only have one parameters - # could call this "value" and set whether it is user usable - # has_value = True/False - # value = string -> int (?) - # value_int - # value_str - # value_float - - def __str__(self): - return "'{}' - '{}'".format(self.product, self.name) - - -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) - - product = models.ForeignKey(Product, - on_delete=models.CASCADE) - - -class VMSnapshotOrder(Order): - pass diff --git a/nicohack202002/uncloud/uncloud_api/views.py b/nicohack202002/uncloud/uncloud_api/views.py deleted file mode 100644 index 68963ff..0000000 --- a/nicohack202002/uncloud/uncloud_api/views.py +++ /dev/null @@ -1,83 +0,0 @@ -from django.shortcuts import render -from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group - -from rest_framework import viewsets, permissions, generics -from .serializers import UserSerializer, GroupSerializer -from rest_framework.views import APIView -from rest_framework.response import Response - - -class CreditCardViewSet(viewsets.ModelViewSet): - - """ - API endpoint that allows credit cards to be listed - """ - queryset = get_user_model().objects.all().order_by('-date_joined') - serializer_class = UserSerializer - - permission_classes = [permissions.IsAuthenticated] - - -class UserViewSet(viewsets.ModelViewSet): - - """ - API endpoint that allows users to be viewed or edited. - """ - queryset = get_user_model().objects.all().order_by('-date_joined') - serializer_class = UserSerializer - - permission_classes = [permissions.IsAuthenticated] - -class GroupViewSet(viewsets.ModelViewSet): - """ - API endpoint that allows groups to be viewed or edited. - """ - queryset = Group.objects.all() - serializer_class = GroupSerializer - - permission_classes = [permissions.IsAuthenticated] - -class GroupViewSet(viewsets.ModelViewSet): - """ - API endpoint that allows groups to be viewed or edited. - """ - queryset = Group.objects.all() - serializer_class = GroupSerializer - - permission_classes = [permissions.IsAuthenticated] - - -# POST /vm/snapshot/ vmuuid=... => create snapshot, returns snapshot uuid -# GET /vm/snapshot => list -# DEL /vm/snapshot/ => delete -# create-list -> get, post => ListCreateAPIView -# del on other! -class VMSnapshotView(generics.ListCreateAPIView): - #lookup_field = 'uuid' - permission_classes = [permissions.IsAuthenticated] - -import inspect -import sys -import re - -# Next: create /order/ urls -# Next: strip off "Product" at the end -class ProductsView(APIView): - def get(self, request, format=None): - clsmembers = inspect.getmembers(sys.modules['uncloud_api.models'], inspect.isclass) - products = [] - for name, c in clsmembers: - # Include everything that ends in Product, but not Product itself - m = re.match(r'(?P.+)Product$', name) - if m: - products.append({ - 'name': m.group('pname'), - 'description': c.description, - 'recurring_period': c.recurring_period, - 'pricing_model': c.pricing_model() - } - ) - - - return Response(products) diff --git a/uncloud/opennebula/management/commands/syncvm.py b/uncloud/opennebula/management/commands/syncvm.py index 55844e3..779db61 100644 --- a/uncloud/opennebula/management/commands/syncvm.py +++ b/uncloud/opennebula/management/commands/syncvm.py @@ -8,15 +8,10 @@ from xmlrpc.client import ServerProxy as RPCClient from django.core.management.base import BaseCommand from django.contrib.auth import get_user_model from xmltodict import parse -from ungleich_common.ldap.ldap_manager import LdapManager from opennebula.models import VM as VMModel - -def find_user_based_on_email(users, email): - for user in users: - if email in user.mail.values: - return user +from django_auth_ldap.backend import LDAPBackend class Command(BaseCommand): @@ -26,39 +21,29 @@ class Command(BaseCommand): pass def handle(self, *args, **options): - ldap_server_uri = secrets.LDAP_SERVER_URI.split(',')[0] - ldap_manager = LdapManager( - server=ldap_server_uri, - admin_dn=secrets.LDAP_ADMIN_DN, - admin_password=secrets.LDAP_ADMIN_PASSWORD, - ) - users = ldap_manager.get('') # Get all users - 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_with_email = set() + unknown_user = set() + + backend = LDAPBackend() for vm in vms: vm_id = vm['ID'] - vm_owner_email = vm['UNAME'] + vm_owner = vm['UNAME'] + + user = backend.populate_user(username=vm_owner) - user = find_user_based_on_email(users, vm_owner_email) if not user: - unknown_user_with_email.add(vm_owner_email) + unknown_user.add(vm_owner) else: - try: - user_in_db = get_user_model().objects.get(email=vm_owner_email) - except get_user_model().DoesNotExist: - user_in_db = get_user_model().objects.create_user(username=user.uid, email=vm_owner_email) VMModel.objects.update_or_create( - id=f'opennebula{vm_id}', - defaults={'data': vm, 'owner': user_in_db} + vmid=vm_id, + defaults={'data': vm, 'owner': user} ) - print('User with email but not found in ldap:', unknown_user_with_email) + print('User not found in ldap:', unknown_user) else: print(response) - print(secrets.OPENNEBULA_USER_PASS) diff --git a/uncloud/opennebula/migrations/0004_auto_20200225_1816.py b/uncloud/opennebula/migrations/0004_auto_20200225_1816.py new file mode 100644 index 0000000..5b39f26 --- /dev/null +++ b/uncloud/opennebula/migrations/0004_auto_20200225_1816.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-02-25 18:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('opennebula', '0003_auto_20200225_1428'), + ] + + operations = [ + migrations.RemoveField( + model_name='vm', + name='id', + ), + migrations.AddField( + model_name='vm', + name='vmid', + field=models.IntegerField(default=42, primary_key=True, serialize=False), + preserve_default=False, + ), + ] diff --git a/uncloud/opennebula/models.py b/uncloud/opennebula/models.py index 904699d..fff811b 100644 --- a/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -5,7 +5,7 @@ from django.contrib.postgres.fields import JSONField class VM(models.Model): - id = models.CharField(primary_key=True, editable=True, default=uuid.uuid4, unique=True, max_length=64) + vmid = models.IntegerField(primary_key=True) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) data = JSONField() @@ -34,7 +34,6 @@ class VM(models.Model): disks = [] if 'DISK' in self.data['TEMPLATE']: - if type(self.data['TEMPLATE']['DISK']) is dict: disks = [ self.data['TEMPLATE']['DISK'] ] else: diff --git a/uncloud/requirements.txt b/uncloud/requirements.txt index c7efd69..1b4e05b 100644 --- a/uncloud/requirements.txt +++ b/uncloud/requirements.txt @@ -3,5 +3,4 @@ djangorestframework django-auth-ldap stripe xmltodict -git+https://code.ungleich.ch/ahmedbilal/ungleich-common/#egg=ungleich-common-ldap&subdirectory=ldap psycopg2 diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index b32b89a..624c9bb 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -18,7 +18,7 @@ import ldap # Uncommitted file with secrets import uncloud.secrets -from django_auth_ldap.config import LDAPSearch +from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion # Uncommitted file with local settings i.e logging try: @@ -129,9 +129,7 @@ AUTH_LDAP_USER_ATTR_MAP = { 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_LDAP_USER_SEARCH = LDAPSearch("dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=%(user)s)") ################################################################################ @@ -174,12 +172,4 @@ USE_TZ = True STATIC_URL = '/static/' -# https://docs.djangoproject.com/en/3.0/ref/settings/#databases -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': uncloud.secrets.POSTGRESQL_DB_NAME, - } -} - stripe.api_key = uncloud.secrets.STRIPE_KEY diff --git a/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py b/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py new file mode 100644 index 0000000..b35317e --- /dev/null +++ b/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-02-25 18:16 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_api', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='vmsnapshotproduct', + name='vm_uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + ] diff --git a/uncloud/uncloud_api/models.py b/uncloud/uncloud_api/models.py index 50857fb..6affaa3 100644 --- a/uncloud/uncloud_api/models.py +++ b/uncloud/uncloud_api/models.py @@ -72,7 +72,7 @@ class VMSnapshotProduct(Product): gb_ssd = models.FloatField() gb_hdd = models.FloatField() - vm_uuid = models.UUIDField() + vm_uuid = models.UUIDField(default=uuid.uuid4, editable=False) # Need to setup recurring_price and one_time_price and recurring period From d4b170f813d997f6c67c3ab0dfaa46a43d4535f3 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 Feb 2020 20:53:12 +0100 Subject: [PATCH 067/193] phase in vmhost Signed-off-by: Nico Schottelius --- uncloud/README.md | 15 ++++ uncloud/uncloud/settings.py | 1 + uncloud/uncloud/urls.py | 12 ++- .../migrations/0003_auto_20200225_1950.py | 36 +++++++++ uncloud/uncloud_api/models.py | 24 ++---- uncloud/uncloud_api/serializers.py | 14 +++- uncloud/uncloud_api/views.py | 43 ++++++++++- uncloud/uncloud_vm/migrations/0001_initial.py | 75 +++++++++++++++++++ .../migrations/0002_auto_20200225_1952.py | 38 ++++++++++ uncloud/uncloud_vm/migrations/__init__.py | 0 uncloud/uncloud_vm/models.py | 40 +++++++--- uncloud/uncloud_vm/serializers.py | 9 +++ uncloud/uncloud_vm/views.py | 24 +++--- 13 files changed, 280 insertions(+), 51 deletions(-) create mode 100644 uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py create mode 100644 uncloud/uncloud_vm/migrations/0001_initial.py create mode 100644 uncloud/uncloud_vm/migrations/0002_auto_20200225_1952.py create mode 100644 uncloud/uncloud_vm/migrations/__init__.py create mode 100644 uncloud/uncloud_vm/serializers.py diff --git a/uncloud/README.md b/uncloud/README.md index 6d5f1c8..e0c0d10 100644 --- a/uncloud/README.md +++ b/uncloud/README.md @@ -48,3 +48,18 @@ Installing the postgresql service is os dependent, but some hints: 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 diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 624c9bb..614cd25 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ 'rest_framework', 'uncloud_api', 'uncloud_auth', + 'uncloud_vm', 'opennebula' ] diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 60054c4..1fe8833 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -17,20 +17,26 @@ from django.contrib import admin from django.urls import path, include from rest_framework import routers -from uncloud_api import views +from uncloud_api import views as apiviews +from uncloud_vm import views as vmviews from opennebula import views as oneviews router = routers.DefaultRouter() -router.register(r'users', views.UserViewSet) +router.register(r'users', apiviews.UserViewSet) router.register(r'opennebula', oneviews.VMViewSet, basename='opennebula') router.register(r'opennebula_raw', oneviews.RawVMViewSet) +router.register(r'vmsnapshot', apiviews.VMSnapshotView, basename='vmsnapshot') + +# admin/staff urls +router.register(r'admin/vmhost', vmviews.VMHostViewSet) + # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. urlpatterns = [ path('', include(router.urls)), path('admin/', admin.site.urls), - path('products/', views.ProductsView.as_view(), name='products'), + path('products/', apiviews.ProductsView.as_view(), name='products'), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) ] diff --git a/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py b/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py new file mode 100644 index 0000000..be7624c --- /dev/null +++ b/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py @@ -0,0 +1,36 @@ +# Generated by Django 3.0.3 on 2020-02-25 19:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_api', '0002_vmsnapshotproduct_vm_uuid'), + ] + + operations = [ + migrations.AlterField( + model_name='vmsnapshotproduct', + name='gb_hdd', + field=models.FloatField(editable=False), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='gb_ssd', + field=models.FloatField(editable=False), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='owner', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='vm_uuid', + field=models.UUIDField(), + ), + ] diff --git a/uncloud/uncloud_api/models.py b/uncloud/uncloud_api/models.py index 6affaa3..acc3c63 100644 --- a/uncloud/uncloud_api/models.py +++ b/uncloud/uncloud_api/models.py @@ -34,7 +34,8 @@ from django.contrib.auth import get_user_model class Product(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE) + on_delete=models.CASCADE, + editable=False) # override these fields by default @@ -52,8 +53,8 @@ class Product(models.Model): ) # This is calculated by each product and saved in the DB - recurring_price = models.FloatField() - one_time_price = models.FloatField() + recurring_price = models.FloatField(editable=False) + one_time_price = models.FloatField(editable=False) @@ -69,14 +70,13 @@ class VMSnapshotProduct(Product): price_per_gb_hdd = 1.5/100 # This we need to get from the VM - gb_ssd = models.FloatField() - gb_hdd = models.FloatField() + gb_ssd = models.FloatField(editable=False) + gb_hdd = models.FloatField(editable=False) - vm_uuid = models.UUIDField(default=uuid.uuid4, editable=False) + vm_uuid = models.UUIDField() # Need to setup recurring_price and one_time_price and recurring period - sample_ssd = 10 sample_hdd = 100 @@ -137,13 +137,3 @@ class Feature(models.Model): def __str__(self): return "'{}' - '{}'".format(self.product, self.name) - - -# 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) - -# product = models.ForeignKey(Product, -# on_delete=models.CASCADE) diff --git a/uncloud/uncloud_api/serializers.py b/uncloud/uncloud_api/serializers.py index 1573bf0..a3a8386 100644 --- a/uncloud/uncloud_api/serializers.py +++ b/uncloud/uncloud_api/serializers.py @@ -3,17 +3,25 @@ from django.contrib.auth import get_user_model from rest_framework import serializers +from .models import VMSnapshotProduct + class UserSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = get_user_model() fields = ['url', 'username', 'email', 'groups'] - class GroupSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Group fields = ['url', 'name'] -class VMSnapshotSerializer(serializers.Serializer): - pass +class VMSnapshotSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VMSnapshotProduct + fields = ['uuid', 'status', 'recurring_price', 'one_time_price' ] + +class VMSnapshotCreateSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VMSnapshotProduct + fields = '__all__' diff --git a/uncloud/uncloud_api/views.py b/uncloud/uncloud_api/views.py index c8ffca7..b71b3d2 100644 --- a/uncloud/uncloud_api/views.py +++ b/uncloud/uncloud_api/views.py @@ -3,14 +3,21 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from rest_framework import viewsets, permissions, generics -from .serializers import UserSerializer, GroupSerializer + from rest_framework.views import APIView from rest_framework.response import Response +from uncloud_vm.models import VMProduct +from .models import VMSnapshotProduct +from .serializers import UserSerializer, GroupSerializer, VMSnapshotSerializer, VMSnapshotCreateSerializer + + import inspect import sys import re + + class UserViewSet(viewsets.ModelViewSet): """ @@ -27,10 +34,40 @@ class UserViewSet(viewsets.ModelViewSet): # DEL /vm/snapshot/ => delete # create-list -> get, post => ListCreateAPIView # del on other! -class VMSnapshotView(generics.ListCreateAPIView): - #lookup_field = 'uuid' +class VMSnapshotView(viewsets.ViewSet): permission_classes = [permissions.IsAuthenticated] + def list(self, request): + queryset = VMSnapshotProduct.objects.filter(owner=request.user) + serializer = VMSnapshotSerializer(queryset, many=True, context={'request': request}) + return Response(serializer.data) + + def retrieve(self, request, pk=None): + queryset = VMSnapshotProduct.objects.filter(owner=request.user) + vm = get_object_or_404(queryset, pk=pk) + serializer = VMSnapshotSerializer(vm, context={'request': request}) + return Response(serializer.data) + + def create(self, request): + print(request.data) + serializer = VMSnapshotCreateSerializer(data=request.data) + + serializer.gb_ssd = 12 + serializer.gb_hdd = 120 + print("F") + serializer.is_valid(raise_exception=True) + + print(serializer) + print("A") + serializer.save() + print("B") + + + # snapshot = VMSnapshotProduct(owner=request.user, + # **serialzer.data) + + return Response(serializer.data) + # Next: create /order/ urls # Next: strip off "Product" at the end diff --git a/uncloud/uncloud_vm/migrations/0001_initial.py b/uncloud/uncloud_vm/migrations/0001_initial.py new file mode 100644 index 0000000..dc4d657 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0001_initial.py @@ -0,0 +1,75 @@ +# Generated by Django 3.0.3 on 2020-02-25 19:50 + +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 = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='VMDiskProduct', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('size_in_gb', models.FloatField()), + ('storage_class', models.CharField(choices=[('hdd', 'HDD'), ('ssd', 'SSD')], default='ssd', max_length=32)), + ], + ), + migrations.CreateModel( + name='VMHost', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('hostname', models.CharField(max_length=253)), + ('physical_cores', models.IntegerField()), + ('usable_cores', models.IntegerField()), + ('usable_ram_in_gb', models.FloatField()), + ('status', models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('unusable', 'Unusable')], default='pending', max_length=32)), + ], + ), + migrations.CreateModel( + name='VMProduct', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('cores', models.IntegerField()), + ('ram_in_gb', models.FloatField()), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('vmhost', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMHost')), + ], + ), + migrations.CreateModel( + name='OperatingSystemDisk', + fields=[ + ('vmdiskproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.VMDiskProduct')), + ('os_name', models.CharField(max_length=128)), + ], + bases=('uncloud_vm.vmdiskproduct',), + ), + 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')), + ], + bases=('uncloud_vm.vmproduct',), + ), + migrations.CreateModel( + name='VMNetworkCard', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('mac_address', models.IntegerField()), + ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')), + ], + ), + migrations.AddField( + model_name='vmdiskproduct', + name='vm', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct'), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0002_auto_20200225_1952.py b/uncloud/uncloud_vm/migrations/0002_auto_20200225_1952.py new file mode 100644 index 0000000..46a207b --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0002_auto_20200225_1952.py @@ -0,0 +1,38 @@ +# Generated by Django 3.0.3 on 2020-02-25 19:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='vmhost', + name='hostname', + field=models.CharField(max_length=253, unique=True), + ), + migrations.AlterField( + model_name='vmhost', + name='physical_cores', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='vmhost', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32), + ), + migrations.AlterField( + model_name='vmhost', + name='usable_cores', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='vmhost', + name='usable_ram_in_gb', + field=models.FloatField(default=0), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/__init__.py b/uncloud/uncloud_vm/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index faf61b0..f79caf3 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -1,20 +1,22 @@ from django.db import models +from django.contrib.auth import get_user_model +import uuid class VMHost(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # 253 is the maximum DNS name length - hostname = models.CharField(max_length=253) + hostname = models.CharField(max_length=253, unique=True) # indirectly gives a maximum number of cores / VM - f.i. 32 - physical_cores = models.IntegerField() + physical_cores = models.IntegerField(default=0) # determines the maximum usable cores - f.i. 320 if you overbook by a factor of 10 - usable_cores = models.IntegerField() + usable_cores = models.IntegerField(default=0) # ram that can be used of the server - usable_ram_in_gb = models.FloatField() + usable_ram_in_gb = models.FloatField(default=0) status = models.CharField(max_length=32, @@ -22,24 +24,33 @@ class VMHost(models.Model): ('pending', 'Pending'), ('active', 'Active'), ('unusable', 'Unusable'), + ('deleted', 'Deleted'), ), default='pending' ) -class VM(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) +class VMProduct(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) + vmhost = models.ForeignKey(VMHost, + on_delete=models.CASCADE, + editable=False) cores = models.IntegerField() ram_in_gb = models.FloatField() - vmhost = models.ForeignKey(VMHost, on_delete=models.CASCADE) +class VMWithOSProduct(VMProduct): + pass -class VMDisk(models.Model): +class VMDiskProduct(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - vm = models.ForeignKey(VM, on_delete=models.CASCADE) + vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) size_in_gb = models.FloatField() storage_class = models.CharField(max_length=32, @@ -49,3 +60,12 @@ class VMDisk(models.Model): ), default='ssd' ) + +class OperatingSystemDisk(VMDiskProduct): + """ Defines an Operating System Disk that can be cloned for a VM """ + os_name = models.CharField(max_length=128) + + +class VMNetworkCard(models.Model): + vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + mac_address = models.IntegerField() diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py new file mode 100644 index 0000000..1279df2 --- /dev/null +++ b/uncloud/uncloud_vm/serializers.py @@ -0,0 +1,9 @@ +from django.contrib.auth import get_user_model + +from rest_framework import serializers +from .models import VMHost + +class VMHostSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VMHost + fields = '__all__' diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index aa5855c..7b4d7a2 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -1,24 +1,18 @@ from django.shortcuts import render - from django.contrib.auth.models import User from django.shortcuts import get_object_or_404 -from myapps.serializers import UserSerializer -from rest_framework import viewsets + +from rest_framework import viewsets, permissions from rest_framework.response import Response + from opennebula.models import VM as OpenNebulaVM -class VMViewSet(viewsets.ViewSet): - def list(self, request): - queryset = User.objects.all() - serializer = UserSerializer(queryset, many=True) - return Response(serializer.data) +from .models import VMHost +from .serializers import VMHostSerializer - def retrieve(self, request, pk=None): - queryset = User.objects.all() - user = get_object_or_404(queryset, pk=pk) - serializer = UserSerializer(user) - return Response(serializer.data) - - permission_classes = [permissions.IsAuthenticated] +class VMHostViewSet(viewsets.ModelViewSet): + serializer_class = VMHostSerializer + queryset = VMHost.objects.all() + permission_classes = [permissions.IsAdminUser] From c7ded96658ee10085642d4f8e07ee08321ce02a3 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 Feb 2020 22:01:55 +0100 Subject: [PATCH 068/193] vmhosts, restructure urls, etc. --- uncloud/uncloud/urls.py | 16 ++--- uncloud/uncloud_api/serializers.py | 5 +- uncloud/uncloud_api/views.py | 63 ++++++++++--------- .../management/commands/schedulevms.py | 21 +++++++ .../management/commands/vmhealth.py | 24 +++++++ .../migrations/0003_auto_20200225_2028.py | 19 ++++++ uncloud/uncloud_vm/models.py | 4 +- uncloud/uncloud_vm/serializers.py | 8 ++- uncloud/uncloud_vm/views.py | 21 +++++-- 9 files changed, 134 insertions(+), 47 deletions(-) create mode 100644 uncloud/uncloud_vm/management/commands/schedulevms.py create mode 100644 uncloud/uncloud_vm/management/commands/vmhealth.py create mode 100644 uncloud/uncloud_vm/migrations/0003_auto_20200225_2028.py diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 1fe8833..23392c5 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -23,20 +23,22 @@ from uncloud_vm import views as vmviews from opennebula import views as oneviews router = routers.DefaultRouter() -router.register(r'users', apiviews.UserViewSet) -router.register(r'opennebula', oneviews.VMViewSet, basename='opennebula') -router.register(r'opennebula_raw', oneviews.RawVMViewSet) -router.register(r'vmsnapshot', apiviews.VMSnapshotView, basename='vmsnapshot') + +router.register(r'user', apiviews.UserViewSet, basename='user') + +router.register(r'vm/snapshot', apiviews.VMSnapshotView, basename='VMSnapshot') +router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') # admin/staff urls router.register(r'admin/vmhost', vmviews.VMHostViewSet) +router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') +router.register(r'admin/opennebula_raw', oneviews.RawVMViewSet) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. urlpatterns = [ path('', include(router.urls)), - path('admin/', admin.site.urls), - path('products/', apiviews.ProductsView.as_view(), name='products'), - path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) + path('admin/', admin.site.urls), # login to django itself + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) # for login to REST API ] diff --git a/uncloud/uncloud_api/serializers.py b/uncloud/uncloud_api/serializers.py index a3a8386..7dc3686 100644 --- a/uncloud/uncloud_api/serializers.py +++ b/uncloud/uncloud_api/serializers.py @@ -5,11 +5,10 @@ from rest_framework import serializers from .models import VMSnapshotProduct - -class UserSerializer(serializers.HyperlinkedModelSerializer): +class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() - fields = ['url', 'username', 'email', 'groups'] + fields = ['url', 'username', 'email'] class GroupSerializer(serializers.HyperlinkedModelSerializer): class Meta: diff --git a/uncloud/uncloud_api/views.py b/uncloud/uncloud_api/views.py index b71b3d2..eb4cc77 100644 --- a/uncloud/uncloud_api/views.py +++ b/uncloud/uncloud_api/views.py @@ -17,18 +17,6 @@ import sys import re - -class UserViewSet(viewsets.ModelViewSet): - - """ - API endpoint that allows users to be viewed or edited. - """ - queryset = get_user_model().objects.all().order_by('-date_joined') - serializer_class = UserSerializer - - permission_classes = [permissions.IsAuthenticated] - - # POST /vm/snapshot/ vmuuid=... => create snapshot, returns snapshot uuid # GET /vm/snapshot => list # DEL /vm/snapshot/ => delete @@ -69,23 +57,38 @@ class VMSnapshotView(viewsets.ViewSet): return Response(serializer.data) -# Next: create /order/ urls -# Next: strip off "Product" at the end -class ProductsView(APIView): - def get(self, request, format=None): - clsmembers = inspect.getmembers(sys.modules['uncloud_api.models'], inspect.isclass) - products = [] - for name, c in clsmembers: - # Include everything that ends in Product, but not Product itself - m = re.match(r'(?P.+)Product$', name) - if m: - products.append({ - 'name': m.group('pname'), - 'description': c.description, - 'recurring_period': c.recurring_period, - 'pricing_model': c.pricing_model() - } - ) + +# maybe drop or not --- we need something to guide the user! +# class ProductsViewSet(viewsets.ViewSet): +# permission_classes = [permissions.IsAuthenticated] + +# def list(self, request): + +# clsmembers = [] +# for modules in [ 'uncloud_api.models', 'uncloud_vm.models' ]: +# clsmembers.extend(inspect.getmembers(sys.modules[modules], inspect.isclass)) - return Response(products) +# products = [] +# for name, c in clsmembers: +# # Include everything that ends in Product, but not Product itself +# m = re.match(r'(?P.+)Product$', name) +# if m: +# products.append({ +# 'name': m.group('pname'), +# 'description': c.description, +# 'recurring_period': c.recurring_period, +# 'pricing_model': c.pricing_model() +# } +# ) + + +# return Response(products) + + +class UserViewSet(viewsets.ModelViewSet): + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return self.request.user diff --git a/uncloud/uncloud_vm/management/commands/schedulevms.py b/uncloud/uncloud_vm/management/commands/schedulevms.py new file mode 100644 index 0000000..836e100 --- /dev/null +++ b/uncloud/uncloud_vm/management/commands/schedulevms.py @@ -0,0 +1,21 @@ +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 VMProduct, VMHost + +class Command(BaseCommand): + help = 'Select VM Host for VMs' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + pending_vms = VMProduct.objects.filter(vmhost__isnull=True) + vmhosts = VMHost.objects.filter(status='active') + for vm in pending_vms: + print(vm) + # FIXME: implement smart placement diff --git a/uncloud/uncloud_vm/management/commands/vmhealth.py b/uncloud/uncloud_vm/management/commands/vmhealth.py new file mode 100644 index 0000000..6109af7 --- /dev/null +++ b/uncloud/uncloud_vm/management/commands/vmhealth.py @@ -0,0 +1,24 @@ +import json + +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model + +from uncloud_vm.models import VMProduct, VMHost + +class Command(BaseCommand): + help = 'Check health of VMs and VMHosts' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + pending_vms = VMProduct.objects.filter(vmhost__isnull=True) + 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 + + + print("Nothing is good, you should implement me") diff --git a/uncloud/uncloud_vm/migrations/0003_auto_20200225_2028.py b/uncloud/uncloud_vm/migrations/0003_auto_20200225_2028.py new file mode 100644 index 0000000..a4e5976 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0003_auto_20200225_2028.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-02-25 20:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0002_auto_20200225_1952'), + ] + + operations = [ + migrations.AlterField( + model_name='vmproduct', + name='vmhost', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMHost'), + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index f79caf3..f4b68dd 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -39,7 +39,9 @@ class VMProduct(models.Model): editable=False) vmhost = models.ForeignKey(VMHost, on_delete=models.CASCADE, - editable=False) + editable=False, + blank=True, + null=True) cores = models.IntegerField() ram_in_gb = models.FloatField() diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 1279df2..4154aee 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -1,9 +1,15 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from .models import VMHost +from .models import VMHost, VMProduct class VMHostSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VMHost fields = '__all__' + + +class VMProductSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VMProduct + fields = '__all__' diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 7b4d7a2..91e81e1 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -6,13 +6,24 @@ from django.shortcuts import get_object_or_404 from rest_framework import viewsets, permissions from rest_framework.response import Response - -from opennebula.models import VM as OpenNebulaVM - -from .models import VMHost -from .serializers import VMHostSerializer +from .models import VMHost, VMProduct +from .serializers import VMHostSerializer, VMProductSerializer class VMHostViewSet(viewsets.ModelViewSet): serializer_class = VMHostSerializer queryset = VMHost.objects.all() permission_classes = [permissions.IsAdminUser] + +class VMProductViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMProductSerializer + + def get_queryset(self): + return VMProduct.objects.filter(owner=self.request.user) + + def create(self, request): + serializer = VMProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + serializer.save(owner=request.user) + + return Response(serializer.data) From bd3d21faa9f90426927341820a8886f3cb1294c7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 25 Feb 2020 22:04:04 +0100 Subject: [PATCH 069/193] add thoughts for health checking --- uncloud/uncloud_vm/management/commands/vmhealth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uncloud/uncloud_vm/management/commands/vmhealth.py b/uncloud/uncloud_vm/management/commands/vmhealth.py index 6109af7..9397b16 100644 --- a/uncloud/uncloud_vm/management/commands/vmhealth.py +++ b/uncloud/uncloud_vm/management/commands/vmhealth.py @@ -20,5 +20,7 @@ class Command(BaseCommand): # 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") From 0b60765e2b4b870943a6e998d1214b5e32d9a683 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 26 Feb 2020 11:16:42 +0100 Subject: [PATCH 070/193] in between commit --- uncloud/uncloud_api/models.py | 2 +- uncloud/uncloud_vm/tests.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 uncloud/uncloud_vm/tests.py diff --git a/uncloud/uncloud_api/models.py b/uncloud/uncloud_api/models.py index 50857fb..fdbcda8 100644 --- a/uncloud/uncloud_api/models.py +++ b/uncloud/uncloud_api/models.py @@ -55,7 +55,7 @@ class Product(models.Model): recurring_price = models.FloatField() one_time_price = models.FloatField() - + # FIXME: need recurring_time_frame class Meta: abstract = True diff --git a/uncloud/uncloud_vm/tests.py b/uncloud/uncloud_vm/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/uncloud/uncloud_vm/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. From 0c7ca1147a4cc813b18574a04e09ee7ae7cc2adf Mon Sep 17 00:00:00 2001 From: Ahmed Bilal <49-ahmedbilal@users.noreply.code.ungleich.ch> Date: Wed, 26 Feb 2020 11:31:17 +0100 Subject: [PATCH 071/193] fix migrations the ugly way Signed-off-by: Nico Schottelius --- nicohack202002/uncloud/opennebula/models.py | 11 -- nicohack202002/uncloud/opennebula/views.py | 59 -------- nicohack202002/uncloud/uncloud_api/models.py | 125 ---------------- nicohack202002/uncloud/uncloud_api/views.py | 83 ----------- notes-nico.org | 22 +-- uncloud/README.md | 18 +++ .../opennebula/management/commands/syncvm.py | 37 ++--- .../migrations/0004_auto_20200225_1816.py | 23 +++ uncloud/opennebula/models.py | 3 +- uncloud/requirements.txt | 2 +- uncloud/uncloud/settings.py | 7 +- uncloud/uncloud/urls.py | 23 +-- .../uncloud_api/migrations/0001_initial.py | 2 +- .../0002_vmsnapshotproduct_vm_uuid.py | 19 +++ .../migrations/0003_auto_20200225_1950.py | 36 +++++ uncloud/uncloud_api/models.py | 35 +++-- uncloud/uncloud_api/serializers.py | 17 ++- uncloud/uncloud_api/views.py | 133 ++++++++++-------- .../management/commands/schedulevms.py | 21 +++ .../management/commands/vmhealth.py | 26 ++++ uncloud/uncloud_vm/migrations/0001_initial.py | 75 ++++++++++ .../migrations/0002_auto_20200225_1952.py | 38 +++++ .../migrations/0003_auto_20200225_2028.py | 19 +++ uncloud/uncloud_vm/migrations/__init__.py | 0 uncloud/uncloud_vm/models.py | 69 ++++++++- uncloud/uncloud_vm/serializers.py | 15 ++ uncloud/uncloud_vm/tests.py | 3 - uncloud/uncloud_vm/views.py | 35 +++-- 28 files changed, 524 insertions(+), 432 deletions(-) delete mode 100644 nicohack202002/uncloud/opennebula/models.py delete mode 100644 nicohack202002/uncloud/opennebula/views.py delete mode 100644 nicohack202002/uncloud/uncloud_api/models.py delete mode 100644 nicohack202002/uncloud/uncloud_api/views.py create mode 100644 uncloud/opennebula/migrations/0004_auto_20200225_1816.py create mode 100644 uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py create mode 100644 uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py create mode 100644 uncloud/uncloud_vm/management/commands/schedulevms.py create mode 100644 uncloud/uncloud_vm/management/commands/vmhealth.py create mode 100644 uncloud/uncloud_vm/migrations/0001_initial.py create mode 100644 uncloud/uncloud_vm/migrations/0002_auto_20200225_1952.py create mode 100644 uncloud/uncloud_vm/migrations/0003_auto_20200225_2028.py create mode 100644 uncloud/uncloud_vm/migrations/__init__.py create mode 100644 uncloud/uncloud_vm/serializers.py delete mode 100644 uncloud/uncloud_vm/tests.py diff --git a/nicohack202002/uncloud/opennebula/models.py b/nicohack202002/uncloud/opennebula/models.py deleted file mode 100644 index 915862a..0000000 --- a/nicohack202002/uncloud/opennebula/models.py +++ /dev/null @@ -1,11 +0,0 @@ -import uuid - -from django.db import models -from django.contrib.auth import get_user_model - - -class VM(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - vmid = models.IntegerField() - owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) - data = models.CharField(max_length=65536, null=True) diff --git a/nicohack202002/uncloud/opennebula/views.py b/nicohack202002/uncloud/opennebula/views.py deleted file mode 100644 index 1030101..0000000 --- a/nicohack202002/uncloud/opennebula/views.py +++ /dev/null @@ -1,59 +0,0 @@ -import json - -from rest_framework import generics -from rest_framework.authentication import SessionAuthentication, BasicAuthentication -from rest_framework.permissions import IsAuthenticated, IsAdminUser - -from .models import VM -from .serializers import VMSerializer - -class VMList(generics.ListAPIView): - authentication_classes = [SessionAuthentication, BasicAuthentication] - permission_classes = [IsAuthenticated, IsAdminUser] - queryset = VM.objects.all() - serializer_class = VMSerializer - - -class VMDetail(generics.RetrieveAPIView): - authentication_classes = [SessionAuthentication, BasicAuthentication] - permission_classes = [IsAuthenticated, IsAdminUser] - lookup_field = 'uuid' - queryset = VM.objects.all() - serializer_class = VMSerializer - - -class UserVMList(generics.ListAPIView): - authentication_classes = [SessionAuthentication, BasicAuthentication] - permission_classes = [IsAuthenticated] - serializer_class = VMSerializer - - def get_queryset(self): - user_email = self.request.user.ldap_user.attrs.data['mail'] - vms = [] - for mail in user_email: - vms += VM.objects.filter(owner__username=mail) - - for vm in vms: - data = json.loads(vm.data) - vm_template = data['TEMPLATE'] - vm.data = { - 'cpu': vm_template['VCPU'], - 'ram': vm_template['MEMORY'], - 'nic': vm_template['NIC'], - 'disks': vm_template['DISK'] - } - - return vms - -####################################### -# Following for quick experimentation # -####################################### - -# from django.http import HttpResponse -# -# def test(request): -# user_email = request.user.ldap_user.attrs.data['mail'] -# vms = [] -# for mail in user_email: -# vms += VM.objects.filter(owner__username=mail) -# return HttpResponse("Hello World") diff --git a/nicohack202002/uncloud/uncloud_api/models.py b/nicohack202002/uncloud/uncloud_api/models.py deleted file mode 100644 index 7eaec7b..0000000 --- a/nicohack202002/uncloud/uncloud_api/models.py +++ /dev/null @@ -1,125 +0,0 @@ -import uuid - -from django.db import models -from django.contrib.auth import get_user_model - -# Product in DB vs. product in code -# DB: -# - need to define params (+param types) in db -> messy? -# - get /products/ is easy / automatic -# -# code -# - can have serializer/verification of fields easily in DRF -# - can have per product side effects / extra code running -# - might (??) make features easier?? -# - how to setup / query the recurring period (?) -# - could get products list via getattr() + re ...Product() classes -# -> this could include the url for ordering => /order/vm_snapshot (params) -# ---> this would work with urlpatterns - -# Combination: create specific product in DB (?) -# - a table per product (?) with 1 entry? - -# Orders -# define state in DB -# select a price from a product => product might change, order stays -# params: -# - the product uuid or name (?) => productuuid -# - the product parameters => for each feature -# - -# logs -# Should have a log = ... => 1:n field for most models! - - -class Product(models.Model): - # override these fields by default - description = "" - recurring_period = "not_recurring" - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - status = models.CharField( - max_length=256, choices=( - ('pending', 'Pending'), - ('being_created', 'Being created'), - ('created_active', 'Created'), - ('deleted', 'Deleted') - ), - default='pending' - ) - - def __str__(self): - return "{}".format(self.name) - - -class VMSnapshotProduct(Product): - price_per_gb_ssd = 0.35 - price_per_gb_hdd = 1.5/100 - - sample_ssd = 10 - sample_hdd = 100 - - def recurring_price(self): - return 0 - - def one_time_price(self): - return 0 - - @classmethod - def sample_price(cls): - return cls.sample_ssd * cls.price_per_gb_ssd + cls.sample_hdd * cls.price_per_gb_hdd - - description = "Create snapshot of a VM" - recurring_period = "monthly" - - @classmethod - def pricing_model(cls): - return """ -Pricing is on monthly basis and storage prices are equivalent to the storage -price in the VM. - -Price per GB SSD is: {} -Price per GB HDD is: {} - - -Sample price for a VM with {} GB SSD and {} GB HDD VM is: {}. -""".format(cls.price_per_gb_ssd, cls.price_per_gb_hdd, - cls.sample_ssd, cls.sample_hdd, cls.sample_price()) - - gb_ssd = models.FloatField() - gb_hdd = models.FloatField() - - -class Feature(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=256) - - recurring_price = models.FloatField(default=0) - one_time_price = models.FloatField() - - product = models.ForeignKey(Product, on_delete=models.CASCADE) - - # params for "cpu": cpu_count -> int - # each feature can only have one parameters - # could call this "value" and set whether it is user usable - # has_value = True/False - # value = string -> int (?) - # value_int - # value_str - # value_float - - def __str__(self): - return "'{}' - '{}'".format(self.product, self.name) - - -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) - - product = models.ForeignKey(Product, - on_delete=models.CASCADE) - - -class VMSnapshotOrder(Order): - pass diff --git a/nicohack202002/uncloud/uncloud_api/views.py b/nicohack202002/uncloud/uncloud_api/views.py deleted file mode 100644 index 68963ff..0000000 --- a/nicohack202002/uncloud/uncloud_api/views.py +++ /dev/null @@ -1,83 +0,0 @@ -from django.shortcuts import render -from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group - -from rest_framework import viewsets, permissions, generics -from .serializers import UserSerializer, GroupSerializer -from rest_framework.views import APIView -from rest_framework.response import Response - - -class CreditCardViewSet(viewsets.ModelViewSet): - - """ - API endpoint that allows credit cards to be listed - """ - queryset = get_user_model().objects.all().order_by('-date_joined') - serializer_class = UserSerializer - - permission_classes = [permissions.IsAuthenticated] - - -class UserViewSet(viewsets.ModelViewSet): - - """ - API endpoint that allows users to be viewed or edited. - """ - queryset = get_user_model().objects.all().order_by('-date_joined') - serializer_class = UserSerializer - - permission_classes = [permissions.IsAuthenticated] - -class GroupViewSet(viewsets.ModelViewSet): - """ - API endpoint that allows groups to be viewed or edited. - """ - queryset = Group.objects.all() - serializer_class = GroupSerializer - - permission_classes = [permissions.IsAuthenticated] - -class GroupViewSet(viewsets.ModelViewSet): - """ - API endpoint that allows groups to be viewed or edited. - """ - queryset = Group.objects.all() - serializer_class = GroupSerializer - - permission_classes = [permissions.IsAuthenticated] - - -# POST /vm/snapshot/ vmuuid=... => create snapshot, returns snapshot uuid -# GET /vm/snapshot => list -# DEL /vm/snapshot/ => delete -# create-list -> get, post => ListCreateAPIView -# del on other! -class VMSnapshotView(generics.ListCreateAPIView): - #lookup_field = 'uuid' - permission_classes = [permissions.IsAuthenticated] - -import inspect -import sys -import re - -# Next: create /order/ urls -# Next: strip off "Product" at the end -class ProductsView(APIView): - def get(self, request, format=None): - clsmembers = inspect.getmembers(sys.modules['uncloud_api.models'], inspect.isclass) - products = [] - for name, c in clsmembers: - # Include everything that ends in Product, but not Product itself - m = re.match(r'(?P.+)Product$', name) - if m: - products.append({ - 'name': m.group('pname'), - 'description': c.description, - 'recurring_period': c.recurring_period, - 'pricing_model': c.pricing_model() - } - ) - - - return Response(products) diff --git a/notes-nico.org b/notes-nico.org index 03c1b97..811fbff 100644 --- a/notes-nico.org +++ b/notes-nico.org @@ -9,6 +9,16 @@ 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] @@ -50,16 +60,8 @@ password=... ** viewset: .list and .create ** view: .get .post * TODO register CC -* TODO list products -* ahmed -** schemas -*** field: is_valid? - used by schemas -*** definition of a "schema" -* penguin pay -## How to place a order with penguin pay - -### Requirements - +* 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) diff --git a/uncloud/README.md b/uncloud/README.md index 9db1c5c..e0c0d10 100644 --- a/uncloud/README.md +++ b/uncloud/README.md @@ -39,9 +39,27 @@ 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` ### 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 diff --git a/uncloud/opennebula/management/commands/syncvm.py b/uncloud/opennebula/management/commands/syncvm.py index 55844e3..779db61 100644 --- a/uncloud/opennebula/management/commands/syncvm.py +++ b/uncloud/opennebula/management/commands/syncvm.py @@ -8,15 +8,10 @@ from xmlrpc.client import ServerProxy as RPCClient from django.core.management.base import BaseCommand from django.contrib.auth import get_user_model from xmltodict import parse -from ungleich_common.ldap.ldap_manager import LdapManager from opennebula.models import VM as VMModel - -def find_user_based_on_email(users, email): - for user in users: - if email in user.mail.values: - return user +from django_auth_ldap.backend import LDAPBackend class Command(BaseCommand): @@ -26,39 +21,29 @@ class Command(BaseCommand): pass def handle(self, *args, **options): - ldap_server_uri = secrets.LDAP_SERVER_URI.split(',')[0] - ldap_manager = LdapManager( - server=ldap_server_uri, - admin_dn=secrets.LDAP_ADMIN_DN, - admin_password=secrets.LDAP_ADMIN_PASSWORD, - ) - users = ldap_manager.get('') # Get all users - 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_with_email = set() + unknown_user = set() + + backend = LDAPBackend() for vm in vms: vm_id = vm['ID'] - vm_owner_email = vm['UNAME'] + vm_owner = vm['UNAME'] + + user = backend.populate_user(username=vm_owner) - user = find_user_based_on_email(users, vm_owner_email) if not user: - unknown_user_with_email.add(vm_owner_email) + unknown_user.add(vm_owner) else: - try: - user_in_db = get_user_model().objects.get(email=vm_owner_email) - except get_user_model().DoesNotExist: - user_in_db = get_user_model().objects.create_user(username=user.uid, email=vm_owner_email) VMModel.objects.update_or_create( - id=f'opennebula{vm_id}', - defaults={'data': vm, 'owner': user_in_db} + vmid=vm_id, + defaults={'data': vm, 'owner': user} ) - print('User with email but not found in ldap:', unknown_user_with_email) + print('User not found in ldap:', unknown_user) else: print(response) - print(secrets.OPENNEBULA_USER_PASS) diff --git a/uncloud/opennebula/migrations/0004_auto_20200225_1816.py b/uncloud/opennebula/migrations/0004_auto_20200225_1816.py new file mode 100644 index 0000000..5b39f26 --- /dev/null +++ b/uncloud/opennebula/migrations/0004_auto_20200225_1816.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-02-25 18:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('opennebula', '0003_auto_20200225_1428'), + ] + + operations = [ + migrations.RemoveField( + model_name='vm', + name='id', + ), + migrations.AddField( + model_name='vm', + name='vmid', + field=models.IntegerField(default=42, primary_key=True, serialize=False), + preserve_default=False, + ), + ] diff --git a/uncloud/opennebula/models.py b/uncloud/opennebula/models.py index 904699d..fff811b 100644 --- a/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -5,7 +5,7 @@ from django.contrib.postgres.fields import JSONField class VM(models.Model): - id = models.CharField(primary_key=True, editable=True, default=uuid.uuid4, unique=True, max_length=64) + vmid = models.IntegerField(primary_key=True) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) data = JSONField() @@ -34,7 +34,6 @@ class VM(models.Model): disks = [] if 'DISK' in self.data['TEMPLATE']: - if type(self.data['TEMPLATE']['DISK']) is dict: disks = [ self.data['TEMPLATE']['DISK'] ] else: diff --git a/uncloud/requirements.txt b/uncloud/requirements.txt index e79f479..1b4e05b 100644 --- a/uncloud/requirements.txt +++ b/uncloud/requirements.txt @@ -3,4 +3,4 @@ djangorestframework django-auth-ldap stripe xmltodict -git+https://code.ungleich.ch/ahmedbilal/ungleich-common/#egg=ungleich-common-ldap&subdirectory=ldap +psycopg2 diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 91d2f73..614cd25 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -18,7 +18,7 @@ import ldap # Uncommitted file with secrets import uncloud.secrets -from django_auth_ldap.config import LDAPSearch +from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion # Uncommitted file with local settings i.e logging try: @@ -62,6 +62,7 @@ INSTALLED_APPS = [ 'rest_framework', 'uncloud_api', 'uncloud_auth', + 'uncloud_vm', 'opennebula' ] @@ -129,9 +130,7 @@ AUTH_LDAP_USER_ATTR_MAP = { 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_LDAP_USER_SEARCH = LDAPSearch("dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=%(user)s)") ################################################################################ diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index a01ef66..23392c5 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -17,21 +17,28 @@ from django.contrib import admin from django.urls import path, include from rest_framework import routers -from uncloud_api import views +from uncloud_api import views as apiviews +from uncloud_vm import views as vmviews from opennebula import views as oneviews router = routers.DefaultRouter() -router.register(r'users', views.UserViewSet) -router.register(r'groups', views.GroupViewSet) -router.register(r'opennebula', oneviews.VMViewSet, basename='opennebula') -router.register(r'opennebula_raw', oneviews.RawVMViewSet) + +router.register(r'user', apiviews.UserViewSet, basename='user') + +router.register(r'vm/snapshot', apiviews.VMSnapshotView, basename='VMSnapshot') +router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') + +# admin/staff urls +router.register(r'admin/vmhost', vmviews.VMHostViewSet) +router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') +router.register(r'admin/opennebula_raw', oneviews.RawVMViewSet) + # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. urlpatterns = [ path('', include(router.urls)), - path('admin/', admin.site.urls), - path('products/', views.ProductsView.as_view(), name='products'), - path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) + path('admin/', admin.site.urls), # login to django itself + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) # for login to REST API ] diff --git a/uncloud/uncloud_api/migrations/0001_initial.py b/uncloud/uncloud_api/migrations/0001_initial.py index c549a9d..67bdd2e 100644 --- a/uncloud/uncloud_api/migrations/0001_initial.py +++ b/uncloud/uncloud_api/migrations/0001_initial.py @@ -19,7 +19,7 @@ class Migration(migrations.Migration): name='VMSnapshotProduct', fields=[ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('created_active', 'Created'), ('deleted', 'Deleted')], default='pending', max_length=256)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), ('gb_ssd', models.FloatField()), ('gb_hdd', models.FloatField()), ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), diff --git a/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py b/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py new file mode 100644 index 0000000..b35317e --- /dev/null +++ b/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-02-25 18:16 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_api', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='vmsnapshotproduct', + name='vm_uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + ] diff --git a/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py b/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py new file mode 100644 index 0000000..be7624c --- /dev/null +++ b/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py @@ -0,0 +1,36 @@ +# Generated by Django 3.0.3 on 2020-02-25 19:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_api', '0002_vmsnapshotproduct_vm_uuid'), + ] + + operations = [ + migrations.AlterField( + model_name='vmsnapshotproduct', + name='gb_hdd', + field=models.FloatField(editable=False), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='gb_ssd', + field=models.FloatField(editable=False), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='owner', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='vm_uuid', + field=models.UUIDField(), + ), + ] diff --git a/uncloud/uncloud_api/models.py b/uncloud/uncloud_api/models.py index 11a7560..6a6f9c8 100644 --- a/uncloud/uncloud_api/models.py +++ b/uncloud/uncloud_api/models.py @@ -34,7 +34,8 @@ from django.contrib.auth import get_user_model class Product(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE) + on_delete=models.CASCADE, + editable=False) # override these fields by default @@ -45,12 +46,18 @@ class Product(models.Model): choices = ( ('pending', 'Pending'), ('being_created', 'Being created'), - ('created_active', 'Created'), + ('active', 'Active'), ('deleted', 'Deleted') ), default='pending' ) + # This is calculated by each product and saved in the DB + recurring_price = models.FloatField(editable=False) + one_time_price = models.FloatField(editable=False) + + # FIXME: need recurring_time_frame + class Meta: abstract = True @@ -62,6 +69,14 @@ class VMSnapshotProduct(Product): price_per_gb_ssd = 0.35 price_per_gb_hdd = 1.5/100 + # This we need to get from the VM + gb_ssd = models.FloatField(editable=False) + gb_hdd = models.FloatField(editable=False) + + vm_uuid = models.UUIDField() + + # Need to setup recurring_price and one_time_price and recurring period + sample_ssd = 10 sample_hdd = 100 @@ -92,8 +107,10 @@ Sample price for a VM with {} GB SSD and {} GB HDD VM is: {}. """.format(cls.price_per_gb_ssd, cls.price_per_gb_hdd, cls.sample_ssd, cls.sample_hdd, cls.sample_price()) - gb_ssd = models.FloatField() - gb_hdd = models.FloatField() + + + + @@ -120,13 +137,3 @@ class Feature(models.Model): def __str__(self): return "'{}' - '{}'".format(self.product, self.name) - - -# 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) - -# product = models.ForeignKey(Product, -# on_delete=models.CASCADE) diff --git a/uncloud/uncloud_api/serializers.py b/uncloud/uncloud_api/serializers.py index 1573bf0..7dc3686 100644 --- a/uncloud/uncloud_api/serializers.py +++ b/uncloud/uncloud_api/serializers.py @@ -3,17 +3,24 @@ from django.contrib.auth import get_user_model from rest_framework import serializers +from .models import VMSnapshotProduct -class UserSerializer(serializers.HyperlinkedModelSerializer): +class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() - fields = ['url', 'username', 'email', 'groups'] - + fields = ['url', 'username', 'email'] class GroupSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Group fields = ['url', 'name'] -class VMSnapshotSerializer(serializers.Serializer): - pass +class VMSnapshotSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VMSnapshotProduct + fields = ['uuid', 'status', 'recurring_price', 'one_time_price' ] + +class VMSnapshotCreateSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VMSnapshotProduct + fields = '__all__' diff --git a/uncloud/uncloud_api/views.py b/uncloud/uncloud_api/views.py index 68963ff..eb4cc77 100644 --- a/uncloud/uncloud_api/views.py +++ b/uncloud/uncloud_api/views.py @@ -3,49 +3,18 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from rest_framework import viewsets, permissions, generics -from .serializers import UserSerializer, GroupSerializer + from rest_framework.views import APIView from rest_framework.response import Response - -class CreditCardViewSet(viewsets.ModelViewSet): - - """ - API endpoint that allows credit cards to be listed - """ - queryset = get_user_model().objects.all().order_by('-date_joined') - serializer_class = UserSerializer - - permission_classes = [permissions.IsAuthenticated] +from uncloud_vm.models import VMProduct +from .models import VMSnapshotProduct +from .serializers import UserSerializer, GroupSerializer, VMSnapshotSerializer, VMSnapshotCreateSerializer -class UserViewSet(viewsets.ModelViewSet): - - """ - API endpoint that allows users to be viewed or edited. - """ - queryset = get_user_model().objects.all().order_by('-date_joined') - serializer_class = UserSerializer - - permission_classes = [permissions.IsAuthenticated] - -class GroupViewSet(viewsets.ModelViewSet): - """ - API endpoint that allows groups to be viewed or edited. - """ - queryset = Group.objects.all() - serializer_class = GroupSerializer - - permission_classes = [permissions.IsAuthenticated] - -class GroupViewSet(viewsets.ModelViewSet): - """ - API endpoint that allows groups to be viewed or edited. - """ - queryset = Group.objects.all() - serializer_class = GroupSerializer - - permission_classes = [permissions.IsAuthenticated] +import inspect +import sys +import re # POST /vm/snapshot/ vmuuid=... => create snapshot, returns snapshot uuid @@ -53,31 +22,73 @@ class GroupViewSet(viewsets.ModelViewSet): # DEL /vm/snapshot/ => delete # create-list -> get, post => ListCreateAPIView # del on other! -class VMSnapshotView(generics.ListCreateAPIView): - #lookup_field = 'uuid' +class VMSnapshotView(viewsets.ViewSet): permission_classes = [permissions.IsAuthenticated] -import inspect -import sys -import re + def list(self, request): + queryset = VMSnapshotProduct.objects.filter(owner=request.user) + serializer = VMSnapshotSerializer(queryset, many=True, context={'request': request}) + return Response(serializer.data) -# Next: create /order/ urls -# Next: strip off "Product" at the end -class ProductsView(APIView): - def get(self, request, format=None): - clsmembers = inspect.getmembers(sys.modules['uncloud_api.models'], inspect.isclass) - products = [] - for name, c in clsmembers: - # Include everything that ends in Product, but not Product itself - m = re.match(r'(?P.+)Product$', name) - if m: - products.append({ - 'name': m.group('pname'), - 'description': c.description, - 'recurring_period': c.recurring_period, - 'pricing_model': c.pricing_model() - } - ) + def retrieve(self, request, pk=None): + queryset = VMSnapshotProduct.objects.filter(owner=request.user) + vm = get_object_or_404(queryset, pk=pk) + serializer = VMSnapshotSerializer(vm, context={'request': request}) + return Response(serializer.data) + + def create(self, request): + print(request.data) + serializer = VMSnapshotCreateSerializer(data=request.data) + + serializer.gb_ssd = 12 + serializer.gb_hdd = 120 + print("F") + serializer.is_valid(raise_exception=True) + + print(serializer) + print("A") + serializer.save() + print("B") - return Response(products) + # snapshot = VMSnapshotProduct(owner=request.user, + # **serialzer.data) + + return Response(serializer.data) + + + +# maybe drop or not --- we need something to guide the user! +# class ProductsViewSet(viewsets.ViewSet): +# permission_classes = [permissions.IsAuthenticated] + +# def list(self, request): + +# clsmembers = [] +# for modules in [ 'uncloud_api.models', 'uncloud_vm.models' ]: +# clsmembers.extend(inspect.getmembers(sys.modules[modules], inspect.isclass)) + + +# products = [] +# for name, c in clsmembers: +# # Include everything that ends in Product, but not Product itself +# m = re.match(r'(?P.+)Product$', name) +# if m: +# products.append({ +# 'name': m.group('pname'), +# 'description': c.description, +# 'recurring_period': c.recurring_period, +# 'pricing_model': c.pricing_model() +# } +# ) + + +# return Response(products) + + +class UserViewSet(viewsets.ModelViewSet): + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return self.request.user diff --git a/uncloud/uncloud_vm/management/commands/schedulevms.py b/uncloud/uncloud_vm/management/commands/schedulevms.py new file mode 100644 index 0000000..836e100 --- /dev/null +++ b/uncloud/uncloud_vm/management/commands/schedulevms.py @@ -0,0 +1,21 @@ +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 VMProduct, VMHost + +class Command(BaseCommand): + help = 'Select VM Host for VMs' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + pending_vms = VMProduct.objects.filter(vmhost__isnull=True) + vmhosts = VMHost.objects.filter(status='active') + for vm in pending_vms: + print(vm) + # FIXME: implement smart placement diff --git a/uncloud/uncloud_vm/management/commands/vmhealth.py b/uncloud/uncloud_vm/management/commands/vmhealth.py new file mode 100644 index 0000000..9397b16 --- /dev/null +++ b/uncloud/uncloud_vm/management/commands/vmhealth.py @@ -0,0 +1,26 @@ +import json + +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model + +from uncloud_vm.models import VMProduct, VMHost + +class Command(BaseCommand): + help = 'Check health of VMs and VMHosts' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + pending_vms = VMProduct.objects.filter(vmhost__isnull=True) + vmhosts = VMHost.objects.filter(status='active') + + # 1. Check that all active hosts reported back N seconds ago + # 2. Check that no VM is running on a dead host + # 3. Migrate VMs if necessary + # 4. Check that no VMs have been pending for longer than Y seconds + + # If VM snapshots exist without a VM -> notify user (?) + + + print("Nothing is good, you should implement me") diff --git a/uncloud/uncloud_vm/migrations/0001_initial.py b/uncloud/uncloud_vm/migrations/0001_initial.py new file mode 100644 index 0000000..dc4d657 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0001_initial.py @@ -0,0 +1,75 @@ +# Generated by Django 3.0.3 on 2020-02-25 19:50 + +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 = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='VMDiskProduct', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('size_in_gb', models.FloatField()), + ('storage_class', models.CharField(choices=[('hdd', 'HDD'), ('ssd', 'SSD')], default='ssd', max_length=32)), + ], + ), + migrations.CreateModel( + name='VMHost', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('hostname', models.CharField(max_length=253)), + ('physical_cores', models.IntegerField()), + ('usable_cores', models.IntegerField()), + ('usable_ram_in_gb', models.FloatField()), + ('status', models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('unusable', 'Unusable')], default='pending', max_length=32)), + ], + ), + migrations.CreateModel( + name='VMProduct', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('cores', models.IntegerField()), + ('ram_in_gb', models.FloatField()), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('vmhost', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMHost')), + ], + ), + migrations.CreateModel( + name='OperatingSystemDisk', + fields=[ + ('vmdiskproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.VMDiskProduct')), + ('os_name', models.CharField(max_length=128)), + ], + bases=('uncloud_vm.vmdiskproduct',), + ), + 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')), + ], + bases=('uncloud_vm.vmproduct',), + ), + migrations.CreateModel( + name='VMNetworkCard', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('mac_address', models.IntegerField()), + ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')), + ], + ), + migrations.AddField( + model_name='vmdiskproduct', + name='vm', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct'), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0002_auto_20200225_1952.py b/uncloud/uncloud_vm/migrations/0002_auto_20200225_1952.py new file mode 100644 index 0000000..46a207b --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0002_auto_20200225_1952.py @@ -0,0 +1,38 @@ +# Generated by Django 3.0.3 on 2020-02-25 19:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='vmhost', + name='hostname', + field=models.CharField(max_length=253, unique=True), + ), + migrations.AlterField( + model_name='vmhost', + name='physical_cores', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='vmhost', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32), + ), + migrations.AlterField( + model_name='vmhost', + name='usable_cores', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='vmhost', + name='usable_ram_in_gb', + field=models.FloatField(default=0), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0003_auto_20200225_2028.py b/uncloud/uncloud_vm/migrations/0003_auto_20200225_2028.py new file mode 100644 index 0000000..a4e5976 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0003_auto_20200225_2028.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-02-25 20:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0002_auto_20200225_1952'), + ] + + operations = [ + migrations.AlterField( + model_name='vmproduct', + name='vmhost', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMHost'), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/__init__.py b/uncloud/uncloud_vm/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index b1aab40..f4b68dd 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -1,12 +1,73 @@ from django.db import models +from django.contrib.auth import get_user_model +import uuid -class VM(models.Model): +class VMHost(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + + # 253 is the maximum DNS name length + hostname = models.CharField(max_length=253, unique=True) + + # indirectly gives a maximum number of cores / VM - f.i. 32 + physical_cores = models.IntegerField(default=0) + + # determines the maximum usable cores - f.i. 320 if you overbook by a factor of 10 + usable_cores = models.IntegerField(default=0) + + # ram that can be used of the server + usable_ram_in_gb = models.FloatField(default=0) + + + status = models.CharField(max_length=32, + choices = ( + ('pending', 'Pending'), + ('active', 'Active'), + ('unusable', 'Unusable'), + ('deleted', 'Deleted'), + ), + default='pending' + ) + + +class VMProduct(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) + vmhost = models.ForeignKey(VMHost, + on_delete=models.CASCADE, + editable=False, + blank=True, + null=True) cores = models.IntegerField() - ram = models.FloatField() + ram_in_gb = models.FloatField() -class VMDisk(models.Model): +class VMWithOSProduct(VMProduct): + pass + +class VMDiskProduct(models.Model): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + size_in_gb = models.FloatField() + + storage_class = models.CharField(max_length=32, + choices = ( + ('hdd', 'HDD'), + ('ssd', 'SSD'), + ), + default='ssd' + ) + +class OperatingSystemDisk(VMDiskProduct): + """ Defines an Operating System Disk that can be cloned for a VM """ + os_name = models.CharField(max_length=128) + + +class VMNetworkCard(models.Model): + vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + mac_address = models.IntegerField() diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py new file mode 100644 index 0000000..4154aee --- /dev/null +++ b/uncloud/uncloud_vm/serializers.py @@ -0,0 +1,15 @@ +from django.contrib.auth import get_user_model + +from rest_framework import serializers +from .models import VMHost, VMProduct + +class VMHostSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VMHost + fields = '__all__' + + +class VMProductSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VMProduct + fields = '__all__' diff --git a/uncloud/uncloud_vm/tests.py b/uncloud/uncloud_vm/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/uncloud/uncloud_vm/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index aa5855c..91e81e1 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -1,24 +1,29 @@ from django.shortcuts import render - from django.contrib.auth.models import User from django.shortcuts import get_object_or_404 -from myapps.serializers import UserSerializer -from rest_framework import viewsets + +from rest_framework import viewsets, permissions from rest_framework.response import Response -from opennebula.models import VM as OpenNebulaVM +from .models import VMHost, VMProduct +from .serializers import VMHostSerializer, VMProductSerializer -class VMViewSet(viewsets.ViewSet): - def list(self, request): - queryset = User.objects.all() - serializer = UserSerializer(queryset, many=True) - return Response(serializer.data) - - def retrieve(self, request, pk=None): - queryset = User.objects.all() - user = get_object_or_404(queryset, pk=pk) - serializer = UserSerializer(user) - return Response(serializer.data) +class VMHostViewSet(viewsets.ModelViewSet): + serializer_class = VMHostSerializer + queryset = VMHost.objects.all() + permission_classes = [permissions.IsAdminUser] +class VMProductViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] + serializer_class = VMProductSerializer + + def get_queryset(self): + return VMProduct.objects.filter(owner=self.request.user) + + def create(self, request): + serializer = VMProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + serializer.save(owner=request.user) + + return Response(serializer.data) From c0bf4d96c48592a41a33d4a0962b912588c25bfc Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 26 Feb 2020 21:13:30 +0100 Subject: [PATCH 072/193] ++ debian/devuan notes --- uncloud/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/uncloud/README.md b/uncloud/README.md index e0c0d10..67f960f 100644 --- a/uncloud/README.md +++ b/uncloud/README.md @@ -8,6 +8,13 @@ Alpine: apk add openldap-dev postgresql-dev ``` +Debian/Devuan: + +``` +apt install postgresql-server-dev-all +``` + + ### Python requirements If you prefer using a venv, use: @@ -42,7 +49,7 @@ 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` ### Secrets From 1ca247148c79620213b31260dabfbef90fb338d3 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Feb 2020 11:21:38 +0100 Subject: [PATCH 073/193] [uncloud_pay] add "prototype" --- uncloud/uncloud_pay/__init__.py | 0 uncloud/uncloud_pay/admin.py | 3 + uncloud/uncloud_pay/apps.py | 5 ++ uncloud/uncloud_pay/migrations/__init__.py | 0 uncloud/uncloud_pay/models.py | 91 ++++++++++++++++++++++ uncloud/uncloud_pay/tests.py | 3 + uncloud/uncloud_pay/views.py | 55 +++++++++++++ 7 files changed, 157 insertions(+) create mode 100644 uncloud/uncloud_pay/__init__.py create mode 100644 uncloud/uncloud_pay/admin.py create mode 100644 uncloud/uncloud_pay/apps.py create mode 100644 uncloud/uncloud_pay/migrations/__init__.py create mode 100644 uncloud/uncloud_pay/models.py create mode 100644 uncloud/uncloud_pay/tests.py create mode 100644 uncloud/uncloud_pay/views.py diff --git a/uncloud/uncloud_pay/__init__.py b/uncloud/uncloud_pay/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/uncloud_pay/admin.py b/uncloud/uncloud_pay/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/uncloud/uncloud_pay/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/uncloud/uncloud_pay/apps.py b/uncloud/uncloud_pay/apps.py new file mode 100644 index 0000000..051ffb4 --- /dev/null +++ b/uncloud/uncloud_pay/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UncloudPayConfig(AppConfig): + name = 'uncloud_pay' diff --git a/uncloud/uncloud_pay/migrations/__init__.py b/uncloud/uncloud_pay/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py new file mode 100644 index 0000000..6910d58 --- /dev/null +++ b/uncloud/uncloud_pay/models.py @@ -0,0 +1,91 @@ +from django.db import models +from django.contrib.auth import get_user_model + +# Create your models here. + + +class Bill(models.Model): + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + + creation_date = models.DateTimeField() + starting_date = models.DateTimeField() + ending_date = models.DateTimeField() + due_date = models.DateField() + + paid = models.BooleanField(default=False) + valid = models.BooleanField(default=True) + + @property + def amount(self): + # iterate over all related orders + pass + + +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) + + creation_date = models.DateTimeField() + starting_date = models.DateTimeField() + ending_date = models.DateTimeField(blank=True, + null=True) + + bill = models.ManyToManyField(Bill, + on_delete=models.CASCADE, + editable=False, + blank=True, + null=True) + + + recurring_price = models.FloatField(editable=False) + one_time_price = models.FloatField(editable=False) + + recurring_period = models.CharField(max_length=32, + choices = ( + ('onetime', 'Onetime'), + ('per_year', 'Per Year'), + ('per_month', 'Per Month'), + ('per_week', 'Per Week'), + ('per_day', 'Per Day'), + ('per_hour', 'Per Hour'), + ('per_minute', 'Per Minute'), + ('per_second', 'Per Second'), + ), + default='onetime' + + ) + + # def amount(self): + # amount = recurring_price + # if recurring and first_month: + # amount += one_time_price + + # return amount # you get the picture + + + +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, + editable=False) + + amount = models.DecimalField( + default=0.0, + validators=[MinValueValidator(0)]) + + source = models.CharField(max_length=256, + choices = ( + ('wire', 'Wire Transfer'), + ('strip', 'Stripe'), + ('voucher', 'Voucher'), + ('referral', 'Referral'), + ('unknown', 'Unknown') + ), + default='unknown') + timestamp = models.DateTimeField(editable=False) diff --git a/uncloud/uncloud_pay/tests.py b/uncloud/uncloud_pay/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/uncloud/uncloud_pay/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py new file mode 100644 index 0000000..b52a2b6 --- /dev/null +++ b/uncloud/uncloud_pay/views.py @@ -0,0 +1,55 @@ +from django.shortcuts import render + +# Create your views here. + + +# to be implemented +class BalanceViewSet(viewsets.ModelViewSet): + # here we return a number + # number = sum(payments) - sum(bills) + + bills = Bills.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 + + +class Bills(viewset.ModelViewSet): + def unpaid(self, request): + return Bills.objects.filter(owner=self.request.user, paid=False) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + serializer_class = BillSerializer + permission_classes = [permissions.IsAuthenticated] + http_method_names = ['get'] + + def get_queryset(self): + return self.request.user.get_bills() From a58a3612544565178a77a81beec4e6dbfec591d3 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Feb 2020 11:36:50 +0100 Subject: [PATCH 074/193] Move snapshot to _pay and _vm --- uncloud/README.md | 6 ++ uncloud/uncloud_api/models.py | 139 ----------------------------- uncloud/uncloud_api/serializers.py | 12 --- uncloud/uncloud_api/views.py | 42 +-------- uncloud/uncloud_pay/models.py | 28 ++++++ uncloud/uncloud_vm/models.py | 45 ++++++++++ uncloud/uncloud_vm/serializers.py | 10 +++ uncloud/uncloud_vm/views.py | 50 +++++++++++ 8 files changed, 140 insertions(+), 192 deletions(-) delete mode 100644 uncloud/uncloud_api/models.py diff --git a/uncloud/README.md b/uncloud/README.md index 67f960f..19896d9 100644 --- a/uncloud/README.md +++ b/uncloud/README.md @@ -51,6 +51,12 @@ 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 diff --git a/uncloud/uncloud_api/models.py b/uncloud/uncloud_api/models.py deleted file mode 100644 index 6a6f9c8..0000000 --- a/uncloud/uncloud_api/models.py +++ /dev/null @@ -1,139 +0,0 @@ -import uuid - -from django.db import models -from django.contrib.auth import get_user_model - -# Product in DB vs. product in code -# DB: -# - need to define params (+param types) in db -> messy? -# - get /products/ is easy / automatic -# -# code -# - can have serializer/verification of fields easily in DRF -# - can have per product side effects / extra code running -# - might (??) make features easier?? -# - how to setup / query the recurring period (?) -# - could get products list via getattr() + re ...Product() classes -# -> this could include the url for ordering => /order/vm_snapshot (params) -# ---> this would work with urlpatterns - -# Combination: create specific product in DB (?) -# - a table per product (?) with 1 entry? - -# Orders -# define state in DB -# select a price from a product => product might change, order stays -# params: -# - the product uuid or name (?) => productuuid -# - the product parameters => for each feature -# - -# logs -# Should have a log = ... => 1:n field for most models! - -class Product(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) - - # override these fields by default - - description = "" - recurring_period = "not_recurring" - - status = models.CharField(max_length=256, - choices = ( - ('pending', 'Pending'), - ('being_created', 'Being created'), - ('active', 'Active'), - ('deleted', 'Deleted') - ), - default='pending' - ) - - # This is calculated by each product and saved in the DB - recurring_price = models.FloatField(editable=False) - one_time_price = models.FloatField(editable=False) - - # FIXME: need recurring_time_frame - - class Meta: - abstract = True - - def __str__(self): - return "{}".format(self.name) - - -class VMSnapshotProduct(Product): - price_per_gb_ssd = 0.35 - price_per_gb_hdd = 1.5/100 - - # This we need to get from the VM - gb_ssd = models.FloatField(editable=False) - gb_hdd = models.FloatField(editable=False) - - vm_uuid = models.UUIDField() - - # Need to setup recurring_price and one_time_price and recurring period - - sample_ssd = 10 - sample_hdd = 100 - - def recurring_price(self): - return 0 - - def one_time_price(self): - return 0 - - @classmethod - def sample_price(cls): - return cls.sample_ssd * cls.price_per_gb_ssd + cls.sample_hdd * cls.price_per_gb_hdd - - description = "Create snapshot of a VM" - recurring_period = "monthly" - - @classmethod - def pricing_model(cls): - return """ -Pricing is on monthly basis and storage prices are equivalent to the storage -price in the VM. - -Price per GB SSD is: {} -Price per GB HDD is: {} - - -Sample price for a VM with {} GB SSD and {} GB HDD VM is: {}. -""".format(cls.price_per_gb_ssd, cls.price_per_gb_hdd, - cls.sample_ssd, cls.sample_hdd, cls.sample_price()) - - - - - - - - -class Feature(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=256) - - recurring_price = models.FloatField(default=0) - one_time_price = models.FloatField() - - product = models.ForeignKey(Product, on_delete=models.CASCADE) - - # params for "cpu": cpu_count -> int - # each feature can only have one parameters - # could call this "value" and set whether it is user usable - # has_value = True/False - # value = string -> int (?) - # value_int - # value_str - # value_float - - class Meta: - abstract = True - - def __str__(self): - return "'{}' - '{}'".format(self.product, self.name) diff --git a/uncloud/uncloud_api/serializers.py b/uncloud/uncloud_api/serializers.py index 7dc3686..cd7fd14 100644 --- a/uncloud/uncloud_api/serializers.py +++ b/uncloud/uncloud_api/serializers.py @@ -3,8 +3,6 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from .models import VMSnapshotProduct - class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() @@ -14,13 +12,3 @@ class GroupSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Group fields = ['url', 'name'] - -class VMSnapshotSerializer(serializers.HyperlinkedModelSerializer): - class Meta: - model = VMSnapshotProduct - fields = ['uuid', 'status', 'recurring_price', 'one_time_price' ] - -class VMSnapshotCreateSerializer(serializers.HyperlinkedModelSerializer): - class Meta: - model = VMSnapshotProduct - fields = '__all__' diff --git a/uncloud/uncloud_api/views.py b/uncloud/uncloud_api/views.py index eb4cc77..7e5c6f9 100644 --- a/uncloud/uncloud_api/views.py +++ b/uncloud/uncloud_api/views.py @@ -17,46 +17,6 @@ import sys import re -# POST /vm/snapshot/ vmuuid=... => create snapshot, returns snapshot uuid -# GET /vm/snapshot => list -# DEL /vm/snapshot/ => delete -# create-list -> get, post => ListCreateAPIView -# del on other! -class VMSnapshotView(viewsets.ViewSet): - permission_classes = [permissions.IsAuthenticated] - - def list(self, request): - queryset = VMSnapshotProduct.objects.filter(owner=request.user) - serializer = VMSnapshotSerializer(queryset, many=True, context={'request': request}) - return Response(serializer.data) - - def retrieve(self, request, pk=None): - queryset = VMSnapshotProduct.objects.filter(owner=request.user) - vm = get_object_or_404(queryset, pk=pk) - serializer = VMSnapshotSerializer(vm, context={'request': request}) - return Response(serializer.data) - - def create(self, request): - print(request.data) - serializer = VMSnapshotCreateSerializer(data=request.data) - - serializer.gb_ssd = 12 - serializer.gb_hdd = 120 - print("F") - serializer.is_valid(raise_exception=True) - - print(serializer) - print("A") - serializer.save() - print("B") - - - # snapshot = VMSnapshotProduct(owner=request.user, - # **serialzer.data) - - return Response(serializer.data) - - # maybe drop or not --- we need something to guide the user! # class ProductsViewSet(viewsets.ViewSet): @@ -91,4 +51,4 @@ class UserViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] def get_queryset(self): - return self.request.user + return self.request.user \ No newline at end of file diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 6910d58..831710b 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -89,3 +89,31 @@ class Payment(models.Model): ), default='unknown') timestamp = models.DateTimeField(editable=False) + + + + +class Product(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) + + description = "" + + status = models.CharField(max_length=256, + choices = ( + ('pending', 'Pending'), + ('being_created', 'Being created'), + ('active', 'Active'), + ('deleted', 'Deleted') + ), + default='pending' + ) + + order = models.ForeignKey(Order, + on_delete=models.CASCADE, + editable=False) + + class Meta: + abstract = True diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index f4b68dd..12d188e 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -2,6 +2,8 @@ from django.db import models from django.contrib.auth import get_user_model import uuid +from uncloud_pay.models import Product + class VMHost(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -71,3 +73,46 @@ class OperatingSystemDisk(VMDiskProduct): class VMNetworkCard(models.Model): vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) mac_address = models.IntegerField() + + +class VMSnapshotProduct(Product): + price_per_gb_ssd = 0.35 + price_per_gb_hdd = 1.5/100 + + # This we need to get from the VM + gb_ssd = models.FloatField(editable=False) + gb_hdd = models.FloatField(editable=False) + + vm_uuid = models.UUIDField() + + # Need to setup recurring_price and one_time_price and recurring period + + sample_ssd = 10 + sample_hdd = 100 + + def recurring_price(self): + return 0 + + def one_time_price(self): + return 0 + + @classmethod + def sample_price(cls): + return cls.sample_ssd * cls.price_per_gb_ssd + cls.sample_hdd * cls.price_per_gb_hdd + + description = "Create snapshot of a VM" + recurring_period = "monthly" + + @classmethod + def pricing_model(cls): + return """ +Pricing is on monthly basis and storage prices are equivalent to the storage +price in the VM. + +Price per GB SSD is: {} +Price per GB HDD is: {} + + +Sample price for a VM with {} GB SSD and {} GB HDD VM is: {}. +""".format(cls.price_per_gb_ssd, cls.price_per_gb_hdd, + cls.sample_ssd, cls.sample_hdd, cls.sample_price()) diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 4154aee..d5549ad 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -13,3 +13,13 @@ class VMProductSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VMProduct fields = '__all__' + +class VMSnapshotProductSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VMSnapshotProduct + fields = ['uuid', 'status', 'recurring_price', 'one_time_price' ] + +class VMSnapshotProductCreateSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VMSnapshotProduct + fields = '__all__' diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 91e81e1..4f2f9f4 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -27,3 +27,53 @@ class VMProductViewSet(viewsets.ModelViewSet): serializer.save(owner=request.user) return Response(serializer.data) + + +class VMSnapshotProductViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMSnapshotProductSerializer + + def get_queryset(self): + return VMSnapshotProduct.objects.filter(owner=self.request.user) + + def create(self, request): + serializer = VMProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + serializer.save(owner=request.user) + + return Response(serializer.data) + + +class VMSnapshotProductView(viewsets.ViewSet): + permission_classes = [permissions.IsAuthenticated] + + def list(self, request): + queryset = VMSnapshotProduct.objects.filter(owner=request.user) + serializer = VMSnapshotSerializer(queryset, many=True, context={'request': request}) + return Response(serializer.data) + + def retrieve(self, request, pk=None): + queryset = VMSnapshotProduct.objects.filter(owner=request.user) + vm = get_object_or_404(queryset, pk=pk) + serializer = VMSnapshotSerializer(vm, context={'request': request}) + return Response(serializer.data) + + def create(self, request): + print(request.data) + serializer = VMSnapshotCreateSerializer(data=request.data) + + serializer.gb_ssd = 12 + serializer.gb_hdd = 120 + print("F") + serializer.is_valid(raise_exception=True) + + print(serializer) + print("A") + serializer.save() + print("B") + + + # snapshot = VMSnapshotProduct(owner=request.user, + # **serialzer.data) + + return Response(serializer.data) From aa59b05a2dedfd65651d62bc00994d03cf34694b Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Feb 2020 11:40:36 +0100 Subject: [PATCH 075/193] cleanup urls --- uncloud/uncloud/urls.py | 7 +------ uncloud/uncloud_api/serializers.py | 6 ------ uncloud/uncloud_api/views.py | 7 ++----- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 23392c5..1e4c9d0 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -18,14 +18,12 @@ from django.urls import path, include from rest_framework import routers -from uncloud_api import views as apiviews from uncloud_vm import views as vmviews from opennebula import views as oneviews router = routers.DefaultRouter() -router.register(r'user', apiviews.UserViewSet, basename='user') - +# user / regular urls router.register(r'vm/snapshot', apiviews.VMSnapshotView, basename='VMSnapshot') router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') @@ -35,10 +33,7 @@ router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') router.register(r'admin/opennebula_raw', oneviews.RawVMViewSet) -# Wire up our API using automatic URL routing. -# Additionally, we include login URLs for the browsable API. urlpatterns = [ path('', include(router.urls)), - path('admin/', admin.site.urls), # login to django itself path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) # for login to REST API ] diff --git a/uncloud/uncloud_api/serializers.py b/uncloud/uncloud_api/serializers.py index cd7fd14..89f4e83 100644 --- a/uncloud/uncloud_api/serializers.py +++ b/uncloud/uncloud_api/serializers.py @@ -1,4 +1,3 @@ -from django.contrib.auth.models import Group from django.contrib.auth import get_user_model from rest_framework import serializers @@ -7,8 +6,3 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() fields = ['url', 'username', 'email'] - -class GroupSerializer(serializers.HyperlinkedModelSerializer): - class Meta: - model = Group - fields = ['url', 'name'] diff --git a/uncloud/uncloud_api/views.py b/uncloud/uncloud_api/views.py index 7e5c6f9..c90b963 100644 --- a/uncloud/uncloud_api/views.py +++ b/uncloud/uncloud_api/views.py @@ -7,10 +7,7 @@ from rest_framework import viewsets, permissions, generics from rest_framework.views import APIView from rest_framework.response import Response -from uncloud_vm.models import VMProduct -from .models import VMSnapshotProduct -from .serializers import UserSerializer, GroupSerializer, VMSnapshotSerializer, VMSnapshotCreateSerializer - +from .serializers import UserSerializer import inspect import sys @@ -51,4 +48,4 @@ class UserViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] def get_queryset(self): - return self.request.user \ No newline at end of file + return self.request.user From 11d629bb512854560cd5f720fd22921dca18c6ef Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Feb 2020 11:42:42 +0100 Subject: [PATCH 076/193] [uncloud_api] completely remove it --- uncloud/uncloud_api/__init__.py | 0 uncloud/uncloud_api/admin.py | 6 --- uncloud/uncloud_api/apps.py | 5 -- uncloud/uncloud_api/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../uncloud_api/management/commands/hack.py | 26 ---------- .../management/commands/snapshot.py | 29 ----------- .../uncloud_api/migrations/0001_initial.py | 31 ----------- .../0002_vmsnapshotproduct_vm_uuid.py | 19 ------- .../migrations/0003_auto_20200225_1950.py | 36 ------------- uncloud/uncloud_api/migrations/__init__.py | 0 uncloud/uncloud_api/serializers.py | 8 --- uncloud/uncloud_api/tests.py | 3 -- uncloud/uncloud_api/views.py | 51 ------------------- 14 files changed, 214 deletions(-) delete mode 100644 uncloud/uncloud_api/__init__.py delete mode 100644 uncloud/uncloud_api/admin.py delete mode 100644 uncloud/uncloud_api/apps.py delete mode 100644 uncloud/uncloud_api/management/__init__.py delete mode 100644 uncloud/uncloud_api/management/commands/__init__.py delete mode 100644 uncloud/uncloud_api/management/commands/hack.py delete mode 100644 uncloud/uncloud_api/management/commands/snapshot.py delete mode 100644 uncloud/uncloud_api/migrations/0001_initial.py delete mode 100644 uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py delete mode 100644 uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py delete mode 100644 uncloud/uncloud_api/migrations/__init__.py delete mode 100644 uncloud/uncloud_api/serializers.py delete mode 100644 uncloud/uncloud_api/tests.py delete mode 100644 uncloud/uncloud_api/views.py diff --git a/uncloud/uncloud_api/__init__.py b/uncloud/uncloud_api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/uncloud/uncloud_api/admin.py b/uncloud/uncloud_api/admin.py deleted file mode 100644 index d242668..0000000 --- a/uncloud/uncloud_api/admin.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.contrib import admin - -from .models import Product, Feature - -#admin.site.register(Product) -#admin.site.register(Feature) diff --git a/uncloud/uncloud_api/apps.py b/uncloud/uncloud_api/apps.py deleted file mode 100644 index 6830fa2..0000000 --- a/uncloud/uncloud_api/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class ApiConfig(AppConfig): - name = 'uncloud_api' diff --git a/uncloud/uncloud_api/management/__init__.py b/uncloud/uncloud_api/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/uncloud/uncloud_api/management/commands/__init__.py b/uncloud/uncloud_api/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/uncloud/uncloud_api/management/commands/hack.py b/uncloud/uncloud_api/management/commands/hack.py deleted file mode 100644 index e129952..0000000 --- a/uncloud/uncloud_api/management/commands/hack.py +++ /dev/null @@ -1,26 +0,0 @@ -import time -from django.conf import settings -from django.core.management.base import BaseCommand - -import uncloud_api.models - -import inspect -import sys -import re - -class Command(BaseCommand): - args = '' - help = 'hacking - only use if you are Nico' - - def add_arguments(self, parser): - parser.add_argument('command', type=str, help='Command') - - def handle(self, *args, **options): - getattr(self, options['command'])(**options) - - @classmethod - def classtest(cls, **_): - clsmembers = inspect.getmembers(sys.modules['uncloud_api.models'], inspect.isclass) - for name, c in clsmembers: - if re.match(r'.+Product$', name): - print("{} -> {}".format(name, c)) diff --git a/uncloud/uncloud_api/management/commands/snapshot.py b/uncloud/uncloud_api/management/commands/snapshot.py deleted file mode 100644 index 41d0e38..0000000 --- a/uncloud/uncloud_api/management/commands/snapshot.py +++ /dev/null @@ -1,29 +0,0 @@ -import time -from django.conf import settings -from django.core.management.base import BaseCommand - -from uncloud_api import models - - -class Command(BaseCommand): - args = '' - help = 'VM Snapshot support' - - def add_arguments(self, parser): - parser.add_argument('command', type=str, help='Command') - - def handle(self, *args, **options): - print("Snapshotting") - #getattr(self, options['command'])(**options) - - @classmethod - def monitor(cls, **_): - while True: - try: - tweets = models.Reply.get_target_tweets() - responses = models.Reply.objects.values_list('tweet_id', flat=True) - new_tweets = [x for x in tweets if x.id not in responses] - models.Reply.send(new_tweets) - except TweepError as e: - print(e) - time.sleep(60) diff --git a/uncloud/uncloud_api/migrations/0001_initial.py b/uncloud/uncloud_api/migrations/0001_initial.py deleted file mode 100644 index 67bdd2e..0000000 --- a/uncloud/uncloud_api/migrations/0001_initial.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-23 17:12 - -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 = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='VMSnapshotProduct', - fields=[ - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), - ('gb_ssd', models.FloatField()), - ('gb_hdd', models.FloatField()), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py b/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py deleted file mode 100644 index b35317e..0000000 --- a/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-25 18:16 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_api', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='vmsnapshotproduct', - name='vm_uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False), - ), - ] diff --git a/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py b/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py deleted file mode 100644 index be7624c..0000000 --- a/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-25 19:50 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_api', '0002_vmsnapshotproduct_vm_uuid'), - ] - - operations = [ - migrations.AlterField( - model_name='vmsnapshotproduct', - name='gb_hdd', - field=models.FloatField(editable=False), - ), - migrations.AlterField( - model_name='vmsnapshotproduct', - name='gb_ssd', - field=models.FloatField(editable=False), - ), - migrations.AlterField( - model_name='vmsnapshotproduct', - name='owner', - field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='vmsnapshotproduct', - name='vm_uuid', - field=models.UUIDField(), - ), - ] diff --git a/uncloud/uncloud_api/migrations/__init__.py b/uncloud/uncloud_api/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/uncloud/uncloud_api/serializers.py b/uncloud/uncloud_api/serializers.py deleted file mode 100644 index 89f4e83..0000000 --- a/uncloud/uncloud_api/serializers.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.contrib.auth import get_user_model - -from rest_framework import serializers - -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = get_user_model() - fields = ['url', 'username', 'email'] diff --git a/uncloud/uncloud_api/tests.py b/uncloud/uncloud_api/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/uncloud/uncloud_api/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/uncloud/uncloud_api/views.py b/uncloud/uncloud_api/views.py deleted file mode 100644 index c90b963..0000000 --- a/uncloud/uncloud_api/views.py +++ /dev/null @@ -1,51 +0,0 @@ -from django.shortcuts import render -from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group - -from rest_framework import viewsets, permissions, generics - -from rest_framework.views import APIView -from rest_framework.response import Response - -from .serializers import UserSerializer - -import inspect -import sys -import re - - - -# maybe drop or not --- we need something to guide the user! -# class ProductsViewSet(viewsets.ViewSet): -# permission_classes = [permissions.IsAuthenticated] - -# def list(self, request): - -# clsmembers = [] -# for modules in [ 'uncloud_api.models', 'uncloud_vm.models' ]: -# clsmembers.extend(inspect.getmembers(sys.modules[modules], inspect.isclass)) - - -# products = [] -# for name, c in clsmembers: -# # Include everything that ends in Product, but not Product itself -# m = re.match(r'(?P.+)Product$', name) -# if m: -# products.append({ -# 'name': m.group('pname'), -# 'description': c.description, -# 'recurring_period': c.recurring_period, -# 'pricing_model': c.pricing_model() -# } -# ) - - -# return Response(products) - - -class UserViewSet(viewsets.ModelViewSet): - serializer_class = UserSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - return self.request.user From 06ab21c577052f5a3fafee8b653bf9ea6d44a04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 11:59:28 +0100 Subject: [PATCH 077/193] Fix python errors on latest hack commits => make runserver happy again --- uncloud/uncloud/settings.py | 1 + uncloud/uncloud/urls.py | 7 +- uncloud/uncloud_api/admin.py | 2 - .../0002_vmsnapshotproduct_vm_uuid.py | 19 ------ .../migrations/0003_auto_20200225_1950.py | 36 ---------- uncloud/uncloud_api/migrations/__init__.py | 0 .../uncloud_pay/migrations/0001_initial.py | 56 ++++++++++++++++ uncloud/uncloud_pay/models.py | 8 ++- uncloud/uncloud_pay/serializers.py | 12 ++++ uncloud/uncloud_pay/views.py | 65 ++++++++----------- .../migrations/0004_vmsnapshotproduct.py} | 14 ++-- uncloud/uncloud_vm/serializers.py | 2 +- uncloud/uncloud_vm/views.py | 6 +- 13 files changed, 121 insertions(+), 107 deletions(-) delete mode 100644 uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py delete mode 100644 uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py delete mode 100644 uncloud/uncloud_api/migrations/__init__.py create mode 100644 uncloud/uncloud_pay/migrations/0001_initial.py create mode 100644 uncloud/uncloud_pay/serializers.py rename uncloud/{uncloud_api/migrations/0001_initial.py => uncloud_vm/migrations/0004_vmsnapshotproduct.py} (57%) diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 614cd25..05c4f35 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -61,6 +61,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'rest_framework', 'uncloud_api', + 'uncloud_pay', 'uncloud_auth', 'uncloud_vm', 'opennebula' diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 1e4c9d0..79958c5 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -19,14 +19,19 @@ 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 opennebula import views as oneviews router = routers.DefaultRouter() # user / regular urls -router.register(r'vm/snapshot', apiviews.VMSnapshotView, basename='VMSnapshot') +router.register(r'vm/snapshot', vmviews.VMSnapshotProductView, basename='VMSnapshot') router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') +# Pay +router.register(r'bill', payviews.BillViewSet, basename='bill') +router.register(r'payment', payviews.PaymentViewSet, basename='payment') + # admin/staff urls router.register(r'admin/vmhost', vmviews.VMHostViewSet) router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') diff --git a/uncloud/uncloud_api/admin.py b/uncloud/uncloud_api/admin.py index d242668..03246ec 100644 --- a/uncloud/uncloud_api/admin.py +++ b/uncloud/uncloud_api/admin.py @@ -1,6 +1,4 @@ from django.contrib import admin -from .models import Product, Feature - #admin.site.register(Product) #admin.site.register(Feature) diff --git a/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py b/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py deleted file mode 100644 index b35317e..0000000 --- a/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-25 18:16 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_api', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='vmsnapshotproduct', - name='vm_uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False), - ), - ] diff --git a/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py b/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py deleted file mode 100644 index be7624c..0000000 --- a/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-25 19:50 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_api', '0002_vmsnapshotproduct_vm_uuid'), - ] - - operations = [ - migrations.AlterField( - model_name='vmsnapshotproduct', - name='gb_hdd', - field=models.FloatField(editable=False), - ), - migrations.AlterField( - model_name='vmsnapshotproduct', - name='gb_ssd', - field=models.FloatField(editable=False), - ), - migrations.AlterField( - model_name='vmsnapshotproduct', - name='owner', - field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='vmsnapshotproduct', - name='vm_uuid', - field=models.UUIDField(), - ), - ] diff --git a/uncloud/uncloud_api/migrations/__init__.py b/uncloud/uncloud_api/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/uncloud/uncloud_pay/migrations/0001_initial.py b/uncloud/uncloud_pay/migrations/0001_initial.py new file mode 100644 index 0000000..6e57c59 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# Generated by Django 3.0.3 on 2020-02-27 10:50 + +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), + ] + + operations = [ + migrations.CreateModel( + name='Bill', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_date', models.DateTimeField()), + ('starting_date', models.DateTimeField()), + ('ending_date', models.DateTimeField()), + ('due_date', models.DateField()), + ('paid', models.BooleanField(default=False)), + ('valid', models.BooleanField(default=True)), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + 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'), ('strip', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256)), + ('timestamp', models.DateTimeField(editable=False)), + ('owner', models.ForeignKey(editable=False, 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()), + ('starting_date', models.DateTimeField()), + ('ending_date', models.DateTimeField(blank=True, null=True)), + ('recurring_price', models.FloatField(editable=False)), + ('one_time_price', models.FloatField(editable=False)), + ('recurring_period', models.CharField(choices=[('onetime', 'Onetime'), ('per_year', 'Per Year'), ('per_month', 'Per Month'), ('per_week', 'Per Week'), ('per_day', 'Per Day'), ('per_hour', 'Per Hour'), ('per_minute', 'Per Minute'), ('per_second', 'Per Second')], default='onetime', max_length=32)), + ('bill', models.ManyToManyField(blank=True, editable=False, null=True, to='uncloud_pay.Bill')), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 831710b..71653fa 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -1,8 +1,11 @@ from django.db import models from django.contrib.auth import get_user_model +from django.core.validators import MinValueValidator -# Create your models here. +import uuid +AMOUNT_MAX_DIGITS=10 +AMOUNT_DECIMALS=2 class Bill(models.Model): owner = models.ForeignKey(get_user_model(), @@ -35,7 +38,6 @@ class Order(models.Model): null=True) bill = models.ManyToManyField(Bill, - on_delete=models.CASCADE, editable=False, blank=True, null=True) @@ -77,6 +79,8 @@ class Payment(models.Model): amount = models.DecimalField( default=0.0, + max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS, validators=[MinValueValidator(0)]) source = models.CharField(max_length=256, diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py new file mode 100644 index 0000000..e11544b --- /dev/null +++ b/uncloud/uncloud_pay/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers +from .models import Bill, Payment + +class BillSerializer(serializers.ModelSerializer): + class Meta: + model = Bill + fields = ['user', 'amount'] + +class PaymentSerializer(serializers.ModelSerializer): + class Meta: + model = Payment + fields = ['user', 'amount', 'source', 'timestamp'] diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index b52a2b6..8fc02ea 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -1,55 +1,46 @@ from django.shortcuts import render +from rest_framework import viewsets, permissions -# Create your views here. +from .models import Bill, Payment +from .serializers import BillSerializer, PaymentSerializer # to be implemented -class BalanceViewSet(viewsets.ModelViewSet): +class BalanceViewSet(viewsets.ViewSet): # here we return a number # number = sum(payments) - sum(bills) - bills = Bills.objects.filter(owner=self.request.user) - payments = Payment.objects.filter(owner=self.request.user) + #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 Bills(viewset.ModelViewSet): - def unpaid(self, request): - return Bills.objects.filter(owner=self.request.user, paid=False) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +class BillViewSet(viewsets.ModelViewSet): serializer_class = BillSerializer permission_classes = [permissions.IsAuthenticated] http_method_names = ['get'] def get_queryset(self): - return self.request.user.get_bills() + return Bill.objects.filter(owner=self.request.user) + + def unpaid(self, request): + return Bill.objects.filter(owner=self.request.user, paid=False) + +class PaymentViewSet(viewsets.ModelViewSet): + serializer_class = PaymentSerializer + permission_classes = [permissions.IsAuthenticated] + http_method_names = ['get', 'post'] + + def get_queryset(self): + return Payment.objects.filter(user=self.request.user) + + def create(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(user=request.user,timestamp=datetime.now()) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/uncloud/uncloud_api/migrations/0001_initial.py b/uncloud/uncloud_vm/migrations/0004_vmsnapshotproduct.py similarity index 57% rename from uncloud/uncloud_api/migrations/0001_initial.py rename to uncloud/uncloud_vm/migrations/0004_vmsnapshotproduct.py index 67bdd2e..13840b5 100644 --- a/uncloud/uncloud_api/migrations/0001_initial.py +++ b/uncloud/uncloud_vm/migrations/0004_vmsnapshotproduct.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-02-23 17:12 +# Generated by Django 3.0.3 on 2020-02-27 10:50 from django.conf import settings from django.db import migrations, models @@ -8,10 +8,10 @@ import uuid class Migration(migrations.Migration): - initial = True - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0001_initial'), + ('uncloud_vm', '0003_auto_20200225_2028'), ] operations = [ @@ -20,9 +20,11 @@ class Migration(migrations.Migration): fields=[ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), - ('gb_ssd', models.FloatField()), - ('gb_hdd', models.FloatField()), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('gb_ssd', models.FloatField(editable=False)), + ('gb_hdd', models.FloatField(editable=False)), + ('vm_uuid', models.UUIDField()), + ('order', models.ForeignKey(editable=False, 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)), ], options={ 'abstract': False, diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index d5549ad..232e954 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -1,7 +1,7 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from .models import VMHost, VMProduct +from .models import VMHost, VMProduct, VMSnapshotProduct class VMHostSerializer(serializers.HyperlinkedModelSerializer): class Meta: diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 4f2f9f4..cb87e9d 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -7,7 +7,7 @@ from rest_framework import viewsets, permissions from rest_framework.response import Response from .models import VMHost, VMProduct -from .serializers import VMHostSerializer, VMProductSerializer +from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer class VMHostViewSet(viewsets.ModelViewSet): serializer_class = VMHostSerializer @@ -49,13 +49,13 @@ class VMSnapshotProductView(viewsets.ViewSet): def list(self, request): queryset = VMSnapshotProduct.objects.filter(owner=request.user) - serializer = VMSnapshotSerializer(queryset, many=True, context={'request': request}) + serializer = VMSnapshotProductSerializer(queryset, many=True, context={'request': request}) return Response(serializer.data) def retrieve(self, request, pk=None): queryset = VMSnapshotProduct.objects.filter(owner=request.user) vm = get_object_or_404(queryset, pk=pk) - serializer = VMSnapshotSerializer(vm, context={'request': request}) + serializer = VMSnapshotProductSerializer(vm, context={'request': request}) return Response(serializer.data) def create(self, request): From fd648ade6579334b953fe3944b25f1b489974d42 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Feb 2020 12:02:41 +0100 Subject: [PATCH 078/193] ++cleanup Signed-off-by: Nico Schottelius --- uncloud/uncloud/settings.py | 1 - uncloud/uncloud_vm/views.py | 45 ++++++++----------------------------- 2 files changed, 9 insertions(+), 37 deletions(-) diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 614cd25..899de1b 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -60,7 +60,6 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', - 'uncloud_api', 'uncloud_auth', 'uncloud_vm', 'opennebula' diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 4f2f9f4..aabf8c5 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -6,7 +6,9 @@ from django.shortcuts import get_object_or_404 from rest_framework import viewsets, permissions from rest_framework.response import Response -from .models import VMHost, VMProduct +from .models import VMHost, VMProduct. VMSnapshotProduct +from uncloud_pay.models import Order + from .serializers import VMHostSerializer, VMProductSerializer class VMHostViewSet(viewsets.ModelViewSet): @@ -14,6 +16,7 @@ class VMHostViewSet(viewsets.ModelViewSet): queryset = VMHost.objects.all() permission_classes = [permissions.IsAdminUser] + class VMProductViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = VMProductSerializer @@ -37,43 +40,13 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet): return VMSnapshotProduct.objects.filter(owner=self.request.user) def create(self, request): + serializer = VMProductSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) + + # Create order + order = Order() + serializer.save(owner=request.user) return Response(serializer.data) - - -class VMSnapshotProductView(viewsets.ViewSet): - permission_classes = [permissions.IsAuthenticated] - - def list(self, request): - queryset = VMSnapshotProduct.objects.filter(owner=request.user) - serializer = VMSnapshotSerializer(queryset, many=True, context={'request': request}) - return Response(serializer.data) - - def retrieve(self, request, pk=None): - queryset = VMSnapshotProduct.objects.filter(owner=request.user) - vm = get_object_or_404(queryset, pk=pk) - serializer = VMSnapshotSerializer(vm, context={'request': request}) - return Response(serializer.data) - - def create(self, request): - print(request.data) - serializer = VMSnapshotCreateSerializer(data=request.data) - - serializer.gb_ssd = 12 - serializer.gb_hdd = 120 - print("F") - serializer.is_valid(raise_exception=True) - - print(serializer) - print("A") - serializer.save() - print("B") - - - # snapshot = VMSnapshotProduct(owner=request.user, - # **serialzer.data) - - return Response(serializer.data) From 41a5eae8796876f847d87106fdddfc3612be65e7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Feb 2020 12:09:29 +0100 Subject: [PATCH 079/193] cleanup views/vmsnapshot --- uncloud/uncloud/urls.py | 2 +- uncloud/uncloud_vm/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 79958c5..a02f24a 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -25,7 +25,7 @@ from opennebula import views as oneviews router = routers.DefaultRouter() # user / regular urls -router.register(r'vm/snapshot', vmviews.VMSnapshotProductView, basename='VMSnapshot') +router.register(r'vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='VMSnapshot') router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') # Pay diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 55b607f..c82dff3 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404 from rest_framework import viewsets, permissions from rest_framework.response import Response -from .models import VMHost, VMProduct. VMSnapshotProduct +from .models import VMHost, VMProduct, VMSnapshotProduct from uncloud_pay.models import Order from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer From f358acca058ab55e51067dad2d9cfb02e441acfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 12:10:26 +0100 Subject: [PATCH 080/193] Fix payment creation --- uncloud/uncloud_pay/serializers.py | 4 ++-- uncloud/uncloud_pay/views.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index e11544b..024fe3f 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -4,9 +4,9 @@ from .models import Bill, Payment class BillSerializer(serializers.ModelSerializer): class Meta: model = Bill - fields = ['user', 'amount'] + fields = ['owner', 'amount'] class PaymentSerializer(serializers.ModelSerializer): class Meta: model = Payment - fields = ['user', 'amount', 'source', 'timestamp'] + fields = ['owner', 'amount', 'source', 'timestamp'] diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 8fc02ea..8f37814 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -1,8 +1,10 @@ from django.shortcuts import render -from rest_framework import viewsets, permissions +from rest_framework import viewsets, permissions, status +from rest_framework.response import Response from .models import Bill, Payment from .serializers import BillSerializer, PaymentSerializer +from datetime import datetime # to be implemented @@ -35,12 +37,12 @@ class PaymentViewSet(viewsets.ModelViewSet): http_method_names = ['get', 'post'] def get_queryset(self): - return Payment.objects.filter(user=self.request.user) + return Payment.objects.filter(owner=self.request.user) def create(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save(user=request.user,timestamp=datetime.now()) + serializer.save(owner=request.user,timestamp=datetime.now()) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) From b9b605f407ed53d26418c2475ef928504411d92e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 12:21:25 +0100 Subject: [PATCH 081/193] Add ADMIN endpoints for bills and payments --- uncloud/uncloud/urls.py | 2 ++ uncloud/uncloud_pay/serializers.py | 2 +- uncloud/uncloud_pay/views.py | 39 +++++++++++++++++++++++++----- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 79958c5..341f81a 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -33,6 +33,8 @@ router.register(r'bill', payviews.BillViewSet, basename='bill') router.register(r'payment', payviews.PaymentViewSet, basename='payment') # 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/vmhost', vmviews.VMHostViewSet) router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') router.register(r'admin/opennebula_raw', oneviews.RawVMViewSet) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 024fe3f..f4fd565 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -4,7 +4,7 @@ from .models import Bill, Payment class BillSerializer(serializers.ModelSerializer): class Meta: model = Bill - fields = ['owner', 'amount'] + fields = ['owner', 'amount', 'due_date', 'creation_date', 'starting_date', 'ending_date', 'paid'] class PaymentSerializer(serializers.ModelSerializer): class Meta: diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 8f37814..d824d27 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -6,8 +6,9 @@ from .models import Bill, Payment from .serializers import BillSerializer, PaymentSerializer from datetime import datetime +### +# Standard user views: -# to be implemented class BalanceViewSet(viewsets.ViewSet): # here we return a number # number = sum(payments) - sum(bills) @@ -20,10 +21,9 @@ class BalanceViewSet(viewsets.ViewSet): pass -class BillViewSet(viewsets.ModelViewSet): +class BillViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = BillSerializer permission_classes = [permissions.IsAuthenticated] - http_method_names = ['get'] def get_queryset(self): return Bill.objects.filter(owner=self.request.user) @@ -31,10 +31,19 @@ class BillViewSet(viewsets.ModelViewSet): def unpaid(self, request): return Bill.objects.filter(owner=self.request.user, paid=False) -class PaymentViewSet(viewsets.ModelViewSet): +class PaymentViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = PaymentSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Payment.objects.filter(owner=self.request.user) + +### +# Admin views. + +class AdminPaymentViewSet(viewsets.ModelViewSet): serializer_class = PaymentSerializer permission_classes = [permissions.IsAuthenticated] - http_method_names = ['get', 'post'] def get_queryset(self): return Payment.objects.filter(owner=self.request.user) @@ -42,7 +51,25 @@ class PaymentViewSet(viewsets.ModelViewSet): def create(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save(owner=request.user,timestamp=datetime.now()) + 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.filter(owner=self.request.user) + + 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(created_at=datetime.now()) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) From 225f20c91b423b2b4a04a7cb83e7d2df03258047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 12:21:52 +0100 Subject: [PATCH 082/193] Fix typo in payment source model --- uncloud/uncloud_pay/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 71653fa..6a33fd5 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -86,7 +86,7 @@ class Payment(models.Model): source = models.CharField(max_length=256, choices = ( ('wire', 'Wire Transfer'), - ('strip', 'Stripe'), + ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown') From a9aac394866a5df90ac0c3945a5d749631cdc7b0 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Feb 2020 12:31:20 +0100 Subject: [PATCH 083/193] Create a vmsnapshot + associated order --- uncloud/uncloud/urls.py | 2 +- uncloud/uncloud_vm/serializers.py | 7 +------ uncloud/uncloud_vm/views.py | 21 ++++++++++++++++++--- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index a02f24a..d6d3b7d 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -25,7 +25,7 @@ from opennebula import views as oneviews router = routers.DefaultRouter() # user / regular urls -router.register(r'vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='VMSnapshot') +router.register(r'vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct') router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') # Pay diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 232e954..c1eafe2 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -14,12 +14,7 @@ class VMProductSerializer(serializers.HyperlinkedModelSerializer): model = VMProduct fields = '__all__' -class VMSnapshotProductSerializer(serializers.HyperlinkedModelSerializer): - class Meta: - model = VMSnapshotProduct - fields = ['uuid', 'status', 'recurring_price', 'one_time_price' ] - -class VMSnapshotProductCreateSerializer(serializers.HyperlinkedModelSerializer): +class VMSnapshotProductSerializer(serializers.ModelSerializer): class Meta: model = VMSnapshotProduct fields = '__all__' diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index c82dff3..53986b4 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -11,6 +11,8 @@ from uncloud_pay.models import Order from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer +import datetime + class VMHostViewSet(viewsets.ModelViewSet): serializer_class = VMHostSerializer queryset = VMHost.objects.all() @@ -40,12 +42,25 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet): return VMSnapshotProduct.objects.filter(owner=self.request.user) def create(self, request): - serializer = VMProductSerializer(data=request.data, context={'request': request}) + serializer = VMSnapshotProductSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) + print(serializer) # Create order - #order = Order() + now = datetime.datetime.now() + order = Order(owner=request.user, + creation_date=now, + starting_date=now, + recurring_price=20, + one_time_price=0, + recurring_period="per_month") + order.save() + print(order) - serializer.save(owner=request.user) + # FIXME: calculate the gb_* values + serializer.save(owner=request.user, + order=order, + gb_ssd=12, + gb_hdd=20) return Response(serializer.data) From 7bf4f2adb22e8665d87a7b011f85ba0368bd43e4 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Feb 2020 12:36:33 +0100 Subject: [PATCH 084/193] --debug Signed-off-by: Nico Schottelius --- uncloud/uncloud_vm/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 53986b4..444d134 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -45,7 +45,6 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet): serializer = VMSnapshotProductSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) - print(serializer) # Create order now = datetime.datetime.now() order = Order(owner=request.user, @@ -55,7 +54,6 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet): one_time_price=0, recurring_period="per_month") order.save() - print(order) # FIXME: calculate the gb_* values serializer.save(owner=request.user, From f5eadd6ddbbcbd08a65a1beb8f3796bb6aa5a9ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 12:38:04 +0100 Subject: [PATCH 085/193] Move user view to uncloud_pay --- uncloud/uncloud/urls.py | 1 + uncloud/uncloud_api/views.py | 6 +++++- uncloud/uncloud_pay/serializers.py | 9 +++++++++ uncloud/uncloud_pay/views.py | 15 ++++++++++++++- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 341f81a..358e4c7 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -29,6 +29,7 @@ router.register(r'vm/snapshot', vmviews.VMSnapshotProductView, basename='VMSnaps router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') # Pay +router.register(r'user', payviews.UserViewSet, basename='user') router.register(r'bill', payviews.BillViewSet, basename='bill') router.register(r'payment', payviews.PaymentViewSet, basename='payment') diff --git a/uncloud/uncloud_api/views.py b/uncloud/uncloud_api/views.py index c90b963..18cc324 100644 --- a/uncloud/uncloud_api/views.py +++ b/uncloud/uncloud_api/views.py @@ -43,9 +43,13 @@ import re # return Response(products) -class UserViewSet(viewsets.ModelViewSet): +class UserViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = UserSerializer permission_classes = [permissions.IsAuthenticated] def get_queryset(self): return self.request.user + + @action(detail=True) + def balance(self, request): + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index f4fd565..5bb22ec 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -1,3 +1,4 @@ +from django.contrib.auth import get_user_model from rest_framework import serializers from .models import Bill, Payment @@ -10,3 +11,11 @@ class PaymentSerializer(serializers.ModelSerializer): class Meta: model = Payment fields = ['owner', 'amount', 'source', 'timestamp'] + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ['username', 'email'] + + def get_balance(self, obj): + return 666 diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index d824d27..5111f6c 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -1,9 +1,11 @@ from django.shortcuts import render +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 from .models import Bill, Payment -from .serializers import BillSerializer, PaymentSerializer +from .serializers import BillSerializer, PaymentSerializer, UserSerializer from datetime import datetime ### @@ -38,6 +40,17 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return Payment.objects.filter(owner=self.request.user) +class UserViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return get_user_model().objects.all() + + @action(detail=True) + def balance(self, request): + return Response(status=status.HTTP_204_NO_CONTENT) + ### # Admin views. From b722f30ea5048a305d627a083d0abfd310fa8092 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Feb 2020 12:42:09 +0100 Subject: [PATCH 086/193] ++doc --- uncloud/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/uncloud/README.md b/uncloud/README.md index 19896d9..1e71f6f 100644 --- a/uncloud/README.md +++ b/uncloud/README.md @@ -76,3 +76,12 @@ sample values with real values. ### Creating a VM Snapshot + + +## Working Beta APIs + +### Snapshotting + +``` +http -a nicoschottelius:$(pass ungleich.ch/nico.schottelius@ungleich.ch) http://localhost:8000/vm/snapshot/ vm_uuid=$(uuidgen) +``` From 1ff5702ce3cd217f2fd26442c76a466fe558d1ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 12:42:24 +0100 Subject: [PATCH 087/193] Expose Order model --- uncloud/uncloud/urls.py | 2 ++ uncloud/uncloud_pay/serializers.py | 11 +++++++++-- uncloud/uncloud_pay/views.py | 22 ++++++++++++++++++---- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 358e4c7..9ea7c6a 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -31,11 +31,13 @@ router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') # Pay router.register(r'user', payviews.UserViewSet, basename='user') router.register(r'bill', payviews.BillViewSet, basename='bill') +router.register(r'order', payviews.OrderViewSet, basename='order') router.register(r'payment', payviews.PaymentViewSet, basename='payment') # 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/opennebula', oneviews.VMViewSet, basename='opennebula') router.register(r'admin/opennebula_raw', oneviews.RawVMViewSet) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 5bb22ec..be00a0c 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -1,17 +1,24 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from .models import Bill, Payment +from .models import Bill, Payment, Order class BillSerializer(serializers.ModelSerializer): class Meta: model = Bill - fields = ['owner', 'amount', 'due_date', 'creation_date', 'starting_date', 'ending_date', 'paid'] + fields = ['owner', 'amount', 'due_date', 'creation_date', + 'starting_date', 'ending_date', 'paid'] class PaymentSerializer(serializers.ModelSerializer): class Meta: model = Payment fields = ['owner', 'amount', 'source', 'timestamp'] +class OrderSerializer(serializers.ModelSerializer): + class Meta: + model = Order + fields = ['owner', 'creation_date', 'starting_date', 'ending_date', + 'bill', 'recurring_price', 'one_time_price', 'recurring_period'] + class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 5111f6c..ae88861 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -4,8 +4,8 @@ from rest_framework import viewsets, permissions, status from rest_framework.response import Response from rest_framework.decorators import action -from .models import Bill, Payment -from .serializers import BillSerializer, PaymentSerializer, UserSerializer +from .models import Bill, Payment, Order +from .serializers import BillSerializer, PaymentSerializer, UserSerializer, OrderSerializer from datetime import datetime ### @@ -40,6 +40,13 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet): 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 UserViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = UserSerializer permission_classes = [permissions.IsAuthenticated] @@ -59,7 +66,7 @@ class AdminPaymentViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] def get_queryset(self): - return Payment.objects.filter(owner=self.request.user) + return Payment.objects.all() def create(self, request): serializer = self.get_serializer(data=request.data) @@ -74,7 +81,7 @@ class AdminBillViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] def get_queryset(self): - return Bill.objects.filter(owner=self.request.user) + return Bill.objects.all() def unpaid(self, request): return Bill.objects.filter(owner=self.request.user, paid=False) @@ -86,3 +93,10 @@ class AdminBillViewSet(viewsets.ModelViewSet): 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() From 70a4fe4d9008b5f6be753d1130e6d331ba457d6f Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Feb 2020 12:45:54 +0100 Subject: [PATCH 088/193] order: serialize all fields --- uncloud/README.md | 6 ++++++ uncloud/uncloud_pay/serializers.py | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/uncloud/README.md b/uncloud/README.md index b1d98a5..390a3af 100644 --- a/uncloud/README.md +++ b/uncloud/README.md @@ -82,6 +82,12 @@ sample values with real values. These APIs can be used for internal testing. +### URL Overview + +``` +http -a nicoschottelius:$(pass ungleich.ch/nico.schottelius@ungleich.ch) http://localhost:8000 +``` + ### Snapshotting ``` diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index be00a0c..130f683 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -16,8 +16,7 @@ class PaymentSerializer(serializers.ModelSerializer): class OrderSerializer(serializers.ModelSerializer): class Meta: model = Order - fields = ['owner', 'creation_date', 'starting_date', 'ending_date', - 'bill', 'recurring_price', 'one_time_price', 'recurring_period'] + fields = '__all__' class UserSerializer(serializers.ModelSerializer): class Meta: From bd6008462d9eb6823f9db90ed8ee895d474cb1a0 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Feb 2020 15:29:05 +0100 Subject: [PATCH 089/193] add template for uncloud_net Signed-off-by: Nico Schottelius --- uncloud/uncloud_net/__init__.py | 0 uncloud/uncloud_net/admin.py | 3 +++ uncloud/uncloud_net/apps.py | 5 +++++ uncloud/uncloud_net/migrations/__init__.py | 0 uncloud/uncloud_net/models.py | 3 +++ uncloud/uncloud_net/tests.py | 3 +++ uncloud/uncloud_net/views.py | 3 +++ 7 files changed, 17 insertions(+) create mode 100644 uncloud/uncloud_net/__init__.py create mode 100644 uncloud/uncloud_net/admin.py create mode 100644 uncloud/uncloud_net/apps.py create mode 100644 uncloud/uncloud_net/migrations/__init__.py create mode 100644 uncloud/uncloud_net/models.py create mode 100644 uncloud/uncloud_net/tests.py create mode 100644 uncloud/uncloud_net/views.py diff --git a/uncloud/uncloud_net/__init__.py b/uncloud/uncloud_net/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/uncloud_net/admin.py b/uncloud/uncloud_net/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/uncloud/uncloud_net/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/uncloud/uncloud_net/apps.py b/uncloud/uncloud_net/apps.py new file mode 100644 index 0000000..489beb1 --- /dev/null +++ b/uncloud/uncloud_net/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UncloudNetConfig(AppConfig): + name = 'uncloud_net' diff --git a/uncloud/uncloud_net/migrations/__init__.py b/uncloud/uncloud_net/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/uncloud_net/models.py b/uncloud/uncloud_net/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/uncloud/uncloud_net/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/uncloud/uncloud_net/tests.py b/uncloud/uncloud_net/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/uncloud/uncloud_net/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/uncloud/uncloud_net/views.py b/uncloud/uncloud_net/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/uncloud/uncloud_net/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From 288a65f2192a93aaa2660b244be67b9bb8faee4a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 27 Feb 2020 15:29:15 +0100 Subject: [PATCH 090/193] ++update Signed-off-by: Nico Schottelius --- .../migrations/0002_auto_20200227_1230.py | 18 ++++++++++ .../migrations/0005_auto_20200227_1230.py | 36 +++++++++++++++++++ uncloud/uncloud_vm/models.py | 15 ++++---- uncloud/uncloud_vm/serializers.py | 10 ++++++ uncloud/uncloud_vm/views.py | 13 ++++++- 5 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0002_auto_20200227_1230.py create mode 100644 uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py diff --git a/uncloud/uncloud_pay/migrations/0002_auto_20200227_1230.py b/uncloud/uncloud_pay/migrations/0002_auto_20200227_1230.py new file mode 100644 index 0000000..0643e9a --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0002_auto_20200227_1230.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-27 12:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='source', + field=models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py new file mode 100644 index 0000000..1bd711b --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py @@ -0,0 +1,36 @@ +# Generated by Django 3.0.3 on 2020-02-27 12:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0002_auto_20200227_1230'), + ('uncloud_vm', '0004_vmsnapshotproduct'), + ] + + operations = [ + migrations.RemoveField( + model_name='vmsnapshotproduct', + name='vm_uuid', + ), + migrations.AddField( + model_name='vmproduct', + name='order', + field=models.ForeignKey(default=0, editable=False, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), + preserve_default=False, + ), + migrations.AddField( + model_name='vmproduct', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256), + ), + migrations.AddField( + model_name='vmsnapshotproduct', + name='vm', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct'), + preserve_default=False, + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 12d188e..4ebae25 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -32,13 +32,7 @@ class VMHost(models.Model): ) -class VMProduct(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) +class VMProduct(Product): vmhost = models.ForeignKey(VMHost, on_delete=models.CASCADE, editable=False, @@ -72,8 +66,12 @@ class OperatingSystemDisk(VMDiskProduct): class VMNetworkCard(models.Model): vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + mac_address = models.IntegerField() + ip_address = models.GenericIPAddressField(blank=True, + null=True) + class VMSnapshotProduct(Product): price_per_gb_ssd = 0.35 @@ -83,7 +81,8 @@ class VMSnapshotProduct(Product): gb_ssd = models.FloatField(editable=False) gb_hdd = models.FloatField(editable=False) - vm_uuid = models.UUIDField() + vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + #vm_uuid = models.UUIDField() # Need to setup recurring_price and one_time_price and recurring period diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index c1eafe2..b247709 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -14,7 +14,17 @@ class VMProductSerializer(serializers.HyperlinkedModelSerializer): model = VMProduct fields = '__all__' + +# def create(self, validated_data): +# return VMSnapshotProduct() + class VMSnapshotProductSerializer(serializers.ModelSerializer): class Meta: model = VMSnapshotProduct fields = '__all__' + + + # verify that vm.owner == user.request + def validate_vm(self, value): + print(value) + return True diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 444d134..7e517f5 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -11,6 +11,7 @@ from uncloud_pay.models import Order from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer + import datetime class VMHostViewSet(viewsets.ModelViewSet): @@ -29,7 +30,17 @@ class VMProductViewSet(viewsets.ModelViewSet): def create(self, request): serializer = VMProductSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) - serializer.save(owner=request.user) + # Create order + now = datetime.datetime.now() + order = Order(owner=request.user, + creation_date=now, + starting_date=now, + recurring_price=20, + one_time_price=0, + recurring_period="per_month") + order.save() + + serializer.save(owner=request.user, order=order) return Response(serializer.data) From 36fcff5149c10e972116b9b64cfb5e9bc41f26ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 15:15:12 +0100 Subject: [PATCH 091/193] Add initial structure for payment methods --- uncloud/uncloud/urls.py | 1 + .../migrations/0002_auto_20200227_1404.py | 32 +++++++++++++++++++ .../migrations/0003_auto_20200227_1414.py | 28 ++++++++++++++++ uncloud/uncloud_pay/models.py | 15 +++++++++ uncloud/uncloud_pay/serializers.py | 7 +++- uncloud/uncloud_pay/views.py | 19 +++++++++-- 6 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0002_auto_20200227_1404.py create mode 100644 uncloud/uncloud_pay/migrations/0003_auto_20200227_1414.py diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 5ee9f07..40b1be5 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -33,6 +33,7 @@ router.register(r'user', payviews.UserViewSet, basename='user') 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') diff --git a/uncloud/uncloud_pay/migrations/0002_auto_20200227_1404.py b/uncloud/uncloud_pay/migrations/0002_auto_20200227_1404.py new file mode 100644 index 0000000..4a6e776 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0002_auto_20200227_1404.py @@ -0,0 +1,32 @@ +# Generated by Django 3.0.3 on 2020-02-27 14:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='source', + field=models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256), + ), + 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()), + ('default', models.BooleanField()), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0003_auto_20200227_1414.py b/uncloud/uncloud_pay/migrations/0003_auto_20200227_1414.py new file mode 100644 index 0000000..1e16235 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0003_auto_20200227_1414.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.3 on 2020-02-27 14:14 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0002_auto_20200227_1404'), + ] + + operations = [ + migrations.AddField( + model_name='paymentmethod', + name='primary', + field=models.BooleanField(default=True), + ), + migrations.AlterUniqueTogether( + name='paymentmethod', + unique_together={('owner', 'primary')}, + ), + migrations.RemoveField( + model_name='paymentmethod', + name='default', + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 6a33fd5..643361a 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -68,7 +68,22 @@ class Order(models.Model): # return amount # you get the picture +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) + class Meta: + unique_together = [['owner', 'primary']] class Payment(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 130f683..93a3031 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -1,6 +1,6 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from .models import Bill, Payment, Order +from .models import * class BillSerializer(serializers.ModelSerializer): class Meta: @@ -13,6 +13,11 @@ class PaymentSerializer(serializers.ModelSerializer): model = Payment fields = ['owner', 'amount', 'source', 'timestamp'] +class PaymentMethodSerializer(serializers.ModelSerializer): + class Meta: + model = PaymentMethod + fields = ['owner', 'primary', 'source', 'description'] + class OrderSerializer(serializers.ModelSerializer): class Meta: model = Order diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index ae88861..0b39ff3 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -4,8 +4,8 @@ from rest_framework import viewsets, permissions, status from rest_framework.response import Response from rest_framework.decorators import action -from .models import Bill, Payment, Order -from .serializers import BillSerializer, PaymentSerializer, UserSerializer, OrderSerializer +from .models import * +from .serializers import * from datetime import datetime ### @@ -58,6 +58,21 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): def balance(self, request): return Response(status=status.HTTP_204_NO_CONTENT) +class PaymentMethodViewSet(viewsets.ModelViewSet): + serializer_class = PaymentMethodSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return PaymentMethod.objects.filter(owner=self.request.user) + + def create(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(owner=request.user) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + ### # Admin views. From 1dd33242756e469afb2779f7abcf11eb9d39d72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 15:50:46 +0100 Subject: [PATCH 092/193] Wiring initial user balance --- uncloud/uncloud_pay/models.py | 11 ++++++----- uncloud/uncloud_pay/serializers.py | 17 ++++++++++++++--- uncloud/uncloud_pay/views.py | 8 +++----- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 643361a..c824a00 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -9,8 +9,7 @@ AMOUNT_DECIMALS=2 class Bill(models.Model): owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - editable=False) + on_delete=models.CASCADE) creation_date = models.DateTimeField() starting_date = models.DateTimeField() @@ -23,7 +22,7 @@ class Bill(models.Model): @property def amount(self): # iterate over all related orders - pass + return 20 class Order(models.Model): @@ -82,6 +81,9 @@ class PaymentMethod(models.Model): description = models.TextField() primary = models.BooleanField(default=True) + def charge(self, amount): + pass + class Meta: unique_together = [['owner', 'primary']] @@ -89,8 +91,7 @@ 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, - editable=False) + on_delete=models.CASCADE) amount = models.DecimalField( default=0.0, diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 93a3031..040c78a 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -2,6 +2,8 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import * +from functools import reduce + class BillSerializer(serializers.ModelSerializer): class Meta: model = Bill @@ -26,7 +28,16 @@ class OrderSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() - fields = ['username', 'email'] + fields = ['username', 'email', 'balance'] - def get_balance(self, obj): - return 666 + # Display current 'balance' + balance = serializers.SerializerMethodField('get_balance') + def __sum_balance(self, entries): + return reduce(lambda acc, entry: acc + entry.amount, entries, 0) + + def get_balance(self, user): + bills = self.__sum_balance(Bill.objects.filter(owner=user)) + payments = self.__sum_balance(Payment.objects.filter(owner=user)) + balance = payments - bills + + return balance diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 0b39ff3..ea3cca7 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -4,6 +4,8 @@ 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 @@ -54,10 +56,6 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return get_user_model().objects.all() - @action(detail=True) - def balance(self, request): - return Response(status=status.HTTP_204_NO_CONTENT) - class PaymentMethodViewSet(viewsets.ModelViewSet): serializer_class = PaymentMethodSerializer permission_classes = [permissions.IsAuthenticated] @@ -104,7 +102,7 @@ class AdminBillViewSet(viewsets.ModelViewSet): def create(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save(created_at=datetime.now()) + serializer.save(creation_date=datetime.now()) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) From b2fe5014d84dc9c4f38cc273e6adf479c72228a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 17:13:56 +0100 Subject: [PATCH 093/193] Make recurring_period an Enum, VMProduct a Product, initial wire for order --- .../migrations/0004_auto_20200227_1532.py | 31 ++++++++++++++++ uncloud/uncloud_pay/models.py | 35 +++++++++++-------- uncloud/uncloud_pay/serializers.py | 20 +++++++++++ uncloud/uncloud_pay/views.py | 2 +- .../migrations/0005_auto_20200227_1532.py | 30 ++++++++++++++++ uncloud/uncloud_vm/models.py | 19 +++++----- 6 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0004_auto_20200227_1532.py create mode 100644 uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py diff --git a/uncloud/uncloud_pay/migrations/0004_auto_20200227_1532.py b/uncloud/uncloud_pay/migrations/0004_auto_20200227_1532.py new file mode 100644 index 0000000..f26b498 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0004_auto_20200227_1532.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.3 on 2020-02-27 15:32 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0003_auto_20200227_1414'), + ] + + operations = [ + migrations.AlterField( + model_name='bill', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='order', + name='recurring_period', + field=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), + ), + migrations.AlterField( + model_name='payment', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index c824a00..d7c4ff1 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -1,12 +1,23 @@ from django.db import models from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator +from django.utils.translation import gettext_lazy as _ import uuid AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 +# 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') + class Bill(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) @@ -31,7 +42,7 @@ class Order(models.Model): on_delete=models.CASCADE, editable=False) - creation_date = models.DateTimeField() + creation_date = models.DateTimeField(auto_now_add=True) starting_date = models.DateTimeField() ending_date = models.DateTimeField(blank=True, null=True) @@ -46,19 +57,8 @@ class Order(models.Model): one_time_price = models.FloatField(editable=False) recurring_period = models.CharField(max_length=32, - choices = ( - ('onetime', 'Onetime'), - ('per_year', 'Per Year'), - ('per_month', 'Per Month'), - ('per_week', 'Per Week'), - ('per_day', 'Per Day'), - ('per_hour', 'Per Hour'), - ('per_minute', 'Per Minute'), - ('per_second', 'Per Second'), - ), - default='onetime' - - ) + choices = RecurringPeriod.choices, + default = RecurringPeriod.PER_MONTH) # def amount(self): # amount = recurring_price @@ -133,7 +133,12 @@ class Product(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, - editable=False) + editable=False, + null=True) + + @property + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + pass # To be implemented in child. class Meta: abstract = True diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 040c78a..4065fbd 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -3,6 +3,7 @@ from rest_framework import serializers from .models import * from functools import reduce +from uncloud_vm.serializers import VMProductSerializer class BillSerializer(serializers.ModelSerializer): class Meta: @@ -20,11 +21,30 @@ class PaymentMethodSerializer(serializers.ModelSerializer): model = PaymentMethod fields = ['owner', 'primary', 'source', 'description'] +class ProductSerializer(serializers.Serializer): + vms = VMProductSerializer(many=True, required=False) + class meta: + fields = ['vms'] + + def create(self, validated_data): + pass + class OrderSerializer(serializers.ModelSerializer): + products = ProductSerializer(many=True) class Meta: model = Order fields = '__all__' + def create(self, validated_data): + products_data = validated_data.pop('products') + order = Order.objects.create(**validated_data) + for product_data in products_data: + print("spouik") + print(product_data) + pass # TODO + + return order + class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index ea3cca7..c641991 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -42,7 +42,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return Payment.objects.filter(owner=self.request.user) -class OrderViewSet(viewsets.ReadOnlyModelViewSet): +class OrderViewSet(viewsets.ModelViewSet): serializer_class = OrderSerializer permission_classes = [permissions.IsAuthenticated] diff --git a/uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py new file mode 100644 index 0000000..b49d6e4 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py @@ -0,0 +1,30 @@ +# Generated by Django 3.0.3 on 2020-02-27 15:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0004_auto_20200227_1532'), + ('uncloud_vm', '0004_vmsnapshotproduct'), + ] + + operations = [ + migrations.AddField( + model_name='vmproduct', + name='order', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), + ), + migrations.AddField( + model_name='vmproduct', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='order', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 12d188e..2510837 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -2,7 +2,7 @@ from django.db import models from django.contrib.auth import get_user_model import uuid -from uncloud_pay.models import Product +from uncloud_pay.models import Product, RecurringPeriod class VMHost(models.Model): @@ -32,22 +32,25 @@ class VMHost(models.Model): ) -class VMProduct(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) +class VMProduct(Product): vmhost = models.ForeignKey(VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True) + description = "Virtual Machine" cores = models.IntegerField() ram_in_gb = models.FloatField() + @property + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + if recurring_period == RecurringPeriod.PER_MONTH: + # TODO: move magic numbers in variables + return self.cores * 3 + self.ram_in_gb * 2 + else: + raise Exception('Invalid recurring period for VM Product pricing.') + class VMWithOSProduct(VMProduct): pass From 809a55e1dd6799725d6d3de1c72b8c395f3de621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 18:54:13 +0100 Subject: [PATCH 094/193] Wire VMProduct creation to order --- uncloud/uncloud_pay/models.py | 7 +++++++ uncloud/uncloud_pay/serializers.py | 30 ++++++++++++++++++++++-------- uncloud/uncloud_vm/models.py | 1 + 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index d7c4ff1..c4506a2 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -60,6 +60,13 @@ class Order(models.Model): choices = RecurringPeriod.choices, default = RecurringPeriod.PER_MONTH) + @property + def products(self): + # Blows up due to circular dependency... + # vms = VMProduct.objects.filter(order=self) + vms = [] + return vms + # def amount(self): # amount = recurring_price # if recurring and first_month: diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 4065fbd..d08f9cf 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -4,6 +4,7 @@ from .models import * from functools import reduce from uncloud_vm.serializers import VMProductSerializer +from uncloud_vm.models import VMProduct class BillSerializer(serializers.ModelSerializer): class Meta: @@ -23,25 +24,38 @@ class PaymentMethodSerializer(serializers.ModelSerializer): class ProductSerializer(serializers.Serializer): vms = VMProductSerializer(many=True, required=False) - class meta: - fields = ['vms'] def create(self, validated_data): - pass + owner = validated_data.pop('owner') + order = validated_data.pop('order') + + vms = validated_data.pop('vms') + for vm in vms: + VMProduct.objects.create(owner=owner, order=order, **vm) + + return True # FIXME: shoudl return created objects + class OrderSerializer(serializers.ModelSerializer): - products = ProductSerializer(many=True) + products = ProductSerializer() class Meta: model = Order fields = '__all__' def create(self, validated_data): products_data = validated_data.pop('products') + validated_data['owner'] = self.context["request"].user + + # FIXME: find something to do with this: + validated_data['recurring_price'] = 0 + validated_data['one_time_price'] = 0 + order = Order.objects.create(**validated_data) - for product_data in products_data: - print("spouik") - print(product_data) - pass # TODO + + # Forward product creation to ProductSerializer. + products = ProductSerializer(data=products_data) + products.is_valid(raise_exception=True) + products.save(order=order,owner=order.owner) return order diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 2510837..2db99f3 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model import uuid from uncloud_pay.models import Product, RecurringPeriod +import uncloud_pay.models as pay_models class VMHost(models.Model): From 38d3a3a5d3619be4a755ec53f3b3d2cf4ab94170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 20:28:48 +0100 Subject: [PATCH 095/193] Commit WIP changes for /order, if needed at any point --- uncloud/uncloud_pay/helpers.py | 45 ++++++++++++++++++++++++++ uncloud/uncloud_pay/models.py | 52 +++--------------------------- uncloud/uncloud_pay/serializers.py | 4 +-- uncloud/uncloud_vm/models.py | 17 +++++----- 4 files changed, 61 insertions(+), 57 deletions(-) create mode 100644 uncloud/uncloud_pay/helpers.py diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py new file mode 100644 index 0000000..8daef2e --- /dev/null +++ b/uncloud/uncloud_pay/helpers.py @@ -0,0 +1,45 @@ +import uuid + +from django.db import models +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ + +# 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') + +class Product(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) + + description = "" + + status = models.CharField(max_length=256, + choices = ( + ('pending', 'Pending'), + ('being_created', 'Being created'), + ('active', 'Active'), + ('deleted', 'Deleted') + ), + default='pending' + ) + + order = models.ForeignKey('uncloud_pay.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. + + class Meta: + abstract = True diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index c4506a2..5f05b9d 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -1,23 +1,15 @@ +import uuid + from django.db import models from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator -from django.utils.translation import gettext_lazy as _ -import uuid +from .helpers import RecurringPeriod +import uncloud_vm.models as vmmodels AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 -# 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') - class Bill(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) @@ -63,8 +55,7 @@ class Order(models.Model): @property def products(self): # Blows up due to circular dependency... - # vms = VMProduct.objects.filter(order=self) - vms = [] + vms = vmmodels.VMProduct.objects.all() #filter(order=self) return vms # def amount(self): @@ -116,36 +107,3 @@ class Payment(models.Model): ), default='unknown') timestamp = models.DateTimeField(editable=False) - - - - -class Product(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) - - description = "" - - status = models.CharField(max_length=256, - choices = ( - ('pending', 'Pending'), - ('being_created', 'Being created'), - ('active', 'Active'), - ('deleted', 'Deleted') - ), - default='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. - - class Meta: - abstract = True diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index d08f9cf..406b751 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -23,7 +23,7 @@ class PaymentMethodSerializer(serializers.ModelSerializer): fields = ['owner', 'primary', 'source', 'description'] class ProductSerializer(serializers.Serializer): - vms = VMProductSerializer(many=True, required=False) + vms = VMProductSerializer(many=True, required=False, queryset=VMProduct.objects.all()) def create(self, validated_data): owner = validated_data.pop('owner') @@ -31,7 +31,7 @@ class ProductSerializer(serializers.Serializer): vms = validated_data.pop('vms') for vm in vms: - VMProduct.objects.create(owner=owner, order=order, **vm) + print(VMProduct.objects.create(owner=owner, order=order, **vm)) return True # FIXME: shoudl return created objects diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 2db99f3..02ec20f 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -1,9 +1,10 @@ -from django.db import models -from django.contrib.auth import get_user_model import uuid -from uncloud_pay.models import Product, RecurringPeriod -import uncloud_pay.models as pay_models +from django.db import models +from django.contrib.auth import get_user_model + +import uncloud_pay.models as paymodels +import uncloud_pay.helpers as payhelpers class VMHost(models.Model): @@ -33,7 +34,7 @@ class VMHost(models.Model): ) -class VMProduct(Product): +class VMProduct(payhelpers.Product): vmhost = models.ForeignKey(VMHost, on_delete=models.CASCADE, editable=False, @@ -45,8 +46,8 @@ class VMProduct(Product): ram_in_gb = models.FloatField() @property - def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): - if recurring_period == RecurringPeriod.PER_MONTH: + def recurring_price(self, recurring_period=paymodels.RecurringPeriod.PER_MONTH): + if recurring_period == paymodels.RecurringPeriod.PER_MONTH: # TODO: move magic numbers in variables return self.cores * 3 + self.ram_in_gb * 2 else: @@ -79,7 +80,7 @@ class VMNetworkCard(models.Model): mac_address = models.IntegerField() -class VMSnapshotProduct(Product): +class VMSnapshotProduct(payhelpers.Product): price_per_gb_ssd = 0.35 price_per_gb_hdd = 1.5/100 From 0e28e50baca121d1f014431f7206f9fcff7282c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 20:29:50 +0100 Subject: [PATCH 096/193] Revert "Commit WIP changes for /order, if needed at any point" This reverts commit 83794a1781a1b84506100b39a6997882c654b4f3. --- uncloud/uncloud_pay/helpers.py | 45 -------------------------- uncloud/uncloud_pay/models.py | 52 +++++++++++++++++++++++++++--- uncloud/uncloud_pay/serializers.py | 4 +-- uncloud/uncloud_vm/models.py | 15 ++++----- 4 files changed, 56 insertions(+), 60 deletions(-) delete mode 100644 uncloud/uncloud_pay/helpers.py diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py deleted file mode 100644 index 8daef2e..0000000 --- a/uncloud/uncloud_pay/helpers.py +++ /dev/null @@ -1,45 +0,0 @@ -import uuid - -from django.db import models -from django.contrib.auth import get_user_model -from django.utils.translation import gettext_lazy as _ - -# 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') - -class Product(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) - - description = "" - - status = models.CharField(max_length=256, - choices = ( - ('pending', 'Pending'), - ('being_created', 'Being created'), - ('active', 'Active'), - ('deleted', 'Deleted') - ), - default='pending' - ) - - order = models.ForeignKey('uncloud_pay.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. - - class Meta: - abstract = True diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 5f05b9d..c4506a2 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -1,15 +1,23 @@ -import uuid - from django.db import models from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator +from django.utils.translation import gettext_lazy as _ -from .helpers import RecurringPeriod -import uncloud_vm.models as vmmodels +import uuid AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 +# 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') + class Bill(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) @@ -55,7 +63,8 @@ class Order(models.Model): @property def products(self): # Blows up due to circular dependency... - vms = vmmodels.VMProduct.objects.all() #filter(order=self) + # vms = VMProduct.objects.filter(order=self) + vms = [] return vms # def amount(self): @@ -107,3 +116,36 @@ class Payment(models.Model): ), default='unknown') timestamp = models.DateTimeField(editable=False) + + + + +class Product(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) + + description = "" + + status = models.CharField(max_length=256, + choices = ( + ('pending', 'Pending'), + ('being_created', 'Being created'), + ('active', 'Active'), + ('deleted', 'Deleted') + ), + default='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. + + class Meta: + abstract = True diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 406b751..d08f9cf 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -23,7 +23,7 @@ class PaymentMethodSerializer(serializers.ModelSerializer): fields = ['owner', 'primary', 'source', 'description'] class ProductSerializer(serializers.Serializer): - vms = VMProductSerializer(many=True, required=False, queryset=VMProduct.objects.all()) + vms = VMProductSerializer(many=True, required=False) def create(self, validated_data): owner = validated_data.pop('owner') @@ -31,7 +31,7 @@ class ProductSerializer(serializers.Serializer): vms = validated_data.pop('vms') for vm in vms: - print(VMProduct.objects.create(owner=owner, order=order, **vm)) + VMProduct.objects.create(owner=owner, order=order, **vm) return True # FIXME: shoudl return created objects diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 02ec20f..2db99f3 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -1,10 +1,9 @@ -import uuid - from django.db import models from django.contrib.auth import get_user_model +import uuid -import uncloud_pay.models as paymodels -import uncloud_pay.helpers as payhelpers +from uncloud_pay.models import Product, RecurringPeriod +import uncloud_pay.models as pay_models class VMHost(models.Model): @@ -34,7 +33,7 @@ class VMHost(models.Model): ) -class VMProduct(payhelpers.Product): +class VMProduct(Product): vmhost = models.ForeignKey(VMHost, on_delete=models.CASCADE, editable=False, @@ -46,8 +45,8 @@ class VMProduct(payhelpers.Product): ram_in_gb = models.FloatField() @property - def recurring_price(self, recurring_period=paymodels.RecurringPeriod.PER_MONTH): - if recurring_period == paymodels.RecurringPeriod.PER_MONTH: + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + if recurring_period == RecurringPeriod.PER_MONTH: # TODO: move magic numbers in variables return self.cores * 3 + self.ram_in_gb * 2 else: @@ -80,7 +79,7 @@ class VMNetworkCard(models.Model): mac_address = models.IntegerField() -class VMSnapshotProduct(payhelpers.Product): +class VMSnapshotProduct(Product): price_per_gb_ssd = 0.35 price_per_gb_hdd = 1.5/100 From b1649a6228a052edfaf2b429b55b8489f8b4aef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 20:37:19 +0100 Subject: [PATCH 097/193] Remove product resolution from /order endpoint --- uncloud/uncloud_pay/models.py | 7 ------- uncloud/uncloud_pay/serializers.py | 31 +----------------------------- uncloud/uncloud_pay/views.py | 2 +- 3 files changed, 2 insertions(+), 38 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index c4506a2..d7c4ff1 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -60,13 +60,6 @@ class Order(models.Model): choices = RecurringPeriod.choices, default = RecurringPeriod.PER_MONTH) - @property - def products(self): - # Blows up due to circular dependency... - # vms = VMProduct.objects.filter(order=self) - vms = [] - return vms - # def amount(self): # amount = recurring_price # if recurring and first_month: diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index d08f9cf..9449ee6 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -23,42 +23,13 @@ class PaymentMethodSerializer(serializers.ModelSerializer): fields = ['owner', 'primary', 'source', 'description'] class ProductSerializer(serializers.Serializer): - vms = VMProductSerializer(many=True, required=False) - - def create(self, validated_data): - owner = validated_data.pop('owner') - order = validated_data.pop('order') - - vms = validated_data.pop('vms') - for vm in vms: - VMProduct.objects.create(owner=owner, order=order, **vm) - - return True # FIXME: shoudl return created objects - + vms = VMProductSerializer(many=True, read_only=True) class OrderSerializer(serializers.ModelSerializer): - products = ProductSerializer() class Meta: model = Order fields = '__all__' - def create(self, validated_data): - products_data = validated_data.pop('products') - validated_data['owner'] = self.context["request"].user - - # FIXME: find something to do with this: - validated_data['recurring_price'] = 0 - validated_data['one_time_price'] = 0 - - order = Order.objects.create(**validated_data) - - # Forward product creation to ProductSerializer. - products = ProductSerializer(data=products_data) - products.is_valid(raise_exception=True) - products.save(order=order,owner=order.owner) - - return order - class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index c641991..ea3cca7 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -42,7 +42,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return Payment.objects.filter(owner=self.request.user) -class OrderViewSet(viewsets.ModelViewSet): +class OrderViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = OrderSerializer permission_classes = [permissions.IsAuthenticated] From ef5e7e80355ae276cbb70b738d8e7b23e376f29b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 27 Feb 2020 20:58:12 +0100 Subject: [PATCH 098/193] Quickly wire vm creation to orders --- uncloud/uncloud/urls.py | 5 ++++- uncloud/uncloud_pay/models.py | 2 +- uncloud/uncloud_vm/models.py | 1 - uncloud/uncloud_vm/views.py | 19 ++++++++++++++++--- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 40b1be5..d1a1cb8 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -35,13 +35,16 @@ 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') +# VMs +router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vm') + # 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/opennebula', oneviews.VMViewSet, basename='opennebula') -router.register(r'admin/opennebula_raw', oneviews.RawVMViewSet) +router.register(r'admin/opennebula_raw', oneviews.RawVMViewSet, basename='opennebula_raw') urlpatterns = [ diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index d7c4ff1..6077963 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -43,7 +43,7 @@ class Order(models.Model): editable=False) creation_date = models.DateTimeField(auto_now_add=True) - starting_date = models.DateTimeField() + starting_date = models.DateTimeField(auto_now_add=True) ending_date = models.DateTimeField(blank=True, null=True) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 2db99f3..26b369f 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -44,7 +44,6 @@ class VMProduct(Product): cores = models.IntegerField() ram_in_gb = models.FloatField() - @property def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): if recurring_period == RecurringPeriod.PER_MONTH: # TODO: move magic numbers in variables diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 444d134..a7171c9 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -7,8 +7,7 @@ from rest_framework import viewsets, permissions from rest_framework.response import Response from .models import VMHost, VMProduct, VMSnapshotProduct -from uncloud_pay.models import Order - +from uncloud_pay.models import Order, RecurringPeriod from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer import datetime @@ -27,9 +26,23 @@ class VMProductViewSet(viewsets.ModelViewSet): return VMProduct.objects.filter(owner=self.request.user) def create(self, request): + # Create base order. + order = Order.objects.create( + recurring_period=RecurringPeriod.PER_MONTH, + recurring_price=0, + one_time_price=0, + owner=request.user + ) + + # Create VM. serializer = VMProductSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) - serializer.save(owner=request.user) + vm = serializer.save(owner=request.user, order=order) + + # FIXME: commit everything (VM + order) at once. + order.recurring_price = vm.recurring_price(order.recurring_period) + order.one_time_price = 0 + order.save() return Response(serializer.data) From 059791e2f216c95b82bb115b84541520c702e688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 08:59:32 +0100 Subject: [PATCH 099/193] Add initial generate-bills and charge-negative-balance uncloud-pay commands --- uncloud/uncloud_pay/helpers.py | 12 +++ .../commands/charge-negative-balance.py | 23 +++++ .../management/commands/generate-bills.py | 48 +++++++++++ .../migrations/0005_auto_20200228_0737.py | 42 ++++++++++ .../migrations/0006_auto_20200228_0741.py | 18 ++++ .../migrations/0007_remove_order_bill.py | 17 ++++ .../uncloud_pay/migrations/0008_order_bill.py | 18 ++++ uncloud/uncloud_pay/models.py | 16 ++-- uncloud/uncloud_pay/serializers.py | 9 +- uncloud/uncloud_vm/views.py.orig | 84 +++++++++++++++++++ 10 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 uncloud/uncloud_pay/helpers.py create mode 100644 uncloud/uncloud_pay/management/commands/charge-negative-balance.py create mode 100644 uncloud/uncloud_pay/management/commands/generate-bills.py create mode 100644 uncloud/uncloud_pay/migrations/0005_auto_20200228_0737.py create mode 100644 uncloud/uncloud_pay/migrations/0006_auto_20200228_0741.py create mode 100644 uncloud/uncloud_pay/migrations/0007_remove_order_bill.py create mode 100644 uncloud/uncloud_pay/migrations/0008_order_bill.py create mode 100644 uncloud/uncloud_vm/views.py.orig diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py new file mode 100644 index 0000000..9dc39cd --- /dev/null +++ b/uncloud/uncloud_pay/helpers.py @@ -0,0 +1,12 @@ +from functools import reduce +from .models import Bill, Payment + +def sum_amounts(entries): + return reduce(lambda acc, entry: acc + entry.amount, entries, 0) + + +def get_balance_for(user): + bills = sum_amounts(Bill.objects.filter(owner=user)) + payments = sum_amounts(Payment.objects.filter(owner=user)) + return payments - bills + diff --git a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py new file mode 100644 index 0000000..ae4c8dc --- /dev/null +++ b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py @@ -0,0 +1,23 @@ +from django.core.management.base import BaseCommand +from uncloud_auth.models import User +from uncloud_pay.models import Order, Bill +from uncloud_pay.helpers import 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)) + # TODO: charge + print("=> Done.") diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud/uncloud_pay/management/commands/generate-bills.py new file mode 100644 index 0000000..92075ce --- /dev/null +++ b/uncloud/uncloud_pay/management/commands/generate-bills.py @@ -0,0 +1,48 @@ +from django.core.management.base import BaseCommand +from uncloud_auth.models import User +from uncloud_pay.models import Order, Bill + +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 + + # TODO: check for existing bills + def handle(self, *args, **options): + customers = User.objects.all() + print("Processing {} users.".format(customers.count())) + for customer in customers: + orders = Order.objects.filter(owner=customer) + + # Pay all non-billed usage untill now. + bill_starting_date = timezone.now() + bill_ending_date = timezone.now() + + billed_orders = [] + for order in orders: + print(order) + if True: # FIXME + billed_orders.append(order) + + # Update starting date if need be. + if order.starting_date < bill_starting_date: + bill_starting_date = order.starting_date + + if len(billed_orders) > 0: + bill = Bill(owner=customer, + starting_date=bill_starting_date, + ending_date=bill_starting_date, + due_date=timezone.now() + timedelta(days=10)) + bill.save() + + for order in billed_orders: + print(order) + order.bill.add(bill) + + print("Created bill {} for user {}".format(bill.uuid, customer.username)) + + print("=> Done.") diff --git a/uncloud/uncloud_pay/migrations/0005_auto_20200228_0737.py b/uncloud/uncloud_pay/migrations/0005_auto_20200228_0737.py new file mode 100644 index 0000000..c646724 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0005_auto_20200228_0737.py @@ -0,0 +1,42 @@ +# Generated by Django 3.0.3 on 2020-02-28 07:37 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0004_auto_20200227_1532'), + ] + + operations = [ + migrations.RemoveField( + model_name='bill', + name='id', + ), + migrations.RemoveField( + model_name='bill', + name='paid', + ), + migrations.AddField( + model_name='bill', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='bill', + name='creation_date', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='order', + name='creation_date', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='order', + name='starting_date', + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0006_auto_20200228_0741.py b/uncloud/uncloud_pay/migrations/0006_auto_20200228_0741.py new file mode 100644 index 0000000..ef03bda --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0006_auto_20200228_0741.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-28 07:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0005_auto_20200228_0737'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='bill', + field=models.ManyToManyField(blank=True, editable=False, to='uncloud_pay.Bill'), + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0007_remove_order_bill.py b/uncloud/uncloud_pay/migrations/0007_remove_order_bill.py new file mode 100644 index 0000000..ea79416 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0007_remove_order_bill.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.3 on 2020-02-28 07:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0006_auto_20200228_0741'), + ] + + operations = [ + migrations.RemoveField( + model_name='order', + name='bill', + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0008_order_bill.py b/uncloud/uncloud_pay/migrations/0008_order_bill.py new file mode 100644 index 0000000..315ac60 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0008_order_bill.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-28 07:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0007_remove_order_bill'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='bill', + field=models.ManyToManyField(blank=True, editable=False, to='uncloud_pay.Bill'), + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 6077963..f3de8c4 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -19,21 +19,26 @@ class RecurringPeriod(models.TextChoices): PER_SECOND = 'SECOND', _('Per Second') 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() + creation_date = models.DateTimeField(auto_now_add=True) starting_date = models.DateTimeField() ending_date = models.DateTimeField() due_date = models.DateField() - paid = models.BooleanField(default=False) valid = models.BooleanField(default=True) @property def amount(self): - # iterate over all related orders - return 20 + orders = Order.objects.filter(bill=self) + amount = 0 + for order in orders: + amount += order.recurring_price + + return amount + class Order(models.Model): @@ -49,8 +54,7 @@ class Order(models.Model): bill = models.ManyToManyField(Bill, editable=False, - blank=True, - null=True) + blank=True) recurring_price = models.FloatField(editable=False) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 9449ee6..a4a1f1b 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import * +from .helpers import get_balance_for from functools import reduce from uncloud_vm.serializers import VMProductSerializer @@ -10,7 +11,7 @@ class BillSerializer(serializers.ModelSerializer): class Meta: model = Bill fields = ['owner', 'amount', 'due_date', 'creation_date', - 'starting_date', 'ending_date', 'paid'] + 'starting_date', 'ending_date'] class PaymentSerializer(serializers.ModelSerializer): class Meta: @@ -41,8 +42,4 @@ class UserSerializer(serializers.ModelSerializer): return reduce(lambda acc, entry: acc + entry.amount, entries, 0) def get_balance(self, user): - bills = self.__sum_balance(Bill.objects.filter(owner=user)) - payments = self.__sum_balance(Payment.objects.filter(owner=user)) - balance = payments - bills - - return balance + return get_balance_for(user) diff --git a/uncloud/uncloud_vm/views.py.orig b/uncloud/uncloud_vm/views.py.orig new file mode 100644 index 0000000..a311320 --- /dev/null +++ b/uncloud/uncloud_vm/views.py.orig @@ -0,0 +1,84 @@ +from django.shortcuts import render + +from django.contrib.auth.models import User +from django.shortcuts import get_object_or_404 + +from rest_framework import viewsets, permissions +from rest_framework.response import Response + +<<<<<<< HEAD +from .models import VMHost, VMProduct, VMSnapshotProduct +from uncloud_pay.models import Order + +======= +from uncloud_pay.models import Order, RecurringPeriod + +from .models import VMHost, VMProduct +>>>>>>> Quickly wire vm creation to orders +from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer + +import datetime + +class VMHostViewSet(viewsets.ModelViewSet): + serializer_class = VMHostSerializer + queryset = VMHost.objects.all() + permission_classes = [permissions.IsAdminUser] + + +class VMProductViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMProductSerializer + + def get_queryset(self): + return VMProduct.objects.filter(owner=self.request.user) + + def create(self, request): + # Create base order. + order = Order.objects.create( + recurring_period=RecurringPeriod.PER_MONTH, + recurring_price=0, + one_time_price=0, + owner=request.user + ) + + # Create VM. + serializer = VMProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + vm = serializer.save(owner=request.user, order=order) + + # FIXME: commit everything (VM + order) at once. + order.recurring_price = vm.recurring_price(order.recurring_period) + order.one_time_price = 0 + order.save() + + return Response(serializer.data) + + +class VMSnapshotProductViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMSnapshotProductSerializer + + def get_queryset(self): + return VMSnapshotProduct.objects.filter(owner=self.request.user) + + def create(self, request): + serializer = VMSnapshotProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + + # Create order + now = datetime.datetime.now() + order = Order(owner=request.user, + creation_date=now, + starting_date=now, + recurring_price=20, + one_time_price=0, + recurring_period="per_month") + order.save() + + # FIXME: calculate the gb_* values + serializer.save(owner=request.user, + order=order, + gb_ssd=12, + gb_hdd=20) + + return Response(serializer.data) From 4bed53c8a87c4220dc34d2d2ac2bb5b5ad225bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 09:10:36 +0100 Subject: [PATCH 100/193] Wire charge-negative-balance to payment methods --- uncloud/uncloud/urls.py | 1 + uncloud/uncloud_pay/helpers.py | 18 +++++++++++++----- .../commands/charge-negative-balance.py | 13 +++++++++++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index d1a1cb8..8244e0e 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -30,6 +30,7 @@ router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') # Pay router.register(r'user', payviews.UserViewSet, basename='user') +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') diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index 9dc39cd..2f68e9e 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -1,12 +1,20 @@ from functools import reduce -from .models import Bill, Payment +from .models import Bill, Payment, PaymentMethod def sum_amounts(entries): - return reduce(lambda acc, entry: acc + entry.amount, entries, 0) + return reduce(lambda acc, entry: acc + entry.amount, entries, 0) def get_balance_for(user): - bills = sum_amounts(Bill.objects.filter(owner=user)) - payments = sum_amounts(Payment.objects.filter(owner=user)) - return payments - bills + bills = sum_amounts(Bill.objects.filter(owner=user)) + payments = sum_amounts(Payment.objects.filter(owner=user)) + return payments - bills +def get_payment_method_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 diff --git a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py index ae4c8dc..3667a03 100644 --- a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py +++ b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py @@ -1,7 +1,7 @@ from django.core.management.base import BaseCommand from uncloud_auth.models import User from uncloud_pay.models import Order, Bill -from uncloud_pay.helpers import get_balance_for +from uncloud_pay.helpers import get_balance_for, get_payment_method_for from datetime import timedelta from django.utils import timezone @@ -19,5 +19,14 @@ class Command(BaseCommand): balance = get_balance_for(user) if balance < 0: print("User {} has negative balance ({}), charging.".format(user.username, balance)) - # TODO: charge + payment_method = get_payment_method_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.") From 37ed126bc17ebe387b63c21c052bb5a5b9217340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 09:26:18 +0100 Subject: [PATCH 101/193] Create payment on strip charging --- uncloud/uncloud_pay/models.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index f3de8c4..8e41e24 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -57,8 +57,16 @@ class Order(models.Model): blank=True) - recurring_price = models.FloatField(editable=False) - one_time_price = models.FloatField(editable=False) + recurring_price = models.DecimalField( + max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS, + validators=[MinValueValidator(0)], + editable=False) + one_time_price = models.DecimalField( + max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS, + validators=[MinValueValidator(0)], + editable=False) recurring_period = models.CharField(max_length=32, choices = RecurringPeriod.choices, @@ -86,7 +94,18 @@ class PaymentMethod(models.Model): primary = models.BooleanField(default=True) def charge(self, amount): - pass + if amount > 0: # Make sure we don't charge negative amount by errors... + if self.source == 'stripe': + # TODO: wire to strip, see meooow-payv1/strip_utils.py + payment = Payment(owner=self.owner, source=self.source, amount=amount) + payment.save() # TODO: Check return status + + return True + else: + # We do not handle that source yet. + return False + else: + return False class Meta: unique_together = [['owner', 'primary']] @@ -112,7 +131,7 @@ class Payment(models.Model): ('unknown', 'Unknown') ), default='unknown') - timestamp = models.DateTimeField(editable=False) + timestamp = models.DateTimeField(editable=False, auto_now_add=True) From 89215e47b6daba0b860eb3d389ec3b1109231dde Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 28 Feb 2020 09:34:29 +0100 Subject: [PATCH 102/193] phase in mac --- uncloud/uncloud_net/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/uncloud/uncloud_net/models.py b/uncloud/uncloud_net/models.py index 71a8362..6d0c742 100644 --- a/uncloud/uncloud_net/models.py +++ b/uncloud/uncloud_net/models.py @@ -1,3 +1,4 @@ from django.db import models -# Create your models here. +class MACAdress(models.Model): + prefix = 0x420000000000 From adb57c55ca0c439a0577ebb1a0c24fbb678350ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 09:58:01 +0100 Subject: [PATCH 103/193] Revamp generate-bills logic to avoid overlapping --- .../management/commands/generate-bills.py | 69 ++++++++++++------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud/uncloud_pay/management/commands/generate-bills.py index 92075ce..aad7a82 100644 --- a/uncloud/uncloud_pay/management/commands/generate-bills.py +++ b/uncloud/uncloud_pay/management/commands/generate-bills.py @@ -1,48 +1,67 @@ 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 from django.utils import timezone +BILL_PAYMENT_DELAY=timedelta(days=10) + class Command(BaseCommand): help = 'Generate bills and charge customers if necessary.' def add_arguments(self, parser): pass - # TODO: check for existing bills def handle(self, *args, **options): - customers = User.objects.all() - print("Processing {} users.".format(customers.count())) - for customer in customers: - orders = Order.objects.filter(owner=customer) + users = User.objects.all() + print("Processing {} users.".format(users.count())) - # Pay all non-billed usage untill now. - bill_starting_date = timezone.now() - bill_ending_date = timezone.now() + for user in users: + # Fetch all the orders of a customer. + orders = Order.objects.filter(owner=user) - billed_orders = [] + # Default values for next bill (if any). Only saved at the end of + # this method, if relevant. + next_bill = Bill(owner=user, + starting_date=timezone.now(), # Will be set to oldest unpaid order (means unpaid starting date). + ending_date=timezone.now(), # Bill covers everything until today. + due_date=timezone.now() + BILL_PAYMENT_DELAY) + + unpaid_orders = [] # Store orders in need of a payment. for order in orders: - print(order) - if True: # FIXME - billed_orders.append(order) + # Only bill if there is an 'unpaid period' on an active order. + # XXX: Assume everything before latest bill is paid. => might be dangerous. + try: + previous_bill = order.bill.latest('ending_date') + except ObjectDoesNotExist: + previous_bill = None - # Update starting date if need be. - if order.starting_date < bill_starting_date: - bill_starting_date = order.starting_date + is_unpaid_period = True + if order.ending_date and previous_bill != None: + is_unpaid_period = previous_bill.ending_date < order.ending_date - if len(billed_orders) > 0: - bill = Bill(owner=customer, - starting_date=bill_starting_date, - ending_date=bill_starting_date, - due_date=timezone.now() + timedelta(days=10)) - bill.save() + if is_unpaid_period: + # Update bill starting date to match period. + if previous_bill == None: + next_bill.starting_date = order.starting_date + elif previous_bill.ending_date < next_bill.starting_date: + next_bill.starting_date = previous_bill.ending_date - for order in billed_orders: - print(order) - order.bill.add(bill) + # Add order to bill + unpaid_orders.append(order) - print("Created bill {} for user {}".format(bill.uuid, customer.username)) + # Save next_bill if it contains any unpaid product. + 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) + + print("Created bill {} for user {}".format(next_bill.uuid, user.username)) + + # We're done for this round :-) print("=> Done.") From e12575e1de662578225397e6a7a42a8ac5132c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 09:59:13 +0100 Subject: [PATCH 104/193] Commit forgotten migration on Orders (Float->Decimal) --- .../migrations/0009_auto_20200228_0825.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 uncloud/uncloud_pay/migrations/0009_auto_20200228_0825.py diff --git a/uncloud/uncloud_pay/migrations/0009_auto_20200228_0825.py b/uncloud/uncloud_pay/migrations/0009_auto_20200228_0825.py new file mode 100644 index 0000000..66feb51 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0009_auto_20200228_0825.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.3 on 2020-02-28 08:25 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0008_order_bill'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='one_time_price', + field=models.DecimalField(decimal_places=2, editable=False, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='order', + name='recurring_price', + field=models.DecimalField(decimal_places=2, editable=False, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='payment', + name='timestamp', + field=models.DateTimeField(auto_now_add=True), + ), + ] From c0512e54b034666f227ff3165bb5e72c24cc47c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 10:18:24 +0100 Subject: [PATCH 105/193] Add handle-overdue-bills --- .../commands/handle-overdue-bills.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 uncloud/uncloud_pay/management/commands/handle-overdue-bills.py diff --git a/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py b/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py new file mode 100644 index 0000000..f4749f0 --- /dev/null +++ b/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py @@ -0,0 +1,43 @@ +from django.core.management.base import BaseCommand +from uncloud_auth.models import User +from uncloud_pay.models import Order, Bill +from uncloud_pay.helpers import get_balance_for, get_payment_method_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 ({}), checking for overdue bills." + .format(user.username, balance)) + + # Get bills DESCENDING by creation date (= latest at top). + bills = Bill.objects.filter( + owner=user, + due_date__lt=timezone.now() + ).order_by('-creation_date') + overdue_balance = abs(balance) + overdue_bills = [] + for bill in bills: + if overdue_balance < 0: + break # XXX: I'm (fnux) not fond of breaks! + + overdue_balance -= bill.amount + overdue_bills.append(bill) + + for bill in overdue_bills: + print("/!\ Overdue bill for {}, {} with amount {}" + .format(user.username, bill.uuid, bill.amount)) + # TODO: take action? + + print("=> Done.") From 1cb1de4876953b3db7d3a9c0d29330514c753dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 11:10:31 +0100 Subject: [PATCH 106/193] Add (broken) charge method to payment method endpoint --- uncloud/uncloud_pay/serializers.py | 2 +- uncloud/uncloud_pay/views.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index a4a1f1b..3b8cc47 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -21,7 +21,7 @@ class PaymentSerializer(serializers.ModelSerializer): class PaymentMethodSerializer(serializers.ModelSerializer): class Meta: model = PaymentMethod - fields = ['owner', 'primary', 'source', 'description'] + fields = '__all__' class ProductSerializer(serializers.Serializer): vms = VMProductSerializer(many=True, read_only=True) diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index ea3cca7..9ed57c8 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -71,6 +71,20 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + # TODO: find a way to customize serializer for actions. + # drf-action-serializer module seems to do that. + @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.data['amount'] + if payment_method.charge(amount): + return Response({'charged', amount}) + else: + return Response(status=status.HTTP_500_INTERNAL_ERROR) + + ### # Admin views. From 3b87a4743053ef054704a7d6bcbea4f1189c9fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 14:46:33 +0100 Subject: [PATCH 107/193] Add initial ungleich_service app with MatrixServiceProduct shell --- uncloud/uncloud/settings.py | 1 + uncloud/uncloud/urls.py | 6 +++- uncloud/ungleich_service/__init__.py | 0 uncloud/ungleich_service/admin.py | 3 ++ uncloud/ungleich_service/apps.py | 5 +++ .../migrations/0001_initial.py | 33 +++++++++++++++++++ .../ungleich_service/migrations/__init__.py | 0 uncloud/ungleich_service/models.py | 20 +++++++++++ uncloud/ungleich_service/serializers.py | 7 ++++ uncloud/ungleich_service/tests.py | 3 ++ uncloud/ungleich_service/views.py | 14 ++++++++ 11 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 uncloud/ungleich_service/__init__.py create mode 100644 uncloud/ungleich_service/admin.py create mode 100644 uncloud/ungleich_service/apps.py create mode 100644 uncloud/ungleich_service/migrations/0001_initial.py create mode 100644 uncloud/ungleich_service/migrations/__init__.py create mode 100644 uncloud/ungleich_service/models.py create mode 100644 uncloud/ungleich_service/serializers.py create mode 100644 uncloud/ungleich_service/tests.py create mode 100644 uncloud/ungleich_service/views.py diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 179ff0b..24a425f 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -63,6 +63,7 @@ INSTALLED_APPS = [ 'uncloud_pay', 'uncloud_auth', 'uncloud_vm', + 'ungleich_service', 'opennebula' ] diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 8244e0e..e4abba5 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -20,14 +20,18 @@ 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 router = routers.DefaultRouter() -# user / regular urls +# VM router.register(r'vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct') router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') +# Services +router.register(r'service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct') + # Pay router.register(r'user', payviews.UserViewSet, basename='user') router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-method') diff --git a/uncloud/ungleich_service/__init__.py b/uncloud/ungleich_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/ungleich_service/admin.py b/uncloud/ungleich_service/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/uncloud/ungleich_service/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/uncloud/ungleich_service/apps.py b/uncloud/ungleich_service/apps.py new file mode 100644 index 0000000..184e181 --- /dev/null +++ b/uncloud/ungleich_service/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UngleichServiceConfig(AppConfig): + name = 'ungleich_service' diff --git a/uncloud/ungleich_service/migrations/0001_initial.py b/uncloud/ungleich_service/migrations/0001_initial.py new file mode 100644 index 0000000..2e19344 --- /dev/null +++ b/uncloud/ungleich_service/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 3.0.3 on 2020-02-28 13:44 + +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', '0010_merge_20200228_1303'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_vm', '0007_auto_20200228_1344'), + ] + + operations = [ + migrations.CreateModel( + name='MatrixServiceProduct', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), + ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/uncloud/ungleich_service/migrations/__init__.py b/uncloud/ungleich_service/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/ungleich_service/models.py b/uncloud/ungleich_service/models.py new file mode 100644 index 0000000..ac1f90e --- /dev/null +++ b/uncloud/ungleich_service/models.py @@ -0,0 +1,20 @@ +import uuid + +from django.db import models +from uncloud_pay.models import Product, RecurringPeriod +from uncloud_vm.models import VMProduct + +class MatrixServiceProduct(Product): + monthly_managment_fee = 20 + setup_fee = 30 + + description = "Managed Matrix HomeServer" + vm = models.ForeignKey( + VMProduct, on_delete=models.CASCADE + ) + + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + if recurring_period == RecurringPeriod.PER_MONTH: + return monthly_managment_fee + vm.recurring_price(RecurringPeriod.PER_MONTH) + else: + raise Exception('Invalid recurring period for VM Product pricing.') diff --git a/uncloud/ungleich_service/serializers.py b/uncloud/ungleich_service/serializers.py new file mode 100644 index 0000000..54737e9 --- /dev/null +++ b/uncloud/ungleich_service/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers +from .models import MatrixServiceProduct + +class MatrixServiceProductSerializer(serializers.ModelSerializer): + class Meta: + model = MatrixServiceProduct + fields = '__all__' diff --git a/uncloud/ungleich_service/tests.py b/uncloud/ungleich_service/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/uncloud/ungleich_service/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/uncloud/ungleich_service/views.py b/uncloud/ungleich_service/views.py new file mode 100644 index 0000000..776b94c --- /dev/null +++ b/uncloud/ungleich_service/views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets, permissions + +from .models import MatrixServiceProduct +from .serializers import MatrixServiceProductSerializer + +class MatrixServiceProductViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = MatrixServiceProductSerializer + + def get_queryset(self): + return MatrixServiceProduct.objects.filter(owner=self.request.user) + def create(self, request): + # TODO + pass From 33cc2b21114edb9dc5e56751871deab4aa9bf678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 14:48:01 +0100 Subject: [PATCH 108/193] Add uncloud_storage template app --- uncloud/uncloud/settings.py | 1 + uncloud/uncloud_storage/__init__.py | 0 uncloud/uncloud_storage/admin.py | 3 +++ uncloud/uncloud_storage/apps.py | 5 +++++ uncloud/uncloud_storage/migrations/__init__.py | 0 uncloud/uncloud_storage/models.py | 3 +++ uncloud/uncloud_storage/tests.py | 3 +++ uncloud/uncloud_storage/views.py | 3 +++ 8 files changed, 18 insertions(+) create mode 100644 uncloud/uncloud_storage/__init__.py create mode 100644 uncloud/uncloud_storage/admin.py create mode 100644 uncloud/uncloud_storage/apps.py create mode 100644 uncloud/uncloud_storage/migrations/__init__.py create mode 100644 uncloud/uncloud_storage/models.py create mode 100644 uncloud/uncloud_storage/tests.py create mode 100644 uncloud/uncloud_storage/views.py diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 24a425f..c6c89d5 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ 'rest_framework', 'uncloud_pay', 'uncloud_auth', + 'uncloud_storage', 'uncloud_vm', 'ungleich_service', 'opennebula' diff --git a/uncloud/uncloud_storage/__init__.py b/uncloud/uncloud_storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/uncloud_storage/admin.py b/uncloud/uncloud_storage/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/uncloud/uncloud_storage/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/uncloud/uncloud_storage/apps.py b/uncloud/uncloud_storage/apps.py new file mode 100644 index 0000000..38b2301 --- /dev/null +++ b/uncloud/uncloud_storage/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UncloudStorageConfig(AppConfig): + name = 'uncloud_storage' diff --git a/uncloud/uncloud_storage/migrations/__init__.py b/uncloud/uncloud_storage/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/uncloud_storage/models.py b/uncloud/uncloud_storage/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/uncloud/uncloud_storage/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/uncloud/uncloud_storage/tests.py b/uncloud/uncloud_storage/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/uncloud/uncloud_storage/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/uncloud/uncloud_storage/views.py b/uncloud/uncloud_storage/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/uncloud/uncloud_storage/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From b3bbfafa04db6aee9d7b496032520355cad2385d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 14:57:45 +0100 Subject: [PATCH 109/193] Introduce custom ProductViewSet preventing customer from updating products --- uncloud/uncloud_pay/helpers.py | 14 +++++++++++++- uncloud/uncloud_vm/views.py | 3 ++- uncloud/ungleich_service/views.py | 5 ++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index 2f68e9e..8ca82aa 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -1,10 +1,11 @@ from functools import reduce +from rest_framework import mixins +from rest_framework.viewsets import GenericViewSet from .models import Bill, Payment, PaymentMethod def sum_amounts(entries): return reduce(lambda acc, entry: acc + entry.amount, entries, 0) - def get_balance_for(user): bills = sum_amounts(Bill.objects.filter(owner=user)) payments = sum_amounts(Payment.objects.filter(owner=user)) @@ -18,3 +19,14 @@ def get_payment_method_for(user): return method return None + + +class ProductViewSet(mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + GenericViewSet): + """ + A customer-facing viewset that provides default `create()`, `retrieve()` + and `list()`. + """ + pass diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index e5fd4ba..c3704e1 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -9,6 +9,7 @@ from rest_framework.response import Response from .models import VMHost, VMProduct, VMSnapshotProduct from uncloud_pay.models import Order, RecurringPeriod from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer +from uncloud_pay.helpers import ProductViewSet import datetime @@ -19,7 +20,7 @@ class VMHostViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAdminUser] -class VMProductViewSet(viewsets.ModelViewSet): +class VMProductViewSet(ProductViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = VMProductSerializer diff --git a/uncloud/ungleich_service/views.py b/uncloud/ungleich_service/views.py index 776b94c..9c27df8 100644 --- a/uncloud/ungleich_service/views.py +++ b/uncloud/ungleich_service/views.py @@ -3,12 +3,15 @@ from rest_framework import viewsets, permissions from .models import MatrixServiceProduct from .serializers import MatrixServiceProductSerializer -class MatrixServiceProductViewSet(viewsets.ModelViewSet): +from uncloud_pay.helpers import ProductViewSet + +class MatrixServiceProductViewSet(ProductViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = MatrixServiceProductSerializer def get_queryset(self): return MatrixServiceProduct.objects.filter(owner=self.request.user) + def create(self, request): # TODO pass From 181005ad6c232b355ae01b62c29a53e3db00b6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 15:07:20 +0100 Subject: [PATCH 110/193] Cleanup VMProduct serializer, add name field to VMProduct --- uncloud/uncloud_vm/models.py | 4 ++++ uncloud/uncloud_vm/serializers.py | 8 +++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 663765a..be1178e 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -41,6 +41,10 @@ class VMProduct(Product): null=True) description = "Virtual Machine" + + # VM-specific. The name is only intended for customers: it's a pain te + # remember IDs (speaking from experience as ungleich customer)! + name = models.CharField(max_length=32) cores = models.IntegerField() ram_in_gb = models.FloatField() diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index b247709..cb60cfe 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -12,11 +12,9 @@ class VMHostSerializer(serializers.HyperlinkedModelSerializer): class VMProductSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VMProduct - fields = '__all__' - - -# def create(self, validated_data): -# return VMSnapshotProduct() + fields = ['uuid', 'description', 'order', 'owner', 'status', 'name', \ + 'cores', 'ram_in_gb'] + read_only_fields = ['uuid', 'description', 'order', 'owner', 'status'] class VMSnapshotProductSerializer(serializers.ModelSerializer): class Meta: From eaa483e018197ce019582e0b25b18ef38fffc391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 15:08:45 +0100 Subject: [PATCH 111/193] Commit forgottem uncloud_vm migrations --- .../migrations/0007_auto_20200228_1344.py | 23 +++++++++++++++++++ .../migrations/0008_vmproduct_name.py | 18 +++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py create mode 100644 uncloud/uncloud_vm/migrations/0008_vmproduct_name.py diff --git a/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py b/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py new file mode 100644 index 0000000..8867f2f --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-02-28 13:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0006_merge_20200228_1303'), + ] + + operations = [ + migrations.AddField( + model_name='vmnetworkcard', + name='ip_address', + field=models.GenericIPAddressField(blank=True, null=True), + ), + migrations.AddField( + model_name='vmproduct', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0008_vmproduct_name.py b/uncloud/uncloud_vm/migrations/0008_vmproduct_name.py new file mode 100644 index 0000000..75ff7d0 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0008_vmproduct_name.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-28 14:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0007_auto_20200228_1344'), + ] + + operations = [ + migrations.AddField( + model_name='vmproduct', + name='name', + field=models.CharField(blank=True, max_length=32), + ), + ] From af1265003eea2521fac647adc9c1b01805b52d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 28 Feb 2020 16:26:45 +0100 Subject: [PATCH 112/193] Define custom fields and serializer for MatrixServiceProduct --- uncloud/uncloud_pay/models.py | 4 ++++ uncloud/uncloud_vm/serializers.py | 4 ++-- uncloud/ungleich_service/models.py | 6 ++++++ uncloud/ungleich_service/serializers.py | 6 +++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 8e41e24..f5639c4 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -163,5 +163,9 @@ class Product(models.Model): def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): pass # To be implemented in child. + @property + def setup_fee(self): + return 0 + class Meta: abstract = True diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index cb60cfe..4257a03 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -12,9 +12,9 @@ class VMHostSerializer(serializers.HyperlinkedModelSerializer): class VMProductSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VMProduct - fields = ['uuid', 'description', 'order', 'owner', 'status', 'name', \ + fields = ['uuid', 'order', 'owner', 'status', 'name', \ 'cores', 'ram_in_gb'] - read_only_fields = ['uuid', 'description', 'order', 'owner', 'status'] + read_only_fields = ['uuid', 'order', 'owner', 'status'] class VMSnapshotProductSerializer(serializers.ModelSerializer): class Meta: diff --git a/uncloud/ungleich_service/models.py b/uncloud/ungleich_service/models.py index ac1f90e..0e84f62 100644 --- a/uncloud/ungleich_service/models.py +++ b/uncloud/ungleich_service/models.py @@ -9,12 +9,18 @@ class MatrixServiceProduct(Product): setup_fee = 30 description = "Managed Matrix HomeServer" + + # Specific to Matrix-as-a-Service vm = models.ForeignKey( VMProduct, on_delete=models.CASCADE ) + domain = models.CharField(max_length=255, default='domain.tld') def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): if recurring_period == RecurringPeriod.PER_MONTH: return monthly_managment_fee + vm.recurring_price(RecurringPeriod.PER_MONTH) else: raise Exception('Invalid recurring period for VM Product pricing.') + + def setup_fee(self): + return setup_fee diff --git a/uncloud/ungleich_service/serializers.py b/uncloud/ungleich_service/serializers.py index 54737e9..ffd206f 100644 --- a/uncloud/ungleich_service/serializers.py +++ b/uncloud/ungleich_service/serializers.py @@ -1,7 +1,11 @@ from rest_framework import serializers from .models import MatrixServiceProduct +from uncloud_vm.serializers import VMProductSerializer class MatrixServiceProductSerializer(serializers.ModelSerializer): + vm = VMProductSerializer() + class Meta: model = MatrixServiceProduct - fields = '__all__' + fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain'] + read_only_fields = ['uuid', 'order', 'owner', 'status'] From e319d1d151f17d257527ce50fa7faa5a7f734e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sat, 29 Feb 2020 09:08:30 +0100 Subject: [PATCH 113/193] WIP revamped bill logic --- uncloud/uncloud_pay/helpers.py | 51 ++++++++++++++- .../management/commands/generate-bills.py | 59 +++++------------ uncloud/uncloud_pay/models.py | 64 ++++++++++++------- .../migrations/0009_auto_20200228_1416.py | 18 ++++++ .../0002_matrixserviceproduct_domain.py | 18 ++++++ uncloud/ungleich_service/serializers.py | 7 ++ uncloud/ungleich_service/views.py | 4 +- 7 files changed, 152 insertions(+), 69 deletions(-) create mode 100644 uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py create mode 100644 uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index 8ca82aa..248fbb4 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -1,7 +1,10 @@ from functools import reduce +from datetime import datetime from rest_framework import mixins from rest_framework.viewsets import GenericViewSet -from .models import Bill, Payment, PaymentMethod +from django.db.models import Q +from .models import Bill, Payment, PaymentMethod, Order +from django.utils import timezone def sum_amounts(entries): return reduce(lambda acc, entry: acc + entry.amount, entries, 0) @@ -20,6 +23,52 @@ def get_payment_method_for(user): return None +def beginning_of_month(date): + return datetime(year=date.year, date=now.month, day=0) + +def generate_bills_for(year, month, user, allowed_delay): + # /!\ 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. + tz = timezone.get_current_timezone() + next_bill = Bill(owner=user, + starting_date=datetime(year=year, month=month, day=1, tzinfo=tz), + ending_date=datetime(year=year, month=month, day=28, tzinfo=tz), + creation_date=timezone.now(), + due_date=timezone.now() + allowed_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) + + return next_bill + + # Return None if no bill was created. class ProductViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud/uncloud_pay/management/commands/generate-bills.py index aad7a82..34432d5 100644 --- a/uncloud/uncloud_pay/management/commands/generate-bills.py +++ b/uncloud/uncloud_pay/management/commands/generate-bills.py @@ -1,67 +1,38 @@ +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 +from datetime import timedelta, date from django.utils import timezone +from uncloud_pay.helpers import generate_bills_for BILL_PAYMENT_DELAY=timedelta(days=10) +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: - # Fetch all the orders of a customer. - orders = Order.objects.filter(owner=user) - - # Default values for next bill (if any). Only saved at the end of - # this method, if relevant. - next_bill = Bill(owner=user, - starting_date=timezone.now(), # Will be set to oldest unpaid order (means unpaid starting date). - ending_date=timezone.now(), # Bill covers everything until today. - due_date=timezone.now() + BILL_PAYMENT_DELAY) - - unpaid_orders = [] # Store orders in need of a payment. - for order in orders: - # Only bill if there is an 'unpaid period' on an active order. - # XXX: Assume everything before latest bill is paid. => might be dangerous. - try: - previous_bill = order.bill.latest('ending_date') - except ObjectDoesNotExist: - previous_bill = None - - is_unpaid_period = True - if order.ending_date and previous_bill != None: - is_unpaid_period = previous_bill.ending_date < order.ending_date - - if is_unpaid_period: - # Update bill starting date to match period. - if previous_bill == None: - next_bill.starting_date = order.starting_date - elif previous_bill.ending_date < next_bill.starting_date: - next_bill.starting_date = previous_bill.ending_date - - # Add order to bill - unpaid_orders.append(order) - - # Save next_bill if it contains any unpaid product. - 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) - - print("Created bill {} for user {}".format(next_bill.uuid, user.username)) + now = timezone.now() + generate_bills_for( + year=now.year, + month=now.month, + user=user, + allowed_delay=BILL_PAYMENT_DELAY) # We're done for this round :-) print("=> Done.") diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index f5639c4..f9e7c35 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -2,6 +2,7 @@ from django.db import models 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 import uuid @@ -31,16 +32,49 @@ class Bill(models.Model): valid = models.BooleanField(default=True) @property - def amount(self): - orders = Order.objects.filter(bill=self) - amount = 0 - for order in orders: - amount += order.recurring_price - - return amount + def entries(self): + # TODO: return list of Bill entries, extract from linked order + # for each related order + # for each product + # build BillEntry + return [] + @property + def total(self): + #return helpers.sum_amounts(self.entries) + pass + +class BillEntry(): + start_date = timezone.now() + end_date = timezone.now() + recurring_period = RecurringPeriod.PER_MONTH + recurring_price = 0 + amount = 0 + description = "" +# /!\ BIG FAT WARNING /!\ # +# +# Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating +# bills. Do **NOT** mutate then! +# +# Why? We need to store the state somewhere since product are mutable (e.g. +# adding RAM to VM, changing price of 1GB of RAM, ...). An alternative could +# have been to only store the state in bills but would have been more +# confusing: the order is a 'contract' with the customer, were both parts +# agree on deal => That's what we want to keep archived. +# +# SOON: +# +# We'll need to add some kind of OrderEntry table (each order might have +# multiple entries) storing: recurring_price, recurring_period, setup_fee, description +# +# FOR NOW: +# +# We dynamically get pricing from linked product, as they are not updated in +# this stage of development. +# +# /!\ BIG FAT WARNING /!\ # class Order(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), @@ -56,22 +90,11 @@ class Order(models.Model): editable=False, blank=True) - - recurring_price = models.DecimalField( - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)], - editable=False) - one_time_price = models.DecimalField( - max_digits=AMOUNT_MAX_DIGITS, - decimal_places=AMOUNT_DECIMALS, - validators=[MinValueValidator(0)], - editable=False) - recurring_period = models.CharField(max_length=32, choices = RecurringPeriod.choices, default = RecurringPeriod.PER_MONTH) + # def amount(self): # amount = recurring_price # if recurring and first_month: @@ -133,9 +156,6 @@ class Payment(models.Model): default='unknown') timestamp = models.DateTimeField(editable=False, auto_now_add=True) - - - class Product(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), diff --git a/uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py b/uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py new file mode 100644 index 0000000..e29bfe9 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-28 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0008_vmproduct_name'), + ] + + operations = [ + migrations.AlterField( + model_name='vmproduct', + name='name', + field=models.CharField(max_length=32), + ), + ] diff --git a/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py b/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py new file mode 100644 index 0000000..fda0075 --- /dev/null +++ b/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-28 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ungleich_service', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='matrixserviceproduct', + name='domain', + field=models.CharField(default='domain.tld', max_length=255), + ), + ] diff --git a/uncloud/ungleich_service/serializers.py b/uncloud/ungleich_service/serializers.py index ffd206f..0c34dcf 100644 --- a/uncloud/ungleich_service/serializers.py +++ b/uncloud/ungleich_service/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from .models import MatrixServiceProduct from uncloud_vm.serializers import VMProductSerializer +from uncloud_vm.models import VMProduct class MatrixServiceProductSerializer(serializers.ModelSerializer): vm = VMProductSerializer() @@ -9,3 +10,9 @@ class MatrixServiceProductSerializer(serializers.ModelSerializer): model = MatrixServiceProduct fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain'] read_only_fields = ['uuid', 'order', 'owner', 'status'] + + def create(self, validated_data): + # Create VM + vm_data = validated_data.pop('vm') + vm = VMProduct.objects.create(**vm_data) + return MatrixServiceProduct.create(vm=vm, **validated_data) diff --git a/uncloud/ungleich_service/views.py b/uncloud/ungleich_service/views.py index 9c27df8..a8de2e0 100644 --- a/uncloud/ungleich_service/views.py +++ b/uncloud/ungleich_service/views.py @@ -13,5 +13,5 @@ class MatrixServiceProductViewSet(ProductViewSet): return MatrixServiceProduct.objects.filter(owner=self.request.user) def create(self, request): - # TODO - pass + # TODO: create order, register service + return Response('{"HIT!"}') From bcbd6f6f8339e7489be0b7e126df6f208dd8465a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 29 Feb 2020 16:45:52 +0100 Subject: [PATCH 114/193] Introduce disk->image relationship Signed-off-by: Nico Schottelius --- uncloud/uncloud/urls.py | 8 ++ .../migrations/0006_auto_20200229_1545.py | 53 ++++++++++++ uncloud/uncloud_vm/models.py | 86 ++++++++++--------- uncloud/uncloud_vm/serializers.py | 34 ++++++-- uncloud/uncloud_vm/views.py | 36 ++++++-- 5 files changed, 161 insertions(+), 56 deletions(-) create mode 100644 uncloud/uncloud_vm/migrations/0006_auto_20200229_1545.py diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 5ee9f07..40b3b20 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -26,8 +26,16 @@ router = routers.DefaultRouter() # user / regular urls router.register(r'vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct') +router.register(r'vm/image/mine', vmviews.VMDiskImageProductMineViewSet, basename='vmdiskimagemineproduct') +router.register(r'vm/image/public', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct') + + +#router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') + router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') + + # Pay router.register(r'user', payviews.UserViewSet, basename='user') router.register(r'bill', payviews.BillViewSet, basename='bill') diff --git a/uncloud/uncloud_vm/migrations/0006_auto_20200229_1545.py b/uncloud/uncloud_vm/migrations/0006_auto_20200229_1545.py new file mode 100644 index 0000000..208aeaa --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0006_auto_20200229_1545.py @@ -0,0 +1,53 @@ +# Generated by Django 3.0.3 on 2020-02-29 15:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_vm', '0005_auto_20200227_1230'), + ] + + 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()), + ('storage_class', models.CharField(choices=[('hdd', 'HDD'), ('ssd', 'SSD')], default='ssd', max_length=32)), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.RemoveField( + model_name='vmdiskproduct', + name='storage_class', + ), + migrations.AddField( + model_name='vmdiskproduct', + name='owner', + field=models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='vmnetworkcard', + name='ip_address', + field=models.GenericIPAddressField(blank=True, null=True), + ), + migrations.DeleteModel( + name='OperatingSystemDisk', + ), + migrations.AddField( + model_name='vmdiskproduct', + name='image', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMDiskImageProduct'), + preserve_default=False, + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 4ebae25..b585cb9 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -46,11 +46,27 @@ class VMProduct(Product): class VMWithOSProduct(VMProduct): pass -class VMDiskProduct(models.Model): + +class VMDiskImageProduct(models.Model): + """ + Images are used for cloning/linking. + + They are the base for images. + + """ + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + + name = models.CharField(max_length=256) + is_os_image = models.BooleanField(default=False) + is_public = models.BooleanField(default=False) + size_in_gb = models.FloatField() + storage_class = models.CharField(max_length=32, choices = ( ('hdd', 'HDD'), @@ -59,9 +75,32 @@ class VMDiskProduct(models.Model): default='ssd' ) -class OperatingSystemDisk(VMDiskProduct): - """ Defines an Operating System Disk that can be cloned for a VM """ - os_name = models.CharField(max_length=128) + # source = models.CharField(max_length=32, + # choices = ( + # ('url', 'HDD'), + # ('ssd', 'SSD'), + # ), + # default='ssd' + # ) + +class VMDiskProduct(models.Model): + """ + The VMDiskProduct is attached to a VM. + + It is based on a VMDiskImageProduct that will be used as a basis. + + It can be enlarged, but not shrinked compared to the VMDiskImageProduct. + """ + + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + + vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + image = models.ForeignKey(VMDiskImageProduct, on_delete=models.CASCADE) + + size_in_gb = models.FloatField() class VMNetworkCard(models.Model): @@ -74,44 +113,7 @@ class VMNetworkCard(models.Model): class VMSnapshotProduct(Product): - price_per_gb_ssd = 0.35 - price_per_gb_hdd = 1.5/100 - - # This we need to get from the VM gb_ssd = models.FloatField(editable=False) gb_hdd = models.FloatField(editable=False) vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) - #vm_uuid = models.UUIDField() - - # Need to setup recurring_price and one_time_price and recurring period - - sample_ssd = 10 - sample_hdd = 100 - - def recurring_price(self): - return 0 - - def one_time_price(self): - return 0 - - @classmethod - def sample_price(cls): - return cls.sample_ssd * cls.price_per_gb_ssd + cls.sample_hdd * cls.price_per_gb_hdd - - description = "Create snapshot of a VM" - recurring_period = "monthly" - - @classmethod - def pricing_model(cls): - return """ -Pricing is on monthly basis and storage prices are equivalent to the storage -price in the VM. - -Price per GB SSD is: {} -Price per GB HDD is: {} - - -Sample price for a VM with {} GB SSD and {} GB HDD VM is: {}. -""".format(cls.price_per_gb_ssd, cls.price_per_gb_hdd, - cls.sample_ssd, cls.sample_hdd, cls.sample_price()) diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index b247709..a64fdd0 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -1,22 +1,28 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from .models import VMHost, VMProduct, VMSnapshotProduct +from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct -class VMHostSerializer(serializers.HyperlinkedModelSerializer): +class VMHostSerializer(serializers.ModelSerializer): class Meta: model = VMHost fields = '__all__' -class VMProductSerializer(serializers.HyperlinkedModelSerializer): +class VMProductSerializer(serializers.ModelSerializer): class Meta: model = VMProduct fields = '__all__' +class VMDiskProductSerializer(serializers.ModelSerializer): + class Meta: + model = VMDiskProduct + fields = '__all__' -# def create(self, validated_data): -# return VMSnapshotProduct() +class VMDiskImageProductSerializer(serializers.ModelSerializer): + class Meta: + model = VMDiskImageProduct + fields = '__all__' class VMSnapshotProductSerializer(serializers.ModelSerializer): class Meta: @@ -26,5 +32,19 @@ class VMSnapshotProductSerializer(serializers.ModelSerializer): # verify that vm.owner == user.request def validate_vm(self, value): - print(value) - return True + + if not value.owner == self.context['request'].user: + raise serializers.ValidationError("VM {} not found for owner {}.".format(value, + self.context['request'].user)) + + disks = VMDiskProduct.objects.filter(vm=value) + + if len(disks) == 0: + raise serializers.ValidationError("VM {} does not have any disks, cannot snapshot".format(value.uuid)) + + return value + + pricing = {} + pricing['per_gb_ssd'] = 0.012 + pricing['per_gb_hdd'] = 0.0006 + pricing['recurring_period'] = 'per_day' diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 7e517f5..b9d80f9 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -6,10 +6,10 @@ from django.shortcuts import get_object_or_404 from rest_framework import viewsets, permissions from rest_framework.response import Response -from .models import VMHost, VMProduct, VMSnapshotProduct +from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct from uncloud_pay.models import Order -from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer +from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer, VMDiskImageProductSerializer import datetime @@ -19,6 +19,20 @@ class VMHostViewSet(viewsets.ModelViewSet): queryset = VMHost.objects.all() permission_classes = [permissions.IsAdminUser] +class VMDiskImageProductMineViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMDiskImageProductSerializer + + def get_queryset(self): + return VMDiskImageProduct.objects.filter(owner=self.request.user) + +class VMDiskImageProductPublicViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMDiskImageProductSerializer + + def get_queryset(self): + return VMDiskImageProduct.objects.filter(is_public=True) + class VMProductViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] @@ -54,22 +68,30 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet): def create(self, request): serializer = VMSnapshotProductSerializer(data=request.data, context={'request': request}) + + # This verifies that the VM belongs to the request user serializer.is_valid(raise_exception=True) + disks = VMDiskProduct.objects.filter(vm=serializer.validated_data['vm']) + ssds_size = sum([d.size_in_gb for d in disks if d.storage_class == 'ssd']) + hdds_size = sum([d.size_in_gb for d in disks if d.storage_class == 'hdd']) + + recurring_price = serializer.pricing['per_gb_ssd'] * ssds_size + serializer.pricing['per_gb_hdd'] * hdds_size + recurring_period = serializer.pricing['recurring_period'] + # Create order now = datetime.datetime.now() order = Order(owner=request.user, creation_date=now, starting_date=now, - recurring_price=20, + recurring_price=recurring_price, one_time_price=0, - recurring_period="per_month") + recurring_period=recurring_period) order.save() - # FIXME: calculate the gb_* values serializer.save(owner=request.user, order=order, - gb_ssd=12, - gb_hdd=20) + gb_ssd=ssds_size, + gb_hdd=hdds_size) return Response(serializer.data) From 6a38e4e0a44576c829685ed503cbca61cbc5b1f5 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 29 Feb 2020 17:00:13 +0100 Subject: [PATCH 115/193] add url for importing disk image Signed-off-by: Nico Schottelius --- .../migrations/0007_auto_20200229_1559.py | 23 +++++++++++++++++++ uncloud/uncloud_vm/models.py | 9 ++++++-- uncloud/uncloud_vm/serializers.py | 9 ++++++-- uncloud/uncloud_vm/views.py | 10 ++++++-- 4 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 uncloud/uncloud_vm/migrations/0007_auto_20200229_1559.py diff --git a/uncloud/uncloud_vm/migrations/0007_auto_20200229_1559.py b/uncloud/uncloud_vm/migrations/0007_auto_20200229_1559.py new file mode 100644 index 0000000..6e08c0c --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0007_auto_20200229_1559.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-02-29 15:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0006_auto_20200229_1545'), + ] + + operations = [ + migrations.AddField( + model_name='vmdiskimageproduct', + name='import_url', + field=models.URLField(blank=True, null=True), + ), + migrations.AlterField( + model_name='vmdiskimageproduct', + name='size_in_gb', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index b585cb9..f2cbf13 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -55,7 +55,9 @@ class VMDiskImageProduct(models.Model): """ - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + uuid = models.UUIDField(primary_key=True, + default=uuid.uuid4, + editable=False) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False) @@ -64,7 +66,10 @@ class VMDiskImageProduct(models.Model): is_os_image = models.BooleanField(default=False) is_public = models.BooleanField(default=False) - size_in_gb = models.FloatField() + size_in_gb = models.FloatField(null=True, + blank=True) + import_url = models.URLField(null=True, + blank=True) storage_class = models.CharField(max_length=32, diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index a64fdd0..f8618ee 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -3,6 +3,13 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct +GB_SSD_PER_DAY=0.012 +GB_HDD_PER_DAY=0.0006 + +GB_SSD_PER_DAY=0.012 +GB_HDD_PER_DAY=0.0006 + + class VMHostSerializer(serializers.ModelSerializer): class Meta: model = VMHost @@ -32,11 +39,9 @@ class VMSnapshotProductSerializer(serializers.ModelSerializer): # verify that vm.owner == user.request def validate_vm(self, value): - if not value.owner == self.context['request'].user: raise serializers.ValidationError("VM {} not found for owner {}.".format(value, self.context['request'].user)) - disks = VMDiskProduct.objects.filter(vm=value) if len(disks) == 0: diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index b9d80f9..851041e 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -26,7 +26,14 @@ class VMDiskImageProductMineViewSet(viewsets.ModelViewSet): def get_queryset(self): return VMDiskImageProduct.objects.filter(owner=self.request.user) -class VMDiskImageProductPublicViewSet(viewsets.ModelViewSet): + def create(self, request): + serializer = VMProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + serializer.save(owner=request.user) + return Response(serializer.data) + + +class VMDiskImageProductPublicViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = VMDiskImageProductSerializer @@ -55,7 +62,6 @@ class VMProductViewSet(viewsets.ModelViewSet): order.save() serializer.save(owner=request.user, order=order) - return Response(serializer.data) From 5c33bc5c02411778e354b322cdcd258b8b33ffc8 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 29 Feb 2020 17:57:57 +0100 Subject: [PATCH 116/193] support creating disks Signed-off-by: Nico Schottelius --- uncloud/uncloud/urls.py | 15 +++++++++++- uncloud/uncloud_vm/models.py | 32 +++++++++++++------------ uncloud/uncloud_vm/serializers.py | 2 ++ uncloud/uncloud_vm/views.py | 40 +++++++++++++++++++++++++++++-- 4 files changed, 71 insertions(+), 18 deletions(-) diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 40b3b20..02862a1 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -26,14 +26,27 @@ router = routers.DefaultRouter() # user / regular urls router.register(r'vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct') +router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') router.register(r'vm/image/mine', vmviews.VMDiskImageProductMineViewSet, basename='vmdiskimagemineproduct') router.register(r'vm/image/public', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct') +# images the provider provides :-) +# router.register(r'vm/image/official', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct') + + -#router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') +# TBD +#router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') + +# 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') + +# allow vm creation from own images # Pay diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index f2cbf13..7aac05b 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -4,6 +4,16 @@ import uuid from uncloud_pay.models import Product +STATUS_CHOICES = ( + ('pending', 'Pending'), # Initial state + ('creating', 'Creating'), # Creating VM/image/etc. + ('active', 'Active'), # Is usable / active + ('disabled', 'Disabled'), # Is usable, but cannot be used for new things + ('unusable', 'Unusable'), # Has some kind of error + ('deleted', 'Deleted'), # Does not exist anymore, only DB entry as a log +) + +STATUS_DEFAULT='pending' class VMHost(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -22,13 +32,8 @@ class VMHost(models.Model): status = models.CharField(max_length=32, - choices = ( - ('pending', 'Pending'), - ('active', 'Active'), - ('unusable', 'Unusable'), - ('deleted', 'Deleted'), - ), - default='pending' + choices=STATUS_CHOICES, + default=STATUS_DEFAULT ) @@ -80,13 +85,10 @@ class VMDiskImageProduct(models.Model): default='ssd' ) - # source = models.CharField(max_length=32, - # choices = ( - # ('url', 'HDD'), - # ('ssd', 'SSD'), - # ), - # default='ssd' - # ) + status = models.CharField(max_length=32, + choices=STATUS_CHOICES, + default=STATUS_DEFAULT + ) class VMDiskProduct(models.Model): """ @@ -105,7 +107,7 @@ class VMDiskProduct(models.Model): vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) image = models.ForeignKey(VMDiskImageProduct, on_delete=models.CASCADE) - size_in_gb = models.FloatField() + size_in_gb = models.FloatField(blank=True) class VMNetworkCard(models.Model): diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index f8618ee..07d6c51 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -22,6 +22,8 @@ class VMProductSerializer(serializers.ModelSerializer): fields = '__all__' class VMDiskProductSerializer(serializers.ModelSerializer): +# vm = VMProductSerializer() + class Meta: model = VMDiskProduct fields = '__all__' diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 851041e..62edaa0 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -5,11 +5,13 @@ from django.shortcuts import get_object_or_404 from rest_framework import viewsets, permissions from rest_framework.response import Response +from rest_framework.exceptions import ValidationError + from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct from uncloud_pay.models import Order -from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer, VMDiskImageProductSerializer +from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer, VMDiskImageProductSerializer, VMDiskProductSerializer import datetime @@ -27,8 +29,14 @@ class VMDiskImageProductMineViewSet(viewsets.ModelViewSet): return VMDiskImageProduct.objects.filter(owner=self.request.user) def create(self, request): - serializer = VMProductSerializer(data=request.data, context={'request': request}) + serializer = VMDiskImageProductSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) + + # did not specify size NOR import url? + if not serializer.validated_data['size_in_gb']: + if not serializer.validated_data['import_url']: + raise ValidationError(detail={ 'error_mesage': 'Specify either import_url or size_in_gb' }) + serializer.save(owner=request.user) return Response(serializer.data) @@ -40,6 +48,34 @@ class VMDiskImageProductPublicViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return VMDiskImageProduct.objects.filter(is_public=True) +class VMDiskProductViewSet(viewsets.ModelViewSet): + """ + Let a user modify their own VMDisks + """ + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMDiskProductSerializer + + def get_queryset(self): + return VMDiskProduct.objects.filter(owner=self.request.user) + + def create(self, request): + serializer = VMDiskProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + + # get disk size from image, if not specified + if not 'size_in_gb' in serializer.validated_data: + size_in_gb = serializer.validated_data['image'].size_in_gb + else: + size_in_gb = serializer.validated_data['size_in_gb'] + + if size_in_gb < serializer.validated_data['image'].size_in_gb: + raise ValidationError(detail={ 'error_mesage': 'Size is smaller than original image' }) + + + serializer.save(owner=request.user, size_in_gb=size_in_gb) + return Response(serializer.data) + + class VMProductViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] From 4115eed2a8398144ef1f1faba628e9a9d2c1edfd Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 29 Feb 2020 17:58:10 +0100 Subject: [PATCH 117/193] +migration Signed-off-by: Nico Schottelius --- .../migrations/0008_auto_20200229_1611.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 uncloud/uncloud_vm/migrations/0008_auto_20200229_1611.py diff --git a/uncloud/uncloud_vm/migrations/0008_auto_20200229_1611.py b/uncloud/uncloud_vm/migrations/0008_auto_20200229_1611.py new file mode 100644 index 0000000..8a9be67 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0008_auto_20200229_1611.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-02-29 16:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0007_auto_20200229_1559'), + ] + + operations = [ + migrations.AddField( + model_name='vmdiskimageproduct', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('creating', 'Creating'), ('active', 'Active'), ('disabled', 'Disabled'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32), + ), + migrations.AlterField( + model_name='vmhost', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('creating', 'Creating'), ('active', 'Active'), ('disabled', 'Disabled'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32), + ), + ] From be2b0a88550f1212b08fbccf84ef81594cc40699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sun, 1 Mar 2020 12:23:04 +0100 Subject: [PATCH 118/193] Fix a few errors on preview billing rework Another WIP commit to sync with laptop, do not forget to rebase! --- uncloud/uncloud_pay/helpers.py | 2 ++ .../migrations/0011_auto_20200229_1459.py | 21 +++++++++++++++++++ uncloud/uncloud_pay/serializers.py | 2 +- uncloud/uncloud_vm/views.py | 8 +------ 4 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0011_auto_20200229_1459.py diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index 248fbb4..aaa1e11 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -5,6 +5,8 @@ from rest_framework.viewsets import GenericViewSet from django.db.models import Q from .models import Bill, Payment, PaymentMethod, Order from django.utils import timezone +from django.core.exceptions import ObjectDoesNotExist +from dateutil.relativedelta import relativedelta def sum_amounts(entries): return reduce(lambda acc, entry: acc + entry.amount, entries, 0) diff --git a/uncloud/uncloud_pay/migrations/0011_auto_20200229_1459.py b/uncloud/uncloud_pay/migrations/0011_auto_20200229_1459.py new file mode 100644 index 0000000..e4edbb0 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0011_auto_20200229_1459.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.3 on 2020-02-29 14:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0010_merge_20200228_1303'), + ] + + operations = [ + migrations.RemoveField( + model_name='order', + name='one_time_price', + ), + migrations.RemoveField( + model_name='order', + name='recurring_price', + ), + ] diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 3b8cc47..eeab444 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -10,7 +10,7 @@ from uncloud_vm.models import VMProduct class BillSerializer(serializers.ModelSerializer): class Meta: model = Bill - fields = ['owner', 'amount', 'due_date', 'creation_date', + fields = ['owner', 'total', 'due_date', 'creation_date', 'starting_date', 'ending_date'] class PaymentSerializer(serializers.ModelSerializer): diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index c3704e1..2dec2ae 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -31,21 +31,15 @@ class VMProductViewSet(ProductViewSet): # Create base order. order = Order.objects.create( recurring_period=RecurringPeriod.PER_MONTH, - recurring_price=0, - one_time_price=0, owner=request.user ) + order.save() # Create VM. serializer = VMProductSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) vm = serializer.save(owner=request.user, order=order) - # FIXME: commit everything (VM + order) at once. - order.recurring_price = vm.recurring_price(order.recurring_period) - order.one_time_price = 0 - order.save() - return Response(serializer.data) From 4f25086a63409700b4fffd872c17b93b3733e122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sun, 1 Mar 2020 15:47:27 +0100 Subject: [PATCH 119/193] Only generate bill if no overlap --- uncloud/uncloud_pay/helpers.py | 24 +++++++++++++++++------- uncloud/uncloud_pay/models.py | 17 ++++++++++------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index aaa1e11..9f775b7 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -6,7 +6,7 @@ from django.db.models import Q from .models import Bill, Payment, PaymentMethod, Order from django.utils import timezone from django.core.exceptions import ObjectDoesNotExist -from dateutil.relativedelta import relativedelta +from calendar import monthrange def sum_amounts(entries): return reduce(lambda acc, entry: acc + entry.amount, entries, 0) @@ -25,18 +25,24 @@ def get_payment_method_for(user): return None -def beginning_of_month(date): - return datetime(year=date.year, date=now.month, day=0) +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) def generate_bills_for(year, month, user, allowed_delay): # /!\ 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. - tz = timezone.get_current_timezone() next_bill = Bill(owner=user, - starting_date=datetime(year=year, month=month, day=1, tzinfo=tz), - ending_date=datetime(year=year, month=month, day=28, tzinfo=tz), + starting_date=beginning_of_month(year, month), + ending_date=end_of_month(year, month), creation_date=timezone.now(), due_date=timezone.now() + allowed_delay) @@ -52,7 +58,7 @@ def generate_bills_for(year, month, user, allowed_delay): unpaid_orders = [] for order in orders: try: - previous_bill = order.bill.latest('-ending_date') + previous_bill = order.bill.latest('ending_date') except ObjectDoesNotExist: previous_bill = None @@ -68,6 +74,10 @@ def generate_bills_for(year, month, user, allowed_delay): 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. diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index f9e7c35..8d4f14c 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -1,4 +1,5 @@ from django.db import models +from functools import reduce from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator from django.utils.translation import gettext_lazy as _ @@ -41,8 +42,8 @@ class Bill(models.Model): @property def total(self): - #return helpers.sum_amounts(self.entries) - pass + orders = Order.objects.filter(bill=self) + return reduce(lambda acc, order: acc + order.amount, orders, 0) class BillEntry(): start_date = timezone.now() @@ -95,12 +96,14 @@ class Order(models.Model): default = RecurringPeriod.PER_MONTH) - # def amount(self): - # amount = recurring_price - # if recurring and first_month: - # amount += one_time_price + @property + def amount(self): + # amount = recurring_price + # if recurring and first_month: + # amount += one_time_price - # return amount # you get the picture + amount=1 + return amount class PaymentMethod(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) From 028fd6789f979115481028d12f1cf575eb7bfe87 Mon Sep 17 00:00:00 2001 From: Ahmed Bilal <49-ahmedbilal@users.noreply.code.ungleich.ch> Date: Mon, 2 Mar 2020 07:17:04 +0100 Subject: [PATCH 120/193] ++cleanup Signed-off-by: Nico Schottelius --- uncloud/README.md | 23 +++ uncloud/uncloud/settings.py | 2 +- uncloud/uncloud/urls.py | 38 ++++- uncloud/uncloud_api/admin.py | 6 - uncloud/uncloud_api/apps.py | 5 - .../uncloud_api/management/commands/hack.py | 26 ---- .../management/commands/snapshot.py | 29 ---- .../0002_vmsnapshotproduct_vm_uuid.py | 19 --- .../migrations/0003_auto_20200225_1950.py | 36 ----- uncloud/uncloud_api/models.py | 139 ------------------ uncloud/uncloud_api/serializers.py | 26 ---- uncloud/uncloud_api/views.py | 94 ------------ .../{uncloud_api => uncloud_net}/__init__.py | 0 uncloud/uncloud_net/admin.py | 3 + uncloud/uncloud_net/apps.py | 5 + .../migrations}/__init__.py | 0 uncloud/uncloud_net/models.py | 4 + uncloud/{uncloud_api => uncloud_net}/tests.py | 0 uncloud/uncloud_net/views.py | 3 + .../commands => uncloud_pay}/__init__.py | 0 uncloud/uncloud_pay/admin.py | 3 + uncloud/uncloud_pay/apps.py | 5 + .../uncloud_pay/migrations/0001_initial.py | 56 +++++++ .../migrations/0002_auto_20200227_1230.py | 18 +++ .../migrations/__init__.py | 0 uncloud/uncloud_pay/models.py | 123 ++++++++++++++++ uncloud/uncloud_pay/serializers.py | 27 ++++ uncloud/uncloud_pay/tests.py | 3 + uncloud/uncloud_pay/views.py | 102 +++++++++++++ .../migrations/0004_vmsnapshotproduct.py} | 14 +- .../migrations/0005_auto_20200227_1230.py | 36 +++++ .../migrations/0006_auto_20200229_1545.py | 53 +++++++ .../migrations/0007_auto_20200229_1559.py | 23 +++ .../migrations/0008_auto_20200229_1611.py | 23 +++ uncloud/uncloud_vm/models.py | 95 +++++++++--- uncloud/uncloud_vm/serializers.py | 48 +++++- uncloud/uncloud_vm/views.py | 116 ++++++++++++++- 37 files changed, 783 insertions(+), 420 deletions(-) delete mode 100644 uncloud/uncloud_api/admin.py delete mode 100644 uncloud/uncloud_api/apps.py delete mode 100644 uncloud/uncloud_api/management/commands/hack.py delete mode 100644 uncloud/uncloud_api/management/commands/snapshot.py delete mode 100644 uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py delete mode 100644 uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py delete mode 100644 uncloud/uncloud_api/models.py delete mode 100644 uncloud/uncloud_api/serializers.py delete mode 100644 uncloud/uncloud_api/views.py rename uncloud/{uncloud_api => uncloud_net}/__init__.py (100%) create mode 100644 uncloud/uncloud_net/admin.py create mode 100644 uncloud/uncloud_net/apps.py rename uncloud/{uncloud_api/management => uncloud_net/migrations}/__init__.py (100%) create mode 100644 uncloud/uncloud_net/models.py rename uncloud/{uncloud_api => uncloud_net}/tests.py (100%) create mode 100644 uncloud/uncloud_net/views.py rename uncloud/{uncloud_api/management/commands => uncloud_pay}/__init__.py (100%) create mode 100644 uncloud/uncloud_pay/admin.py create mode 100644 uncloud/uncloud_pay/apps.py create mode 100644 uncloud/uncloud_pay/migrations/0001_initial.py create mode 100644 uncloud/uncloud_pay/migrations/0002_auto_20200227_1230.py rename uncloud/{uncloud_api => uncloud_pay}/migrations/__init__.py (100%) create mode 100644 uncloud/uncloud_pay/models.py create mode 100644 uncloud/uncloud_pay/serializers.py create mode 100644 uncloud/uncloud_pay/tests.py create mode 100644 uncloud/uncloud_pay/views.py rename uncloud/{uncloud_api/migrations/0001_initial.py => uncloud_vm/migrations/0004_vmsnapshotproduct.py} (57%) create mode 100644 uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py create mode 100644 uncloud/uncloud_vm/migrations/0006_auto_20200229_1545.py create mode 100644 uncloud/uncloud_vm/migrations/0007_auto_20200229_1559.py create mode 100644 uncloud/uncloud_vm/migrations/0008_auto_20200229_1611.py diff --git a/uncloud/README.md b/uncloud/README.md index 67f960f..390a3af 100644 --- a/uncloud/README.md +++ b/uncloud/README.md @@ -51,6 +51,12 @@ 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 @@ -70,3 +76,20 @@ sample values with real values. ### Creating a VM Snapshot + + +## Working Beta APIs + +These APIs can be used for internal testing. + +### URL Overview + +``` +http -a nicoschottelius:$(pass ungleich.ch/nico.schottelius@ungleich.ch) http://localhost:8000 +``` + +### Snapshotting + +``` +http -a nicoschottelius:$(pass ungleich.ch/nico.schottelius@ungleich.ch) http://localhost:8000/vm/snapshot/ vm_uuid=$(uuidgen) +``` diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 614cd25..179ff0b 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -60,7 +60,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', - 'uncloud_api', + 'uncloud_pay', 'uncloud_auth', 'uncloud_vm', 'opennebula' diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 23392c5..02862a1 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -18,27 +18,53 @@ from django.urls import path, include from rest_framework import routers -from uncloud_api import views as apiviews from uncloud_vm import views as vmviews +from uncloud_pay import views as payviews from opennebula import views as oneviews router = routers.DefaultRouter() -router.register(r'user', apiviews.UserViewSet, basename='user') +# user / regular urls +router.register(r'vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct') +router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') +router.register(r'vm/image/mine', vmviews.VMDiskImageProductMineViewSet, basename='vmdiskimagemineproduct') +router.register(r'vm/image/public', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct') + +# images the provider provides :-) +# router.register(r'vm/image/official', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct') + + + -router.register(r'vm/snapshot', apiviews.VMSnapshotView, basename='VMSnapshot') router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') +# TBD +#router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') + +# 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') + +# allow vm creation from own images + + +# Pay +router.register(r'user', payviews.UserViewSet, basename='user') +router.register(r'bill', payviews.BillViewSet, basename='bill') +router.register(r'order', payviews.OrderViewSet, basename='order') +router.register(r'payment', payviews.PaymentViewSet, basename='payment') + # 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/opennebula', oneviews.VMViewSet, basename='opennebula') router.register(r'admin/opennebula_raw', oneviews.RawVMViewSet) -# Wire up our API using automatic URL routing. -# Additionally, we include login URLs for the browsable API. urlpatterns = [ path('', include(router.urls)), - path('admin/', admin.site.urls), # login to django itself path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) # for login to REST API ] diff --git a/uncloud/uncloud_api/admin.py b/uncloud/uncloud_api/admin.py deleted file mode 100644 index d242668..0000000 --- a/uncloud/uncloud_api/admin.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.contrib import admin - -from .models import Product, Feature - -#admin.site.register(Product) -#admin.site.register(Feature) diff --git a/uncloud/uncloud_api/apps.py b/uncloud/uncloud_api/apps.py deleted file mode 100644 index 6830fa2..0000000 --- a/uncloud/uncloud_api/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class ApiConfig(AppConfig): - name = 'uncloud_api' diff --git a/uncloud/uncloud_api/management/commands/hack.py b/uncloud/uncloud_api/management/commands/hack.py deleted file mode 100644 index e129952..0000000 --- a/uncloud/uncloud_api/management/commands/hack.py +++ /dev/null @@ -1,26 +0,0 @@ -import time -from django.conf import settings -from django.core.management.base import BaseCommand - -import uncloud_api.models - -import inspect -import sys -import re - -class Command(BaseCommand): - args = '' - help = 'hacking - only use if you are Nico' - - def add_arguments(self, parser): - parser.add_argument('command', type=str, help='Command') - - def handle(self, *args, **options): - getattr(self, options['command'])(**options) - - @classmethod - def classtest(cls, **_): - clsmembers = inspect.getmembers(sys.modules['uncloud_api.models'], inspect.isclass) - for name, c in clsmembers: - if re.match(r'.+Product$', name): - print("{} -> {}".format(name, c)) diff --git a/uncloud/uncloud_api/management/commands/snapshot.py b/uncloud/uncloud_api/management/commands/snapshot.py deleted file mode 100644 index 41d0e38..0000000 --- a/uncloud/uncloud_api/management/commands/snapshot.py +++ /dev/null @@ -1,29 +0,0 @@ -import time -from django.conf import settings -from django.core.management.base import BaseCommand - -from uncloud_api import models - - -class Command(BaseCommand): - args = '' - help = 'VM Snapshot support' - - def add_arguments(self, parser): - parser.add_argument('command', type=str, help='Command') - - def handle(self, *args, **options): - print("Snapshotting") - #getattr(self, options['command'])(**options) - - @classmethod - def monitor(cls, **_): - while True: - try: - tweets = models.Reply.get_target_tweets() - responses = models.Reply.objects.values_list('tweet_id', flat=True) - new_tweets = [x for x in tweets if x.id not in responses] - models.Reply.send(new_tweets) - except TweepError as e: - print(e) - time.sleep(60) diff --git a/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py b/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py deleted file mode 100644 index b35317e..0000000 --- a/uncloud/uncloud_api/migrations/0002_vmsnapshotproduct_vm_uuid.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-25 18:16 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_api', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='vmsnapshotproduct', - name='vm_uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False), - ), - ] diff --git a/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py b/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py deleted file mode 100644 index be7624c..0000000 --- a/uncloud/uncloud_api/migrations/0003_auto_20200225_1950.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-25 19:50 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_api', '0002_vmsnapshotproduct_vm_uuid'), - ] - - operations = [ - migrations.AlterField( - model_name='vmsnapshotproduct', - name='gb_hdd', - field=models.FloatField(editable=False), - ), - migrations.AlterField( - model_name='vmsnapshotproduct', - name='gb_ssd', - field=models.FloatField(editable=False), - ), - migrations.AlterField( - model_name='vmsnapshotproduct', - name='owner', - field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='vmsnapshotproduct', - name='vm_uuid', - field=models.UUIDField(), - ), - ] diff --git a/uncloud/uncloud_api/models.py b/uncloud/uncloud_api/models.py deleted file mode 100644 index 6a6f9c8..0000000 --- a/uncloud/uncloud_api/models.py +++ /dev/null @@ -1,139 +0,0 @@ -import uuid - -from django.db import models -from django.contrib.auth import get_user_model - -# Product in DB vs. product in code -# DB: -# - need to define params (+param types) in db -> messy? -# - get /products/ is easy / automatic -# -# code -# - can have serializer/verification of fields easily in DRF -# - can have per product side effects / extra code running -# - might (??) make features easier?? -# - how to setup / query the recurring period (?) -# - could get products list via getattr() + re ...Product() classes -# -> this could include the url for ordering => /order/vm_snapshot (params) -# ---> this would work with urlpatterns - -# Combination: create specific product in DB (?) -# - a table per product (?) with 1 entry? - -# Orders -# define state in DB -# select a price from a product => product might change, order stays -# params: -# - the product uuid or name (?) => productuuid -# - the product parameters => for each feature -# - -# logs -# Should have a log = ... => 1:n field for most models! - -class Product(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) - - # override these fields by default - - description = "" - recurring_period = "not_recurring" - - status = models.CharField(max_length=256, - choices = ( - ('pending', 'Pending'), - ('being_created', 'Being created'), - ('active', 'Active'), - ('deleted', 'Deleted') - ), - default='pending' - ) - - # This is calculated by each product and saved in the DB - recurring_price = models.FloatField(editable=False) - one_time_price = models.FloatField(editable=False) - - # FIXME: need recurring_time_frame - - class Meta: - abstract = True - - def __str__(self): - return "{}".format(self.name) - - -class VMSnapshotProduct(Product): - price_per_gb_ssd = 0.35 - price_per_gb_hdd = 1.5/100 - - # This we need to get from the VM - gb_ssd = models.FloatField(editable=False) - gb_hdd = models.FloatField(editable=False) - - vm_uuid = models.UUIDField() - - # Need to setup recurring_price and one_time_price and recurring period - - sample_ssd = 10 - sample_hdd = 100 - - def recurring_price(self): - return 0 - - def one_time_price(self): - return 0 - - @classmethod - def sample_price(cls): - return cls.sample_ssd * cls.price_per_gb_ssd + cls.sample_hdd * cls.price_per_gb_hdd - - description = "Create snapshot of a VM" - recurring_period = "monthly" - - @classmethod - def pricing_model(cls): - return """ -Pricing is on monthly basis and storage prices are equivalent to the storage -price in the VM. - -Price per GB SSD is: {} -Price per GB HDD is: {} - - -Sample price for a VM with {} GB SSD and {} GB HDD VM is: {}. -""".format(cls.price_per_gb_ssd, cls.price_per_gb_hdd, - cls.sample_ssd, cls.sample_hdd, cls.sample_price()) - - - - - - - - -class Feature(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=256) - - recurring_price = models.FloatField(default=0) - one_time_price = models.FloatField() - - product = models.ForeignKey(Product, on_delete=models.CASCADE) - - # params for "cpu": cpu_count -> int - # each feature can only have one parameters - # could call this "value" and set whether it is user usable - # has_value = True/False - # value = string -> int (?) - # value_int - # value_str - # value_float - - class Meta: - abstract = True - - def __str__(self): - return "'{}' - '{}'".format(self.product, self.name) diff --git a/uncloud/uncloud_api/serializers.py b/uncloud/uncloud_api/serializers.py deleted file mode 100644 index 7dc3686..0000000 --- a/uncloud/uncloud_api/serializers.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.contrib.auth.models import Group -from django.contrib.auth import get_user_model - -from rest_framework import serializers - -from .models import VMSnapshotProduct - -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = get_user_model() - fields = ['url', 'username', 'email'] - -class GroupSerializer(serializers.HyperlinkedModelSerializer): - class Meta: - model = Group - fields = ['url', 'name'] - -class VMSnapshotSerializer(serializers.HyperlinkedModelSerializer): - class Meta: - model = VMSnapshotProduct - fields = ['uuid', 'status', 'recurring_price', 'one_time_price' ] - -class VMSnapshotCreateSerializer(serializers.HyperlinkedModelSerializer): - class Meta: - model = VMSnapshotProduct - fields = '__all__' diff --git a/uncloud/uncloud_api/views.py b/uncloud/uncloud_api/views.py deleted file mode 100644 index eb4cc77..0000000 --- a/uncloud/uncloud_api/views.py +++ /dev/null @@ -1,94 +0,0 @@ -from django.shortcuts import render -from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group - -from rest_framework import viewsets, permissions, generics - -from rest_framework.views import APIView -from rest_framework.response import Response - -from uncloud_vm.models import VMProduct -from .models import VMSnapshotProduct -from .serializers import UserSerializer, GroupSerializer, VMSnapshotSerializer, VMSnapshotCreateSerializer - - -import inspect -import sys -import re - - -# POST /vm/snapshot/ vmuuid=... => create snapshot, returns snapshot uuid -# GET /vm/snapshot => list -# DEL /vm/snapshot/ => delete -# create-list -> get, post => ListCreateAPIView -# del on other! -class VMSnapshotView(viewsets.ViewSet): - permission_classes = [permissions.IsAuthenticated] - - def list(self, request): - queryset = VMSnapshotProduct.objects.filter(owner=request.user) - serializer = VMSnapshotSerializer(queryset, many=True, context={'request': request}) - return Response(serializer.data) - - def retrieve(self, request, pk=None): - queryset = VMSnapshotProduct.objects.filter(owner=request.user) - vm = get_object_or_404(queryset, pk=pk) - serializer = VMSnapshotSerializer(vm, context={'request': request}) - return Response(serializer.data) - - def create(self, request): - print(request.data) - serializer = VMSnapshotCreateSerializer(data=request.data) - - serializer.gb_ssd = 12 - serializer.gb_hdd = 120 - print("F") - serializer.is_valid(raise_exception=True) - - print(serializer) - print("A") - serializer.save() - print("B") - - - # snapshot = VMSnapshotProduct(owner=request.user, - # **serialzer.data) - - return Response(serializer.data) - - - -# maybe drop or not --- we need something to guide the user! -# class ProductsViewSet(viewsets.ViewSet): -# permission_classes = [permissions.IsAuthenticated] - -# def list(self, request): - -# clsmembers = [] -# for modules in [ 'uncloud_api.models', 'uncloud_vm.models' ]: -# clsmembers.extend(inspect.getmembers(sys.modules[modules], inspect.isclass)) - - -# products = [] -# for name, c in clsmembers: -# # Include everything that ends in Product, but not Product itself -# m = re.match(r'(?P.+)Product$', name) -# if m: -# products.append({ -# 'name': m.group('pname'), -# 'description': c.description, -# 'recurring_period': c.recurring_period, -# 'pricing_model': c.pricing_model() -# } -# ) - - -# return Response(products) - - -class UserViewSet(viewsets.ModelViewSet): - serializer_class = UserSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - return self.request.user diff --git a/uncloud/uncloud_api/__init__.py b/uncloud/uncloud_net/__init__.py similarity index 100% rename from uncloud/uncloud_api/__init__.py rename to uncloud/uncloud_net/__init__.py diff --git a/uncloud/uncloud_net/admin.py b/uncloud/uncloud_net/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/uncloud/uncloud_net/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/uncloud/uncloud_net/apps.py b/uncloud/uncloud_net/apps.py new file mode 100644 index 0000000..489beb1 --- /dev/null +++ b/uncloud/uncloud_net/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UncloudNetConfig(AppConfig): + name = 'uncloud_net' diff --git a/uncloud/uncloud_api/management/__init__.py b/uncloud/uncloud_net/migrations/__init__.py similarity index 100% rename from uncloud/uncloud_api/management/__init__.py rename to uncloud/uncloud_net/migrations/__init__.py diff --git a/uncloud/uncloud_net/models.py b/uncloud/uncloud_net/models.py new file mode 100644 index 0000000..6d0c742 --- /dev/null +++ b/uncloud/uncloud_net/models.py @@ -0,0 +1,4 @@ +from django.db import models + +class MACAdress(models.Model): + prefix = 0x420000000000 diff --git a/uncloud/uncloud_api/tests.py b/uncloud/uncloud_net/tests.py similarity index 100% rename from uncloud/uncloud_api/tests.py rename to uncloud/uncloud_net/tests.py diff --git a/uncloud/uncloud_net/views.py b/uncloud/uncloud_net/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/uncloud/uncloud_net/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/uncloud/uncloud_api/management/commands/__init__.py b/uncloud/uncloud_pay/__init__.py similarity index 100% rename from uncloud/uncloud_api/management/commands/__init__.py rename to uncloud/uncloud_pay/__init__.py diff --git a/uncloud/uncloud_pay/admin.py b/uncloud/uncloud_pay/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/uncloud/uncloud_pay/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/uncloud/uncloud_pay/apps.py b/uncloud/uncloud_pay/apps.py new file mode 100644 index 0000000..051ffb4 --- /dev/null +++ b/uncloud/uncloud_pay/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UncloudPayConfig(AppConfig): + name = 'uncloud_pay' diff --git a/uncloud/uncloud_pay/migrations/0001_initial.py b/uncloud/uncloud_pay/migrations/0001_initial.py new file mode 100644 index 0000000..6e57c59 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# Generated by Django 3.0.3 on 2020-02-27 10:50 + +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), + ] + + operations = [ + migrations.CreateModel( + name='Bill', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_date', models.DateTimeField()), + ('starting_date', models.DateTimeField()), + ('ending_date', models.DateTimeField()), + ('due_date', models.DateField()), + ('paid', models.BooleanField(default=False)), + ('valid', models.BooleanField(default=True)), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + 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'), ('strip', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256)), + ('timestamp', models.DateTimeField(editable=False)), + ('owner', models.ForeignKey(editable=False, 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()), + ('starting_date', models.DateTimeField()), + ('ending_date', models.DateTimeField(blank=True, null=True)), + ('recurring_price', models.FloatField(editable=False)), + ('one_time_price', models.FloatField(editable=False)), + ('recurring_period', models.CharField(choices=[('onetime', 'Onetime'), ('per_year', 'Per Year'), ('per_month', 'Per Month'), ('per_week', 'Per Week'), ('per_day', 'Per Day'), ('per_hour', 'Per Hour'), ('per_minute', 'Per Minute'), ('per_second', 'Per Second')], default='onetime', max_length=32)), + ('bill', models.ManyToManyField(blank=True, editable=False, null=True, to='uncloud_pay.Bill')), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0002_auto_20200227_1230.py b/uncloud/uncloud_pay/migrations/0002_auto_20200227_1230.py new file mode 100644 index 0000000..0643e9a --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0002_auto_20200227_1230.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-02-27 12:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='source', + field=models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256), + ), + ] diff --git a/uncloud/uncloud_api/migrations/__init__.py b/uncloud/uncloud_pay/migrations/__init__.py similarity index 100% rename from uncloud/uncloud_api/migrations/__init__.py rename to uncloud/uncloud_pay/migrations/__init__.py diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py new file mode 100644 index 0000000..6a33fd5 --- /dev/null +++ b/uncloud/uncloud_pay/models.py @@ -0,0 +1,123 @@ +from django.db import models +from django.contrib.auth import get_user_model +from django.core.validators import MinValueValidator + +import uuid + +AMOUNT_MAX_DIGITS=10 +AMOUNT_DECIMALS=2 + +class Bill(models.Model): + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + + creation_date = models.DateTimeField() + starting_date = models.DateTimeField() + ending_date = models.DateTimeField() + due_date = models.DateField() + + paid = models.BooleanField(default=False) + valid = models.BooleanField(default=True) + + @property + def amount(self): + # iterate over all related orders + pass + + +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) + + creation_date = models.DateTimeField() + starting_date = models.DateTimeField() + ending_date = models.DateTimeField(blank=True, + null=True) + + bill = models.ManyToManyField(Bill, + editable=False, + blank=True, + null=True) + + + recurring_price = models.FloatField(editable=False) + one_time_price = models.FloatField(editable=False) + + recurring_period = models.CharField(max_length=32, + choices = ( + ('onetime', 'Onetime'), + ('per_year', 'Per Year'), + ('per_month', 'Per Month'), + ('per_week', 'Per Week'), + ('per_day', 'Per Day'), + ('per_hour', 'Per Hour'), + ('per_minute', 'Per Minute'), + ('per_second', 'Per Second'), + ), + default='onetime' + + ) + + # def amount(self): + # amount = recurring_price + # if recurring and first_month: + # amount += one_time_price + + # return amount # you get the picture + + + +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, + editable=False) + + 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) + + + + +class Product(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) + + description = "" + + status = models.CharField(max_length=256, + choices = ( + ('pending', 'Pending'), + ('being_created', 'Being created'), + ('active', 'Active'), + ('deleted', 'Deleted') + ), + default='pending' + ) + + order = models.ForeignKey(Order, + on_delete=models.CASCADE, + editable=False) + + class Meta: + abstract = True diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py new file mode 100644 index 0000000..130f683 --- /dev/null +++ b/uncloud/uncloud_pay/serializers.py @@ -0,0 +1,27 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers +from .models import Bill, Payment, Order + +class BillSerializer(serializers.ModelSerializer): + class Meta: + model = Bill + fields = ['owner', 'amount', 'due_date', 'creation_date', + 'starting_date', 'ending_date', 'paid'] + +class PaymentSerializer(serializers.ModelSerializer): + class Meta: + model = Payment + fields = ['owner', 'amount', 'source', 'timestamp'] + +class OrderSerializer(serializers.ModelSerializer): + class Meta: + model = Order + fields = '__all__' + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ['username', 'email'] + + def get_balance(self, obj): + return 666 diff --git a/uncloud/uncloud_pay/tests.py b/uncloud/uncloud_pay/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/uncloud/uncloud_pay/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py new file mode 100644 index 0000000..ae88861 --- /dev/null +++ b/uncloud/uncloud_pay/views.py @@ -0,0 +1,102 @@ +from django.shortcuts import render +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 + +from .models import Bill, Payment, Order +from .serializers import BillSerializer, PaymentSerializer, UserSerializer, OrderSerializer +from datetime import datetime + +### +# 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) + + def unpaid(self, request): + return Bill.objects.filter(owner=self.request.user, paid=False) + +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 UserViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return get_user_model().objects.all() + + @action(detail=True) + def balance(self, request): + return Response(status=status.HTTP_204_NO_CONTENT) + +### +# 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(created_at=datetime.now()) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + +class AdminOrderViewSet(viewsets.ModelViewSet): + serializer_class = OrderSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Order.objects.all() diff --git a/uncloud/uncloud_api/migrations/0001_initial.py b/uncloud/uncloud_vm/migrations/0004_vmsnapshotproduct.py similarity index 57% rename from uncloud/uncloud_api/migrations/0001_initial.py rename to uncloud/uncloud_vm/migrations/0004_vmsnapshotproduct.py index 67bdd2e..13840b5 100644 --- a/uncloud/uncloud_api/migrations/0001_initial.py +++ b/uncloud/uncloud_vm/migrations/0004_vmsnapshotproduct.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-02-23 17:12 +# Generated by Django 3.0.3 on 2020-02-27 10:50 from django.conf import settings from django.db import migrations, models @@ -8,10 +8,10 @@ import uuid class Migration(migrations.Migration): - initial = True - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0001_initial'), + ('uncloud_vm', '0003_auto_20200225_2028'), ] operations = [ @@ -20,9 +20,11 @@ class Migration(migrations.Migration): fields=[ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), - ('gb_ssd', models.FloatField()), - ('gb_hdd', models.FloatField()), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('gb_ssd', models.FloatField(editable=False)), + ('gb_hdd', models.FloatField(editable=False)), + ('vm_uuid', models.UUIDField()), + ('order', models.ForeignKey(editable=False, 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)), ], options={ 'abstract': False, diff --git a/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py new file mode 100644 index 0000000..1bd711b --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py @@ -0,0 +1,36 @@ +# Generated by Django 3.0.3 on 2020-02-27 12:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0002_auto_20200227_1230'), + ('uncloud_vm', '0004_vmsnapshotproduct'), + ] + + operations = [ + migrations.RemoveField( + model_name='vmsnapshotproduct', + name='vm_uuid', + ), + migrations.AddField( + model_name='vmproduct', + name='order', + field=models.ForeignKey(default=0, editable=False, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), + preserve_default=False, + ), + migrations.AddField( + model_name='vmproduct', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256), + ), + migrations.AddField( + model_name='vmsnapshotproduct', + name='vm', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct'), + preserve_default=False, + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0006_auto_20200229_1545.py b/uncloud/uncloud_vm/migrations/0006_auto_20200229_1545.py new file mode 100644 index 0000000..208aeaa --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0006_auto_20200229_1545.py @@ -0,0 +1,53 @@ +# Generated by Django 3.0.3 on 2020-02-29 15:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_vm', '0005_auto_20200227_1230'), + ] + + 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()), + ('storage_class', models.CharField(choices=[('hdd', 'HDD'), ('ssd', 'SSD')], default='ssd', max_length=32)), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.RemoveField( + model_name='vmdiskproduct', + name='storage_class', + ), + migrations.AddField( + model_name='vmdiskproduct', + name='owner', + field=models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='vmnetworkcard', + name='ip_address', + field=models.GenericIPAddressField(blank=True, null=True), + ), + migrations.DeleteModel( + name='OperatingSystemDisk', + ), + migrations.AddField( + model_name='vmdiskproduct', + name='image', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMDiskImageProduct'), + preserve_default=False, + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0007_auto_20200229_1559.py b/uncloud/uncloud_vm/migrations/0007_auto_20200229_1559.py new file mode 100644 index 0000000..6e08c0c --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0007_auto_20200229_1559.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-02-29 15:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0006_auto_20200229_1545'), + ] + + operations = [ + migrations.AddField( + model_name='vmdiskimageproduct', + name='import_url', + field=models.URLField(blank=True, null=True), + ), + migrations.AlterField( + model_name='vmdiskimageproduct', + name='size_in_gb', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0008_auto_20200229_1611.py b/uncloud/uncloud_vm/migrations/0008_auto_20200229_1611.py new file mode 100644 index 0000000..8a9be67 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0008_auto_20200229_1611.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-02-29 16:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0007_auto_20200229_1559'), + ] + + operations = [ + migrations.AddField( + model_name='vmdiskimageproduct', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('creating', 'Creating'), ('active', 'Active'), ('disabled', 'Disabled'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32), + ), + migrations.AlterField( + model_name='vmhost', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('creating', 'Creating'), ('active', 'Active'), ('disabled', 'Disabled'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32), + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index f4b68dd..7aac05b 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -2,6 +2,18 @@ from django.db import models from django.contrib.auth import get_user_model import uuid +from uncloud_pay.models import Product + +STATUS_CHOICES = ( + ('pending', 'Pending'), # Initial state + ('creating', 'Creating'), # Creating VM/image/etc. + ('active', 'Active'), # Is usable / active + ('disabled', 'Disabled'), # Is usable, but cannot be used for new things + ('unusable', 'Unusable'), # Has some kind of error + ('deleted', 'Deleted'), # Does not exist anymore, only DB entry as a log +) + +STATUS_DEFAULT='pending' class VMHost(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -20,23 +32,12 @@ class VMHost(models.Model): status = models.CharField(max_length=32, - choices = ( - ('pending', 'Pending'), - ('active', 'Active'), - ('unusable', 'Unusable'), - ('deleted', 'Deleted'), - ), - default='pending' + choices=STATUS_CHOICES, + default=STATUS_DEFAULT ) -class VMProduct(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) +class VMProduct(Product): vmhost = models.ForeignKey(VMHost, on_delete=models.CASCADE, editable=False, @@ -50,10 +51,31 @@ class VMProduct(models.Model): class VMWithOSProduct(VMProduct): pass -class VMDiskProduct(models.Model): - uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) - size_in_gb = models.FloatField() + +class VMDiskImageProduct(models.Model): + """ + Images are used for cloning/linking. + + They are the base for images. + + """ + + uuid = models.UUIDField(primary_key=True, + default=uuid.uuid4, + editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + + name = models.CharField(max_length=256) + is_os_image = models.BooleanField(default=False) + is_public = models.BooleanField(default=False) + + size_in_gb = models.FloatField(null=True, + blank=True) + import_url = models.URLField(null=True, + blank=True) + storage_class = models.CharField(max_length=32, choices = ( @@ -63,11 +85,42 @@ class VMDiskProduct(models.Model): default='ssd' ) -class OperatingSystemDisk(VMDiskProduct): - """ Defines an Operating System Disk that can be cloned for a VM """ - os_name = models.CharField(max_length=128) + status = models.CharField(max_length=32, + choices=STATUS_CHOICES, + default=STATUS_DEFAULT + ) + +class VMDiskProduct(models.Model): + """ + The VMDiskProduct is attached to a VM. + + It is based on a VMDiskImageProduct that will be used as a basis. + + It can be enlarged, but not shrinked compared to the VMDiskImageProduct. + """ + + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), + on_delete=models.CASCADE, + editable=False) + + vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + image = models.ForeignKey(VMDiskImageProduct, on_delete=models.CASCADE) + + size_in_gb = models.FloatField(blank=True) class VMNetworkCard(models.Model): vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + mac_address = models.IntegerField() + + ip_address = models.GenericIPAddressField(blank=True, + null=True) + + +class VMSnapshotProduct(Product): + gb_ssd = models.FloatField(editable=False) + gb_hdd = models.FloatField(editable=False) + + vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 4154aee..07d6c51 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -1,15 +1,57 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from .models import VMHost, VMProduct +from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct -class VMHostSerializer(serializers.HyperlinkedModelSerializer): +GB_SSD_PER_DAY=0.012 +GB_HDD_PER_DAY=0.0006 + +GB_SSD_PER_DAY=0.012 +GB_HDD_PER_DAY=0.0006 + + +class VMHostSerializer(serializers.ModelSerializer): class Meta: model = VMHost fields = '__all__' -class VMProductSerializer(serializers.HyperlinkedModelSerializer): +class VMProductSerializer(serializers.ModelSerializer): class Meta: model = VMProduct fields = '__all__' + +class VMDiskProductSerializer(serializers.ModelSerializer): +# vm = VMProductSerializer() + + class Meta: + model = VMDiskProduct + fields = '__all__' + +class VMDiskImageProductSerializer(serializers.ModelSerializer): + class Meta: + model = VMDiskImageProduct + fields = '__all__' + +class VMSnapshotProductSerializer(serializers.ModelSerializer): + class Meta: + model = VMSnapshotProduct + fields = '__all__' + + + # verify that vm.owner == user.request + def validate_vm(self, value): + if not value.owner == self.context['request'].user: + raise serializers.ValidationError("VM {} not found for owner {}.".format(value, + self.context['request'].user)) + disks = VMDiskProduct.objects.filter(vm=value) + + if len(disks) == 0: + raise serializers.ValidationError("VM {} does not have any disks, cannot snapshot".format(value.uuid)) + + return value + + pricing = {} + pricing['per_gb_ssd'] = 0.012 + pricing['per_gb_hdd'] = 0.0006 + pricing['recurring_period'] = 'per_day' diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 91e81e1..62edaa0 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -5,15 +5,78 @@ from django.shortcuts import get_object_or_404 from rest_framework import viewsets, permissions from rest_framework.response import Response +from rest_framework.exceptions import ValidationError -from .models import VMHost, VMProduct -from .serializers import VMHostSerializer, VMProductSerializer + +from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct +from uncloud_pay.models import Order + +from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer, VMDiskImageProductSerializer, VMDiskProductSerializer + + +import datetime class VMHostViewSet(viewsets.ModelViewSet): serializer_class = VMHostSerializer queryset = VMHost.objects.all() permission_classes = [permissions.IsAdminUser] +class VMDiskImageProductMineViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMDiskImageProductSerializer + + def get_queryset(self): + return VMDiskImageProduct.objects.filter(owner=self.request.user) + + def create(self, request): + serializer = VMDiskImageProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + + # did not specify size NOR import url? + if not serializer.validated_data['size_in_gb']: + if not serializer.validated_data['import_url']: + raise ValidationError(detail={ 'error_mesage': 'Specify either import_url or size_in_gb' }) + + serializer.save(owner=request.user) + return Response(serializer.data) + + +class VMDiskImageProductPublicViewSet(viewsets.ReadOnlyModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMDiskImageProductSerializer + + def get_queryset(self): + return VMDiskImageProduct.objects.filter(is_public=True) + +class VMDiskProductViewSet(viewsets.ModelViewSet): + """ + Let a user modify their own VMDisks + """ + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMDiskProductSerializer + + def get_queryset(self): + return VMDiskProduct.objects.filter(owner=self.request.user) + + def create(self, request): + serializer = VMDiskProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + + # get disk size from image, if not specified + if not 'size_in_gb' in serializer.validated_data: + size_in_gb = serializer.validated_data['image'].size_in_gb + else: + size_in_gb = serializer.validated_data['size_in_gb'] + + if size_in_gb < serializer.validated_data['image'].size_in_gb: + raise ValidationError(detail={ 'error_mesage': 'Size is smaller than original image' }) + + + serializer.save(owner=request.user, size_in_gb=size_in_gb) + return Response(serializer.data) + + + class VMProductViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = VMProductSerializer @@ -24,6 +87,53 @@ class VMProductViewSet(viewsets.ModelViewSet): def create(self, request): serializer = VMProductSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) - serializer.save(owner=request.user) + # Create order + now = datetime.datetime.now() + order = Order(owner=request.user, + creation_date=now, + starting_date=now, + recurring_price=20, + one_time_price=0, + recurring_period="per_month") + order.save() + + serializer.save(owner=request.user, order=order) + return Response(serializer.data) + + +class VMSnapshotProductViewSet(viewsets.ModelViewSet): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VMSnapshotProductSerializer + + def get_queryset(self): + return VMSnapshotProduct.objects.filter(owner=self.request.user) + + def create(self, request): + serializer = VMSnapshotProductSerializer(data=request.data, context={'request': request}) + + # This verifies that the VM belongs to the request user + serializer.is_valid(raise_exception=True) + + disks = VMDiskProduct.objects.filter(vm=serializer.validated_data['vm']) + ssds_size = sum([d.size_in_gb for d in disks if d.storage_class == 'ssd']) + hdds_size = sum([d.size_in_gb for d in disks if d.storage_class == 'hdd']) + + recurring_price = serializer.pricing['per_gb_ssd'] * ssds_size + serializer.pricing['per_gb_hdd'] * hdds_size + recurring_period = serializer.pricing['recurring_period'] + + # Create order + now = datetime.datetime.now() + order = Order(owner=request.user, + creation_date=now, + starting_date=now, + recurring_price=recurring_price, + one_time_price=0, + recurring_period=recurring_period) + order.save() + + serializer.save(owner=request.user, + order=order, + gb_ssd=ssds_size, + gb_hdd=hdds_size) return Response(serializer.data) From 8e41b894c030ad549b8130c3eeec873005a44ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Mon, 2 Mar 2020 08:09:42 +0100 Subject: [PATCH 121/193] Add OrderRecord model --- .../migrations/0012_orderrecord.py | 25 +++++++++++++++++++ uncloud/uncloud_pay/models.py | 23 +++++++++++++---- 2 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0012_orderrecord.py diff --git a/uncloud/uncloud_pay/migrations/0012_orderrecord.py b/uncloud/uncloud_pay/migrations/0012_orderrecord.py new file mode 100644 index 0000000..7c655e4 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0012_orderrecord.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.3 on 2020-03-01 16:04 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0011_auto_20200229_1459'), + ] + + operations = [ + migrations.CreateModel( + name='OrderRecord', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('setup_fee', 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')), + ], + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 8d4f14c..2862940 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -98,12 +98,25 @@ class Order(models.Model): @property def amount(self): - # amount = recurring_price - # if recurring and first_month: - # amount += one_time_price + records = OrderRecord.objects.filter(order=self) + return 1 - amount=1 - return amount +class OrderRecord(models.Model): + order = models.ForeignKey(Order, on_delete=models.CASCADE) + setup_fee = 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 class PaymentMethod(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) From 81bd54116a8d6d078a22d20df19aedbbc5cf3177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Mon, 2 Mar 2020 09:25:03 +0100 Subject: [PATCH 122/193] Add records to orders --- uncloud/uncloud_pay/models.py | 28 +++++++++++++++------------- uncloud/uncloud_pay/serializers.py | 9 ++++++++- uncloud/uncloud_vm/models.py | 6 ++++-- uncloud/uncloud_vm/views.py | 7 +++++++ 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 2862940..8b19c37 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -65,16 +65,6 @@ class BillEntry(): # confusing: the order is a 'contract' with the customer, were both parts # agree on deal => That's what we want to keep archived. # -# SOON: -# -# We'll need to add some kind of OrderEntry table (each order might have -# multiple entries) storing: recurring_price, recurring_period, setup_fee, description -# -# FOR NOW: -# -# We dynamically get pricing from linked product, as they are not updated in -# this stage of development. -# # /!\ BIG FAT WARNING /!\ # class Order(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -95,11 +85,23 @@ class Order(models.Model): choices = RecurringPeriod.choices, default = RecurringPeriod.PER_MONTH) + @property + def records(self): + return OrderRecord.objects.filter(order=self) @property - def amount(self): - records = OrderRecord.objects.filter(order=self) - return 1 + def setup_fee(self): + return reduce(lambda acc, record: acc + record.setup_fee, self.records, 0) + + @property + def recurring_price(self): + return reduce(lambda acc, record: acc + record.recurring_price, self.records, 0) + + def add_record(self, setup_fee, recurring_price, description): + OrderRecord.objects.create(order=self, + setup_fee=setup_fee, + recurring_price=recurring_price, + description=description) class OrderRecord(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index eeab444..83eebb6 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -26,10 +26,17 @@ class PaymentMethodSerializer(serializers.ModelSerializer): class ProductSerializer(serializers.Serializer): vms = VMProductSerializer(many=True, read_only=True) +class OrderRecordSerializer(serializers.ModelSerializer): + class Meta: + model = OrderRecord + fields = ['setup_fee', 'recurring_price', 'description'] + class OrderSerializer(serializers.ModelSerializer): + records = OrderRecordSerializer(many=True, read_only=True) class Meta: model = Order - fields = '__all__' + fields = ['uuid', 'creation_date', 'starting_date', 'ending_date', + 'bill', 'recurring_period', 'records', 'recurring_price', 'setup_fee'] class UserSerializer(serializers.ModelSerializer): class Meta: diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index be1178e..c32f3a5 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -40,8 +40,6 @@ class VMProduct(Product): blank=True, null=True) - description = "Virtual Machine" - # VM-specific. The name is only intended for customers: it's a pain te # remember IDs (speaking from experience as ungleich customer)! name = models.CharField(max_length=32) @@ -55,6 +53,10 @@ class VMProduct(Product): else: raise Exception('Invalid recurring period for VM Product pricing.') + @property + def description(self): + return "Virtual machine '{}': {} core(s), {}GB memory".format( + self.name, self.cores, self.ram_in_gb) class VMWithOSProduct(VMProduct): pass diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 2dec2ae..5eeec7b 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -28,6 +28,9 @@ class VMProductViewSet(ProductViewSet): return VMProduct.objects.filter(owner=self.request.user) def create(self, request): + # TODO: what if something blows-up midway? + # => need a transaction + # Create base order. order = Order.objects.create( recurring_period=RecurringPeriod.PER_MONTH, @@ -40,6 +43,10 @@ class VMProductViewSet(ProductViewSet): serializer.is_valid(raise_exception=True) vm = serializer.save(owner=request.user, order=order) + # Add Product record to order (VM is mutable, allows to keep history in order). + order.add_record(vm.setup_fee, + vm.recurring_price(order.recurring_period), vm.description) + return Response(serializer.data) From 9e253d497bfcdb41dc54df94ad9d85c55b554492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Mon, 2 Mar 2020 09:30:51 +0100 Subject: [PATCH 123/193] Wrap VM creation in database transaction --- uncloud/uncloud_vm/views.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 5eeec7b..5de904c 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -1,3 +1,4 @@ +from django.db import transaction from django.shortcuts import render from django.contrib.auth.models import User @@ -27,10 +28,10 @@ class VMProductViewSet(ProductViewSet): def get_queryset(self): return VMProduct.objects.filter(owner=self.request.user) + # Use a database transaction so that we do not get half-created structure + # if something goes wrong. + @transaction.atomic def create(self, request): - # TODO: what if something blows-up midway? - # => need a transaction - # Create base order. order = Order.objects.create( recurring_period=RecurringPeriod.PER_MONTH, From 9e9018060efac5e6536965222d44dd5a02e876fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Mon, 2 Mar 2020 10:46:04 +0100 Subject: [PATCH 124/193] Wire order records to bills, fix user balance --- uncloud/uncloud_pay/helpers.py | 13 +++++---- uncloud/uncloud_pay/models.py | 44 ++++++++++++++++++++---------- uncloud/uncloud_pay/serializers.py | 10 ++++++- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index 9f775b7..b4216f6 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -8,12 +8,15 @@ from django.utils import timezone from django.core.exceptions import ObjectDoesNotExist from calendar import monthrange -def sum_amounts(entries): - return reduce(lambda acc, entry: acc + entry.amount, entries, 0) - def get_balance_for(user): - bills = sum_amounts(Bill.objects.filter(owner=user)) - payments = sum_amounts(Payment.objects.filter(owner=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 def get_payment_method_for(user): diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 8b19c37..e257b9e 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -33,26 +33,40 @@ class Bill(models.Model): valid = models.BooleanField(default=True) @property - def entries(self): - # TODO: return list of Bill entries, extract from linked order - # for each related order - # for each product - # build BillEntry - return [] + 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.setup_fee, + order_record.recurring_price, + order_record.recurring_period, + order_record.description) + bill_records.append(bill_record) + + return bill_records @property def total(self): - orders = Order.objects.filter(bill=self) - return reduce(lambda acc, order: acc + order.amount, orders, 0) + return reduce(lambda acc, record: acc + record.amount(), self.records, 0) -class BillEntry(): - start_date = timezone.now() - end_date = timezone.now() - recurring_period = RecurringPeriod.PER_MONTH - recurring_price = 0 - amount = 0 - description = "" +class BillRecord(): + def __init__(self, bill, setup_fee, recurring_price, recurring_period, description): + self.bill = bill + self.setup_fee = setup_fee + self.recurring_price = recurring_price + self.recurring_period = recurring_period + self.description = description + def amount(self): + # TODO: Billing logic here! + if self.recurring_period == RecurringPeriod.PER_MONTH: + return self.recurring_price # TODO + else: + raise Exception('Unsupported recurring period: {}.'. + format(record.recurring_period)) # /!\ BIG FAT WARNING /!\ # # diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 83eebb6..976ab6b 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -7,11 +7,19 @@ from functools import reduce from uncloud_vm.serializers import VMProductSerializer from uncloud_vm.models import VMProduct +# TODO: remove magic numbers for decimal fields +class BillRecordSerializer(serializers.Serializer): + description = serializers.CharField() + recurring_period = serializers.CharField() + recurring_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 = ['owner', 'total', 'due_date', 'creation_date', - 'starting_date', 'ending_date'] + 'starting_date', 'ending_date', 'records'] class PaymentSerializer(serializers.ModelSerializer): class Meta: From 6c9c63e0da2ce69d5199628a76aa2d8137f9daae Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 2 Mar 2020 16:54:36 +0500 Subject: [PATCH 125/193] Add sample clean() for model + Add tests for uncloud_vm --- uncloud/uncloud_vm/models.py | 84 ++++++++++++++------------- uncloud/uncloud_vm/tests.py | 107 +++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 38 deletions(-) create mode 100644 uncloud/uncloud_vm/tests.py diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 7aac05b..9733841 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -1,19 +1,24 @@ +import uuid + from django.db import models from django.contrib.auth import get_user_model -import uuid + +# Uncomment if you override model's clean method +# from django.core.exceptions import ValidationError from uncloud_pay.models import Product STATUS_CHOICES = ( ('pending', 'Pending'), # Initial state - ('creating', 'Creating'), # Creating VM/image/etc. - ('active', 'Active'), # Is usable / active - ('disabled', 'Disabled'), # Is usable, but cannot be used for new things - ('unusable', 'Unusable'), # Has some kind of error + ('creating', 'Creating'), # Creating VM/image/etc. + ('active', 'Active'), # Is usable / active + ('disabled', 'Disabled'), # Is usable, but cannot be used for new things + ('unusable', 'Unusable'), # Has some kind of error ('deleted', 'Deleted'), # Does not exist anymore, only DB entry as a log ) -STATUS_DEFAULT='pending' +STATUS_DEFAULT = 'pending' + class VMHost(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -30,19 +35,13 @@ class VMHost(models.Model): # ram that can be used of the server usable_ram_in_gb = models.FloatField(default=0) - - status = models.CharField(max_length=32, - choices=STATUS_CHOICES, - default=STATUS_DEFAULT - ) + status = models.CharField(max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT) class VMProduct(Product): - vmhost = models.ForeignKey(VMHost, - on_delete=models.CASCADE, - editable=False, - blank=True, - null=True) + vmhost = models.ForeignKey( + VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True + ) cores = models.IntegerField() ram_in_gb = models.FloatField() @@ -60,36 +59,30 @@ class VMDiskImageProduct(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) + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False) name = models.CharField(max_length=256) is_os_image = models.BooleanField(default=False) is_public = models.BooleanField(default=False) - size_in_gb = models.FloatField(null=True, - blank=True) - import_url = models.URLField(null=True, - blank=True) + size_in_gb = models.FloatField(null=True, blank=True) + import_url = models.URLField(null=True, blank=True) - - storage_class = models.CharField(max_length=32, - choices = ( - ('hdd', 'HDD'), - ('ssd', 'SSD'), - ), - default='ssd' + storage_class = models.CharField( + max_length=32, + choices=( + ('hdd', 'HDD'), + ('ssd', 'SSD'), + ), + default='ssd' ) - status = models.CharField(max_length=32, - choices=STATUS_CHOICES, - default=STATUS_DEFAULT + status = models.CharField( + max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT ) + class VMDiskProduct(models.Model): """ The VMDiskProduct is attached to a VM. @@ -104,14 +97,29 @@ class VMDiskProduct(models.Model): on_delete=models.CASCADE, editable=False) - vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) image = models.ForeignKey(VMDiskImageProduct, on_delete=models.CASCADE) size_in_gb = models.FloatField(blank=True) + # Sample code for clean method + + # Ensures that a VMDiskProduct can only be created from a VMDiskImageProduct + # that is in status 'active' + + # def clean(self): + # if self.image.status != 'active': + # raise ValidationError({ + # 'image': 'VM Disk must be created from an active disk image.' + # }) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + class VMNetworkCard(models.Model): - vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) mac_address = models.IntegerField() diff --git a/uncloud/uncloud_vm/tests.py b/uncloud/uncloud_vm/tests.py new file mode 100644 index 0000000..a9ca5ee --- /dev/null +++ b/uncloud/uncloud_vm/tests.py @@ -0,0 +1,107 @@ +import datetime + +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.utils import timezone +from django.core.exceptions import ValidationError + +from uncloud_vm.models import VMDiskImageProduct, VMDiskProduct, VMProduct, VMHost +from uncloud_pay.models import Order + +User = get_user_model() + + +# If you want to check the test database then use the following connecting parameters + +# host: localhost +# database: test_uncloud +# user: root +# password: +# port: 5432 + +class VMTestCase(TestCase): + @classmethod + def setUpClass(cls): + # Setup vm host + cls.vm_host, created = VMHost.objects.get_or_create( + hostname='server1.place11.ungleich.ch', physical_cores=32, usable_cores=320, + usable_ram_in_gb=512.0, status='active' + ) + super().setUpClass() + + def setUp(self) -> None: + # Setup two users as it is common to test with different user + self.user = User.objects.create_user( + username='testuser', email='test@test.com', first_name='Test', last_name='User' + ) + self.user2 = User.objects.create_user( + username='Meow', email='meow123@test.com', first_name='Meow', last_name='Cat' + ) + super().setUp() + + def create_sample_vm(self, owner): + return VMProduct.objects.create( + vmhost=self.vm_host, cores=2, ram_in_gb=4, owner=owner, + order=Order.objects.create( + owner=owner, + creation_date=datetime.datetime.now(tz=timezone.utc), + starting_date=datetime.datetime.now(tz=timezone.utc), + ending_date=datetime.datetime(2020, 4, 2, tzinfo=timezone.utc), + recurring_price=4.0, one_time_price=5.0, recurring_period='per_month' + ) + ) + + def test_disk_product(self): + """Ensures that a VMDiskProduct can only be created from a VMDiskImageProduct + that is in status 'active'""" + + vm = self.create_sample_vm(owner=self.user) + + pending_disk_image = VMDiskImageProduct.objects.create( + owner=self.user, name='alpine3.11', is_os_image=True, is_public=True, size_in_gb=10, + status='pending' + ) + try: + vm_disk_product = VMDiskProduct.objects.create( + owner=self.user, vm=vm, image=pending_disk_image, size_in_gb=10 + ) + except ValidationError: + vm_disk_product = None + + self.assertIsNone( + vm_disk_product, + msg='VMDiskProduct created with disk image whose status is not active.' + ) + + def test_vm_disk_product_creation(self): + """Ensure that a user can only create a VMDiskProduct for an existing VM""" + + disk_image = VMDiskImageProduct.objects.create( + owner=self.user, name='alpine3.11', is_os_image=True, is_public=True, size_in_gb=10, + status='active' + ) + + with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for non-existing VM'): + # Create VMProduct object but don't save it in database + vm = VMProduct() + + vm_disk_product = VMDiskProduct.objects.create( + owner=self.user, vm=vm, image=disk_image, size_in_gb=10 + ) + + def test_vm_disk_product_creation_for_someone_else(self): + """Ensure that a user can only create a VMDiskProduct for his/her own VM""" + + # Create a VM which is ownership of self.user2 + someone_else_vm = self.create_sample_vm(owner=self.user2) + + # 'self.user' would try to create a VMDiskProduct for 'user2's VM + with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for someone else VM.'): + vm_disk_product = VMDiskProduct.objects.create( + owner=self.user, vm=someone_else_vm, + size_in_gb=10, + image=VMDiskImageProduct.objects.create( + owner=self.user, name='alpine3.11', is_os_image=True, is_public=True, size_in_gb=10, + status='active' + ) + ) From afdba3d7d9de49395e008c3e860e8799aab47843 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 2 Mar 2020 17:17:30 +0500 Subject: [PATCH 126/193] Remove duplicate code --- uncloud/uncloud_vm/models.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index e59d5d2..e54c4ea 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -19,18 +19,6 @@ STATUS_CHOICES = ( STATUS_DEFAULT = 'pending' -from uncloud_pay.models import Product - -STATUS_CHOICES = ( - ('pending', 'Pending'), # Initial state - ('creating', 'Creating'), # Creating VM/image/etc. - ('active', 'Active'), # Is usable / active - ('disabled', 'Disabled'), # Is usable, but cannot be used for new things - ('unusable', 'Unusable'), # Has some kind of error - ('deleted', 'Deleted'), # Does not exist anymore, only DB entry as a log -) - -STATUS_DEFAULT='pending' class VMHost(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) From 0c3e6d10ae79b63f9450a7924b043ab917e50958 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 2 Mar 2020 17:20:30 +0500 Subject: [PATCH 127/193] Indentation/Spacing fixes --- uncloud/uncloud_vm/models.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index e54c4ea..4b0d511 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -35,15 +35,15 @@ class VMHost(models.Model): # ram that can be used of the server usable_ram_in_gb = models.FloatField(default=0) - status = models.CharField(max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT) + status = models.CharField( + max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT + ) class VMProduct(Product): - vmhost = models.ForeignKey(VMHost, - on_delete=models.CASCADE, - editable=False, - blank=True, - null=True) + vmhost = models.ForeignKey( + VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True + ) cores = models.IntegerField() ram_in_gb = models.FloatField() From 750d8c8cbf2f6a27202110c09ac72c790c283eeb Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 2 Mar 2020 17:42:54 +0500 Subject: [PATCH 128/193] Use fictional hostname for VMHost --- uncloud/uncloud_vm/tests.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/uncloud/uncloud_vm/tests.py b/uncloud/uncloud_vm/tests.py index a9ca5ee..c51f597 100644 --- a/uncloud/uncloud_vm/tests.py +++ b/uncloud/uncloud_vm/tests.py @@ -11,7 +11,8 @@ from uncloud_pay.models import Order User = get_user_model() -# If you want to check the test database then use the following connecting parameters +# If you want to check the test database using some GUI/cli tool +# then use the following connecting parameters # host: localhost # database: test_uncloud @@ -24,7 +25,7 @@ class VMTestCase(TestCase): def setUpClass(cls): # Setup vm host cls.vm_host, created = VMHost.objects.get_or_create( - hostname='server1.place11.ungleich.ch', physical_cores=32, usable_cores=320, + hostname='serverx.placey.ungleich.ch', physical_cores=32, usable_cores=320, usable_ram_in_gb=512.0, status='active' ) super().setUpClass() From 531bfa176837170b53b1f56cc245e5e8b1d884b3 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 2 Mar 2020 19:20:12 +0500 Subject: [PATCH 129/193] actual thing name is replaced by pseudo names --- uncloud/uncloud_vm/tests.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/uncloud/uncloud_vm/tests.py b/uncloud/uncloud_vm/tests.py index c51f597..8d7994f 100644 --- a/uncloud/uncloud_vm/tests.py +++ b/uncloud/uncloud_vm/tests.py @@ -1,5 +1,7 @@ import datetime +import parsedatetime + from django.test import TestCase from django.contrib.auth import get_user_model from django.utils import timezone @@ -9,6 +11,7 @@ from uncloud_vm.models import VMDiskImageProduct, VMDiskProduct, VMProduct, VMHo from uncloud_pay.models import Order User = get_user_model() +cal = parsedatetime.Calendar() # If you want to check the test database using some GUI/cli tool @@ -41,13 +44,14 @@ class VMTestCase(TestCase): super().setUp() def create_sample_vm(self, owner): + one_month_later, parse_status = cal.parse("1 month later") return VMProduct.objects.create( vmhost=self.vm_host, cores=2, ram_in_gb=4, owner=owner, order=Order.objects.create( owner=owner, creation_date=datetime.datetime.now(tz=timezone.utc), starting_date=datetime.datetime.now(tz=timezone.utc), - ending_date=datetime.datetime(2020, 4, 2, tzinfo=timezone.utc), + ending_date=datetime.datetime(*one_month_later[:6], tzinfo=timezone.utc), recurring_price=4.0, one_time_price=5.0, recurring_period='per_month' ) ) @@ -59,7 +63,7 @@ class VMTestCase(TestCase): vm = self.create_sample_vm(owner=self.user) pending_disk_image = VMDiskImageProduct.objects.create( - owner=self.user, name='alpine3.11', is_os_image=True, is_public=True, size_in_gb=10, + owner=self.user, name='pending_disk_image', is_os_image=True, is_public=True, size_in_gb=10, status='pending' ) try: @@ -78,7 +82,7 @@ class VMTestCase(TestCase): """Ensure that a user can only create a VMDiskProduct for an existing VM""" disk_image = VMDiskImageProduct.objects.create( - owner=self.user, name='alpine3.11', is_os_image=True, is_public=True, size_in_gb=10, + owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10, status='active' ) @@ -102,7 +106,7 @@ class VMTestCase(TestCase): owner=self.user, vm=someone_else_vm, size_in_gb=10, image=VMDiskImageProduct.objects.create( - owner=self.user, name='alpine3.11', is_os_image=True, is_public=True, size_in_gb=10, + owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10, status='active' ) ) From c651c4ddaa7aca5b6e48aefb2a33520ed7a09201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Mon, 2 Mar 2020 16:41:49 +0100 Subject: [PATCH 130/193] Cleanup a bit BillRecord --- uncloud/uncloud_pay/models.py | 19 +++++++------------ uncloud/uncloud_pay/serializers.py | 1 + 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index e257b9e..9cbeb48 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -38,12 +38,7 @@ class Bill(models.Model): orders = Order.objects.filter(bill=self) for order in orders: for order_record in order.records: - bill_record = BillRecord( - self, - order_record.setup_fee, - order_record.recurring_price, - order_record.recurring_period, - order_record.description) + bill_record = BillRecord(order_record) bill_records.append(bill_record) return bill_records @@ -53,12 +48,12 @@ class Bill(models.Model): return reduce(lambda acc, record: acc + record.amount(), self.records, 0) class BillRecord(): - def __init__(self, bill, setup_fee, recurring_price, recurring_period, description): - self.bill = bill - self.setup_fee = setup_fee - self.recurring_price = recurring_price - self.recurring_period = recurring_period - self.description = description + def __init__(self, order_record): + self.order = order_record.order.uuid + self.setup_fee = order_record.setup_fee + self.recurring_price = order_record.recurring_price + self.recurring_period = order_record.recurring_period + self.description = order_record.description def amount(self): # TODO: Billing logic here! diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 976ab6b..e3ac0eb 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -9,6 +9,7 @@ from uncloud_vm.models import VMProduct # TODO: remove magic numbers for decimal fields class BillRecordSerializer(serializers.Serializer): + order = serializers.CharField() description = serializers.CharField() recurring_period = serializers.CharField() recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2) From 4ad737ed904a508601808a4623152dc76395471f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Mon, 2 Mar 2020 22:26:40 +0100 Subject: [PATCH 131/193] Initial stripe playground --- uncloud/uncloud/secrets_sample.py | 3 + uncloud/uncloud/settings.py | 5 ++ .../0013_paymentmethod_stripe_card_id.py | 18 ++++++ uncloud/uncloud_pay/models.py | 5 +- uncloud/uncloud_pay/serializers.py | 29 ++++++++- uncloud/{uncloud => uncloud_pay}/stripe.py | 59 +++++++++++++++++++ uncloud/uncloud_pay/views.py | 8 ++- 7 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0013_paymentmethod_stripe_card_id.py rename uncloud/{uncloud => uncloud_pay}/stripe.py (55%) diff --git a/uncloud/uncloud/secrets_sample.py b/uncloud/uncloud/secrets_sample.py index 36ff0df..464662f 100644 --- a/uncloud/uncloud/secrets_sample.py +++ b/uncloud/uncloud/secrets_sample.py @@ -14,4 +14,7 @@ LDAP_ADMIN_DN="" LDAP_ADMIN_PASSWORD="" LDAP_SERVER_URI = "" +# Stripe (Credit Card payments) +STRIPE_API_key="" + SECRET_KEY="dx$iqt=lc&yrp^!z5$ay^%g5lhx1y3bcu=jg(jx0yj0ogkfqvf" diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index c6c89d5..f28e0f4 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -176,3 +176,8 @@ USE_TZ = True STATIC_URL = '/static/' stripe.api_key = uncloud.secrets.STRIPE_KEY + +############ +# Stripe + +STRIPE_API_KEY = uncloud.secrets.STRIPE_API_KEY diff --git a/uncloud/uncloud_pay/migrations/0013_paymentmethod_stripe_card_id.py b/uncloud/uncloud_pay/migrations/0013_paymentmethod_stripe_card_id.py new file mode 100644 index 0000000..df7c065 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0013_paymentmethod_stripe_card_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-02 20:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0012_orderrecord'), + ] + + operations = [ + migrations.AddField( + model_name='paymentmethod', + name='stripe_card_id', + field=models.CharField(blank=True, max_length=32, null=True), + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 9cbeb48..a29dc3c 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -143,10 +143,13 @@ class PaymentMethod(models.Model): 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) + def charge(self, amount): if amount > 0: # Make sure we don't charge negative amount by errors... if self.source == 'stripe': - # TODO: wire to strip, see meooow-payv1/strip_utils.py + # TODO: wire to stripe, see meooow-payv1/strip_utils.py payment = Payment(owner=self.owner, source=self.source, amount=amount) payment.save() # TODO: Check return status diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index e3ac0eb..6c6c04e 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -7,6 +7,8 @@ from functools import reduce from uncloud_vm.serializers import VMProductSerializer from uncloud_vm.models import VMProduct +import uncloud_pay.stripe as stripe + # TODO: remove magic numbers for decimal fields class BillRecordSerializer(serializers.Serializer): order = serializers.CharField() @@ -27,10 +29,35 @@ class PaymentSerializer(serializers.ModelSerializer): model = Payment fields = ['owner', 'amount', 'source', 'timestamp'] +class CreditCardSerializer(serializers.Serializer): + number = serializers.IntegerField() + exp_month = serializers.IntegerField() + exp_year = serializers.IntegerField() + cvc = serializers.IntegerField() + class PaymentMethodSerializer(serializers.ModelSerializer): class Meta: model = PaymentMethod - fields = '__all__' + fields = ['source', 'description', 'primary'] + +class CreatePaymentMethodSerializer(serializers.ModelSerializer): + credit_card = CreditCardSerializer() + + class Meta: + model = PaymentMethod + fields = ['source', 'description', 'primary', 'credit_card'] + + def create(self, validated_data): + credit_card = stripe.CreditCard(**validated_data.pop('credit_card')) + user = self.context['request'].user + customer = stripe.create_customer(user.username, user.email) + # TODO check customer error + customer_id = customer['response_object']['id'] + stripe_card = stripe.create_card(customer_id, credit_card) + # TODO: check credit card error + validated_data['stripe_card_id'] = stripe_card['response_object']['id'] + payment_method = PaymentMethod.objects.create(**validated_data) + return payment_method class ProductSerializer(serializers.Serializer): vms = VMProductSerializer(many=True, read_only=True) diff --git a/uncloud/uncloud/stripe.py b/uncloud/uncloud_pay/stripe.py similarity index 55% rename from uncloud/uncloud/stripe.py rename to uncloud/uncloud_pay/stripe.py index ce35fd9..6399a1a 100644 --- a/uncloud/uncloud/stripe.py +++ b/uncloud/uncloud_pay/stripe.py @@ -1,5 +1,16 @@ import stripe +import stripe.error +import logging +from django.conf import settings + +# Static stripe configuration used below. +CURRENCY = 'chf' + +# Register stripe (secret) API key from config. +stripe.api_key = settings.STRIPE_API_KEY + +# Helper (decorator) used to catch errors raised by stripe logic. def handle_stripe_error(f): def handle_problems(*args, **kwargs): response = { @@ -53,3 +64,51 @@ def handle_stripe_error(f): 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. + +@handle_stripe_error +def create_card(customer_id, credit_card): + # Test settings + credit_card.number = "5555555555554444" + + 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.Card.retrieve_source(customer_id, card_id) + +@handle_stripe_error +def charge_customer(amount, source): + return stripe.Charge.create( + amount=amount, + currenty=CURRENCY, + source=source) + +@handle_stripe_error +def create_customer(name, email): + return stripe.Customer.create(name=name, email=email) + +@handle_stripe_error +def get_customer(customer_id): + return stripe.Customer.retrieve(customer_id) diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 9ed57c8..aaee9de 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -57,9 +57,15 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): return get_user_model().objects.all() class PaymentMethodViewSet(viewsets.ModelViewSet): - serializer_class = PaymentMethodSerializer permission_classes = [permissions.IsAuthenticated] + def get_serializer_class(self): + if self.action == 'create': + return CreatePaymentMethodSerializer + else: + return PaymentMethodSerializer + + def get_queryset(self): return PaymentMethod.objects.filter(owner=self.request.user) From 4e51670a901ac28bf2d1d0691caa984afb293b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 08:53:19 +0100 Subject: [PATCH 132/193] Expand recurring period billing logic for DD/MM/hh/month --- uncloud/uncloud_pay/models.py | 69 ++++++++++++++++++++++++++++-- uncloud/uncloud_pay/serializers.py | 2 +- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index a29dc3c..3be3c2c 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -4,6 +4,9 @@ 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 math import ceil +from datetime import timedelta +from calendar import monthrange import uuid @@ -38,7 +41,7 @@ class Bill(models.Model): orders = Order.objects.filter(bill=self) for order in orders: for order_record in order.records: - bill_record = BillRecord(order_record) + bill_record = BillRecord(self, order_record) bill_records.append(bill_record) return bill_records @@ -47,18 +50,66 @@ class Bill(models.Model): 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() + class BillRecord(): - def __init__(self, order_record): - self.order = order_record.order.uuid + def __init__(self, bill, order_record): + self.bill = bill + self.order = order_record.order self.setup_fee = order_record.setup_fee self.recurring_price = order_record.recurring_price self.recurring_period = order_record.recurring_period self.description = order_record.description def amount(self): - # TODO: Billing logic here! + # 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) + adjusted_recurring_price = self.recurring_price / days_in_month + recurring_price = adjusted_recurring_price * days + return self.recurring_price # TODO + elif self.recurring_period == RecurringPeriod.PER_DAY: + days = ceil(billed_delta / timedelta(days=1)) + return self.recurring_price * days + elif self.recurring_period == RecurringPeriod.PER_HOUR: + hours = ceil(billed_delta / timedelta(hours=1)) + return self.recurring_price * hours + elif self.recurring_period == RecurringPeriod.PER_SECOND: + seconds = ceil(billed_delta / timedelta(seconds=1)) + return self.recurring_price * seconds else: raise Exception('Unsupported recurring period: {}.'. format(record.recurring_period)) @@ -75,12 +126,14 @@ class BillRecord(): # agree on deal => That's what we want to keep archived. # # /!\ BIG FAT WARNING /!\ # + 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, @@ -129,6 +182,14 @@ class OrderRecord(models.Model): 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 + class PaymentMethod(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 6c6c04e..d523b7a 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -22,7 +22,7 @@ class BillSerializer(serializers.ModelSerializer): class Meta: model = Bill fields = ['owner', 'total', 'due_date', 'creation_date', - 'starting_date', 'ending_date', 'records'] + 'starting_date', 'ending_date', 'records', 'final'] class PaymentSerializer(serializers.ModelSerializer): class Meta: From 5559d600c7f36e374a440fecd4d76a07ed58008d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 09:13:04 +0100 Subject: [PATCH 133/193] Move things around for readability in uncloud_pay models and serializer --- uncloud/uncloud_pay/models.py | 131 +++++++++++++++++------------ uncloud/uncloud_pay/serializers.py | 93 ++++++++++++++------ 2 files changed, 141 insertions(+), 83 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 3be3c2c..52e5281 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -10,6 +10,7 @@ from calendar import monthrange import uuid +# Define DecimalField properties, used to represent amounts of money. AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 @@ -23,6 +24,70 @@ class RecurringPeriod(models.TextChoices): PER_HOUR = 'HOUR', _('Per Hour') PER_SECOND = 'SECOND', _('Per Second') +### +# 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) + +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) + + def charge(self, amount): + if amount > 0: # Make sure we don't charge negative amount by errors... + if self.source == 'stripe': + # TODO: wire to stripe, see meooow-payv1/strip_utils.py + payment = Payment(owner=self.owner, source=self.source, amount=amount) + payment.save() # TODO: Check return status + + return True + else: + # We do not handle that source yet. + return False + else: + return False + + 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(), @@ -56,6 +121,10 @@ class Bill(models.Model): return self.ending_date < timezone.now() 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 @@ -114,6 +183,9 @@ class BillRecord(): raise Exception('Unsupported recurring period: {}.'. format(record.recurring_period)) +### +# Orders. + # /!\ BIG FAT WARNING /!\ # # # Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating @@ -190,63 +262,12 @@ class OrderRecord(models.Model): def ending_date(self): return self.order.ending_date -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) - - def charge(self, amount): - if amount > 0: # Make sure we don't charge negative amount by errors... - if self.source == 'stripe': - # TODO: wire to stripe, see meooow-payv1/strip_utils.py - payment = Payment(owner=self.owner, source=self.source, amount=amount) - payment.save() # TODO: Check return status - - return True - else: - # We do not handle that source yet. - return False - else: - return False - - class Meta: - unique_together = [['owner', 'primary']] - -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) +### +# Products +# Abstract (= no database representation) class used as parent for products +# (e.g. uncloud_vm.models.VMProduct). class Product(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index d523b7a..6e4b2d3 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -9,36 +9,62 @@ from uncloud_vm.models import VMProduct import uncloud_pay.stripe as stripe -# TODO: remove magic numbers for decimal fields -class BillRecordSerializer(serializers.Serializer): - order = serializers.CharField() - description = serializers.CharField() - recurring_period = serializers.CharField() - recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2) - amount = serializers.DecimalField(max_digits=10, decimal_places=2) +### +# Users. -class BillSerializer(serializers.ModelSerializer): - records = BillRecordSerializer(many=True, read_only=True) +class UserSerializer(serializers.ModelSerializer): class Meta: - model = Bill - fields = ['owner', 'total', 'due_date', 'creation_date', - 'starting_date', 'ending_date', 'records', 'final'] + model = get_user_model() + fields = ['username', 'email', 'balance'] + + # Display current 'balance' + balance = serializers.SerializerMethodField('get_balance') + def __sum_balance(self, entries): + return reduce(lambda acc, entry: acc + entry.amount, entries, 0) + + def get_balance(self, user): + return get_balance_for(user) + +### +# Payments and Payment Methods. class PaymentSerializer(serializers.ModelSerializer): class Meta: model = Payment fields = ['owner', 'amount', 'source', 'timestamp'] +class PaymentMethodSerializer(serializers.ModelSerializer): + class Meta: + model = PaymentMethod + fields = ['source', 'description', 'primary'] + class CreditCardSerializer(serializers.Serializer): number = serializers.IntegerField() exp_month = serializers.IntegerField() exp_year = serializers.IntegerField() cvc = serializers.IntegerField() -class PaymentMethodSerializer(serializers.ModelSerializer): +class CreatePaymentMethodSerializer(serializers.ModelSerializer): + credit_card = CreditCardSerializer() + class Meta: model = PaymentMethod - fields = ['source', 'description', 'primary'] + fields = ['source', 'description', 'primary', 'credit_card'] + + def create(self, validated_data): + credit_card = stripe.CreditCard(**validated_data.pop('credit_card')) + user = self.context['request'].user + customer = stripe.create_customer(user.username, user.email) + # TODO check customer error + customer_id = customer['response_object']['id'] + stripe_card = stripe.create_card(customer_id, credit_card) + # TODO: check credit card error + validated_data['stripe_card_id'] = stripe_card['response_object']['id'] +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() @@ -58,15 +84,36 @@ class CreatePaymentMethodSerializer(serializers.ModelSerializer): validated_data['stripe_card_id'] = stripe_card['response_object']['id'] payment_method = PaymentMethod.objects.create(**validated_data) return payment_method + payment_method = PaymentMethod.objects.create(**validated_data) + return payment_method -class ProductSerializer(serializers.Serializer): - vms = VMProductSerializer(many=True, read_only=True) +### +# Bills + +# TODO: remove magic numbers for decimal fields +class BillRecordSerializer(serializers.Serializer): + order = serializers.CharField() + description = serializers.CharField() + recurring_period = serializers.CharField() + recurring_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 = ['owner', 'total', 'due_date', 'creation_date', + 'starting_date', 'ending_date', 'records', 'final'] + +### +# Orders & Products. class OrderRecordSerializer(serializers.ModelSerializer): class Meta: model = OrderRecord fields = ['setup_fee', 'recurring_price', 'description'] + class OrderSerializer(serializers.ModelSerializer): records = OrderRecordSerializer(many=True, read_only=True) class Meta: @@ -74,15 +121,5 @@ class OrderSerializer(serializers.ModelSerializer): fields = ['uuid', 'creation_date', 'starting_date', 'ending_date', 'bill', 'recurring_period', 'records', 'recurring_price', 'setup_fee'] -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = get_user_model() - fields = ['username', 'email', 'balance'] - - # Display current 'balance' - balance = serializers.SerializerMethodField('get_balance') - def __sum_balance(self, entries): - return reduce(lambda acc, entry: acc + entry.amount, entries, 0) - - def get_balance(self, user): - return get_balance_for(user) +class ProductSerializer(serializers.Serializer): + vms = VMProductSerializer(many=True, read_only=True) From b31aa72f8405f72a4a00b86072d3f8bf8dc52996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 10:14:56 +0100 Subject: [PATCH 134/193] Allow to select billing period when registering VM --- uncloud/uncloud_pay/models.py | 4 ++++ uncloud/uncloud_vm/models.py | 4 +++- uncloud/uncloud_vm/serializers.py | 12 +++++++++++- uncloud/uncloud_vm/views.py | 10 +++++++--- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 52e5281..f4bd4f0 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -299,5 +299,9 @@ class Product(models.Model): def setup_fee(self): return 0 + @property + def recurring_period(self): + return self.order.recurring_period + class Meta: abstract = True diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index c32f3a5..7732964 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -47,9 +47,11 @@ class VMProduct(Product): ram_in_gb = models.FloatField() def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): + # TODO: move magic numbers in variables if recurring_period == RecurringPeriod.PER_MONTH: - # TODO: move magic numbers in variables return self.cores * 3 + self.ram_in_gb * 2 + elif recurring_period == RecurringPeriod.PER_HOUR: + return self.cores * 4.0/(30 * 24) + self.ram_in_gb * 3.0/(30* 24) else: raise Exception('Invalid recurring period for VM Product pricing.') diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 4257a03..daf36ab 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import VMHost, VMProduct, VMSnapshotProduct +from uncloud_pay.models import RecurringPeriod class VMHostSerializer(serializers.HyperlinkedModelSerializer): class Meta: @@ -10,10 +11,19 @@ class VMHostSerializer(serializers.HyperlinkedModelSerializer): class VMProductSerializer(serializers.HyperlinkedModelSerializer): + # TODO: move this to VMProduct. + allowed_recurring_periods=list(filter( + lambda pair: pair[0] in [RecurringPeriod.PER_MONTH, RecurringPeriod.PER_HOUR], + RecurringPeriod.choices)) + + # Custom field used at creation (= ordering) only. + recurring_period = serializers.ChoiceField( + choices=allowed_recurring_periods) + class Meta: model = VMProduct fields = ['uuid', 'order', 'owner', 'status', 'name', \ - 'cores', 'ram_in_gb'] + 'cores', 'ram_in_gb', 'recurring_period'] read_only_fields = ['uuid', 'order', 'owner', 'status'] class VMSnapshotProductSerializer(serializers.ModelSerializer): diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 5de904c..107f23e 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -32,19 +32,23 @@ class VMProductViewSet(ProductViewSet): # if something goes wrong. @transaction.atomic def create(self, request): + # Extract serializer data. + serializer = VMProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + order_recurring_period = serializer.validated_data.pop("recurring_period") + # Create base order. order = Order.objects.create( - recurring_period=RecurringPeriod.PER_MONTH, + recurring_period=order_recurring_period, owner=request.user ) order.save() # Create VM. - serializer = VMProductSerializer(data=request.data, context={'request': request}) - serializer.is_valid(raise_exception=True) vm = serializer.save(owner=request.user, order=order) # Add Product record to order (VM is mutable, allows to keep history in order). + # XXX: Move this to some kind of on_create hook in parent Product class? order.add_record(vm.setup_fee, vm.recurring_price(order.recurring_period), vm.description) From 9fdf66ed744192a2d25164bdfec45719423b0420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 10:51:16 +0100 Subject: [PATCH 135/193] Fix MatrixService ordering --- uncloud/uncloud_pay/models.py | 4 +++ uncloud/uncloud_pay/views.py | 1 - uncloud/uncloud_vm/models.py | 6 ++++ uncloud/uncloud_vm/serializers.py | 16 +++++---- uncloud/ungleich_service/models.py | 12 +++++-- uncloud/ungleich_service/serializers.py | 17 +++++---- uncloud/ungleich_service/views.py | 48 +++++++++++++++++++++++-- 7 files changed, 83 insertions(+), 21 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index f4bd4f0..8964cb3 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -303,5 +303,9 @@ class Product(models.Model): def recurring_period(self): return self.order.recurring_period + @staticmethod + def allowed_recurring_periods(): + return RecurringPeriod.choices + class Meta: abstract = True diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index aaee9de..936d4c7 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -90,7 +90,6 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): else: return Response(status=status.HTTP_500_INTERNAL_ERROR) - ### # Admin views. diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 7732964..2f048ec 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -60,6 +60,12 @@ class VMProduct(Product): return "Virtual machine '{}': {} core(s), {}GB memory".format( self.name, self.cores, self.ram_in_gb) + @staticmethod + def allowed_recurring_periods(): + return list(filter( + lambda pair: pair[0] in [RecurringPeriod.PER_MONTH, RecurringPeriod.PER_HOUR], + RecurringPeriod.choices)) + class VMWithOSProduct(VMProduct): pass diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index daf36ab..490a8d2 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -11,14 +11,9 @@ class VMHostSerializer(serializers.HyperlinkedModelSerializer): class VMProductSerializer(serializers.HyperlinkedModelSerializer): - # TODO: move this to VMProduct. - allowed_recurring_periods=list(filter( - lambda pair: pair[0] in [RecurringPeriod.PER_MONTH, RecurringPeriod.PER_HOUR], - RecurringPeriod.choices)) - # Custom field used at creation (= ordering) only. recurring_period = serializers.ChoiceField( - choices=allowed_recurring_periods) + choices=VMProduct.allowed_recurring_periods()) class Meta: model = VMProduct @@ -26,6 +21,15 @@ class VMProductSerializer(serializers.HyperlinkedModelSerializer): 'cores', 'ram_in_gb', 'recurring_period'] read_only_fields = ['uuid', 'order', 'owner', 'status'] +class ManagedVMProductSerializer(serializers.ModelSerializer): + """ + Managed VM serializer used in ungleich_service app. + """ + class Meta: + model = VMProduct + fields = [ 'cores', 'ram_in_gb'] + + class VMSnapshotProductSerializer(serializers.ModelSerializer): class Meta: model = VMSnapshotProduct diff --git a/uncloud/ungleich_service/models.py b/uncloud/ungleich_service/models.py index 0e84f62..8f95973 100644 --- a/uncloud/ungleich_service/models.py +++ b/uncloud/ungleich_service/models.py @@ -6,7 +6,6 @@ from uncloud_vm.models import VMProduct class MatrixServiceProduct(Product): monthly_managment_fee = 20 - setup_fee = 30 description = "Managed Matrix HomeServer" @@ -18,9 +17,16 @@ class MatrixServiceProduct(Product): def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): if recurring_period == RecurringPeriod.PER_MONTH: - return monthly_managment_fee + vm.recurring_price(RecurringPeriod.PER_MONTH) + return self.monthly_managment_fee else: raise Exception('Invalid recurring period for VM Product pricing.') + @staticmethod + def allowed_recurring_periods(): + return list(filter( + lambda pair: pair[0] in [RecurringPeriod.PER_MONTH], + RecurringPeriod.choices)) + + @property def setup_fee(self): - return setup_fee + return 30 diff --git a/uncloud/ungleich_service/serializers.py b/uncloud/ungleich_service/serializers.py index 0c34dcf..b4038b7 100644 --- a/uncloud/ungleich_service/serializers.py +++ b/uncloud/ungleich_service/serializers.py @@ -1,18 +1,17 @@ from rest_framework import serializers from .models import MatrixServiceProduct -from uncloud_vm.serializers import VMProductSerializer +from uncloud_vm.serializers import ManagedVMProductSerializer from uncloud_vm.models import VMProduct +from uncloud_pay.models import RecurringPeriod class MatrixServiceProductSerializer(serializers.ModelSerializer): - vm = VMProductSerializer() + vm = ManagedVMProductSerializer() + + # Custom field used at creation (= ordering) only. + recurring_period = serializers.ChoiceField( + choices=MatrixServiceProduct.allowed_recurring_periods()) class Meta: model = MatrixServiceProduct - fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain'] + fields = ['uuid', 'order', 'owner', 'status', 'vm', 'domain', 'recurring_period'] read_only_fields = ['uuid', 'order', 'owner', 'status'] - - def create(self, validated_data): - # Create VM - vm_data = validated_data.pop('vm') - vm = VMProduct.objects.create(**vm_data) - return MatrixServiceProduct.create(vm=vm, **validated_data) diff --git a/uncloud/ungleich_service/views.py b/uncloud/ungleich_service/views.py index a8de2e0..d5191a2 100644 --- a/uncloud/ungleich_service/views.py +++ b/uncloud/ungleich_service/views.py @@ -1,9 +1,13 @@ from rest_framework import viewsets, permissions +from rest_framework.response import Response +from django.db import transaction from .models import MatrixServiceProduct from .serializers import MatrixServiceProductSerializer from uncloud_pay.helpers import ProductViewSet +from uncloud_pay.models import Order +from uncloud_vm.models import VMProduct class MatrixServiceProductViewSet(ProductViewSet): permission_classes = [permissions.IsAuthenticated] @@ -12,6 +16,46 @@ class MatrixServiceProductViewSet(ProductViewSet): def get_queryset(self): return MatrixServiceProduct.objects.filter(owner=self.request.user) + @transaction.atomic def create(self, request): - # TODO: create order, register service - return Response('{"HIT!"}') + # Extract serializer data. + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + order_recurring_period = serializer.validated_data.pop("recurring_period") + + # Create base order. + order = Order.objects.create( + recurring_period=order_recurring_period, + owner=request.user + ) + order.save() + + # Create unerderlying VM. + # TODO: move this logic to a method for use with other + # products. + vm_data = serializer.validated_data.pop('vm') + vm_data['owner'] = request.user + vm_data['order'] = order + vm = VMProduct.objects.create(**vm_data) + + # XXX: Move this to some kind of on_create hook in parent + # Product class? + order.add_record( + vm.setup_fee, + vm.recurring_price(order.recurring_period), + vm.description) + + # Create service. + service = serializer.save( + order=order, + owner=self.request.user, + vm=vm) + + # XXX: Move this to some kind of on_create hook in parent + # Product class? + order.add_record( + service.setup_fee, + service.recurring_price(order.recurring_period), + service.description) + + return Response(serializer.data) From 2eaaad49db737157f8a86682e78cbc3f0c4c6b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 10:59:21 +0100 Subject: [PATCH 136/193] Handle setup fee in bills --- uncloud/uncloud_pay/models.py | 15 +++++++++------ uncloud/uncloud_pay/serializers.py | 1 + 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 8964cb3..24cc858 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -167,22 +167,25 @@ class BillRecord(): self.bill.starting_date.year, self.bill.starting_date.month) adjusted_recurring_price = self.recurring_price / days_in_month - recurring_price = adjusted_recurring_price * days - - return self.recurring_price # TODO + amount = adjusted_recurring_price * days elif self.recurring_period == RecurringPeriod.PER_DAY: days = ceil(billed_delta / timedelta(days=1)) - return self.recurring_price * days + amount = self.recurring_price * days elif self.recurring_period == RecurringPeriod.PER_HOUR: hours = ceil(billed_delta / timedelta(hours=1)) - return self.recurring_price * hours + amount = self.recurring_price * hours elif self.recurring_period == RecurringPeriod.PER_SECOND: seconds = ceil(billed_delta / timedelta(seconds=1)) - return self.recurring_price * seconds + amount = self.recurring_price * seconds else: raise Exception('Unsupported recurring period: {}.'. format(record.recurring_period)) + if self.order.starting_date > self.bill.starting_date: + amount += self.setup_fee + + return amount + ### # Orders. diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 6e4b2d3..fcbaf73 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -96,6 +96,7 @@ class BillRecordSerializer(serializers.Serializer): description = serializers.CharField() recurring_period = serializers.CharField() recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2) + setup_fee = serializers.DecimalField(max_digits=10, decimal_places=2) amount = serializers.DecimalField(max_digits=10, decimal_places=2) class BillSerializer(serializers.ModelSerializer): From a40da401692a0e8591984cc38c21401e1445d986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 11:15:48 +0100 Subject: [PATCH 137/193] Add recurring_count to bills --- uncloud/uncloud_pay/models.py | 18 +++++++++++------- uncloud/uncloud_pay/serializers.py | 1 + 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 24cc858..4e5770a 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -8,6 +8,7 @@ from math import ceil from datetime import timedelta from calendar import monthrange +from decimal import Decimal import uuid # Define DecimalField properties, used to represent amounts of money. @@ -113,7 +114,7 @@ class Bill(models.Model): @property def total(self): - return reduce(lambda acc, record: acc + record.amount(), self.records, 0) + return reduce(lambda acc, record: acc + record.amount, self.records, 0) @property def final(self): @@ -133,7 +134,8 @@ class BillRecord(): self.recurring_period = order_record.recurring_period self.description = order_record.description - def amount(self): + @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: @@ -166,21 +168,23 @@ class BillRecord(): (_, days_in_month) = monthrange( self.bill.starting_date.year, self.bill.starting_date.month) - adjusted_recurring_price = self.recurring_price / days_in_month - amount = adjusted_recurring_price * days + return Decimal(days / days_in_month) elif self.recurring_period == RecurringPeriod.PER_DAY: days = ceil(billed_delta / timedelta(days=1)) - amount = self.recurring_price * days + return Decimal(days) elif self.recurring_period == RecurringPeriod.PER_HOUR: hours = ceil(billed_delta / timedelta(hours=1)) - amount = self.recurring_price * hours + return Decimal(hours) elif self.recurring_period == RecurringPeriod.PER_SECOND: seconds = ceil(billed_delta / timedelta(seconds=1)) - amount = self.recurring_price * seconds + return Decimal(seconds) else: raise Exception('Unsupported recurring period: {}.'. format(record.recurring_period)) + @property + def amount(self): + amount = self.recurring_count * self.recurring_price if self.order.starting_date > self.bill.starting_date: amount += self.setup_fee diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index fcbaf73..051b882 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -96,6 +96,7 @@ class BillRecordSerializer(serializers.Serializer): 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) setup_fee = serializers.DecimalField(max_digits=10, decimal_places=2) amount = serializers.DecimalField(max_digits=10, decimal_places=2) From e176ad08176aa25ad0a237a226eafeb9830b8875 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 3 Mar 2020 11:26:16 +0100 Subject: [PATCH 138/193] Remove second stripe key definition --- uncloud/uncloud/settings.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index f28e0f4..c6c89d5 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -176,8 +176,3 @@ USE_TZ = True STATIC_URL = '/static/' stripe.api_key = uncloud.secrets.STRIPE_KEY - -############ -# Stripe - -STRIPE_API_KEY = uncloud.secrets.STRIPE_API_KEY From 11e22f5001cf7469f5a28974b411a566f3740509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 11:27:35 +0100 Subject: [PATCH 139/193] Consistently use one_time_price instead of setup_fee --- .../migrations/0014_auto_20200303_1027.py | 18 +++++++++++++ uncloud/uncloud_pay/models.py | 26 ++++++++++--------- uncloud/uncloud_pay/serializers.py | 6 ++--- uncloud/uncloud_vm/views.py | 2 +- uncloud/ungleich_service/models.py | 2 +- uncloud/ungleich_service/views.py | 4 +-- 6 files changed, 39 insertions(+), 19 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0014_auto_20200303_1027.py diff --git a/uncloud/uncloud_pay/migrations/0014_auto_20200303_1027.py b/uncloud/uncloud_pay/migrations/0014_auto_20200303_1027.py new file mode 100644 index 0000000..05759d1 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0014_auto_20200303_1027.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-03 10:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0013_paymentmethod_stripe_card_id'), + ] + + operations = [ + migrations.RenameField( + model_name='orderrecord', + old_name='setup_fee', + new_name='one_time_price', + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 4e5770a..551b96d 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -129,11 +129,15 @@ class BillRecord(): def __init__(self, bill, order_record): self.bill = bill self.order = order_record.order - self.setup_fee = order_record.setup_fee 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 = one_time_price + else: + self.one_time_price = 0 + @property def recurring_count(self): # Compute billing delta. @@ -178,17 +182,15 @@ class BillRecord(): 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): - amount = self.recurring_count * self.recurring_price - if self.order.starting_date > self.bill.starting_date: - amount += self.setup_fee - - return amount + return self.recurring_price * self.recurring_count + self.one_time_price ### # Orders. @@ -231,22 +233,22 @@ class Order(models.Model): return OrderRecord.objects.filter(order=self) @property - def setup_fee(self): - return reduce(lambda acc, record: acc + record.setup_fee, self.records, 0) + 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, setup_fee, recurring_price, description): + def add_record(self, one_time_price, recurring_price, description): OrderRecord.objects.create(order=self, - setup_fee=setup_fee, + one_time_price=one_time_price, recurring_price=recurring_price, description=description) class OrderRecord(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE) - setup_fee = models.DecimalField(default=0.0, + one_time_price = models.DecimalField(default=0.0, max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS, validators=[MinValueValidator(0)]) @@ -303,7 +305,7 @@ class Product(models.Model): pass # To be implemented in child. @property - def setup_fee(self): + def one_time_price(self): return 0 @property diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 051b882..16b725a 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -97,7 +97,7 @@ class BillRecordSerializer(serializers.Serializer): recurring_period = serializers.CharField() recurring_price = serializers.DecimalField(max_digits=10, decimal_places=2) recurring_count = serializers.DecimalField(max_digits=10, decimal_places=2) - setup_fee = 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): @@ -113,7 +113,7 @@ class BillSerializer(serializers.ModelSerializer): class OrderRecordSerializer(serializers.ModelSerializer): class Meta: model = OrderRecord - fields = ['setup_fee', 'recurring_price', 'description'] + fields = ['one_time_price', 'recurring_price', 'description'] class OrderSerializer(serializers.ModelSerializer): @@ -121,7 +121,7 @@ class OrderSerializer(serializers.ModelSerializer): class Meta: model = Order fields = ['uuid', 'creation_date', 'starting_date', 'ending_date', - 'bill', 'recurring_period', 'records', 'recurring_price', 'setup_fee'] + 'bill', 'recurring_period', 'records', 'recurring_price', 'one_time_price'] class ProductSerializer(serializers.Serializer): vms = VMProductSerializer(many=True, read_only=True) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 107f23e..d9a5732 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -49,7 +49,7 @@ class VMProductViewSet(ProductViewSet): # Add Product record to order (VM is mutable, allows to keep history in order). # XXX: Move this to some kind of on_create hook in parent Product class? - order.add_record(vm.setup_fee, + order.add_record(vm.one_time_price, vm.recurring_price(order.recurring_period), vm.description) return Response(serializer.data) diff --git a/uncloud/ungleich_service/models.py b/uncloud/ungleich_service/models.py index 8f95973..9d6a8ac 100644 --- a/uncloud/ungleich_service/models.py +++ b/uncloud/ungleich_service/models.py @@ -28,5 +28,5 @@ class MatrixServiceProduct(Product): RecurringPeriod.choices)) @property - def setup_fee(self): + def one_time_price(self): return 30 diff --git a/uncloud/ungleich_service/views.py b/uncloud/ungleich_service/views.py index d5191a2..47c15e2 100644 --- a/uncloud/ungleich_service/views.py +++ b/uncloud/ungleich_service/views.py @@ -41,7 +41,7 @@ class MatrixServiceProductViewSet(ProductViewSet): # XXX: Move this to some kind of on_create hook in parent # Product class? order.add_record( - vm.setup_fee, + vm.one_time_price, vm.recurring_price(order.recurring_period), vm.description) @@ -54,7 +54,7 @@ class MatrixServiceProductViewSet(ProductViewSet): # XXX: Move this to some kind of on_create hook in parent # Product class? order.add_record( - service.setup_fee, + service.one_time_price, service.recurring_price(order.recurring_period), service.description) From 53baf0d9f39a7a8679f94eb314507ea7e09fb20c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 11:29:57 +0100 Subject: [PATCH 140/193] Fix typo in BillRecord --- uncloud/uncloud_pay/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 551b96d..f4b8fdd 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -134,7 +134,7 @@ class BillRecord(): self.description = order_record.description if self.order.starting_date > self.bill.starting_date: - self.one_time_price = one_time_price + self.one_time_price = order_record.one_time_price else: self.one_time_price = 0 From ea00e81b1e99d6b7f6b207447b0c2157a0c40ca3 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 3 Mar 2020 11:31:32 +0100 Subject: [PATCH 141/193] Move all stripe stuff to stripe.py --- uncloud/uncloud/settings.py | 4 ---- uncloud/uncloud_pay/stripe.py | 5 ++--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index c6c89d5..cc0ec3a 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -11,8 +11,6 @@ https://docs.djangoproject.com/en/3.0/ref/settings/ """ import os - -import stripe import ldap # Uncommitted file with secrets @@ -174,5 +172,3 @@ USE_TZ = True # https://docs.djangoproject.com/en/3.0/howto/static-files/ STATIC_URL = '/static/' - -stripe.api_key = uncloud.secrets.STRIPE_KEY diff --git a/uncloud/uncloud_pay/stripe.py b/uncloud/uncloud_pay/stripe.py index 6399a1a..c50317f 100644 --- a/uncloud/uncloud_pay/stripe.py +++ b/uncloud/uncloud_pay/stripe.py @@ -2,13 +2,12 @@ import stripe import stripe.error import logging -from django.conf import settings +import uncloud.secrets # Static stripe configuration used below. CURRENCY = 'chf' -# Register stripe (secret) API key from config. -stripe.api_key = settings.STRIPE_API_KEY +stripe.api_key = uncloud.secrets.STRIPE_KEY # Helper (decorator) used to catch errors raised by stripe logic. def handle_stripe_error(f): From 28407bf3e33723f2b1fda9ab44057b13e2012e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 11:34:47 +0100 Subject: [PATCH 142/193] Quickly document OrderRecord class --- uncloud/uncloud_pay/models.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index f4b8fdd..62fa098 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -195,19 +195,8 @@ class BillRecord(): ### # Orders. -# /!\ BIG FAT WARNING /!\ # -# # Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating # bills. Do **NOT** mutate then! -# -# Why? We need to store the state somewhere since product are mutable (e.g. -# adding RAM to VM, changing price of 1GB of RAM, ...). An alternative could -# have been to only store the state in bills but would have been more -# confusing: the order is a 'contract' with the customer, were both parts -# agree on deal => That's what we want to keep archived. -# -# /!\ BIG FAT WARNING /!\ # - class Order(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), @@ -247,6 +236,14 @@ class Order(models.Model): 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, From 3846e493954db1435f82b1673c61f28e52b16a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 11:40:37 +0100 Subject: [PATCH 143/193] Fix migration issue introduced in previous merge --- uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py b/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py index 8867f2f..8b56a8b 100644 --- a/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py +++ b/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py @@ -10,11 +10,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='vmnetworkcard', - name='ip_address', - field=models.GenericIPAddressField(blank=True, null=True), - ), migrations.AddField( model_name='vmproduct', name='status', From e9ef2acb06fc6b03d6c5dbbd4ad38fe0be4c1449 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 3 Mar 2020 12:15:05 +0100 Subject: [PATCH 144/193] Add readme for objects --- uncloud/README-object-relations.md | 58 ++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 uncloud/README-object-relations.md diff --git a/uncloud/README-object-relations.md b/uncloud/README-object-relations.md new file mode 100644 index 0000000..7bbc11a --- /dev/null +++ b/uncloud/README-object-relations.md @@ -0,0 +1,58 @@ +## 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 + +## 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) From 5c2d2a5b942ae498aff3504e29ef699c870957ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 13:14:51 +0100 Subject: [PATCH 145/193] Document relations for Orders and Managed Services --- uncloud/README-object-relations.md | 34 +++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/uncloud/README-object-relations.md b/uncloud/README-object-relations.md index 7bbc11a..58f2413 100644 --- a/uncloud/README-object-relations.md +++ b/uncloud/README-object-relations.md @@ -19,16 +19,29 @@ stack VM" can be a VM + a disk + an IPv4 address). 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 +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 @@ -39,12 +52,10 @@ 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) @@ -56,3 +67,16 @@ VMSnapshotproduct sets the order to deleted. ### 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 From 94a39ed81de5094cca2d4f6afac4c5d4700ef54b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 16:55:56 +0100 Subject: [PATCH 146/193] Properly wire stripe card to payment methods --- .../migrations/0015_stripecustomer.py | 24 ++++++++++++ .../migrations/0016_auto_20200303_1552.py | 25 ++++++++++++ uncloud/uncloud_pay/models.py | 20 ++++++++++ uncloud/uncloud_pay/serializers.py | 39 ++----------------- uncloud/uncloud_pay/stripe.py | 24 ++++++++++-- uncloud/uncloud_pay/views.py | 28 +++++++++++-- 6 files changed, 117 insertions(+), 43 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0015_stripecustomer.py create mode 100644 uncloud/uncloud_pay/migrations/0016_auto_20200303_1552.py diff --git a/uncloud/uncloud_pay/migrations/0015_stripecustomer.py b/uncloud/uncloud_pay/migrations/0015_stripecustomer.py new file mode 100644 index 0000000..14fdbf0 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0015_stripecustomer.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.3 on 2020-03-03 13:56 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0014_auto_20200303_1027'), + ] + + operations = [ + migrations.CreateModel( + name='StripeCustomer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stripe_id', models.CharField(max_length=32)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0016_auto_20200303_1552.py b/uncloud/uncloud_pay/migrations/0016_auto_20200303_1552.py new file mode 100644 index 0000000..08e3f2f --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0016_auto_20200303_1552.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.3 on 2020-03-03 15:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0015_stripecustomer'), + ] + + operations = [ + migrations.RemoveField( + model_name='stripecustomer', + name='id', + ), + migrations.AlterField( + model_name='stripecustomer', + name='owner', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 62fa098..fa775fc 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -7,6 +7,7 @@ from django.utils import timezone from math import ceil from datetime import timedelta from calendar import monthrange +import uncloud_pay.stripe from decimal import Decimal import uuid @@ -68,6 +69,20 @@ class PaymentMethod(models.Model): # 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': @@ -85,6 +100,11 @@ class PaymentMethod(models.Model): class Meta: unique_together = [['owner', 'primary']] +class StripeCustomer(models.Model): + owner = models.OneToOneField( get_user_model(), + primary_key=True, + on_delete=models.CASCADE) + stripe_id = models.CharField(max_length=32) ### # Bills & Payments. diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 16b725a..f5136f6 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -34,9 +34,11 @@ class PaymentSerializer(serializers.ModelSerializer): fields = ['owner', 'amount', 'source', 'timestamp'] class PaymentMethodSerializer(serializers.ModelSerializer): + stripe_card_last4 = serializers.IntegerField() + class Meta: model = PaymentMethod - fields = ['source', 'description', 'primary'] + fields = ['source', 'description', 'primary', 'stripe_card_last4'] class CreditCardSerializer(serializers.Serializer): number = serializers.IntegerField() @@ -51,41 +53,6 @@ class CreatePaymentMethodSerializer(serializers.ModelSerializer): model = PaymentMethod fields = ['source', 'description', 'primary', 'credit_card'] - def create(self, validated_data): - credit_card = stripe.CreditCard(**validated_data.pop('credit_card')) - user = self.context['request'].user - customer = stripe.create_customer(user.username, user.email) - # TODO check customer error - customer_id = customer['response_object']['id'] - stripe_card = stripe.create_card(customer_id, credit_card) - # TODO: check credit card error - validated_data['stripe_card_id'] = stripe_card['response_object']['id'] -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'] - - def create(self, validated_data): - credit_card = stripe.CreditCard(**validated_data.pop('credit_card')) - user = self.context['request'].user - customer = stripe.create_customer(user.username, user.email) - # TODO check customer error - customer_id = customer['response_object']['id'] - stripe_card = stripe.create_card(customer_id, credit_card) - # TODO: check credit card error - validated_data['stripe_card_id'] = stripe_card['response_object']['id'] - payment_method = PaymentMethod.objects.create(**validated_data) - return payment_method - payment_method = PaymentMethod.objects.create(**validated_data) - return payment_method ### # Bills diff --git a/uncloud/uncloud_pay/stripe.py b/uncloud/uncloud_pay/stripe.py index c50317f..ab2d865 100644 --- a/uncloud/uncloud_pay/stripe.py +++ b/uncloud/uncloud_pay/stripe.py @@ -2,6 +2,9 @@ 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. @@ -79,11 +82,24 @@ class CreditCard(): # 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): - # Test settings - credit_card.number = "5555555555554444" - return stripe.Customer.create_source( customer_id, card={ @@ -95,7 +111,7 @@ def create_card(customer_id, credit_card): @handle_stripe_error def get_card(customer_id, card_id): - return stripe.Card.retrieve_source(customer_id, card_id) + return stripe.Customer.retrieve_source(customer_id, card_id) @handle_stripe_error def charge_customer(amount, source): diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 936d4c7..294b518 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -1,4 +1,5 @@ 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 @@ -69,13 +70,34 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): 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) - serializer.save(owner=request.user) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + # Retrieve Stripe customer ID for user. + customer_id = 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 = stripe.CreditCard(**serializer.validated_data.pop('credit_card')) + card_request = 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) # TODO: find a way to customize serializer for actions. # drf-action-serializer module seems to do that. From ebc92388451b92b4dad169db70c253ed144b7101 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 3 Mar 2020 17:50:52 +0100 Subject: [PATCH 147/193] recreate all migrations Signed-off-by: Nico Schottelius --- .../uncloud_auth/migrations/0001_initial.py | 2 +- uncloud/uncloud_net/migrations/__init__.py | 0 .../uncloud_pay/migrations/0001_initial.py | 55 +++++++++----- .../migrations/0002_auto_20200227_1230.py | 18 ----- .../migrations/0002_auto_20200227_1404.py | 32 --------- .../migrations/0003_auto_20200227_1414.py | 28 -------- .../migrations/0004_auto_20200227_1532.py | 31 -------- .../migrations/0005_auto_20200228_0737.py | 42 ----------- .../migrations/0006_auto_20200228_0741.py | 18 ----- .../migrations/0007_remove_order_bill.py | 17 ----- .../uncloud_pay/migrations/0008_order_bill.py | 18 ----- .../migrations/0009_auto_20200228_0825.py | 29 -------- .../migrations/0010_merge_20200228_1303.py | 14 ---- .../migrations/0011_auto_20200229_1459.py | 21 ------ .../migrations/0012_orderrecord.py | 25 ------- .../0013_paymentmethod_stripe_card_id.py | 18 ----- .../uncloud_storage/migrations/__init__.py | 0 uncloud/uncloud_vm/migrations/0001_initial.py | 71 +++++++++++++------ .../migrations/0002_auto_20200225_1952.py | 38 ---------- .../migrations/0003_auto_20200225_2028.py | 19 ----- .../migrations/0004_vmsnapshotproduct.py | 33 --------- .../migrations/0005_auto_20200227_1230.py | 25 ------- .../migrations/0005_auto_20200227_1532.py | 25 ------- .../migrations/0006_auto_20200229_1545.py | 53 -------------- .../migrations/0006_merge_20200228_1303.py | 14 ---- .../migrations/0007_auto_20200228_1344.py | 23 ------ .../migrations/0007_auto_20200229_1559.py | 23 ------ .../migrations/0008_auto_20200229_1611.py | 23 ------ .../migrations/0008_vmproduct_name.py | 18 ----- .../migrations/0009_auto_20200228_1416.py | 18 ----- .../migrations/0001_initial.py | 33 --------- .../0002_matrixserviceproduct_domain.py | 18 ----- .../ungleich_service/migrations/__init__.py | 0 33 files changed, 89 insertions(+), 713 deletions(-) delete mode 100644 uncloud/uncloud_net/migrations/__init__.py delete mode 100644 uncloud/uncloud_pay/migrations/0002_auto_20200227_1230.py delete mode 100644 uncloud/uncloud_pay/migrations/0002_auto_20200227_1404.py delete mode 100644 uncloud/uncloud_pay/migrations/0003_auto_20200227_1414.py delete mode 100644 uncloud/uncloud_pay/migrations/0004_auto_20200227_1532.py delete mode 100644 uncloud/uncloud_pay/migrations/0005_auto_20200228_0737.py delete mode 100644 uncloud/uncloud_pay/migrations/0006_auto_20200228_0741.py delete mode 100644 uncloud/uncloud_pay/migrations/0007_remove_order_bill.py delete mode 100644 uncloud/uncloud_pay/migrations/0008_order_bill.py delete mode 100644 uncloud/uncloud_pay/migrations/0009_auto_20200228_0825.py delete mode 100644 uncloud/uncloud_pay/migrations/0010_merge_20200228_1303.py delete mode 100644 uncloud/uncloud_pay/migrations/0011_auto_20200229_1459.py delete mode 100644 uncloud/uncloud_pay/migrations/0012_orderrecord.py delete mode 100644 uncloud/uncloud_pay/migrations/0013_paymentmethod_stripe_card_id.py delete mode 100644 uncloud/uncloud_storage/migrations/__init__.py delete mode 100644 uncloud/uncloud_vm/migrations/0002_auto_20200225_1952.py delete mode 100644 uncloud/uncloud_vm/migrations/0003_auto_20200225_2028.py delete mode 100644 uncloud/uncloud_vm/migrations/0004_vmsnapshotproduct.py delete mode 100644 uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py delete mode 100644 uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py delete mode 100644 uncloud/uncloud_vm/migrations/0006_auto_20200229_1545.py delete mode 100644 uncloud/uncloud_vm/migrations/0006_merge_20200228_1303.py delete mode 100644 uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py delete mode 100644 uncloud/uncloud_vm/migrations/0007_auto_20200229_1559.py delete mode 100644 uncloud/uncloud_vm/migrations/0008_auto_20200229_1611.py delete mode 100644 uncloud/uncloud_vm/migrations/0008_vmproduct_name.py delete mode 100644 uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py delete mode 100644 uncloud/ungleich_service/migrations/0001_initial.py delete mode 100644 uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py delete mode 100644 uncloud/ungleich_service/migrations/__init__.py diff --git a/uncloud/uncloud_auth/migrations/0001_initial.py b/uncloud/uncloud_auth/migrations/0001_initial.py index 63885c4..a1f8d00 100644 --- a/uncloud/uncloud_auth/migrations/0001_initial.py +++ b/uncloud/uncloud_auth/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-02-23 17:11 +# Generated by Django 3.0.3 on 2020-03-03 16:49 import django.contrib.auth.models import django.contrib.auth.validators diff --git a/uncloud/uncloud_net/migrations/__init__.py b/uncloud/uncloud_net/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/uncloud/uncloud_pay/migrations/0001_initial.py b/uncloud/uncloud_pay/migrations/0001_initial.py index 6e57c59..f99021a 100644 --- a/uncloud/uncloud_pay/migrations/0001_initial.py +++ b/uncloud/uncloud_pay/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-02-27 10:50 +# Generated by Django 3.0.3 on 2020-03-03 16:50 from django.conf import settings import django.core.validators @@ -19,13 +19,24 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Bill', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('creation_date', models.DateTimeField()), + ('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()), - ('paid', models.BooleanField(default=False)), ('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)), ], ), @@ -34,23 +45,33 @@ class Migration(migrations.Migration): 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'), ('strip', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256)), - ('timestamp', models.DateTimeField(editable=False)), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('source', models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), migrations.CreateModel( - name='Order', + name='OrderRecord', fields=[ - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('creation_date', models.DateTimeField()), - ('starting_date', models.DateTimeField()), - ('ending_date', models.DateTimeField(blank=True, null=True)), - ('recurring_price', models.FloatField(editable=False)), - ('one_time_price', models.FloatField(editable=False)), - ('recurring_period', models.CharField(choices=[('onetime', 'Onetime'), ('per_year', 'Per Year'), ('per_month', 'Per Month'), ('per_week', 'Per Week'), ('per_day', 'Per Day'), ('per_hour', 'Per Hour'), ('per_minute', 'Per Minute'), ('per_second', 'Per Second')], default='onetime', max_length=32)), - ('bill', models.ManyToManyField(blank=True, editable=False, null=True, to='uncloud_pay.Bill')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('setup_fee', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), + ('recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), + ('description', models.TextField()), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), ], ), + migrations.CreateModel( + name='PaymentMethod', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('source', models.CharField(choices=[('stripe', 'Stripe'), ('unknown', 'Unknown')], default='stripe', max_length=256)), + ('description', models.TextField()), + ('primary', models.BooleanField(default=True)), + ('stripe_card_id', models.CharField(blank=True, max_length=32, null=True)), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('owner', 'primary')}, + }, + ), ] diff --git a/uncloud/uncloud_pay/migrations/0002_auto_20200227_1230.py b/uncloud/uncloud_pay/migrations/0002_auto_20200227_1230.py deleted file mode 100644 index 0643e9a..0000000 --- a/uncloud/uncloud_pay/migrations/0002_auto_20200227_1230.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-27 12:30 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='payment', - name='source', - field=models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256), - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0002_auto_20200227_1404.py b/uncloud/uncloud_pay/migrations/0002_auto_20200227_1404.py deleted file mode 100644 index 4a6e776..0000000 --- a/uncloud/uncloud_pay/migrations/0002_auto_20200227_1404.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-27 14:04 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_pay', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='payment', - name='source', - field=models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral'), ('unknown', 'Unknown')], default='unknown', max_length=256), - ), - 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()), - ('default', models.BooleanField()), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0003_auto_20200227_1414.py b/uncloud/uncloud_pay/migrations/0003_auto_20200227_1414.py deleted file mode 100644 index 1e16235..0000000 --- a/uncloud/uncloud_pay/migrations/0003_auto_20200227_1414.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-27 14:14 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_pay', '0002_auto_20200227_1404'), - ] - - operations = [ - migrations.AddField( - model_name='paymentmethod', - name='primary', - field=models.BooleanField(default=True), - ), - migrations.AlterUniqueTogether( - name='paymentmethod', - unique_together={('owner', 'primary')}, - ), - migrations.RemoveField( - model_name='paymentmethod', - name='default', - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0004_auto_20200227_1532.py b/uncloud/uncloud_pay/migrations/0004_auto_20200227_1532.py deleted file mode 100644 index f26b498..0000000 --- a/uncloud/uncloud_pay/migrations/0004_auto_20200227_1532.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-27 15:32 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_pay', '0003_auto_20200227_1414'), - ] - - operations = [ - migrations.AlterField( - model_name='bill', - name='owner', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='order', - name='recurring_period', - field=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), - ), - migrations.AlterField( - model_name='payment', - name='owner', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0005_auto_20200228_0737.py b/uncloud/uncloud_pay/migrations/0005_auto_20200228_0737.py deleted file mode 100644 index c646724..0000000 --- a/uncloud/uncloud_pay/migrations/0005_auto_20200228_0737.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-28 07:37 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0004_auto_20200227_1532'), - ] - - operations = [ - migrations.RemoveField( - model_name='bill', - name='id', - ), - migrations.RemoveField( - model_name='bill', - name='paid', - ), - migrations.AddField( - model_name='bill', - name='uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='bill', - name='creation_date', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='order', - name='creation_date', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='order', - name='starting_date', - field=models.DateTimeField(auto_now_add=True), - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0006_auto_20200228_0741.py b/uncloud/uncloud_pay/migrations/0006_auto_20200228_0741.py deleted file mode 100644 index ef03bda..0000000 --- a/uncloud/uncloud_pay/migrations/0006_auto_20200228_0741.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-28 07:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0005_auto_20200228_0737'), - ] - - operations = [ - migrations.AlterField( - model_name='order', - name='bill', - field=models.ManyToManyField(blank=True, editable=False, to='uncloud_pay.Bill'), - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0007_remove_order_bill.py b/uncloud/uncloud_pay/migrations/0007_remove_order_bill.py deleted file mode 100644 index ea79416..0000000 --- a/uncloud/uncloud_pay/migrations/0007_remove_order_bill.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-28 07:44 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0006_auto_20200228_0741'), - ] - - operations = [ - migrations.RemoveField( - model_name='order', - name='bill', - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0008_order_bill.py b/uncloud/uncloud_pay/migrations/0008_order_bill.py deleted file mode 100644 index 315ac60..0000000 --- a/uncloud/uncloud_pay/migrations/0008_order_bill.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-28 07:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0007_remove_order_bill'), - ] - - operations = [ - migrations.AddField( - model_name='order', - name='bill', - field=models.ManyToManyField(blank=True, editable=False, to='uncloud_pay.Bill'), - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0009_auto_20200228_0825.py b/uncloud/uncloud_pay/migrations/0009_auto_20200228_0825.py deleted file mode 100644 index 66feb51..0000000 --- a/uncloud/uncloud_pay/migrations/0009_auto_20200228_0825.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-28 08:25 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0008_order_bill'), - ] - - operations = [ - migrations.AlterField( - model_name='order', - name='one_time_price', - field=models.DecimalField(decimal_places=2, editable=False, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), - ), - migrations.AlterField( - model_name='order', - name='recurring_price', - field=models.DecimalField(decimal_places=2, editable=False, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), - ), - migrations.AlterField( - model_name='payment', - name='timestamp', - field=models.DateTimeField(auto_now_add=True), - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0010_merge_20200228_1303.py b/uncloud/uncloud_pay/migrations/0010_merge_20200228_1303.py deleted file mode 100644 index 2ea423c..0000000 --- a/uncloud/uncloud_pay/migrations/0010_merge_20200228_1303.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-28 13:03 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0002_auto_20200227_1230'), - ('uncloud_pay', '0009_auto_20200228_0825'), - ] - - operations = [ - ] diff --git a/uncloud/uncloud_pay/migrations/0011_auto_20200229_1459.py b/uncloud/uncloud_pay/migrations/0011_auto_20200229_1459.py deleted file mode 100644 index e4edbb0..0000000 --- a/uncloud/uncloud_pay/migrations/0011_auto_20200229_1459.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-29 14:59 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0010_merge_20200228_1303'), - ] - - operations = [ - migrations.RemoveField( - model_name='order', - name='one_time_price', - ), - migrations.RemoveField( - model_name='order', - name='recurring_price', - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0012_orderrecord.py b/uncloud/uncloud_pay/migrations/0012_orderrecord.py deleted file mode 100644 index 7c655e4..0000000 --- a/uncloud/uncloud_pay/migrations/0012_orderrecord.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-01 16:04 - -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0011_auto_20200229_1459'), - ] - - operations = [ - migrations.CreateModel( - name='OrderRecord', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('setup_fee', 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')), - ], - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0013_paymentmethod_stripe_card_id.py b/uncloud/uncloud_pay/migrations/0013_paymentmethod_stripe_card_id.py deleted file mode 100644 index df7c065..0000000 --- a/uncloud/uncloud_pay/migrations/0013_paymentmethod_stripe_card_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-02 20:14 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0012_orderrecord'), - ] - - operations = [ - migrations.AddField( - model_name='paymentmethod', - name='stripe_card_id', - field=models.CharField(blank=True, max_length=32, null=True), - ), - ] diff --git a/uncloud/uncloud_storage/migrations/__init__.py b/uncloud/uncloud_storage/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/uncloud/uncloud_vm/migrations/0001_initial.py b/uncloud/uncloud_vm/migrations/0001_initial.py index dc4d657..6c3d54f 100644 --- a/uncloud/uncloud_vm/migrations/0001_initial.py +++ b/uncloud/uncloud_vm/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-02-25 19:50 +# Generated by Django 3.0.3 on 2020-03-03 16:50 from django.conf import settings from django.db import migrations, models @@ -11,65 +11,94 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('uncloud_pay', '__first__'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='VMDiskProduct', + name='VMDiskImageProduct', fields=[ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('size_in_gb', models.FloatField()), + ('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)), ('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)), - ('physical_cores', models.IntegerField()), - ('usable_cores', models.IntegerField()), - ('usable_ram_in_gb', models.FloatField()), - ('status', models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('unusable', 'Unusable')], default='pending', max_length=32)), + ('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)), ], ), migrations.CreateModel( name='VMProduct', fields=[ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), + ('name', models.CharField(max_length=32)), ('cores', models.IntegerField()), ('ram_in_gb', models.FloatField()), + ('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(editable=False, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMHost')), + ('vmhost', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMHost')), ], - ), - migrations.CreateModel( - name='OperatingSystemDisk', - fields=[ - ('vmdiskproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.VMDiskProduct')), - ('os_name', models.CharField(max_length=128)), - ], - bases=('uncloud_vm.vmdiskproduct',), + 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'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), + ('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.IntegerField()), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')), ], ), - migrations.AddField( - model_name='vmdiskproduct', - name='vm', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct'), + migrations.CreateModel( + name='VMDiskProduct', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('size_in_gb', models.FloatField(blank=True)), + ('image', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMDiskImageProduct')), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')), + ], ), ] diff --git a/uncloud/uncloud_vm/migrations/0002_auto_20200225_1952.py b/uncloud/uncloud_vm/migrations/0002_auto_20200225_1952.py deleted file mode 100644 index 46a207b..0000000 --- a/uncloud/uncloud_vm/migrations/0002_auto_20200225_1952.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-25 19:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='vmhost', - name='hostname', - field=models.CharField(max_length=253, unique=True), - ), - migrations.AlterField( - model_name='vmhost', - name='physical_cores', - field=models.IntegerField(default=0), - ), - migrations.AlterField( - model_name='vmhost', - name='status', - field=models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32), - ), - migrations.AlterField( - model_name='vmhost', - name='usable_cores', - field=models.IntegerField(default=0), - ), - migrations.AlterField( - model_name='vmhost', - name='usable_ram_in_gb', - field=models.FloatField(default=0), - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0003_auto_20200225_2028.py b/uncloud/uncloud_vm/migrations/0003_auto_20200225_2028.py deleted file mode 100644 index a4e5976..0000000 --- a/uncloud/uncloud_vm/migrations/0003_auto_20200225_2028.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-25 20:28 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0002_auto_20200225_1952'), - ] - - operations = [ - migrations.AlterField( - model_name='vmproduct', - name='vmhost', - field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMHost'), - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0004_vmsnapshotproduct.py b/uncloud/uncloud_vm/migrations/0004_vmsnapshotproduct.py deleted file mode 100644 index 13840b5..0000000 --- a/uncloud/uncloud_vm/migrations/0004_vmsnapshotproduct.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-27 10:50 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_pay', '0001_initial'), - ('uncloud_vm', '0003_auto_20200225_2028'), - ] - - operations = [ - migrations.CreateModel( - name='VMSnapshotProduct', - fields=[ - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), - ('gb_ssd', models.FloatField(editable=False)), - ('gb_hdd', models.FloatField(editable=False)), - ('vm_uuid', models.UUIDField()), - ('order', models.ForeignKey(editable=False, 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)), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py deleted file mode 100644 index 5535071..0000000 --- a/uncloud/uncloud_vm/migrations/0005_auto_20200227_1230.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-27 12:30 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0002_auto_20200227_1230'), - ('uncloud_vm', '0004_vmsnapshotproduct'), - ] - - operations = [ - migrations.RemoveField( - model_name='vmsnapshotproduct', - name='vm_uuid', - ), - migrations.AddField( - model_name='vmsnapshotproduct', - name='vm', - field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct'), - preserve_default=False, - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py b/uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py deleted file mode 100644 index 3ebd472..0000000 --- a/uncloud/uncloud_vm/migrations/0005_auto_20200227_1532.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-27 15:32 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0004_auto_20200227_1532'), - ('uncloud_vm', '0004_vmsnapshotproduct'), - ] - - operations = [ - migrations.AddField( - model_name='vmproduct', - name='order', - field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), - ), - migrations.AlterField( - model_name='vmsnapshotproduct', - name='order', - field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0006_auto_20200229_1545.py b/uncloud/uncloud_vm/migrations/0006_auto_20200229_1545.py deleted file mode 100644 index 208aeaa..0000000 --- a/uncloud/uncloud_vm/migrations/0006_auto_20200229_1545.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-29 15:45 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_vm', '0005_auto_20200227_1230'), - ] - - 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()), - ('storage_class', models.CharField(choices=[('hdd', 'HDD'), ('ssd', 'SSD')], default='ssd', max_length=32)), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.RemoveField( - model_name='vmdiskproduct', - name='storage_class', - ), - migrations.AddField( - model_name='vmdiskproduct', - name='owner', - field=models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - preserve_default=False, - ), - migrations.AddField( - model_name='vmnetworkcard', - name='ip_address', - field=models.GenericIPAddressField(blank=True, null=True), - ), - migrations.DeleteModel( - name='OperatingSystemDisk', - ), - migrations.AddField( - model_name='vmdiskproduct', - name='image', - field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMDiskImageProduct'), - preserve_default=False, - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0006_merge_20200228_1303.py b/uncloud/uncloud_vm/migrations/0006_merge_20200228_1303.py deleted file mode 100644 index 29411ca..0000000 --- a/uncloud/uncloud_vm/migrations/0006_merge_20200228_1303.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-28 13:03 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0005_auto_20200227_1532'), - ('uncloud_vm', '0005_auto_20200227_1230'), - ] - - operations = [ - ] diff --git a/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py b/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py deleted file mode 100644 index 8867f2f..0000000 --- a/uncloud/uncloud_vm/migrations/0007_auto_20200228_1344.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-28 13:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0006_merge_20200228_1303'), - ] - - operations = [ - migrations.AddField( - model_name='vmnetworkcard', - name='ip_address', - field=models.GenericIPAddressField(blank=True, null=True), - ), - migrations.AddField( - model_name='vmproduct', - name='status', - field=models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256), - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0007_auto_20200229_1559.py b/uncloud/uncloud_vm/migrations/0007_auto_20200229_1559.py deleted file mode 100644 index 6e08c0c..0000000 --- a/uncloud/uncloud_vm/migrations/0007_auto_20200229_1559.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-29 15:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0006_auto_20200229_1545'), - ] - - operations = [ - migrations.AddField( - model_name='vmdiskimageproduct', - name='import_url', - field=models.URLField(blank=True, null=True), - ), - migrations.AlterField( - model_name='vmdiskimageproduct', - name='size_in_gb', - field=models.FloatField(blank=True, null=True), - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0008_auto_20200229_1611.py b/uncloud/uncloud_vm/migrations/0008_auto_20200229_1611.py deleted file mode 100644 index 8a9be67..0000000 --- a/uncloud/uncloud_vm/migrations/0008_auto_20200229_1611.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-29 16:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0007_auto_20200229_1559'), - ] - - operations = [ - migrations.AddField( - model_name='vmdiskimageproduct', - name='status', - field=models.CharField(choices=[('pending', 'Pending'), ('creating', 'Creating'), ('active', 'Active'), ('disabled', 'Disabled'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32), - ), - migrations.AlterField( - model_name='vmhost', - name='status', - field=models.CharField(choices=[('pending', 'Pending'), ('creating', 'Creating'), ('active', 'Active'), ('disabled', 'Disabled'), ('unusable', 'Unusable'), ('deleted', 'Deleted')], default='pending', max_length=32), - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0008_vmproduct_name.py b/uncloud/uncloud_vm/migrations/0008_vmproduct_name.py deleted file mode 100644 index 75ff7d0..0000000 --- a/uncloud/uncloud_vm/migrations/0008_vmproduct_name.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-28 14:05 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0007_auto_20200228_1344'), - ] - - operations = [ - migrations.AddField( - model_name='vmproduct', - name='name', - field=models.CharField(blank=True, max_length=32), - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py b/uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py deleted file mode 100644 index e29bfe9..0000000 --- a/uncloud/uncloud_vm/migrations/0009_auto_20200228_1416.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-28 14:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0008_vmproduct_name'), - ] - - operations = [ - migrations.AlterField( - model_name='vmproduct', - name='name', - field=models.CharField(max_length=32), - ), - ] diff --git a/uncloud/ungleich_service/migrations/0001_initial.py b/uncloud/ungleich_service/migrations/0001_initial.py deleted file mode 100644 index 2e19344..0000000 --- a/uncloud/ungleich_service/migrations/0001_initial.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-28 13:44 - -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', '0010_merge_20200228_1303'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_vm', '0007_auto_20200228_1344'), - ] - - operations = [ - migrations.CreateModel( - name='MatrixServiceProduct', - fields=[ - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), - ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py b/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py deleted file mode 100644 index fda0075..0000000 --- a/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_domain.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-02-28 14:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ungleich_service', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='matrixserviceproduct', - name='domain', - field=models.CharField(default='domain.tld', max_length=255), - ), - ] diff --git a/uncloud/ungleich_service/migrations/__init__.py b/uncloud/ungleich_service/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 From e0cb6ac670d674a45023041d0ba615a70997774d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 3 Mar 2020 18:16:25 +0100 Subject: [PATCH 148/193] Allow for charging customers --- uncloud/uncloud_pay/models.py | 17 ++++++++++------- uncloud/uncloud_pay/serializers.py | 5 ++++- uncloud/uncloud_pay/stripe.py | 14 +++++++++----- uncloud/uncloud_pay/views.py | 16 +++++++++------- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index fa775fc..772ab38 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -86,16 +86,19 @@ class PaymentMethod(models.Model): def charge(self, amount): if amount > 0: # Make sure we don't charge negative amount by errors... if self.source == 'stripe': - # TODO: wire to stripe, see meooow-payv1/strip_utils.py - payment = Payment(owner=self.owner, source=self.source, amount=amount) - payment.save() # TODO: Check return status + 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 True + return payment + else: + raise Exception('Stripe error: {}'.format(charge_request['error'])) else: - # We do not handle that source yet. - return False + raise Exception('This payment method is unsupported/cannot be charged.') else: - return False + raise Exception('Cannot charge negative amount.') class Meta: unique_together = [['owner', 'primary']] diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index f5136f6..94c9b61 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -38,7 +38,10 @@ class PaymentMethodSerializer(serializers.ModelSerializer): class Meta: model = PaymentMethod - fields = ['source', 'description', 'primary', 'stripe_card_last4'] + 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() diff --git a/uncloud/uncloud_pay/stripe.py b/uncloud/uncloud_pay/stripe.py index ab2d865..4f28d94 100644 --- a/uncloud/uncloud_pay/stripe.py +++ b/uncloud/uncloud_pay/stripe.py @@ -21,7 +21,7 @@ def handle_stripe_error(f): 'error': None } - common_message = "Currently it's not possible to make payments." + common_message = "Currently it is not possible to make payments." try: response_object = f(*args, **kwargs) response = { @@ -114,11 +114,15 @@ def get_card(customer_id, card_id): return stripe.Customer.retrieve_source(customer_id, card_id) @handle_stripe_error -def charge_customer(amount, source): +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=amount, - currenty=CURRENCY, - source=source) + amount=adjusted_amount, + currency=CURRENCY, + customer=customer_id, + source=card_id) @handle_stripe_error def create_customer(name, email): diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 294b518..a6066b4 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -63,6 +63,8 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): def get_serializer_class(self): if self.action == 'create': return CreatePaymentMethodSerializer + elif self.action == 'charge': + return ChargePaymentMethodSerializer else: return PaymentMethodSerializer @@ -99,18 +101,18 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): output_serializer = PaymentMethodSerializer(payment_method) return Response(output_serializer.data) - # TODO: find a way to customize serializer for actions. - # drf-action-serializer module seems to do that. @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.data['amount'] - if payment_method.charge(amount): - return Response({'charged', amount}) - else: - return Response(status=status.HTTP_500_INTERNAL_ERROR) + 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. From fea0568bb96be25d62c2fa27fae9094def750f3f Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 3 Mar 2020 23:46:39 +0500 Subject: [PATCH 149/193] init commit --- .../commands/migrate-one-vm-to-regular.py | 107 ++++++++++++++++++ .../management/commands/synchost.py | 74 ++++++++++++ .../opennebula/management/commands/syncvm.py | 3 +- uncloud/opennebula/models.py | 29 ++++- .../migrations/0009_auto_20200303_0927.py | 23 ++++ .../migrations/0010_auto_20200303_1208.py | 18 +++ .../0011_vmdiskimageproduct_source_type.py | 18 +++ .../0012_vmdiskimageproduct_source.py | 18 +++ .../migrations/0013_auto_20200303_1845.py | 23 ++++ uncloud/uncloud_vm/models.py | 11 +- 10 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py create mode 100644 uncloud/opennebula/management/commands/synchost.py create mode 100644 uncloud/uncloud_vm/migrations/0009_auto_20200303_0927.py create mode 100644 uncloud/uncloud_vm/migrations/0010_auto_20200303_1208.py create mode 100644 uncloud/uncloud_vm/migrations/0011_vmdiskimageproduct_source_type.py create mode 100644 uncloud/uncloud_vm/migrations/0012_vmdiskimageproduct_source.py create mode 100644 uncloud/uncloud_vm/migrations/0013_auto_20200303_1845.py diff --git a/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py b/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py new file mode 100644 index 0000000..16a6449 --- /dev/null +++ b/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py @@ -0,0 +1,107 @@ +from datetime import datetime + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from opennebula.models import VM as VMModel +from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct +from uncloud_pay.models import Order + + +def get_vm_price(core, ram, storage, n_of_ipv4, n_of_ipv6): + storage = storage / 10 + total = 3 * core + 4 * ram + 3.5 * storage + 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 = nic.get('MAC') + ip_address = nic.get('IP', None) or nic.get('IP6_GLOBAL', None) + + mac_address = mac_address.replace(':', '') + mac_address = mac_address.replace('.', '') + mac_address = mac_address.replace('-', '') + mac_address = mac_address.replace(' ', '') + mac_address = int(mac_address, base=16) + + VMNetworkCard.objects.create( + mac_address=mac_address, vm=vm_product, ip_address=ip_address + ) + + +def create_disk_and_image(one_vm, vm_product): + for disk in one_vm.disks: + owner = one_vm.owner + name = disk.get('image') + + # TODO: Fix the following hard coded values + is_os_image = True + is_public = True + status = 'active' + + image_size_in_gb = disk.get('image_size_in_gb') + disk_size_in_gb = disk.get('size_in_gb') + storage_class = disk.get('pool_name') + image_source = disk.get('source') + image_source_type = disk.get('source_type') + + image = VMDiskImageProduct.objects.create( + owner=owner, name=name, 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 + ) + vm_disk = VMDiskProduct.objects.create( + owner=owner, vm=vm_product, image=image, size_in_gb=disk_size_in_gb + ) + + +class Command(BaseCommand): + help = 'Migrate Opennebula VM to regular (uncloud) vm' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + for one_vm in VMModel.objects.all(): + # Host on which the VM is currently residing + host = VMHost.objects.filter(vms__icontains=one_vm.vmid).first() + + # VCPU, RAM, Owner, Status + # TODO: Set actual status instead of hard coded 'active' + cores, ram_in_gb, owner, status = one_vm.cores, one_vm.ram_in_gb, one_vm.owner, 'active' + + # Total Amount of SSD Storage + # TODO: What would happen if the attached storage is not SSD but HDD? + total_storage_in_gb = sum([disk['size_in_gb'] for disk in one_vm.disks]) + + # 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 + order = Order.objects.create( + owner=one_vm.owner, + creation_date=datetime.now(tz=timezone.utc), + starting_date=datetime.now(tz=timezone.utc), + ending_date=datetime.now(tz=timezone.utc), + one_time_price=0, + recurring_price=get_vm_price(cores, ram_in_gb, total_storage_in_gb, len(ipv4), len(ipv6)), + recurring_period='per_month' + ) + + vm_product = VMProduct.objects.create( + cores=cores, ram_in_gb=ram_in_gb, + owner=one_vm.owner, vmhost=host, + order=order, status=status + ) + + # Create VMNetworkCards + create_nics(one_vm, vm_product) + + # Create VMDiskImageProduct and VMDiskProduct + create_disk_and_image(one_vm, vm_product) diff --git a/uncloud/opennebula/management/commands/synchost.py b/uncloud/opennebula/management/commands/synchost.py new file mode 100644 index 0000000..6e4ea0f --- /dev/null +++ b/uncloud/opennebula/management/commands/synchost.py @@ -0,0 +1,74 @@ +import json + +import uncloud.secrets as secrets + +from xmlrpc.client import ServerProxy as RPCClient + +from django.core.management.base import BaseCommand +from xmltodict import parse +from enum import IntEnum +from opennebula.models import VM as VMModel +from uncloud_vm.models import VMHost +from django_auth_ldap.backend import LDAPBackend + + +class HostStates(IntEnum): + """ + The following flags are copied from + https://docs.opennebula.org/5.8/integration/system_interfaces/api.html#schemas-for-host + """ + INIT = 0 # Initial state for enabled hosts + MONITORING_MONITORED = 1 # Monitoring the host (from monitored) + MONITORED = 2 # The host has been successfully monitored + ERROR = 3 # An error ocurrer while monitoring the host + DISABLED = 4 # The host is disabled + MONITORING_ERROR = 5 # Monitoring the host (from error) + MONITORING_INIT = 6 # Monitoring the host (from init) + MONITORING_DISABLED = 7 # Monitoring the host (from disabled) + OFFLINE = 8 # The host is totally offline + + +class Command(BaseCommand): + help = 'Syncronize Host information from OpenNebula' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + with RPCClient(secrets.OPENNEBULA_URL) as rpc_client: + success, response, *_ = rpc_client.one.hostpool.info(secrets.OPENNEBULA_USER_PASS) + if success: + response = json.loads(json.dumps(parse(response))) + host_pool = response.get('HOST_POOL', {}).get('HOST', {}) + for host in host_pool: + host_share = host.get('HOST_SHARE', {}) + + host_name = host.get('NAME') + state = int(host.get('STATE', HostStates.OFFLINE.value)) + + if state == HostStates.MONITORED: + status = 'active' + elif state == HostStates.DISABLED: + status = 'disabled' + else: + status = 'unusable' + + usable_cores = host_share.get('TOTAL_CPU') + usable_ram_in_kb = int(host_share.get('TOTAL_MEM', 0)) + usable_ram_in_gb = int(usable_ram_in_kb / 2 ** 20) + + vms = 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, + 'vms': vms + } + ) + else: + print(response) diff --git a/uncloud/opennebula/management/commands/syncvm.py b/uncloud/opennebula/management/commands/syncvm.py index 779db61..ff620f7 100644 --- a/uncloud/opennebula/management/commands/syncvm.py +++ b/uncloud/opennebula/management/commands/syncvm.py @@ -6,7 +6,6 @@ import uncloud.secrets as secrets from xmlrpc.client import ServerProxy as RPCClient from django.core.management.base import BaseCommand -from django.contrib.auth import get_user_model from xmltodict import parse from opennebula.models import VM as VMModel @@ -31,7 +30,7 @@ class Command(BaseCommand): backend = LDAPBackend() - for vm in vms: + for i, vm in enumerate(vms): vm_id = vm['ID'] vm_owner = vm['UNAME'] diff --git a/uncloud/opennebula/models.py b/uncloud/opennebula/models.py index fff811b..059ba5a 100644 --- a/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -35,15 +35,21 @@ class VM(models.Model): if 'DISK' in self.data['TEMPLATE']: if type(self.data['TEMPLATE']['DISK']) is dict: - disks = [ self.data['TEMPLATE']['DISK'] ] + disks = [self.data['TEMPLATE']['DISK']] else: disks = self.data['TEMPLATE']['DISK'] disks = [ { - 'size_in_gb': int(d['SIZE'])/1024. , + 'size_in_gb': int(d['SIZE'])/1024.0, 'opennebula_source': d['SOURCE'], 'opennebula_name': d['IMAGE'], + 'image_size_in_gb': int(d['ORIGINAL_SIZE'])/1024.0, + 'pool_name': d['POOL_NAME'], + 'image': d['IMAGE'], + 'source': d['SOURCE'], + 'source_type': d['TM_MAD'] + } for d in disks ] @@ -57,3 +63,22 @@ class VM(models.Model): @property def graphics(self): return self.data.get('TEMPLATE', {}).get('GRAPHICS', {}) + + @property + def nics(self): + _nics = self.data.get('TEMPLATE', {}).get('NIC', {}) + if isinstance(_nics, dict): + _nics = [_nics] + return _nics + + @property + def ips(self): + ipv4, ipv6 = [], [] + for nic in self.nics: + ip = nic.get('IP') + ip6 = nic.get('IP6_GLOBAL') + if ip: + ipv4.append(ip) + if ip6: + ipv6.append(ip6) + return ipv4, ipv6 diff --git a/uncloud/uncloud_vm/migrations/0009_auto_20200303_0927.py b/uncloud/uncloud_vm/migrations/0009_auto_20200303_0927.py new file mode 100644 index 0000000..7815f46 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0009_auto_20200303_0927.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-03-03 09:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0008_auto_20200229_1611'), + ] + + operations = [ + migrations.AddField( + model_name='vmhost', + name='vms', + field=models.TextField(default=''), + ), + migrations.AlterField( + model_name='vmdiskproduct', + name='size_in_gb', + field=models.FloatField(blank=True), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0010_auto_20200303_1208.py b/uncloud/uncloud_vm/migrations/0010_auto_20200303_1208.py new file mode 100644 index 0000000..39a20e3 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0010_auto_20200303_1208.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-03 12:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0009_auto_20200303_0927'), + ] + + operations = [ + migrations.AlterField( + model_name='vmnetworkcard', + name='mac_address', + field=models.BigIntegerField(), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0011_vmdiskimageproduct_source_type.py b/uncloud/uncloud_vm/migrations/0011_vmdiskimageproduct_source_type.py new file mode 100644 index 0000000..3d445cf --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0011_vmdiskimageproduct_source_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-03 18:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0010_auto_20200303_1208'), + ] + + operations = [ + migrations.AddField( + model_name='vmdiskimageproduct', + name='source_type', + field=models.CharField(max_length=128, null=True), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0012_vmdiskimageproduct_source.py b/uncloud/uncloud_vm/migrations/0012_vmdiskimageproduct_source.py new file mode 100644 index 0000000..4072d82 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0012_vmdiskimageproduct_source.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-03 18:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0011_vmdiskimageproduct_source_type'), + ] + + operations = [ + migrations.AddField( + model_name='vmdiskimageproduct', + name='source', + field=models.CharField(max_length=128, null=True), + ), + ] diff --git a/uncloud/uncloud_vm/migrations/0013_auto_20200303_1845.py b/uncloud/uncloud_vm/migrations/0013_auto_20200303_1845.py new file mode 100644 index 0000000..55aed73 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0013_auto_20200303_1845.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-03-03 18:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0012_vmdiskimageproduct_source'), + ] + + operations = [ + migrations.RenameField( + model_name='vmdiskimageproduct', + old_name='source', + new_name='image_source', + ), + migrations.RenameField( + model_name='vmdiskimageproduct', + old_name='source_type', + new_name='image_source_type', + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 4b0d511..72317be 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -39,6 +39,12 @@ class VMHost(models.Model): max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT ) + # List of VMs running on this host + vms = models.TextField(default='') + + def get_vms(self): + return self.vms.split(',') + class VMProduct(Product): vmhost = models.ForeignKey( @@ -74,7 +80,8 @@ class VMDiskImageProduct(models.Model): size_in_gb = models.FloatField(null=True, blank=True) import_url = models.URLField(null=True, blank=True) - + image_source = models.CharField(max_length=128, null=True) + image_source_type = models.CharField(max_length=128, null=True) storage_class = models.CharField( max_length=32, choices=( @@ -127,7 +134,7 @@ class VMDiskProduct(models.Model): class VMNetworkCard(models.Model): vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) - mac_address = models.IntegerField() + mac_address = models.BigIntegerField() ip_address = models.GenericIPAddressField(blank=True, null=True) From 88c10e2e4a0fda2b8c0965b88b7f8ee67d015dbb Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 3 Mar 2020 23:53:45 +0500 Subject: [PATCH 150/193] improve readability --- .../commands/migrate-one-vm-to-regular.py | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py b/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py index 16a6449..13f292b 100644 --- a/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py +++ b/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py @@ -9,7 +9,7 @@ from uncloud_pay.models import Order def get_vm_price(core, ram, storage, n_of_ipv4, n_of_ipv6): - storage = storage / 10 + storage = storage / 10 # Division by 10 because our base storage unit is 10 GB total = 3 * core + 4 * ram + 3.5 * storage + 8 * n_of_ipv4 + 0 * n_of_ipv6 # TODO: Find some reason about the following magical subtraction. @@ -23,10 +23,13 @@ def create_nics(one_vm, vm_product): mac_address = nic.get('MAC') ip_address = nic.get('IP', None) or nic.get('IP6_GLOBAL', None) + # 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) VMNetworkCard.objects.create( @@ -84,20 +87,33 @@ class Command(BaseCommand): # TODO: Insert actual/real creation_date, starting_date, ending_date # instead of pseudo one we are putting currently + creation_date = starting_date = ending_date = datetime.now(tz=timezone.utc) + + # Price calculation + + # TODO: Make the following non-hardcoded + one_time_price = 0 + recurring_period = 'per_month' + + recurring_price = get_vm_price(cores, ram_in_gb, total_storage_in_gb, len(ipv4), len(ipv6)) + order = Order.objects.create( owner=one_vm.owner, - creation_date=datetime.now(tz=timezone.utc), - starting_date=datetime.now(tz=timezone.utc), - ending_date=datetime.now(tz=timezone.utc), - one_time_price=0, - recurring_price=get_vm_price(cores, ram_in_gb, total_storage_in_gb, len(ipv4), len(ipv6)), - recurring_period='per_month' + creation_date=creation_date, + starting_date=starting_date, + ending_date=ending_date, + one_time_price=one_time_price, + recurring_price=recurring_price, + recurring_period=recurring_period ) vm_product = VMProduct.objects.create( - cores=cores, ram_in_gb=ram_in_gb, - owner=one_vm.owner, vmhost=host, - order=order, status=status + cores=cores, + ram_in_gb=ram_in_gb, + owner=one_vm.owner, + vmhost=host, + order=order, + status=status ) # Create VMNetworkCards From a662b1fe293e51b7ec6c452fde7c7eca42879ddd Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 4 Mar 2020 13:25:46 +0500 Subject: [PATCH 151/193] Make migrate-one-vm-to-regular command idempotent --- .../commands/migrate-one-vm-to-regular.py | 104 ++++++++++-------- uncloud/opennebula/models.py | 2 +- .../migrations/0014_vmproduct_vmid.py | 18 +++ uncloud/uncloud_vm/models.py | 1 + 4 files changed, 80 insertions(+), 45 deletions(-) create mode 100644 uncloud/uncloud_vm/migrations/0014_vmproduct_vmid.py diff --git a/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py b/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py index 13f292b..68cf1f2 100644 --- a/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py +++ b/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py @@ -8,6 +8,19 @@ from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProdu from uncloud_pay.models import Order +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, storage, n_of_ipv4, n_of_ipv6): storage = storage / 10 # Division by 10 because our base storage unit is 10 GB total = 3 * core + 4 * ram + 3.5 * storage + 8 * n_of_ipv4 + 0 * n_of_ipv6 @@ -20,20 +33,11 @@ def get_vm_price(core, ram, storage, n_of_ipv4, n_of_ipv6): def create_nics(one_vm, vm_product): for nic in one_vm.nics: - mac_address = nic.get('MAC') + mac_address = convert_mac_to_int(nic.get('MAC')) ip_address = nic.get('IP', None) or nic.get('IP6_GLOBAL', None) - # 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) - - VMNetworkCard.objects.create( - mac_address=mac_address, vm=vm_product, ip_address=ip_address + VMNetworkCard.objects.update_or_create( + mac_address=mac_address, vm=vm_product, defaults={'ip_address': ip_address} ) @@ -43,9 +47,7 @@ def create_disk_and_image(one_vm, vm_product): name = disk.get('image') # TODO: Fix the following hard coded values - is_os_image = True - is_public = True - status = 'active' + is_os_image, is_public, status = True, True, 'active' image_size_in_gb = disk.get('image_size_in_gb') disk_size_in_gb = disk.get('size_in_gb') @@ -53,22 +55,31 @@ def create_disk_and_image(one_vm, vm_product): image_source = disk.get('source') image_source_type = disk.get('source_type') - image = VMDiskImageProduct.objects.create( - owner=owner, name=name, 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 + image, _ = VMDiskImageProduct.objects.update_or_create( + name=name, + defaults={ + 'owner': 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 + } ) - vm_disk = VMDiskProduct.objects.create( - owner=owner, vm=vm_product, image=image, size_in_gb=disk_size_in_gb + VMDiskProduct.objects.update_or_create( + owner=owner, vm=vm_product, + defaults={ + 'image': image, + 'size_in_gb': disk_size_in_gb + } ) class Command(BaseCommand): help = 'Migrate Opennebula VM to regular (uncloud) vm' - def add_arguments(self, parser): - pass - def handle(self, *args, **options): for one_vm in VMModel.objects.all(): # Host on which the VM is currently residing @@ -76,7 +87,8 @@ class Command(BaseCommand): # VCPU, RAM, Owner, Status # TODO: Set actual status instead of hard coded 'active' - cores, ram_in_gb, owner, status = one_vm.cores, one_vm.ram_in_gb, one_vm.owner, 'active' + vm_id, cores, ram_in_gb = one_vm.vmid, one_vm.cores, one_vm.ram_in_gb + owner, status = one_vm.owner, 'active' # Total Amount of SSD Storage # TODO: What would happen if the attached storage is not SSD but HDD? @@ -96,25 +108,29 @@ class Command(BaseCommand): recurring_period = 'per_month' recurring_price = get_vm_price(cores, ram_in_gb, total_storage_in_gb, len(ipv4), len(ipv6)) - - order = Order.objects.create( - owner=one_vm.owner, - creation_date=creation_date, - starting_date=starting_date, - ending_date=ending_date, - one_time_price=one_time_price, - recurring_price=recurring_price, - recurring_period=recurring_period - ) - - vm_product = VMProduct.objects.create( - cores=cores, - ram_in_gb=ram_in_gb, - owner=one_vm.owner, - vmhost=host, - order=order, - status=status - ) + try: + vm_product = VMProduct.objects.get(vmid=vm_id) + except VMProduct.DoesNotExist: + order = Order.objects.create( + owner=one_vm.owner, + creation_date=creation_date, + starting_date=starting_date, + ending_date=ending_date, + one_time_price=one_time_price, + recurring_price=recurring_price, + recurring_period=recurring_period + ) + vm_product, _ = VMProduct.objects.update_or_create( + vmid=vm_id, + defaults={ + 'cores': cores, + 'ram_in_gb': ram_in_gb, + 'owner': owner, + 'vmhost': host, + 'order': order, + 'status': status + } + ) # Create VMNetworkCards create_nics(one_vm, vm_product) diff --git a/uncloud/opennebula/models.py b/uncloud/opennebula/models.py index 059ba5a..e69b4d0 100644 --- a/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -19,7 +19,7 @@ class VM(models.Model): @property def ram_in_gb(self): - return (int(self.data['TEMPLATE']['MEMORY'])/1024.) + return int(self.data['TEMPLATE']['MEMORY'])/1024.0 @property def disks(self): diff --git a/uncloud/uncloud_vm/migrations/0014_vmproduct_vmid.py b/uncloud/uncloud_vm/migrations/0014_vmproduct_vmid.py new file mode 100644 index 0000000..4f43f77 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0014_vmproduct_vmid.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-04 07:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0013_auto_20200303_1845'), + ] + + operations = [ + migrations.AddField( + model_name='vmproduct', + name='vmid', + field=models.IntegerField(null=True), + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 72317be..772c021 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -53,6 +53,7 @@ class VMProduct(Product): cores = models.IntegerField() ram_in_gb = models.FloatField() + vmid = models.IntegerField(null=True) class VMWithOSProduct(VMProduct): From 9aabc66e574c04d11c27383af34528ea5b853103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Wed, 4 Mar 2020 09:39:18 +0100 Subject: [PATCH 152/193] Pay: move some model-related methods from helpers to models Otherwise we end up in circular dependency hell --- uncloud/uncloud_pay/helpers.py | 70 ---------- .../commands/charge-negative-balance.py | 5 +- .../management/commands/generate-bills.py | 4 +- .../commands/handle-overdue-bills.py | 3 +- uncloud/uncloud_pay/models.py | 121 +++++++++++++++--- uncloud/uncloud_pay/serializers.py | 12 -- 6 files changed, 108 insertions(+), 107 deletions(-) diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index b4216f6..d02b916 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -2,32 +2,9 @@ from functools import reduce from datetime import datetime from rest_framework import mixins from rest_framework.viewsets import GenericViewSet -from django.db.models import Q -from .models import Bill, Payment, PaymentMethod, Order from django.utils import timezone -from django.core.exceptions import ObjectDoesNotExist from calendar import monthrange -def get_balance_for(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 - -def get_payment_method_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 - def beginning_of_month(year, month): tz = timezone.get_current_timezone() return datetime(year=year, month=month, day=1, tzinfo=tz) @@ -38,53 +15,6 @@ def end_of_month(year, month): return datetime(year=year, month=month, day=days, hour=23, minute=59, second=59, tzinfo=tz) -def generate_bills_for(year, month, user, allowed_delay): - # /!\ 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() + allowed_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. - class ProductViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, diff --git a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py index 3667a03..24d53bf 100644 --- a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py +++ b/uncloud/uncloud_pay/management/commands/charge-negative-balance.py @@ -1,7 +1,6 @@ from django.core.management.base import BaseCommand from uncloud_auth.models import User -from uncloud_pay.models import Order, Bill -from uncloud_pay.helpers import get_balance_for, get_payment_method_for +from uncloud_pay.models import Order, Bill, PaymentMethod, get_balance_for from datetime import timedelta from django.utils import timezone @@ -19,7 +18,7 @@ class Command(BaseCommand): balance = get_balance_for(user) if balance < 0: print("User {} has negative balance ({}), charging.".format(user.username, balance)) - payment_method = get_payment_method_for(user) + 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) diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud/uncloud_pay/management/commands/generate-bills.py index 34432d5..a7dbe78 100644 --- a/uncloud/uncloud_pay/management/commands/generate-bills.py +++ b/uncloud/uncloud_pay/management/commands/generate-bills.py @@ -7,7 +7,7 @@ from django.core.exceptions import ObjectDoesNotExist from datetime import timedelta, date from django.utils import timezone -from uncloud_pay.helpers import generate_bills_for +from uncloud_pay.models import Bill BILL_PAYMENT_DELAY=timedelta(days=10) @@ -28,7 +28,7 @@ class Command(BaseCommand): for user in users: now = timezone.now() - generate_bills_for( + Bill.generate_for( year=now.year, month=now.month, user=user, diff --git a/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py b/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py index f4749f0..40468ba 100644 --- a/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py +++ b/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py @@ -1,7 +1,6 @@ from django.core.management.base import BaseCommand from uncloud_auth.models import User -from uncloud_pay.models import Order, Bill -from uncloud_pay.helpers import get_balance_for, get_payment_method_for +from uncloud_pay.models import Order, Bill, get_balance_for from datetime import timedelta from django.utils import timezone diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 772ab38..6f18931 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -1,17 +1,23 @@ from django.db import models -from functools import reduce +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 + import uncloud_pay.stripe +from uncloud_pay.helpers import beginning_of_month, end_of_month from decimal import Decimal -import uuid - # Define DecimalField properties, used to represent amounts of money. AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 @@ -26,6 +32,34 @@ class RecurringPeriod(models.TextChoices): PER_HOUR = 'HOUR', _('Per Hour') PER_SECOND = 'SECOND', _('Per Second') +# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types +class ProductStatus(models.TextChoices): + PENDING = 'PENDING', _('Pending') + AWAITING_PAYMENT = 'AWAITING_PAYMENT', _('Awaiting payment') + BEING_CREATED = 'BEING_CREATED', _('Being created') + ACTIVE = 'ACTIVE', _('Active') + DELETED = 'DELETED', _('Deleted') + +### +# Users. + +def get_balance_for(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. @@ -100,15 +134,19 @@ class PaymentMethod(models.Model): 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']] -class StripeCustomer(models.Model): - owner = models.OneToOneField( get_user_model(), - primary_key=True, - on_delete=models.CASCADE) - stripe_id = models.CharField(max_length=32) - ### # Bills & Payments. @@ -144,6 +182,55 @@ class Bill(models.Model): # A bill is final when its ending date is passed. return self.ending_date < timezone.now() + @staticmethod + def generate_for(year, month, user, allowed_delay): + # /!\ 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() + allowed_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 + class BillRecord(): """ Entry of a bill, dynamically generated from order records. @@ -258,6 +345,10 @@ class Order(models.Model): recurring_price=recurring_price, description=description) + def generate_bill(self): + pass + + class OrderRecord(models.Model): """ Order records store billing informations for products: the actual product @@ -305,15 +396,9 @@ class Product(models.Model): description = "" - status = models.CharField(max_length=256, - choices = ( - ('pending', 'Pending'), - ('being_created', 'Being created'), - ('active', 'Active'), - ('deleted', 'Deleted') - ), - default='pending' - ) + status = models.CharField(max_length=32, + choices=ProductStatus.choices, + default=ProductStatus.AWAITING_PAYMENT) order = models.ForeignKey(Order, on_delete=models.CASCADE, diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 94c9b61..aa75fd9 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -1,13 +1,6 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import * -from .helpers import get_balance_for - -from functools import reduce -from uncloud_vm.serializers import VMProductSerializer -from uncloud_vm.models import VMProduct - -import uncloud_pay.stripe as stripe ### # Users. @@ -19,8 +12,6 @@ class UserSerializer(serializers.ModelSerializer): # Display current 'balance' balance = serializers.SerializerMethodField('get_balance') - def __sum_balance(self, entries): - return reduce(lambda acc, entry: acc + entry.amount, entries, 0) def get_balance(self, user): return get_balance_for(user) @@ -92,6 +83,3 @@ class OrderSerializer(serializers.ModelSerializer): model = Order fields = ['uuid', 'creation_date', 'starting_date', 'ending_date', 'bill', 'recurring_period', 'records', 'recurring_price', 'one_time_price'] - -class ProductSerializer(serializers.Serializer): - vms = VMProductSerializer(many=True, read_only=True) From 02b287eff846320a4b4f19d42fa23270ea00d4ff Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 4 Mar 2020 14:44:41 +0500 Subject: [PATCH 153/193] small cleaning --- uncloud/opennebula/management/commands/syncvm.py | 5 ++--- uncloud/opennebula/models.py | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/uncloud/opennebula/management/commands/syncvm.py b/uncloud/opennebula/management/commands/syncvm.py index ff620f7..458528b 100644 --- a/uncloud/opennebula/management/commands/syncvm.py +++ b/uncloud/opennebula/management/commands/syncvm.py @@ -5,13 +5,12 @@ 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 -from django_auth_ldap.backend import LDAPBackend - class Command(BaseCommand): help = 'Syncronize VM information from OpenNebula' @@ -30,7 +29,7 @@ class Command(BaseCommand): backend = LDAPBackend() - for i, vm in enumerate(vms): + for vm in vms: vm_id = vm['ID'] vm_owner = vm['UNAME'] diff --git a/uncloud/opennebula/models.py b/uncloud/opennebula/models.py index e69b4d0..0748ff5 100644 --- a/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -19,7 +19,7 @@ class VM(models.Model): @property def ram_in_gb(self): - return int(self.data['TEMPLATE']['MEMORY'])/1024.0 + return int(self.data['TEMPLATE']['MEMORY'])/1024 @property def disks(self): @@ -41,10 +41,10 @@ class VM(models.Model): disks = [ { - 'size_in_gb': int(d['SIZE'])/1024.0, + 'size_in_gb': int(d['SIZE'])/1024, 'opennebula_source': d['SOURCE'], 'opennebula_name': d['IMAGE'], - 'image_size_in_gb': int(d['ORIGINAL_SIZE'])/1024.0, + 'image_size_in_gb': int(d['ORIGINAL_SIZE'])/1024, 'pool_name': d['POOL_NAME'], 'image': d['IMAGE'], 'source': d['SOURCE'], From 9e8149135bdad0b53beff72d74d4a74271ed4140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Wed, 4 Mar 2020 10:55:12 +0100 Subject: [PATCH 154/193] Move bill generation logic to Bill class, initial work for prepaid --- .../commands/handle-overdue-bills.py | 31 ++-------- uncloud/uncloud_pay/models.py | 56 +++++++++++++++++-- 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py b/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py index 40468ba..595fbc2 100644 --- a/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py +++ b/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py @@ -1,12 +1,12 @@ from django.core.management.base import BaseCommand from uncloud_auth.models import User -from uncloud_pay.models import Order, Bill, get_balance_for +from uncloud_pay.models import Bill from datetime import timedelta from django.utils import timezone class Command(BaseCommand): - help = 'Generate bills and charge customers if necessary.' + help = 'Take action on overdue bills.' def add_arguments(self, parser): pass @@ -15,28 +15,9 @@ class Command(BaseCommand): 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 ({}), checking for overdue bills." - .format(user.username, balance)) - - # Get bills DESCENDING by creation date (= latest at top). - bills = Bill.objects.filter( - owner=user, - due_date__lt=timezone.now() - ).order_by('-creation_date') - overdue_balance = abs(balance) - overdue_bills = [] - for bill in bills: - if overdue_balance < 0: - break # XXX: I'm (fnux) not fond of breaks! - - overdue_balance -= bill.amount - overdue_bills.append(bill) - - for bill in overdue_bills: - print("/!\ Overdue bill for {}, {} with amount {}" - .format(user.username, bill.uuid, bill.amount)) - # TODO: take action? + for bill in Bill.get_overdue_for(user): + print("/!\ Overdue bill for {}, {} with amount {}" + .format(user.username, bill.uuid, bill.amount)) + # TODO: take action? print("=> Done.") diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 6f18931..43064e4 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -18,10 +18,14 @@ import uncloud_pay.stripe from uncloud_pay.helpers import beginning_of_month, end_of_month from decimal import Decimal + # Define DecimalField properties, used to represent amounts of money. AMOUNT_MAX_DIGITS=10 AMOUNT_DECIMALS=2 +# 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') @@ -86,6 +90,20 @@ class Payment(models.Model): 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(), @@ -183,7 +201,7 @@ class Bill(models.Model): return self.ending_date < timezone.now() @staticmethod - def generate_for(year, month, user, allowed_delay): + 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 @@ -192,7 +210,7 @@ class Bill(models.Model): starting_date=beginning_of_month(year, month), ending_date=end_of_month(year, month), creation_date=timezone.now(), - due_date=timezone.now() + allowed_delay) + due_date=timezone.now() + BILL_PAYMENT_DELAY) # Select all orders active on the request period. orders = Order.objects.filter( @@ -231,6 +249,35 @@ class Bill(models.Model): # 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. @@ -345,9 +392,6 @@ class Order(models.Model): recurring_price=recurring_price, description=description) - def generate_bill(self): - pass - class OrderRecord(models.Model): """ @@ -398,7 +442,7 @@ class Product(models.Model): status = models.CharField(max_length=32, choices=ProductStatus.choices, - default=ProductStatus.AWAITING_PAYMENT) + default=ProductStatus.PENDING) order = models.ForeignKey(Order, on_delete=models.CASCADE, From faca104459955d4c138274ce3bee4d9268516a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Wed, 4 Mar 2020 11:05:21 +0100 Subject: [PATCH 155/193] Fix stripe import in uncloud_pay.models --- uncloud/uncloud_pay/views.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index a6066b4..38d1aa4 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -10,6 +10,7 @@ import json from .models import * from .serializers import * from datetime import datetime +import uncloud_pay.stripe as uncloud_stripe ### # Standard user views: @@ -79,15 +80,15 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) # Retrieve Stripe customer ID for user. - customer_id = stripe.get_customer_id_for(request.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 = stripe.CreditCard(**serializer.validated_data.pop('credit_card')) - card_request = stripe.create_card(customer_id, credit_card) + 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'] From 4fc1c36ae9c6e219e248d4305a1769c95c838792 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 5 Mar 2020 11:17:30 +0100 Subject: [PATCH 156/193] fix incorrect migrations from fnux-stable branch Signed-off-by: Nico Schottelius --- .../uncloud_pay/migrations/0001_initial.py | 12 +++++++-- .../migrations/0014_auto_20200303_1027.py | 18 ------------- .../migrations/0015_stripecustomer.py | 24 ------------------ .../migrations/0016_auto_20200303_1552.py | 25 ------------------- 4 files changed, 10 insertions(+), 69 deletions(-) delete mode 100644 uncloud/uncloud_pay/migrations/0014_auto_20200303_1027.py delete mode 100644 uncloud/uncloud_pay/migrations/0015_stripecustomer.py delete mode 100644 uncloud/uncloud_pay/migrations/0016_auto_20200303_1552.py diff --git a/uncloud/uncloud_pay/migrations/0001_initial.py b/uncloud/uncloud_pay/migrations/0001_initial.py index f99021a..89fa586 100644 --- a/uncloud/uncloud_pay/migrations/0001_initial.py +++ b/uncloud/uncloud_pay/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-03-03 16:50 +# Generated by Django 3.0.3 on 2020-03-05 10:17 from django.conf import settings import django.core.validators @@ -13,6 +13,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_auth', '0001_initial'), ] operations = [ @@ -40,6 +41,13 @@ class Migration(migrations.Migration): ('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=[ @@ -54,7 +62,7 @@ class Migration(migrations.Migration): name='OrderRecord', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('setup_fee', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), + ('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')), diff --git a/uncloud/uncloud_pay/migrations/0014_auto_20200303_1027.py b/uncloud/uncloud_pay/migrations/0014_auto_20200303_1027.py deleted file mode 100644 index bd71834..0000000 --- a/uncloud/uncloud_pay/migrations/0014_auto_20200303_1027.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-03 10:27 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0001_initial'), - ] - - operations = [ - migrations.RenameField( - model_name='orderrecord', - old_name='setup_fee', - new_name='one_time_price', - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0015_stripecustomer.py b/uncloud/uncloud_pay/migrations/0015_stripecustomer.py deleted file mode 100644 index 14fdbf0..0000000 --- a/uncloud/uncloud_pay/migrations/0015_stripecustomer.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-03 13:56 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_pay', '0014_auto_20200303_1027'), - ] - - operations = [ - migrations.CreateModel( - name='StripeCustomer', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('stripe_id', models.CharField(max_length=32)), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0016_auto_20200303_1552.py b/uncloud/uncloud_pay/migrations/0016_auto_20200303_1552.py deleted file mode 100644 index 08e3f2f..0000000 --- a/uncloud/uncloud_pay/migrations/0016_auto_20200303_1552.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-03 15:52 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_pay', '0015_stripecustomer'), - ] - - operations = [ - migrations.RemoveField( - model_name='stripecustomer', - name='id', - ), - migrations.AlterField( - model_name='stripecustomer', - name='owner', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL), - ), - ] From cf17373b3f96fae1038eb180d374ddc86fedfcad Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 5 Mar 2020 11:35:00 +0100 Subject: [PATCH 157/193] Fix ahmed introduced migrations Signed-off-by: Nico Schottelius --- uncloud/uncloud_vm/migrations/0001_initial.py | 14 +++++++---- .../migrations/0009_auto_20200303_0927.py | 23 ------------------- .../migrations/0010_auto_20200303_1208.py | 18 --------------- .../0011_vmdiskimageproduct_source_type.py | 18 --------------- .../0012_vmdiskimageproduct_source.py | 18 --------------- .../migrations/0013_auto_20200303_1845.py | 23 ------------------- .../migrations/0014_vmproduct_vmid.py | 18 --------------- 7 files changed, 9 insertions(+), 123 deletions(-) delete mode 100644 uncloud/uncloud_vm/migrations/0009_auto_20200303_0927.py delete mode 100644 uncloud/uncloud_vm/migrations/0010_auto_20200303_1208.py delete mode 100644 uncloud/uncloud_vm/migrations/0011_vmdiskimageproduct_source_type.py delete mode 100644 uncloud/uncloud_vm/migrations/0012_vmdiskimageproduct_source.py delete mode 100644 uncloud/uncloud_vm/migrations/0013_auto_20200303_1845.py delete mode 100644 uncloud/uncloud_vm/migrations/0014_vmproduct_vmid.py diff --git a/uncloud/uncloud_vm/migrations/0001_initial.py b/uncloud/uncloud_vm/migrations/0001_initial.py index 6c3d54f..f9f40d8 100644 --- a/uncloud/uncloud_vm/migrations/0001_initial.py +++ b/uncloud/uncloud_vm/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.3 on 2020-03-03 16:50 +# Generated by Django 3.0.3 on 2020-03-05 10:34 from django.conf import settings from django.db import migrations, models @@ -11,7 +11,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('uncloud_pay', '__first__'), + ('uncloud_pay', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -25,6 +25,8 @@ class Migration(migrations.Migration): ('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)), @@ -39,16 +41,18 @@ class Migration(migrations.Migration): ('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'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), + ('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')), @@ -71,7 +75,7 @@ class Migration(migrations.Migration): name='VMSnapshotProduct', fields=[ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('status', models.CharField(choices=[('pending', 'Pending'), ('being_created', 'Being created'), ('active', 'Active'), ('deleted', 'Deleted')], default='pending', max_length=256)), + ('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')), @@ -86,7 +90,7 @@ class Migration(migrations.Migration): name='VMNetworkCard', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('mac_address', models.IntegerField()), + ('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')), ], diff --git a/uncloud/uncloud_vm/migrations/0009_auto_20200303_0927.py b/uncloud/uncloud_vm/migrations/0009_auto_20200303_0927.py deleted file mode 100644 index 7815f46..0000000 --- a/uncloud/uncloud_vm/migrations/0009_auto_20200303_0927.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-03 09:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0008_auto_20200229_1611'), - ] - - operations = [ - migrations.AddField( - model_name='vmhost', - name='vms', - field=models.TextField(default=''), - ), - migrations.AlterField( - model_name='vmdiskproduct', - name='size_in_gb', - field=models.FloatField(blank=True), - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0010_auto_20200303_1208.py b/uncloud/uncloud_vm/migrations/0010_auto_20200303_1208.py deleted file mode 100644 index 39a20e3..0000000 --- a/uncloud/uncloud_vm/migrations/0010_auto_20200303_1208.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-03 12:08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0009_auto_20200303_0927'), - ] - - operations = [ - migrations.AlterField( - model_name='vmnetworkcard', - name='mac_address', - field=models.BigIntegerField(), - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0011_vmdiskimageproduct_source_type.py b/uncloud/uncloud_vm/migrations/0011_vmdiskimageproduct_source_type.py deleted file mode 100644 index 3d445cf..0000000 --- a/uncloud/uncloud_vm/migrations/0011_vmdiskimageproduct_source_type.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-03 18:33 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0010_auto_20200303_1208'), - ] - - operations = [ - migrations.AddField( - model_name='vmdiskimageproduct', - name='source_type', - field=models.CharField(max_length=128, null=True), - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0012_vmdiskimageproduct_source.py b/uncloud/uncloud_vm/migrations/0012_vmdiskimageproduct_source.py deleted file mode 100644 index 4072d82..0000000 --- a/uncloud/uncloud_vm/migrations/0012_vmdiskimageproduct_source.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-03 18:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0011_vmdiskimageproduct_source_type'), - ] - - operations = [ - migrations.AddField( - model_name='vmdiskimageproduct', - name='source', - field=models.CharField(max_length=128, null=True), - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0013_auto_20200303_1845.py b/uncloud/uncloud_vm/migrations/0013_auto_20200303_1845.py deleted file mode 100644 index 55aed73..0000000 --- a/uncloud/uncloud_vm/migrations/0013_auto_20200303_1845.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-03 18:45 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0012_vmdiskimageproduct_source'), - ] - - operations = [ - migrations.RenameField( - model_name='vmdiskimageproduct', - old_name='source', - new_name='image_source', - ), - migrations.RenameField( - model_name='vmdiskimageproduct', - old_name='source_type', - new_name='image_source_type', - ), - ] diff --git a/uncloud/uncloud_vm/migrations/0014_vmproduct_vmid.py b/uncloud/uncloud_vm/migrations/0014_vmproduct_vmid.py deleted file mode 100644 index 4f43f77..0000000 --- a/uncloud/uncloud_vm/migrations/0014_vmproduct_vmid.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.3 on 2020-03-04 07:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_vm', '0013_auto_20200303_1845'), - ] - - operations = [ - migrations.AddField( - model_name='vmproduct', - name='vmid', - field=models.IntegerField(null=True), - ), - ] From ec7a2a3c3aad73c449656d792c226e8ada362957 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 5 Mar 2020 14:00:14 +0100 Subject: [PATCH 158/193] Correct pricing for VMProduct --- uncloud/uncloud_vm/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 7612d86..c0acee2 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -62,9 +62,9 @@ class VMProduct(Product): def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): # TODO: move magic numbers in variables if recurring_period == RecurringPeriod.PER_MONTH: - return self.cores * 3 + self.ram_in_gb * 2 + return self.cores * 3 + self.ram_in_gb * 4 elif recurring_period == RecurringPeriod.PER_HOUR: - return self.cores * 4.0/(30 * 24) + self.ram_in_gb * 3.0/(30* 24) + return self.cores * 4.0/(30 * 24) + self.ram_in_gb * 4.5/(30* 24) else: raise Exception('Invalid recurring period for VM Product pricing.') From 66e224e9262a458b3343f8b7ba2b0f00548732ae Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 5 Mar 2020 14:21:10 +0100 Subject: [PATCH 159/193] [storage] move choices to uncloud_storage --- uncloud/uncloud_storage/models.py | 6 +++++- uncloud/uncloud_vm/models.py | 17 +++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/uncloud/uncloud_storage/models.py b/uncloud/uncloud_storage/models.py index 71a8362..0dac5c2 100644 --- a/uncloud/uncloud_storage/models.py +++ b/uncloud/uncloud_storage/models.py @@ -1,3 +1,7 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ -# Create your models here. + +class StorageClass(models.TextChoices): + HDD = 'HDD', _('HDD') + SSD = 'SSD', _('SSD') diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index c0acee2..41a1e93 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -8,6 +8,7 @@ from django.contrib.auth import get_user_model from uncloud_pay.models import Product, RecurringPeriod import uncloud_pay.models as pay_models +import uncloud_storage.models STATUS_CHOICES = ( ('pending', 'Pending'), # Initial state @@ -52,9 +53,9 @@ class VMProduct(Product): VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True ) - # VM-specific. The name is only intended for customers: it's a pain te + # VM-specific. The name is only intended for customers: it's a pain to # remember IDs (speaking from experience as ungleich customer)! - name = models.CharField(max_length=32) + name = models.CharField(max_length=32, blank=True, null=True) cores = models.IntegerField() ram_in_gb = models.FloatField() vmid = models.IntegerField(null=True) @@ -106,14 +107,10 @@ class VMDiskImageProduct(models.Model): import_url = models.URLField(null=True, blank=True) image_source = models.CharField(max_length=128, null=True) image_source_type = models.CharField(max_length=128, null=True) - storage_class = models.CharField( - max_length=32, - choices=( - ('hdd', 'HDD'), - ('ssd', 'SSD'), - ), - default='ssd' - ) + + storage_class = models.CharField(max_length=32, + choices = uncloud_storage.models.StorageClass.choices, + default = uncloud_storage.models.StorageClass.SSD) status = models.CharField( max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT From 2a73f0e767e3593e0250cdabb8bd7d6d8d9cf3b8 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 5 Mar 2020 14:22:56 +0100 Subject: [PATCH 160/193] [migration] make vm name optional, use storage class choices Signed-off-by: Nico Schottelius --- .../migrations/0002_auto_20200305_1321.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py diff --git a/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py b/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py new file mode 100644 index 0000000..2711b33 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-03-05 13:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='vmdiskimageproduct', + name='storage_class', + field=models.CharField(choices=[('HDD', 'HDD'), ('SSD', 'SSD')], default='SSD', max_length=32), + ), + migrations.AlterField( + model_name='vmproduct', + name='name', + field=models.CharField(blank=True, max_length=32, null=True), + ), + ] From 139aca6a61879cebc4dc07ec782782d82ed0c2fe Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 5 Mar 2020 14:58:45 +0100 Subject: [PATCH 161/193] Remove vms field from vmhost Signed-off-by: Nico Schottelius --- .../migrations/0003_remove_vmhost_vms.py | 17 +++++++++++++++++ uncloud/uncloud_vm/models.py | 8 +++----- uncloud/uncloud_vm/serializers.py | 3 +++ 3 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py diff --git a/uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py b/uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py new file mode 100644 index 0000000..70ee863 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.3 on 2020-03-05 13:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0002_auto_20200305_1321'), + ] + + operations = [ + migrations.RemoveField( + model_name='vmhost', + name='vms', + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 41a1e93..3f07e1e 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -41,11 +41,9 @@ class VMHost(models.Model): max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT ) - # List of VMs running on this host - vms = models.TextField(default='') - - def get_vms(self): - return self.vms.split(',') + @property + def vms(self): + return VMProduct.objects.filter(vmhost=self) class VMProduct(Product): diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 3bb9298..7302116 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -13,9 +13,12 @@ GB_HDD_PER_DAY=0.0006 class VMHostSerializer(serializers.ModelSerializer): + vms = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + class Meta: model = VMHost fields = '__all__' + read_only_fields = [ 'vms' ] class VMDiskProductSerializer(serializers.ModelSerializer): From b8c2f80e452c6358eaf664a46561002687875405 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 5 Mar 2020 15:06:34 +0100 Subject: [PATCH 162/193] [vmhost] add available_ram_in_gb and available_cores --- uncloud/uncloud_vm/models.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 3f07e1e..c523e83 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -45,6 +45,14 @@ class VMHost(models.Model): def vms(self): return VMProduct.objects.filter(vmhost=self) + @property + def available_ram_in_gb(self): + return self.usable_ram_in_gb - sum([vm.ram_in_gb for vm in self.vms ]) + + @property + def available_cores(self): + return self.usable_cores - sum([vm.cores for vm in self.vms ]) + class VMProduct(Product): vmhost = models.ForeignKey( From efbe1c0596d487208d867f6411bb762d625081ed Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 5 Mar 2020 17:52:01 +0100 Subject: [PATCH 163/193] Merge commands into the "vm" command --- .../management/commands/schedulevms.py | 21 ----- uncloud/uncloud_vm/management/commands/vm.py | 85 +++++++++++++++++++ .../management/commands/vmhealth.py | 26 ------ 3 files changed, 85 insertions(+), 47 deletions(-) delete mode 100644 uncloud/uncloud_vm/management/commands/schedulevms.py create mode 100644 uncloud/uncloud_vm/management/commands/vm.py delete mode 100644 uncloud/uncloud_vm/management/commands/vmhealth.py diff --git a/uncloud/uncloud_vm/management/commands/schedulevms.py b/uncloud/uncloud_vm/management/commands/schedulevms.py deleted file mode 100644 index 836e100..0000000 --- a/uncloud/uncloud_vm/management/commands/schedulevms.py +++ /dev/null @@ -1,21 +0,0 @@ -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 VMProduct, VMHost - -class Command(BaseCommand): - help = 'Select VM Host for VMs' - - def add_arguments(self, parser): - pass - - def handle(self, *args, **options): - pending_vms = VMProduct.objects.filter(vmhost__isnull=True) - vmhosts = VMHost.objects.filter(status='active') - for vm in pending_vms: - print(vm) - # FIXME: implement smart placement diff --git a/uncloud/uncloud_vm/management/commands/vm.py b/uncloud/uncloud_vm/management/commands/vm.py new file mode 100644 index 0000000..c0e2783 --- /dev/null +++ b/uncloud/uncloud_vm/management/commands/vm.py @@ -0,0 +1,85 @@ +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 VMProduct, VMHost + +class Command(BaseCommand): + help = 'Select VM Host for VMs' + + def add_arguments(self, parser): + parser.add_argument('--schedule-vms', action='store_true') + parser.add_argument('--start-vms-here', action='store_true') + parser.add_argument('--check-health', action='store_true') + parser.add_argument('--vmhostname') + print(parser) + + + def handle(self, *args, **options): + print(args) + print(options) + + if options['schedule_vms']: + self.schedule_vms(args, option) + if options['start_vms_here']: + if not options['vmhostname']: + raise Exception("Argument vmhostname is required to know which vmhost we are on") + self.start_vms(args, options) + if options['check_health']: + self.check_health(args, option) + + def start_vms(self, *args, **options): + vmhost = VMHost.objects.get(status='active', + hostname=options['vmhostname']) + + if not vmhost: + print("No active vmhost {} exists".format(options['vmhostname'])) + return + + vms_to_start = VMProduct.objects.filter(vmhost=vmhost, + status='creating') + for vm in vms_to_start: + + """ run qemu: + check if VM is not already active / qemu running + prepare / create the Qemu arguments + + + """ + + def schedule_vms(self, *args, **options)): + pending_vms = VMProduct.objects.filter(vmhost__isnull=True) + vmhosts = VMHost.objects.filter(status='active') + + for vm in pending_vms: + print(vm) + + found_vmhost = False + for vmhost in vmhosts: + if vmhost.available_cores >= vm.cores and vmhost.available_ram_in_gb >= vm.ram_in_gb: + vm.vmhost = vmhost + vm.status = "creating" + vm.save() + found_vmhost = True + print("Scheduled VM {} on VMHOST {}".format(vm, vmhost)) + break + + if not found_vmhost: + print("Error: cannot schedule VM {}, no suitable host found".format(vm)) + + def check_health(self, *args, **options): + pending_vms = VMProduct.objects.filter(vmhost__isnull=True) + vmhosts = VMHost.objects.filter(status='active') + + # 1. Check that all active hosts reported back N seconds ago + # 2. Check that no VM is running on a dead host + # 3. Migrate VMs if necessary + # 4. Check that no VMs have been pending for longer than Y seconds + + # If VM snapshots exist without a VM -> notify user (?) + + + print("Nothing is good, you should implement me") diff --git a/uncloud/uncloud_vm/management/commands/vmhealth.py b/uncloud/uncloud_vm/management/commands/vmhealth.py deleted file mode 100644 index 9397b16..0000000 --- a/uncloud/uncloud_vm/management/commands/vmhealth.py +++ /dev/null @@ -1,26 +0,0 @@ -import json - -from django.core.management.base import BaseCommand -from django.contrib.auth import get_user_model - -from uncloud_vm.models import VMProduct, VMHost - -class Command(BaseCommand): - help = 'Check health of VMs and VMHosts' - - def add_arguments(self, parser): - pass - - def handle(self, *args, **options): - pending_vms = VMProduct.objects.filter(vmhost__isnull=True) - 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") From 0e6a6afd88bcd5a688b304735d82e86b93f0d07e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 5 Mar 2020 23:18:07 +0100 Subject: [PATCH 164/193] [opennebula] Fix fields/serializers --- uncloud/opennebula/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uncloud/opennebula/serializers.py b/uncloud/opennebula/serializers.py index 6bfaf56..64fe005 100644 --- a/uncloud/opennebula/serializers.py +++ b/uncloud/opennebula/serializers.py @@ -5,10 +5,10 @@ from opennebula.models import VM class VMSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VM - fields = ['id', 'owner', 'data'] + fields = '__all__' class OpenNebulaVMSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VM - fields = ['id', 'owner', 'cores', 'ram_in_gb', 'disks', 'last_host', 'graphics'] + fields = '__all__' From aa8336b7e411794a8984c6d7bddeb4be7c49208d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 5 Mar 2020 23:55:33 +0100 Subject: [PATCH 165/193] VM: def __str__ --- uncloud/uncloud_vm/models.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index c523e83..c3c9d38 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -75,6 +75,11 @@ class VMProduct(Product): else: raise Exception('Invalid recurring period for VM Product pricing.') + def __str__(self): + return "VM {} ({}): {} cores {} gb ram".format(self.uuid, + self.name, + self.cores, + self.ram_in_gb) @property def description(self): return "Virtual machine '{}': {} core(s), {}GB memory".format( From 4016c28c5f4100db0fd9ff3ecee6b0e8aa91812d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 11:27:43 +0100 Subject: [PATCH 166/193] Fix generate-bills --- uncloud/uncloud_pay/management/commands/generate-bills.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud/uncloud_pay/management/commands/generate-bills.py index a7dbe78..5bd4519 100644 --- a/uncloud/uncloud_pay/management/commands/generate-bills.py +++ b/uncloud/uncloud_pay/management/commands/generate-bills.py @@ -9,8 +9,6 @@ from datetime import timedelta, date from django.utils import timezone from uncloud_pay.models import Bill -BILL_PAYMENT_DELAY=timedelta(days=10) - logger = logging.getLogger(__name__) class Command(BaseCommand): @@ -31,8 +29,7 @@ class Command(BaseCommand): Bill.generate_for( year=now.year, month=now.month, - user=user, - allowed_delay=BILL_PAYMENT_DELAY) + user=user) # We're done for this round :-) print("=> Done.") From c41b55573a27e7830c50ec607727be24ebb7d47d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 6 Mar 2020 09:32:25 +0100 Subject: [PATCH 167/193] Fix order link in BillRecordSerializer --- uncloud/uncloud_pay/serializers.py | 44 ++++++++++++++++-------------- uncloud/uncloud_pay/views.py | 3 -- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index aa75fd9..d763590 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -47,27 +47,6 @@ class CreatePaymentMethodSerializer(serializers.ModelSerializer): model = PaymentMethod fields = ['source', 'description', 'primary', 'credit_card'] - -### -# Bills - -# TODO: remove magic numbers for decimal fields -class BillRecordSerializer(serializers.Serializer): - order = serializers.CharField() - 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 = ['owner', 'total', 'due_date', 'creation_date', - 'starting_date', 'ending_date', 'records', 'final'] - ### # Orders & Products. @@ -83,3 +62,26 @@ class OrderSerializer(serializers.ModelSerializer): 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 = ['owner', 'total', 'due_date', 'creation_date', + 'starting_date', 'ending_date', 'records', 'final'] diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 38d1aa4..57c284d 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -34,9 +34,6 @@ class BillViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return Bill.objects.filter(owner=self.request.user) - def unpaid(self, request): - return Bill.objects.filter(owner=self.request.user, paid=False) - class PaymentViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = PaymentSerializer permission_classes = [permissions.IsAuthenticated] From 658262c5993b2e3c18a9910920b09029cf2e948c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 6 Mar 2020 09:39:41 +0100 Subject: [PATCH 168/193] Add human readable reference to bills --- uncloud/uncloud_pay/models.py | 6 ++++++ uncloud/uncloud_pay/serializers.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 43064e4..32d3eac 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -180,6 +180,12 @@ class Bill(models.Model): 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 = [] diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index d763590..60ddc75 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -83,5 +83,5 @@ class BillSerializer(serializers.ModelSerializer): records = BillRecordSerializer(many=True, read_only=True) class Meta: model = Bill - fields = ['owner', 'total', 'due_date', 'creation_date', + fields = ['reference', 'owner', 'total', 'due_date', 'creation_date', 'starting_date', 'ending_date', 'records', 'final'] From 263125048da3c0fd9a25df916f63b92072a239f4 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 6 Mar 2020 11:10:20 +0100 Subject: [PATCH 169/193] Begin to introduce a DCL alike view for VMs --- uncloud/uncloud_vm/models.py | 6 +++++ uncloud/uncloud_vm/serializers.py | 16 +++++++++++ uncloud/uncloud_vm/views.py | 45 +++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index c3c9d38..60dfc0a 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -127,6 +127,12 @@ class VMDiskImageProduct(models.Model): max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT ) + def __str__(self): + return "VMDiskImage {} ({}): {} gb".format(self.uuid, + self.name, + self.size_in_gb) + + class VMDiskProduct(models.Model): """ diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 7302116..c92f108 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -42,6 +42,22 @@ class VMProductSerializer(serializers.HyperlinkedModelSerializer): 'cores', 'ram_in_gb', 'recurring_period'] read_only_fields = ['uuid', 'order', 'owner', 'status'] + +class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer): + """ + Create an interface similar to standard DCL + """ + + # Custom field used at creation (= ordering) only. + recurring_period = serializers.ChoiceField( + choices=VMProduct.allowed_recurring_periods()) + + os_disk_uuid = serializers.UUIDField() + # os_disk_size = + + class Meta: + model = VMProduct + class ManagedVMProductSerializer(serializers.ModelSerializer): """ Managed VM serializer used in ungleich_service app. diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 052f521..faac214 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -148,3 +148,48 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet): gb_hdd=hdds_size) return Response(serializer.data) + + + +# Also create: +# - /dcl/available_os +# Basically a view of public and my disk images +# - +class DCLCreateVMProductViewSet(ProductViewSet): + """ + This view resembles the way how DCL VMs are created by default. + + The user chooses an OS, os disk size, ram, cpu and whether or not to have a mapped IPv4 address + """ + + permission_classes = [permissions.IsAuthenticated] + serializer_class = DCLVMProductSerializer + + def get_queryset(self): + return VMProduct.objects.filter(owner=self.request.user) + + # Use a database transaction so that we do not get half-created structure + # if something goes wrong. + @transaction.atomic + def create(self, request): + # Extract serializer data. + serializer = VMProductSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + order_recurring_period = serializer.validated_data.pop("recurring_period") + + # Create base order. + order = Order.objects.create( + recurring_period=order_recurring_period, + owner=request.user + ) + order.save() + + # Create VM. + vm = serializer.save(owner=request.user, order=order) + + # Add Product record to order (VM is mutable, allows to keep history in order). + # XXX: Move this to some kind of on_create hook in parent Product class? + order.add_record(vm.one_time_price, + vm.recurring_price(order.recurring_period), vm.description) + + return Response(serializer.data) From 47148454f606e6dab80bb17c0c718e6a73671820 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 6 Mar 2020 11:11:16 +0100 Subject: [PATCH 170/193] s/_/-/ for bill id --- uncloud/uncloud_pay/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 32d3eac..17afbcb 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -182,7 +182,7 @@ class Bill(models.Model): @property def reference(self): - return "{}_{}".format( + return "{}-{}".format( self.owner.username, self.creation_date.strftime("%Y-%m-%d-%H%M")) From b15a12dc71a7da818c22d1fe95ba5e7f3a832aaf Mon Sep 17 00:00:00 2001 From: meow Date: Fri, 13 Mar 2020 14:22:49 +0500 Subject: [PATCH 171/193] Missing import for DCLVMProductSerializer --- uncloud/uncloud_vm/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index faac214..cac743c 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -11,7 +11,9 @@ from rest_framework.exceptions import ValidationError from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct from uncloud_pay.models import Order -from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer, VMDiskImageProductSerializer, VMDiskProductSerializer +from .serializers import (VMHostSerializer, VMProductSerializer, + VMSnapshotProductSerializer, VMDiskImageProductSerializer, + VMDiskProductSerializer, DCLVMProductSerializer) from uncloud_pay.helpers import ProductViewSet From 8f4e7cca1b705cb34d6e4b291854b6094155f192 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 12:46:02 +0100 Subject: [PATCH 172/193] add migrations to ungleich_service so tests don't fail Signed-off-by: Nico Schottelius --- .../migrations/0001_initial.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 uncloud/ungleich_service/migrations/0001_initial.py diff --git a/uncloud/ungleich_service/migrations/0001_initial.py b/uncloud/ungleich_service/migrations/0001_initial.py new file mode 100644 index 0000000..5b843c8 --- /dev/null +++ b/uncloud/ungleich_service/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 3.0.3 on 2020-03-17 11:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('uncloud_vm', '0003_remove_vmhost_vms'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='MatrixServiceProduct', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted')], default='PENDING', max_length=32)), + ('domain', models.CharField(default='domain.tld', max_length=255)), + ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')), + ], + options={ + 'abstract': False, + }, + ), + ] From 723d2a99ccd9cd5d44f1a26af0be84e5383312e6 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 13:30:48 +0100 Subject: [PATCH 173/193] =?UTF-8?q?add=20django=E2=80=A6extensions=20to=20?= =?UTF-8?q?support=20"graph=5Fmodels"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- uncloud/requirements.txt | 2 ++ uncloud/uncloud/settings.py | 1 + 2 files changed, 3 insertions(+) diff --git a/uncloud/requirements.txt b/uncloud/requirements.txt index 1b4e05b..c8a15d3 100644 --- a/uncloud/requirements.txt +++ b/uncloud/requirements.txt @@ -4,3 +4,5 @@ django-auth-ldap stripe xmltodict psycopg2 + +parsedatetime diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index cc0ec3a..99cf7a1 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -57,6 +57,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django_extensions', 'rest_framework', 'uncloud_pay', 'uncloud_auth', From 8356404fe424aba1bb179a34be2f0124ac3a05b0 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 14:49:36 +0100 Subject: [PATCH 174/193] ++ product readme --- uncloud/README-how-to-create-a-product.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 uncloud/README-how-to-create-a-product.md diff --git a/uncloud/README-how-to-create-a-product.md b/uncloud/README-how-to-create-a-product.md new file mode 100644 index 0000000..6ddd1fa --- /dev/null +++ b/uncloud/README-how-to-create-a-product.md @@ -0,0 +1,9 @@ +## Introduction + +This document describes how to create a product and use it. + +A product (like a VMSnapshotproduct) creates an order when ordered. +The "order" is used to combine products together. + +Sub-products or related products link to the same order. +Each product has one (?) orderrecord From ac7ea86668b6dfcd4065ae38aba3ebfe83a8a539 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 14:49:49 +0100 Subject: [PATCH 175/193] rename opennebula commands --- .../commands/{synchost.py => opennebula-synchosts.py} | 10 +++++----- .../commands/{syncvm.py => opennebula-syncvms.py} | 0 ...e-one-vm-to-regular.py => opennebula-to-uncloud.py} | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename uncloud/opennebula/management/commands/{synchost.py => opennebula-synchosts.py} (90%) rename uncloud/opennebula/management/commands/{syncvm.py => opennebula-syncvms.py} (100%) rename uncloud/opennebula/management/commands/{migrate-one-vm-to-regular.py => opennebula-to-uncloud.py} (98%) diff --git a/uncloud/opennebula/management/commands/synchost.py b/uncloud/opennebula/management/commands/opennebula-synchosts.py similarity index 90% rename from uncloud/opennebula/management/commands/synchost.py rename to uncloud/opennebula/management/commands/opennebula-synchosts.py index 6e4ea0f..29f9ac1 100644 --- a/uncloud/opennebula/management/commands/synchost.py +++ b/uncloud/opennebula/management/commands/opennebula-synchosts.py @@ -57,17 +57,17 @@ class Command(BaseCommand): usable_ram_in_kb = int(host_share.get('TOTAL_MEM', 0)) usable_ram_in_gb = int(usable_ram_in_kb / 2 ** 20) - vms = host.get('VMS', {}) or {} - vms = vms.get('ID', []) or [] - vms = ','.join(vms) + # 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, - 'vms': vms + 'status': status } ) else: diff --git a/uncloud/opennebula/management/commands/syncvm.py b/uncloud/opennebula/management/commands/opennebula-syncvms.py similarity index 100% rename from uncloud/opennebula/management/commands/syncvm.py rename to uncloud/opennebula/management/commands/opennebula-syncvms.py diff --git a/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py similarity index 98% rename from uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py rename to uncloud/opennebula/management/commands/opennebula-to-uncloud.py index 68cf1f2..2f91f83 100644 --- a/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py +++ b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py @@ -83,7 +83,7 @@ class Command(BaseCommand): def handle(self, *args, **options): for one_vm in VMModel.objects.all(): # Host on which the VM is currently residing - host = VMHost.objects.filter(vms__icontains=one_vm.vmid).first() + #host = VMHost.objects.filter(vms__icontains=one_vm.vmid).first() # VCPU, RAM, Owner, Status # TODO: Set actual status instead of hard coded 'active' From 8634d667d5267a2565b37fd532742e6020767101 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 14:49:59 +0100 Subject: [PATCH 176/193] update requirements for graphing --- uncloud/requirements.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/uncloud/requirements.txt b/uncloud/requirements.txt index c8a15d3..c7ebc65 100644 --- a/uncloud/requirements.txt +++ b/uncloud/requirements.txt @@ -6,3 +6,8 @@ xmltodict psycopg2 parsedatetime + +# Follow are for creating graph models +pyparsing +pydot +django-extensions From 55bd42fe64707b43c8ec713c83fde5760b5e6a6b Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 14:50:14 +0100 Subject: [PATCH 177/193] List all VMs for admins --- uncloud/uncloud_vm/views.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index cac743c..e1bbd22 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -85,7 +85,12 @@ class VMProductViewSet(ProductViewSet): serializer_class = VMProductSerializer def get_queryset(self): - return VMProduct.objects.filter(owner=self.request.user) + if self.request.user.is_superuser: + obj = VMProduct.objects.all() + else: + obj = VMProduct.objects.filter(owner=self.request.user) + + return obj # Use a database transaction so that we do not get half-created structure # if something goes wrong. From 9f4b927c742ffe9389662ff471f7638ad3315784 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 14:50:28 +0100 Subject: [PATCH 178/193] Introduce mirations to ungleich_service to make tests work --- uncloud/ungleich_service/migrations/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 uncloud/ungleich_service/migrations/__init__.py diff --git a/uncloud/ungleich_service/migrations/__init__.py b/uncloud/ungleich_service/migrations/__init__.py new file mode 100644 index 0000000..e69de29 From 5d840de55c04920ea269992c0402090048642b11 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 15:39:24 +0100 Subject: [PATCH 179/193] [opennebula] refresh formula, cleanup vm import/migration to uncloud --- .../commands/opennebula-to-uncloud.py | 34 +++++++++---------- uncloud/opennebula/models.py | 6 ++-- uncloud/opennebula/serializers.py | 6 ---- uncloud/opennebula/views.py | 29 +++++----------- 4 files changed, 29 insertions(+), 46 deletions(-) diff --git a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py index 2f91f83..7b4b864 100644 --- a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py +++ b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py @@ -21,9 +21,8 @@ def convert_mac_to_int(mac_address: str): return mac_address -def get_vm_price(core, ram, storage, n_of_ipv4, n_of_ipv6): - storage = storage / 10 # Division by 10 because our base storage unit is 10 GB - total = 3 * core + 4 * ram + 3.5 * storage + 8 * n_of_ipv4 + 0 * n_of_ipv6 +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 @@ -82,17 +81,18 @@ class Command(BaseCommand): def handle(self, *args, **options): for one_vm in VMModel.objects.all(): - # Host on which the VM is currently residing - #host = VMHost.objects.filter(vms__icontains=one_vm.vmid).first() - # VCPU, RAM, Owner, Status - # TODO: Set actual status instead of hard coded 'active' - vm_id, cores, ram_in_gb = one_vm.vmid, one_vm.cores, one_vm.ram_in_gb - owner, status = one_vm.owner, 'active' + vmhost = VMHost.objects.get(hostname=one_vm.last_host) + cores = one_vm.cores + ram_in_gb = one_vm.ram_in_gb + owner = one_vm.owner + status = 'active' # Total Amount of SSD Storage # TODO: What would happen if the attached storage is not SSD but HDD? - total_storage_in_gb = sum([disk['size_in_gb'] for disk in one_vm.disks]) + + ssd_size = sum([ disk['size_in_gb'] for disk in one.disks if disk['pool_name'] in ['ssd', 'one'] ]) + hdd_size = sum([ disk['size_in_gb'] for disk in one.disks if disk['pool_name'] in ['hdd'] ]) # List of IPv4 addresses and Global IPv6 addresses ipv4, ipv6 = one_vm.ips @@ -101,18 +101,18 @@ class Command(BaseCommand): # instead of pseudo one we are putting currently creation_date = starting_date = ending_date = datetime.now(tz=timezone.utc) - # Price calculation - - # TODO: Make the following non-hardcoded + # 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)) - recurring_price = get_vm_price(cores, ram_in_gb, total_storage_in_gb, len(ipv4), len(ipv6)) try: - vm_product = VMProduct.objects.get(vmid=vm_id) + vm_product = VMProduct.objects.get(name=one_vm.uncloud_name) except VMProduct.DoesNotExist: order = Order.objects.create( - owner=one_vm.owner, + owner=owner, creation_date=creation_date, starting_date=starting_date, ending_date=ending_date, @@ -121,7 +121,7 @@ class Command(BaseCommand): recurring_period=recurring_period ) vm_product, _ = VMProduct.objects.update_or_create( - vmid=vm_id, + name= defaults={ 'cores': cores, 'ram_in_gb': ram_in_gb, diff --git a/uncloud/opennebula/models.py b/uncloud/opennebula/models.py index 0748ff5..f5faeb5 100644 --- a/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -9,9 +9,9 @@ class VM(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) data = JSONField() - def save(self, *args, **kwargs): - self.id = 'opennebula' + str(self.data.get("ID")) - super().save(*args, **kwargs) + @property + def uncloud_name(self): + return "opennebula-{}".format(self.vmid) @property def cores(self): diff --git a/uncloud/opennebula/serializers.py b/uncloud/opennebula/serializers.py index 64fe005..8e0c513 100644 --- a/uncloud/opennebula/serializers.py +++ b/uncloud/opennebula/serializers.py @@ -2,12 +2,6 @@ from rest_framework import serializers from opennebula.models import VM -class VMSerializer(serializers.HyperlinkedModelSerializer): - class Meta: - model = VM - fields = '__all__' - - class OpenNebulaVMSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VM diff --git a/uncloud/opennebula/views.py b/uncloud/opennebula/views.py index 61ed5a4..89b1a52 100644 --- a/uncloud/opennebula/views.py +++ b/uncloud/opennebula/views.py @@ -1,27 +1,16 @@ from rest_framework import viewsets, permissions -from rest_framework.response import Response -from django.shortcuts import get_object_or_404 from .models import VM -from .serializers import VMSerializer, OpenNebulaVMSerializer +from .serializers import OpenNebulaVMSerializer - -class RawVMViewSet(viewsets.ModelViewSet): - queryset = VM.objects.all() - serializer_class = VMSerializer - permission_classes = [permissions.IsAdminUser] - - -class VMViewSet(viewsets.ViewSet): +class VMViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] + serializer_class = OpenNebulaVMSerializer - def list(self, request): - queryset = VM.objects.filter(owner=request.user) - serializer = OpenNebulaVMSerializer(queryset, many=True, context={'request': request}) - return Response(serializer.data) + def get_queryset(self): + if self.request.user.is_superuser: + obj = VM.objects.all() + else: + obj = VM.objects.filter(owner=self.request.user) - def retrieve(self, request, pk=None): - queryset = VM.objects.filter(owner=request.user) - vm = get_object_or_404(queryset, pk=pk) - serializer = OpenNebulaVMSerializer(vm, context={'request': request}) - return Response(serializer.data) + return obj From cc2efa5c145e884f06ce42a222a3575a49b0f704 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 15:40:08 +0100 Subject: [PATCH 180/193] Remove old opennebula view, remove vmid field --- uncloud/uncloud/urls.py | 1 - .../migrations/0004_remove_vmproduct_vmid.py | 17 +++++++++++++++++ uncloud/uncloud_vm/models.py | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index d7ee153..29575e9 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -72,7 +72,6 @@ router.register(r'admin/payment', payviews.AdminPaymentViewSet, basename='admin/ router.register(r'admin/order', payviews.AdminOrderViewSet, basename='admin/order') router.register(r'admin/vmhost', vmviews.VMHostViewSet) router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') -router.register(r'admin/opennebula_raw', oneviews.RawVMViewSet, basename='opennebula_raw') urlpatterns = [ diff --git a/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py b/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py new file mode 100644 index 0000000..5f44b57 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.3 on 2020-03-17 14:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0003_remove_vmhost_vms'), + ] + + operations = [ + migrations.RemoveField( + model_name='vmproduct', + name='vmid', + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 60dfc0a..2bb27e9 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -64,7 +64,7 @@ class VMProduct(Product): name = models.CharField(max_length=32, blank=True, null=True) cores = models.IntegerField() ram_in_gb = models.FloatField() - vmid = models.IntegerField(null=True) + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): # TODO: move magic numbers in variables From b9473c180306b79df3b1c1435f14bbacf15e92ee Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 16:03:41 +0100 Subject: [PATCH 181/193] ++ fix opennebula migration --- .../commands/opennebula-to-uncloud.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py index 7b4b864..dc7cb45 100644 --- a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py +++ b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py @@ -5,6 +5,7 @@ from django.utils import timezone from opennebula.models import VM as VMModel from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct + from uncloud_pay.models import Order @@ -82,7 +83,16 @@ class Command(BaseCommand): def handle(self, *args, **options): for one_vm in VMModel.objects.all(): - vmhost = VMHost.objects.get(hostname=one_vm.last_host) + if not one_vm.last_host: + print("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: + print("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 @@ -91,15 +101,15 @@ class Command(BaseCommand): # Total Amount of SSD Storage # TODO: What would happen if the attached storage is not SSD but HDD? - ssd_size = sum([ disk['size_in_gb'] for disk in one.disks if disk['pool_name'] in ['ssd', 'one'] ]) - hdd_size = sum([ disk['size_in_gb'] for disk in one.disks if disk['pool_name'] in ['hdd'] ]) + 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 = ending_date = datetime.now(tz=timezone.utc) + creation_date = starting_date = datetime.now(tz=timezone.utc) # Price calculation based on datacenterlight.ch one_time_price = 0 @@ -114,19 +124,18 @@ class Command(BaseCommand): order = Order.objects.create( owner=owner, creation_date=creation_date, - starting_date=starting_date, - ending_date=ending_date, - one_time_price=one_time_price, - recurring_price=recurring_price, - recurring_period=recurring_period + starting_date=starting_date +# one_time_price=one_time_price, +# recurring_price=recurring_price, +# recurring_period=recurring_period ) vm_product, _ = VMProduct.objects.update_or_create( - name= + name=one_vm.uncloud_name, defaults={ 'cores': cores, 'ram_in_gb': ram_in_gb, 'owner': owner, - 'vmhost': host, + 'vmhost': vmhost, 'order': order, 'status': status } From 6a382fab23cdb26701faad33e4a0a83ae1ee43bf Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 19:07:00 +0100 Subject: [PATCH 182/193] [vmhost] add used_ram_in_gb --- uncloud/uncloud_vm/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 2bb27e9..70ffd80 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -45,6 +45,10 @@ class VMHost(models.Model): def vms(self): return VMProduct.objects.filter(vmhost=self) + @property + def used_ram_in_gb(self): + return sum([vm.ram_in_gb for vm in VMProduct.objects.filter(vmhost=self)]) + @property def available_ram_in_gb(self): return self.usable_ram_in_gb - sum([vm.ram_in_gb for vm in self.vms ]) From 2f1aee818113d41506e4817af4c8fd29048fca47 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 19:53:14 +0100 Subject: [PATCH 183/193] Can create a VMSnapshot w/ order (bugs to be removed) --- uncloud/uncloud_pay/helpers.py | 6 +++--- uncloud/uncloud_vm/serializers.py | 2 +- uncloud/uncloud_vm/views.py | 22 ++++++++++++++-------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index d02b916..f791564 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -16,9 +16,9 @@ def end_of_month(year, month): hour=23, minute=59, second=59, tzinfo=tz) class ProductViewSet(mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.ListModelMixin, - GenericViewSet): + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + GenericViewSet): """ A customer-facing viewset that provides default `create()`, `retrieve()` and `list()`. diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index c92f108..75bcabe 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -31,7 +31,7 @@ class VMDiskImageProductSerializer(serializers.ModelSerializer): model = VMDiskImageProduct fields = '__all__' -class VMProductSerializer(serializers.HyperlinkedModelSerializer): +class VMProductSerializer(serializers.ModelSerializer): # Custom field used at creation (= ordering) only. recurring_period = serializers.ChoiceField( choices=VMProduct.allowed_recurring_periods()) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index e1bbd22..7b5fa4f 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -29,7 +29,13 @@ class VMDiskImageProductMineViewSet(viewsets.ModelViewSet): serializer_class = VMDiskImageProductSerializer def get_queryset(self): - return VMDiskImageProduct.objects.filter(owner=self.request.user) + if self.request.user.is_superuser: + obj = VMDiskImageProduct.objects.all() + else: + obj = VMDiskImageProduct.objects.filter(owner=self.request.user) + + return obj + def create(self, request): serializer = VMDiskImageProductSerializer(data=request.data, context={'request': request}) @@ -132,9 +138,10 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet): # This verifies that the VM belongs to the request user serializer.is_valid(raise_exception=True) - disks = VMDiskProduct.objects.filter(vm=serializer.validated_data['vm']) - ssds_size = sum([d.size_in_gb for d in disks if d.storage_class == 'ssd']) - hdds_size = sum([d.size_in_gb for d in disks if d.storage_class == 'hdd']) + vm = vm=serializer.validated_data['vm'] + disks = VMDiskProduct.objects.filter(vm=vm) + ssds_size = sum([d.size_in_gb for d in disks if d.image.storage_class == 'ssd']) + hdds_size = sum([d.size_in_gb for d in disks if d.image.storage_class == 'hdd']) recurring_price = serializer.pricing['per_gb_ssd'] * ssds_size + serializer.pricing['per_gb_hdd'] * hdds_size recurring_period = serializer.pricing['recurring_period'] @@ -142,12 +149,11 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet): # Create order now = datetime.datetime.now() order = Order(owner=request.user, - creation_date=now, - starting_date=now, - recurring_price=recurring_price, - one_time_price=0, recurring_period=recurring_period) order.save() + order.add_record(one_time_price=0, + recurring_price=recurring_price, + description="Snapshot of VM {} from {}".format(vm, now)) serializer.save(owner=request.user, order=order, From cd01f62fdef8d96405588569d0af62f900c5d9ff Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 18 Mar 2020 14:36:40 +0100 Subject: [PATCH 184/193] Move user view to uncloud_auth --- uncloud/uncloud/urls.py | 5 ++++- uncloud/uncloud_auth/serializers.py | 14 ++++++++++++++ uncloud/uncloud_auth/views.py | 16 ++++++++++++++++ uncloud/uncloud_pay/models.py | 4 +--- uncloud/uncloud_pay/serializers.py | 14 -------------- uncloud/uncloud_pay/views.py | 6 ------ 6 files changed, 35 insertions(+), 24 deletions(-) create mode 100644 uncloud/uncloud_auth/serializers.py create mode 100644 uncloud/uncloud_auth/views.py diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 29575e9..856e59c 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -22,6 +22,7 @@ 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() @@ -56,7 +57,6 @@ router.register(r'service/matrix', serviceviews.MatrixServiceProductViewSet, bas # Pay -router.register(r'user', payviews.UserViewSet, basename='user') 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') @@ -73,6 +73,9 @@ router.register(r'admin/order', payviews.AdminOrderViewSet, basename='admin/orde router.register(r'admin/vmhost', vmviews.VMHostViewSet) router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') +# User/Account +router.register(r'user', authviews.UserViewSet, basename='user') + urlpatterns = [ path('', include(router.urls)), diff --git a/uncloud/uncloud_auth/serializers.py b/uncloud/uncloud_auth/serializers.py new file mode 100644 index 0000000..cd05112 --- /dev/null +++ b/uncloud/uncloud_auth/serializers.py @@ -0,0 +1,14 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers +from uncloud_pay.models import get_balance_for_user + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ['username', 'email', 'balance'] + + # Display current 'balance' + balance = serializers.SerializerMethodField('get_balance') + + def get_balance(self, user): + return get_balance_for_user(user) diff --git a/uncloud/uncloud_auth/views.py b/uncloud/uncloud_auth/views.py new file mode 100644 index 0000000..40b8408 --- /dev/null +++ b/uncloud/uncloud_auth/views.py @@ -0,0 +1,16 @@ +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. + obj = get_user_model().objects.filter(username=self.request.user.username) + + return obj diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 17afbcb..63f351a 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -44,10 +44,8 @@ class ProductStatus(models.TextChoices): ACTIVE = 'ACTIVE', _('Active') DELETED = 'DELETED', _('Deleted') -### -# Users. -def get_balance_for(user): +def get_balance_for_user(user): bills = reduce( lambda acc, entry: acc + entry.total, Bill.objects.filter(owner=user), diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 60ddc75..a0a8635 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -2,20 +2,6 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import * -### -# Users. - -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = get_user_model() - fields = ['username', 'email', 'balance'] - - # Display current 'balance' - balance = serializers.SerializerMethodField('get_balance') - - def get_balance(self, user): - return get_balance_for(user) - ### # Payments and Payment Methods. diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 57c284d..e86a464 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -48,12 +48,6 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return Order.objects.filter(owner=self.request.user) -class UserViewSet(viewsets.ReadOnlyModelViewSet): - serializer_class = UserSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - return get_user_model().objects.all() class PaymentMethodViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] From c6a9bd4363a1b039ad2983cfb77f4b31e8bf0773 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 18 Mar 2020 14:53:26 +0100 Subject: [PATCH 185/193] Make balance a user attribute + decimalfield --- uncloud/uncloud/__init__.py | 4 +++ .../migrations/0002_auto_20200318_1343.py | 25 +++++++++++++++++++ .../migrations/0003_auto_20200318_1345.py | 23 +++++++++++++++++ uncloud/uncloud_auth/models.py | 20 ++++++++++++++- uncloud/uncloud_auth/serializers.py | 13 +++++----- uncloud/uncloud_auth/views.py | 3 ++- uncloud/uncloud_pay/models.py | 12 ++++----- 7 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py create mode 100644 uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py diff --git a/uncloud/uncloud/__init__.py b/uncloud/uncloud/__init__.py index e69de29..9e2545a 100644 --- a/uncloud/uncloud/__init__.py +++ b/uncloud/uncloud/__init__.py @@ -0,0 +1,4 @@ +# Define DecimalField properties, used to represent amounts of money. +# Used in pay and auth +AMOUNT_MAX_DIGITS=10 +AMOUNT_DECIMALS=2 diff --git a/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py b/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py new file mode 100644 index 0000000..ad2654f --- /dev/null +++ b/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.3 on 2020-03-18 13:43 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_auth', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='amount', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AddField( + model_name='user', + name='maximum_credit', + field=models.FloatField(default=0), + preserve_default=False, + ), + ] diff --git a/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py b/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py new file mode 100644 index 0000000..31b1717 --- /dev/null +++ b/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-03-18 13:45 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_auth', '0002_auto_20200318_1343'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='amount', + ), + migrations.AlterField( + model_name='user', + name='maximum_credit', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/uncloud/uncloud_auth/models.py b/uncloud/uncloud_auth/models.py index 3d30525..aef1e20 100644 --- a/uncloud/uncloud_auth/models.py +++ b/uncloud/uncloud_auth/models.py @@ -1,5 +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): - pass + """ + We use the standard user and add a maximum negative credit that is allowed + to be accumulated + """ + + maximum_credit = models.DecimalField( + default=0.0, + max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS, + validators=[MinValueValidator(0)]) + + @property + def balance(self): + return get_balance_for_user(self) diff --git a/uncloud/uncloud_auth/serializers.py b/uncloud/uncloud_auth/serializers.py index cd05112..3627149 100644 --- a/uncloud/uncloud_auth/serializers.py +++ b/uncloud/uncloud_auth/serializers.py @@ -1,14 +1,13 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from uncloud_pay.models import get_balance_for_user + +from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS class UserSerializer(serializers.ModelSerializer): + class Meta: model = get_user_model() - fields = ['username', 'email', 'balance'] + fields = ['username', 'email', 'balance', 'maximum_credit' ] - # Display current 'balance' - balance = serializers.SerializerMethodField('get_balance') - - def get_balance(self, user): - return get_balance_for_user(user) + balance = serializers.DecimalField(max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS) diff --git a/uncloud/uncloud_auth/views.py b/uncloud/uncloud_auth/views.py index 40b8408..2f78e1f 100644 --- a/uncloud/uncloud_auth/views.py +++ b/uncloud/uncloud_auth/views.py @@ -10,7 +10,8 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): 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. + # matching on the username. But I don't know a "nicer" way. + # Nico, 2020-03-18 obj = get_user_model().objects.filter(username=self.request.user.username) return obj diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 63f351a..a11c3c1 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -14,14 +14,14 @@ from math import ceil from datetime import timedelta from calendar import monthrange -import uncloud_pay.stripe -from uncloud_pay.helpers import beginning_of_month, end_of_month - from decimal import Decimal -# Define DecimalField properties, used to represent amounts of money. -AMOUNT_MAX_DIGITS=10 -AMOUNT_DECIMALS=2 +import uncloud_pay.stripe +from uncloud_pay.helpers import beginning_of_month, end_of_month +from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS + + + # Used to generate bill due dates. BILL_PAYMENT_DELAY=timedelta(days=10) From 4b4cbbf009a1146800ae0374993e0bec6a0165da Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 18 Mar 2020 15:19:06 +0100 Subject: [PATCH 186/193] Also list snapshots for a VM --- uncloud/uncloud_auth/models.py | 4 ++-- uncloud/uncloud_vm/models.py | 4 +++- uncloud/uncloud_vm/serializers.py | 16 ++++++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/uncloud/uncloud_auth/models.py b/uncloud/uncloud_auth/models.py index aef1e20..c3a0912 100644 --- a/uncloud/uncloud_auth/models.py +++ b/uncloud/uncloud_auth/models.py @@ -8,8 +8,8 @@ from uncloud_pay.models import get_balance_for_user class User(AbstractUser): """ - We use the standard user and add a maximum negative credit that is allowed - to be accumulated + 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( diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 70ffd80..57b54cf 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -186,4 +186,6 @@ class VMSnapshotProduct(Product): gb_ssd = models.FloatField(editable=False) gb_hdd = models.FloatField(editable=False) - vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + vm = models.ForeignKey(VMProduct, + related_name='snapshots', + on_delete=models.CASCADE) diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 75bcabe..f759d01 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -32,16 +32,20 @@ class VMDiskImageProductSerializer(serializers.ModelSerializer): fields = '__all__' class VMProductSerializer(serializers.ModelSerializer): - # Custom field used at creation (= ordering) only. - recurring_period = serializers.ChoiceField( - choices=VMProduct.allowed_recurring_periods()) - class Meta: model = VMProduct - fields = ['uuid', 'order', 'owner', 'status', 'name', \ - 'cores', 'ram_in_gb', 'recurring_period'] + fields = ['uuid', 'order', 'owner', 'status', 'name', + 'cores', 'ram_in_gb', 'recurring_period', + 'snapshots' ] read_only_fields = ['uuid', 'order', 'owner', 'status'] + # Custom field used at creation (= ordering) only. + recurring_period = serializers.ChoiceField( + choices=VMProduct.allowed_recurring_periods()) + + snapshots = serializers.PrimaryKeyRelatedField(many=True, + read_only=True) + class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer): """ From a32f7522b551deaeb3f7dbf0a5534762a49f9b51 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 18 Mar 2020 15:43:01 +0100 Subject: [PATCH 187/193] Relate VM to disks and snapshots --- uncloud/uncloud_vm/models.py | 4 +++- uncloud/uncloud_vm/serializers.py | 35 ++++++++++++++++++------------- uncloud/uncloud_vm/views.py | 7 ++++++- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 57b54cf..7e38ded 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -152,7 +152,9 @@ class VMDiskProduct(models.Model): on_delete=models.CASCADE, editable=False) - vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + vm = models.ForeignKey(VMProduct, + related_name='disks', + on_delete=models.CASCADE) image = models.ForeignKey(VMDiskImageProduct, on_delete=models.CASCADE) size_in_gb = models.FloatField(blank=True) diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index f759d01..96454f7 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -31,20 +31,6 @@ class VMDiskImageProductSerializer(serializers.ModelSerializer): model = VMDiskImageProduct fields = '__all__' -class VMProductSerializer(serializers.ModelSerializer): - class Meta: - model = VMProduct - fields = ['uuid', 'order', 'owner', 'status', 'name', - 'cores', 'ram_in_gb', 'recurring_period', - 'snapshots' ] - read_only_fields = ['uuid', 'order', 'owner', 'status'] - - # Custom field used at creation (= ordering) only. - recurring_period = serializers.ChoiceField( - choices=VMProduct.allowed_recurring_periods()) - - snapshots = serializers.PrimaryKeyRelatedField(many=True, - read_only=True) class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer): @@ -92,3 +78,24 @@ class VMSnapshotProductSerializer(serializers.ModelSerializer): pricing['per_gb_ssd'] = 0.012 pricing['per_gb_hdd'] = 0.0006 pricing['recurring_period'] = 'per_day' + +class VMProductSerializer(serializers.ModelSerializer): + class Meta: + model = VMProduct + fields = ['uuid', 'order', 'owner', 'status', 'name', + 'cores', 'ram_in_gb', 'recurring_period', + 'snapshots', 'disks' ] + read_only_fields = ['uuid', 'order', 'owner', 'status'] + + # Custom field used at creation (= ordering) only. + recurring_period = serializers.ChoiceField( + choices=VMProduct.allowed_recurring_periods()) + + # snapshots = serializers.PrimaryKeyRelatedField(many=True, + # read_only=True) + + snapshots = VMSnapshotProductSerializer(many=True, + read_only=True) + + disks = VMDiskProductSerializer(many=True, + read_only=True) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 7b5fa4f..1ef4974 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -65,7 +65,12 @@ class VMDiskProductViewSet(viewsets.ModelViewSet): serializer_class = VMDiskProductSerializer def get_queryset(self): - return VMDiskProduct.objects.filter(owner=self.request.user) + if self.request.user.is_superuser: + obj = VMDiskProduct.objects.all() + else: + obj = VMDiskProduct.objects.filter(owner=self.request.user) + + return obj def create(self, request): serializer = VMDiskProductSerializer(data=request.data, context={'request': request}) From 10c5257f90cf587b50bc06502bfbc7edd045c8c8 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 21 Mar 2020 11:59:04 +0100 Subject: [PATCH 188/193] Introduce "extra_data" jsonfield --- uncloud/uncloud/models.py | 22 ++++++++ uncloud/uncloud_pay/models.py | 6 +-- .../migrations/0005_auto_20200321_1058.py | 50 +++++++++++++++++++ uncloud/uncloud_vm/models.py | 9 ++-- .../0002_matrixserviceproduct_extra_data.py | 19 +++++++ 5 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 uncloud/uncloud/models.py create mode 100644 uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py create mode 100644 uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py diff --git a/uncloud/uncloud/models.py b/uncloud/uncloud/models.py new file mode 100644 index 0000000..7ca5dfa --- /dev/null +++ b/uncloud/uncloud/models.py @@ -0,0 +1,22 @@ +from django.db import models +from django.contrib.postgres.fields import JSONField + +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 diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index a11c3c1..532e130 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -19,8 +19,7 @@ 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 # Used to generate bill due dates. @@ -418,6 +417,7 @@ class OrderRecord(models.Model): description = models.TextField() + @property def recurring_period(self): return self.order.recurring_period @@ -436,7 +436,7 @@ class OrderRecord(models.Model): # Abstract (= no database representation) class used as parent for products # (e.g. uncloud_vm.models.VMProduct). -class Product(models.Model): +class Product(UncloudModel): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, diff --git a/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py b/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py new file mode 100644 index 0000000..3799e6a --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py @@ -0,0 +1,50 @@ +# Generated by Django 3.0.3 on 2020-03-21 10:58 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0004_remove_vmproduct_vmid'), + ] + + operations = [ + migrations.AddField( + model_name='vmdiskimageproduct', + name='extra_data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='vmdiskproduct', + name='extra_data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='vmhost', + name='extra_data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='vmproduct', + name='extra_data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='vmsnapshotproduct', + name='extra_data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True), + ), + migrations.AlterField( + model_name='vmdiskproduct', + name='vm', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='disks', to='uncloud_vm.VMProduct'), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='vm', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='uncloud_vm.VMProduct'), + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 7e38ded..bdd3a43 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -3,10 +3,13 @@ import uuid from django.db import models from django.contrib.auth import get_user_model + # Uncomment if you override model's clean method # from django.core.exceptions import ValidationError from uncloud_pay.models import Product, RecurringPeriod +from uncloud.models import UncloudModel + import uncloud_pay.models as pay_models import uncloud_storage.models @@ -22,7 +25,7 @@ STATUS_CHOICES = ( STATUS_DEFAULT = 'pending' -class VMHost(models.Model): +class VMHost(UncloudModel): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # 253 is the maximum DNS name length @@ -99,7 +102,7 @@ class VMWithOSProduct(VMProduct): pass -class VMDiskImageProduct(models.Model): +class VMDiskImageProduct(UncloudModel): """ Images are used for cloning/linking. @@ -138,7 +141,7 @@ class VMDiskImageProduct(models.Model): -class VMDiskProduct(models.Model): +class VMDiskProduct(UncloudModel): """ The VMDiskProduct is attached to a VM. diff --git a/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py b/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py new file mode 100644 index 0000000..f755ddb --- /dev/null +++ b/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-03-21 10:58 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ungleich_service', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='matrixserviceproduct', + name='extra_data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True), + ), + ] From 08fe3e689ef6acc11e66f721fc26cf1cb601039e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 22 Mar 2020 17:30:55 +0100 Subject: [PATCH 189/193] Add debug to opennebula, create VM disks from opennebula correctly --- .../commands/opennebula-to-uncloud.py | 113 ++++++++++++------ uncloud/opennebula/models.py | 9 +- uncloud/opennebula/serializers.py | 4 +- uncloud/uncloud/urls.py | 4 +- uncloud/uncloud_auth/serializers.py | 2 + uncloud/uncloud_vm/models.py | 2 +- uncloud/uncloud_vm/serializers.py | 5 +- uncloud/uncloud_vm/views.py | 4 +- 8 files changed, 100 insertions(+), 43 deletions(-) diff --git a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py index dc7cb45..230159a 100644 --- a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py +++ b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py @@ -1,13 +1,18 @@ +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 @@ -41,24 +46,35 @@ def create_nics(one_vm, vm_product): ) -def create_disk_and_image(one_vm, vm_product): - for disk in one_vm.disks: - owner = one_vm.owner - name = disk.get('image') +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 + """ - # TODO: Fix the following hard coded values - is_os_image, is_public, status = True, True, 'active' + 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('pool_name') + 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': owner, + 'owner': disk_owner, 'is_os_image': is_os_image, 'is_public': is_public, 'size_in_gb': image_size_in_gb, @@ -68,29 +84,59 @@ def create_disk_and_image(one_vm, vm_product): 'status': status } ) - VMDiskProduct.objects.update_or_create( - owner=owner, vm=vm_product, - defaults={ - 'image': image, - 'size_in_gb': disk_size_in_gb - } - ) + # 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: - print("No VMHost for VM {} - VM might be on hold - skipping".format(one_vm.vmid)) + 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: - print("VMHost {} does not exist, aborting".format(one_vm.last_host)) + log.error("VMHost {} does not exist, aborting".format(one_vm.last_host)) raise cores = one_vm.cores @@ -98,9 +144,6 @@ class Command(BaseCommand): owner = one_vm.owner status = 'active' - # Total Amount of SSD Storage - # TODO: What would happen if the attached storage is not SSD but HDD? - 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'] ]) @@ -119,30 +162,32 @@ class Command(BaseCommand): len(ipv4), len(ipv6)) try: - vm_product = VMProduct.objects.get(name=one_vm.uncloud_name) + 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 -# one_time_price=one_time_price, -# recurring_price=recurring_price, -# recurring_period=recurring_period ) - vm_product, _ = VMProduct.objects.update_or_create( + vm_product = VMProduct( + extra_data={ 'opennebula_id': one_vm.vmid }, name=one_vm.uncloud_name, - defaults={ - 'cores': cores, - 'ram_in_gb': ram_in_gb, - 'owner': owner, - 'vmhost': vmhost, - 'order': order, - 'status': status - } + 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 - create_disk_and_image(one_vm, vm_product) + sync_disk_and_image(one_vm, vm_product, disk_owner=disk_owner) diff --git a/uncloud/opennebula/models.py b/uncloud/opennebula/models.py index f5faeb5..826b615 100644 --- a/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -3,6 +3,12 @@ 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) @@ -48,7 +54,8 @@ class VM(models.Model): 'pool_name': d['POOL_NAME'], 'image': d['IMAGE'], 'source': d['SOURCE'], - 'source_type': d['TM_MAD'] + 'source_type': d['TM_MAD'], + 'storage_class': storage_class_mapping[d['POOL_NAME']] } for d in disks diff --git a/uncloud/opennebula/serializers.py b/uncloud/opennebula/serializers.py index 8e0c513..cd00622 100644 --- a/uncloud/opennebula/serializers.py +++ b/uncloud/opennebula/serializers.py @@ -5,4 +5,6 @@ from opennebula.models import VM class OpenNebulaVMSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VM - fields = '__all__' + fields = [ 'vmid', 'owner', 'data', + 'uncloud_name', 'cores', 'ram_in_gb', + 'disks', 'nics', 'ips' ] diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 856e59c..50d59c3 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -28,9 +28,9 @@ 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/image/mine', vmviews.VMDiskImageProductMineViewSet, basename='vmdiskimagemineproduct') -router.register(r'vm/image/public', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct') + # images the provider provides :-) # router.register(r'vm/image/official', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct') diff --git a/uncloud/uncloud_auth/serializers.py b/uncloud/uncloud_auth/serializers.py index 3627149..de369c3 100644 --- a/uncloud/uncloud_auth/serializers.py +++ b/uncloud/uncloud_auth/serializers.py @@ -9,5 +9,7 @@ class UserSerializer(serializers.ModelSerializer): model = get_user_model() fields = ['username', 'email', 'balance', 'maximum_credit' ] + + balance = serializers.DecimalField(max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index bdd3a43..a4b7f2a 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -119,7 +119,7 @@ class VMDiskImageProduct(UncloudModel): name = models.CharField(max_length=256) is_os_image = models.BooleanField(default=False) - is_public = models.BooleanField(default=False) + is_public = models.BooleanField(default=False, editable=False) # only allow admins to set this size_in_gb = models.FloatField(null=True, blank=True) import_url = models.URLField(null=True, blank=True) diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 96454f7..6d26cbe 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -84,8 +84,9 @@ class VMProductSerializer(serializers.ModelSerializer): model = VMProduct fields = ['uuid', 'order', 'owner', 'status', 'name', 'cores', 'ram_in_gb', 'recurring_period', - 'snapshots', 'disks' ] - read_only_fields = ['uuid', 'order', 'owner', 'status'] + 'snapshots', 'disks', + 'extra_data' ] + read_only_fields = ['uuid', 'order', 'owner', 'status' ] # Custom field used at creation (= ordering) only. recurring_period = serializers.ChoiceField( diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 1ef4974..6d4e5a9 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -24,7 +24,7 @@ class VMHostViewSet(viewsets.ModelViewSet): queryset = VMHost.objects.all() permission_classes = [permissions.IsAdminUser] -class VMDiskImageProductMineViewSet(viewsets.ModelViewSet): +class VMDiskImageProductViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = VMDiskImageProductSerializer @@ -32,7 +32,7 @@ class VMDiskImageProductMineViewSet(viewsets.ModelViewSet): if self.request.user.is_superuser: obj = VMDiskImageProduct.objects.all() else: - obj = VMDiskImageProduct.objects.filter(owner=self.request.user) + obj = VMDiskImageProduct.objects.filter(owner=self.request.user) | VMDiskImageProduct.objects.filter(is_public=True) return obj From 105142f76aa901d7bf5229de66d252318fc3058a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 22 Mar 2020 18:52:31 +0100 Subject: [PATCH 190/193] Add template for creating VMs --- .../commands/vm-create-snapshots.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 uncloud/uncloud_vm/management/commands/vm-create-snapshots.py diff --git a/uncloud/uncloud_vm/management/commands/vm-create-snapshots.py b/uncloud/uncloud_vm/management/commands/vm-create-snapshots.py new file mode 100644 index 0000000..bd3bb65 --- /dev/null +++ b/uncloud/uncloud_vm/management/commands/vm-create-snapshots.py @@ -0,0 +1,35 @@ +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 +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('--start-vms-here', action='store_true') + # parser.add_argument('--check-health', action='store_true') + # parser.add_argument('--vmhostname') + # print(parser) + + + def handle(self, *args, **options): + for snapshot in VMSnapshotProduct.objects.filter(status='PENDING'): + 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() + + print(snapshot) From 9961ca0446bea82f17d934f1f3f69d309bf7de3c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 22 Mar 2020 18:59:59 +0100 Subject: [PATCH 191/193] add new migrations Signed-off-by: Nico Schottelius --- .../migrations/0006_auto_20200322_1758.py | 57 +++++++++++++++++++ .../migrations/0003_auto_20200322_1758.py | 18 ++++++ 2 files changed, 75 insertions(+) create mode 100644 uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py create mode 100644 uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py diff --git a/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py b/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py new file mode 100644 index 0000000..7726c9b --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py @@ -0,0 +1,57 @@ +# Generated by Django 3.0.3 on 2020-03-22 17:58 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0005_auto_20200321_1058'), + ] + + operations = [ + migrations.CreateModel( + name='VMCluster', + fields=[ + ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=128, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterField( + model_name='vmdiskimageproduct', + name='is_public', + field=models.BooleanField(default=False, editable=False), + ), + migrations.AlterField( + model_name='vmdiskimageproduct', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + migrations.AlterField( + model_name='vmhost', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + migrations.AlterField( + model_name='vmproduct', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + migrations.AddField( + model_name='vmproduct', + name='vmcluster', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMCluster'), + ), + ] diff --git a/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py b/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py new file mode 100644 index 0000000..73dbd6a --- /dev/null +++ b/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-22 17:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ungleich_service', '0002_matrixserviceproduct_extra_data'), + ] + + operations = [ + migrations.AlterField( + model_name='matrixserviceproduct', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + ] From 23203ff418051669351692067883eddcbc6e268c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 22 Mar 2020 20:55:11 +0100 Subject: [PATCH 192/193] vmsnapshot progress --- .../uncloud/management/commands/uncloud.py | 28 +++++ uncloud/uncloud/models.py | 13 ++ uncloud/uncloud/settings.py | 1 + uncloud/uncloud/urls.py | 19 +-- uncloud/uncloud_pay/models.py | 13 +- .../commands/vm-create-snapshots.py | 35 ------ uncloud/uncloud_vm/management/commands/vm.py | 114 ++++++++++++------ .../migrations/0007_vmhost_vmcluster.py | 19 +++ uncloud/uncloud_vm/models.py | 29 ++--- uncloud/uncloud_vm/serializers.py | 12 +- uncloud/uncloud_vm/views.py | 17 ++- 11 files changed, 175 insertions(+), 125 deletions(-) create mode 100644 uncloud/uncloud/management/commands/uncloud.py delete mode 100644 uncloud/uncloud_vm/management/commands/vm-create-snapshots.py create mode 100644 uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py diff --git a/uncloud/uncloud/management/commands/uncloud.py b/uncloud/uncloud/management/commands/uncloud.py new file mode 100644 index 0000000..bd47c6b --- /dev/null +++ b/uncloud/uncloud/management/commands/uncloud.py @@ -0,0 +1,28 @@ +import sys +from datetime import datetime + +from django.core.management.base import BaseCommand + +from django.contrib.auth import get_user_model + +from opennebula.models import VM as VMModel +from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct, VMCluster + +import logging +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'General uncloud commands' + + def add_arguments(self, parser): + parser.add_argument('--bootstrap', action='store_true', help='Bootstrap a typical uncloud installation') + + def handle(self, *args, **options): + + if options['bootstrap']: + self.bootstrap() + + def bootstrap(self): + default_cluster = VMCluster.objects.get_or_create(name="default") +# local_host = diff --git a/uncloud/uncloud/models.py b/uncloud/uncloud/models.py index 7ca5dfa..bd7a931 100644 --- a/uncloud/uncloud/models.py +++ b/uncloud/uncloud/models.py @@ -1,5 +1,6 @@ from django.db import models from django.contrib.postgres.fields import JSONField +from django.utils.translation import gettext_lazy as _ class UncloudModel(models.Model): """ @@ -20,3 +21,15 @@ class UncloudModel(models.Model): class Meta: abstract = True + +# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types +class UncloudStatus(models.TextChoices): + PENDING = 'PENDING', _('Pending') + AWAITING_PAYMENT = 'AWAITING_PAYMENT', _('Awaiting payment') + BEING_CREATED = 'BEING_CREATED', _('Being created') + SCHEDULED = 'SCHEDULED', _('Scheduled') # resource selected, waiting for dispatching + ACTIVE = 'ACTIVE', _('Active') + MODIFYING = 'MODIFYING', _('Modifying') # Resource is being changed + DELETED = 'DELETED', _('Deleted') # Resource has been deleted + DISABLED = 'DISABLED', _('Disabled') # Is usable, but cannot be used for new things + UNUSABLE = 'UNUSABLE', _('Unusable'), # Has some kind of error diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 99cf7a1..5b4744d 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -59,6 +59,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django_extensions', 'rest_framework', + 'uncloud', 'uncloud_pay', 'uncloud_auth', 'uncloud_storage', diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 50d59c3..a848dff 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -30,32 +30,16 @@ router = routers.DefaultRouter() 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') - - -# images the provider provides :-) -# router.register(r'vm/image/official', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct') - - - - router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') - -# TBD -#router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') - # 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') -# allow vm creation from own images - - # 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') @@ -63,14 +47,13 @@ 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') -# VMs -router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vm') # 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 diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 532e130..945187b 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -19,7 +19,7 @@ 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 +from uncloud.models import UncloudModel, UncloudStatus # Used to generate bill due dates. @@ -35,13 +35,6 @@ class RecurringPeriod(models.TextChoices): PER_HOUR = 'HOUR', _('Per Hour') PER_SECOND = 'SECOND', _('Per Second') -# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types -class ProductStatus(models.TextChoices): - PENDING = 'PENDING', _('Pending') - AWAITING_PAYMENT = 'AWAITING_PAYMENT', _('Awaiting payment') - BEING_CREATED = 'BEING_CREATED', _('Being created') - ACTIVE = 'ACTIVE', _('Active') - DELETED = 'DELETED', _('Deleted') def get_balance_for_user(user): @@ -445,8 +438,8 @@ class Product(UncloudModel): description = "" status = models.CharField(max_length=32, - choices=ProductStatus.choices, - default=ProductStatus.PENDING) + choices=UncloudStatus.choices, + default=UncloudStatus.PENDING) order = models.ForeignKey(Order, on_delete=models.CASCADE, diff --git a/uncloud/uncloud_vm/management/commands/vm-create-snapshots.py b/uncloud/uncloud_vm/management/commands/vm-create-snapshots.py deleted file mode 100644 index bd3bb65..0000000 --- a/uncloud/uncloud_vm/management/commands/vm-create-snapshots.py +++ /dev/null @@ -1,35 +0,0 @@ -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 -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('--start-vms-here', action='store_true') - # parser.add_argument('--check-health', action='store_true') - # parser.add_argument('--vmhostname') - # print(parser) - - - def handle(self, *args, **options): - for snapshot in VMSnapshotProduct.objects.filter(status='PENDING'): - 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() - - print(snapshot) diff --git a/uncloud/uncloud_vm/management/commands/vm.py b/uncloud/uncloud_vm/management/commands/vm.py index c0e2783..667c5ad 100644 --- a/uncloud/uncloud_vm/management/commands/vm.py +++ b/uncloud/uncloud_vm/management/commands/vm.py @@ -5,73 +5,108 @@ 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 VMProduct, VMHost +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-here', action='store_true') - parser.add_argument('--check-health', action='store_true') - parser.add_argument('--vmhostname') - print(parser) + parser.add_argument('--start-vms', action='store_true') def handle(self, *args, **options): - print(args) - print(options) + for cmd in [ 'create_vm_snapshots', 'schedule_vms', 'start_vms' ]: + if options[cmd]: + f = getattr(self, cmd) + f(args, options) - if options['schedule_vms']: - self.schedule_vms(args, option) - if options['start_vms_here']: - if not options['vmhostname']: - raise Exception("Argument vmhostname is required to know which vmhost we are on") - self.start_vms(args, options) - if options['check_health']: - self.check_health(args, option) + 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(status='active', - hostname=options['vmhostname']) + vmhost = VMHost.objects.get(hostname=options['this_hostname']) if not vmhost: - print("No active vmhost {} exists".format(options['vmhostname'])) + 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='creating') + 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 schedule_vms(self, *args, **options)): - pending_vms = VMProduct.objects.filter(vmhost__isnull=True) - vmhosts = VMHost.objects.filter(status='active') + def check_vms(self, *args, **options): + """ + Check if all VMs that are supposed to run are running + """ - for vm in pending_vms: - print(vm) + def modify_vms(self, *args, **options): + """ + Check all VMs that are requested to be modified and restart them + """ - found_vmhost = False - for vmhost in vmhosts: - if vmhost.available_cores >= vm.cores and vmhost.available_ram_in_gb >= vm.ram_in_gb: - vm.vmhost = vmhost - vm.status = "creating" - vm.save() - found_vmhost = True - print("Scheduled VM {} on VMHOST {}".format(vm, vmhost)) - break + def create_vm_snapshots(self, *args, **options): + this_cluster = VMCluster(option['this_cluster']) - if not found_vmhost: - print("Error: cannot schedule VM {}, no suitable host found".format(vm)) + 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(vmhost__isnull=True) + pending_vms = VMProduct.objects.filter(status='PENDING') vmhosts = VMHost.objects.filter(status='active') # 1. Check that all active hosts reported back N seconds ago @@ -81,5 +116,4 @@ class Command(BaseCommand): # If VM snapshots exist without a VM -> notify user (?) - print("Nothing is good, you should implement me") diff --git a/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py b/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py new file mode 100644 index 0000000..6766dd7 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-03-22 18:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0006_auto_20200322_1758'), + ] + + operations = [ + migrations.AddField( + model_name='vmhost', + name='vmcluster', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMCluster'), + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index a4b7f2a..3b2c46b 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -8,21 +8,14 @@ from django.contrib.auth import get_user_model # from django.core.exceptions import ValidationError from uncloud_pay.models import Product, RecurringPeriod -from uncloud.models import UncloudModel +from uncloud.models import UncloudModel, UncloudStatus import uncloud_pay.models as pay_models import uncloud_storage.models -STATUS_CHOICES = ( - ('pending', 'Pending'), # Initial state - ('creating', 'Creating'), # Creating VM/image/etc. - ('active', 'Active'), # Is usable / active - ('disabled', 'Disabled'), # Is usable, but cannot be used for new things - ('unusable', 'Unusable'), # Has some kind of error - ('deleted', 'Deleted'), # Does not exist anymore, only DB entry as a log -) - -STATUS_DEFAULT = 'pending' +class VMCluster(UncloudModel): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=128, unique=True) class VMHost(UncloudModel): @@ -31,6 +24,10 @@ class VMHost(UncloudModel): # 253 is the maximum DNS name length hostname = models.CharField(max_length=253, unique=True) + vmcluster = models.ForeignKey( + VMCluster, on_delete=models.CASCADE, editable=False, blank=True, null=True + ) + # indirectly gives a maximum number of cores / VM - f.i. 32 physical_cores = models.IntegerField(default=0) @@ -41,7 +38,7 @@ class VMHost(UncloudModel): usable_ram_in_gb = models.FloatField(default=0) status = models.CharField( - max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT + max_length=32, choices=UncloudStatus.choices, default=UncloudStatus.PENDING ) @property @@ -54,7 +51,7 @@ class VMHost(UncloudModel): @property def available_ram_in_gb(self): - return self.usable_ram_in_gb - sum([vm.ram_in_gb for vm in self.vms ]) + return self.usable_ram_in_gb - self.used_ram_in_gb @property def available_cores(self): @@ -66,6 +63,10 @@ class VMProduct(Product): VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True ) + vmcluster = models.ForeignKey( + VMCluster, on_delete=models.CASCADE, editable=False, blank=True, null=True + ) + # VM-specific. The name is only intended for customers: it's a pain to # remember IDs (speaking from experience as ungleich customer)! name = models.CharField(max_length=32, blank=True, null=True) @@ -131,7 +132,7 @@ class VMDiskImageProduct(UncloudModel): default = uncloud_storage.models.StorageClass.SSD) status = models.CharField( - max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT + max_length=32, choices=UncloudStatus.choices, default=UncloudStatus.PENDING ) def __str__(self): diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 6d26cbe..c0cca48 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct +from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct, VMCluster from uncloud_pay.models import RecurringPeriod GB_SSD_PER_DAY=0.012 @@ -12,7 +12,7 @@ GB_SSD_PER_DAY=0.012 GB_HDD_PER_DAY=0.0006 -class VMHostSerializer(serializers.ModelSerializer): +class VMHostSerializer(serializers.HyperlinkedModelSerializer): vms = serializers.PrimaryKeyRelatedField(many=True, read_only=True) class Meta: @@ -20,6 +20,11 @@ class VMHostSerializer(serializers.ModelSerializer): fields = '__all__' read_only_fields = [ 'vms' ] +class VMClusterSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VMCluster + fields = '__all__' + class VMDiskProductSerializer(serializers.ModelSerializer): class Meta: @@ -92,9 +97,6 @@ class VMProductSerializer(serializers.ModelSerializer): recurring_period = serializers.ChoiceField( choices=VMProduct.allowed_recurring_periods()) - # snapshots = serializers.PrimaryKeyRelatedField(many=True, - # read_only=True) - snapshots = VMSnapshotProductSerializer(many=True, read_only=True) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 6d4e5a9..0672904 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -8,12 +8,13 @@ from rest_framework import viewsets, permissions from rest_framework.response import Response from rest_framework.exceptions import ValidationError -from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct +from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct, VMCluster from uncloud_pay.models import Order from .serializers import (VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer, VMDiskImageProductSerializer, - VMDiskProductSerializer, DCLVMProductSerializer) + VMDiskProductSerializer, DCLVMProductSerializer, + VMClusterSerializer) from uncloud_pay.helpers import ProductViewSet @@ -24,6 +25,11 @@ class VMHostViewSet(viewsets.ModelViewSet): queryset = VMHost.objects.all() permission_classes = [permissions.IsAdminUser] +class VMClusterViewSet(viewsets.ModelViewSet): + serializer_class = VMClusterSerializer + queryset = VMCluster.objects.all() + permission_classes = [permissions.IsAdminUser] + class VMDiskImageProductViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = VMDiskImageProductSerializer @@ -135,7 +141,12 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet): serializer_class = VMSnapshotProductSerializer def get_queryset(self): - return VMSnapshotProduct.objects.filter(owner=self.request.user) + if self.request.user.is_superuser: + obj = VMSnapshotProduct.objects.all() + else: + obj = VMSnapshotProduct.objects.filter(owner=self.request.user) + + return obj def create(self, request): serializer = VMSnapshotProductSerializer(data=request.data, context={'request': request}) From 7a6c8739f6652f588f62517db6809a593139eafd Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 2 Apr 2020 19:31:03 +0200 Subject: [PATCH 193/193] Rename / prepare for merge with uncloud repo --- .gitignore | 17 + .../abk-hacks.py | 0 .../abkhack}/opennebula_hacks.py | 0 .../meow-payv1}/README.md | 0 .../meow-payv1}/config.py | 0 .../meow-payv1}/hack-a-vpn.py | 0 .../meow-payv1}/helper.py | 0 .../meow-payv1}/ldaptest.py | 0 .../products/ipv6-only-django.json | 0 .../meow-payv1}/products/ipv6-only-vm.json | 0 .../meow-payv1}/products/ipv6-only-vpn.json | 0 .../meow-payv1}/products/ipv6box.json | 0 .../meow-payv1}/products/membership.json | 0 .../meow-payv1}/requirements.txt | 0 .../meow-payv1}/sample-pay.conf | 0 .../meow-payv1}/schemas.py | 0 .../meow-payv1}/stripe_hack.py | 0 .../meow-payv1}/stripe_utils.py | 0 .../meow-payv1}/ucloud_pay.py | 0 .../notes-abk.md | 0 .../notes-nico.org | 0 plan.org => uncloud_django_based/plan.org | 0 .../uncloud}/.gitignore | 0 .../README-how-to-create-a-product.md | 0 .../uncloud}/README-object-relations.md | 0 .../uncloud}/README.md | 0 .../uncloud}/manage.py | 0 uncloud_django_based/uncloud/models.dot | 1482 +++++++++++++++++ uncloud_django_based/uncloud/models.png | Bin 0 -> 408110 bytes .../uncloud}/opennebula/__init__.py | 0 .../uncloud}/opennebula/admin.py | 0 .../uncloud}/opennebula/apps.py | 0 .../commands/opennebula-synchosts.py | 0 .../management/commands/opennebula-syncvms.py | 0 .../commands/opennebula-to-uncloud.py | 0 .../opennebula/migrations/0001_initial.py | 0 .../migrations/0002_auto_20200225_1335.py | 0 .../migrations/0003_auto_20200225_1428.py | 0 .../migrations/0004_auto_20200225_1816.py | 0 .../opennebula/migrations/__init__.py | 0 .../uncloud}/opennebula/models.py | 0 .../uncloud}/opennebula/serializers.py | 0 .../uncloud}/opennebula/tests.py | 0 .../uncloud}/opennebula/views.py | 0 .../uncloud}/requirements.txt | 0 .../uncloud}/uncloud/.gitignore | 0 .../uncloud}/uncloud/__init__.py | 0 .../uncloud}/uncloud/asgi.py | 0 .../uncloud/management/commands/uncloud.py | 0 .../uncloud}/uncloud/models.py | 0 .../uncloud}/uncloud/secrets_sample.py | 0 .../uncloud}/uncloud/settings.py | 0 .../uncloud}/uncloud/urls.py | 0 .../uncloud}/uncloud/wsgi.py | 0 .../uncloud}/uncloud_auth/__init__.py | 0 .../uncloud}/uncloud_auth/admin.py | 0 .../uncloud}/uncloud_auth/apps.py | 0 .../uncloud_auth/migrations/0001_initial.py | 0 .../migrations/0002_auto_20200318_1343.py | 0 .../migrations/0003_auto_20200318_1345.py | 0 .../uncloud_auth/migrations/__init__.py | 0 .../uncloud}/uncloud_auth/models.py | 0 .../uncloud}/uncloud_auth/serializers.py | 0 .../uncloud}/uncloud_auth/views.py | 0 .../uncloud}/uncloud_net/__init__.py | 0 .../uncloud}/uncloud_net/admin.py | 0 .../uncloud}/uncloud_net/apps.py | 0 .../uncloud}/uncloud_net/models.py | 0 .../uncloud}/uncloud_net/tests.py | 0 .../uncloud}/uncloud_net/views.py | 0 .../uncloud}/uncloud_pay/__init__.py | 0 .../uncloud}/uncloud_pay/admin.py | 0 .../uncloud}/uncloud_pay/apps.py | 0 .../uncloud}/uncloud_pay/helpers.py | 0 .../commands/charge-negative-balance.py | 0 .../management/commands/generate-bills.py | 0 .../commands/handle-overdue-bills.py | 0 .../uncloud_pay/migrations/0001_initial.py | 0 .../uncloud_pay/migrations/__init__.py | 0 .../uncloud}/uncloud_pay/models.py | 0 .../uncloud}/uncloud_pay/serializers.py | 0 .../uncloud}/uncloud_pay/stripe.py | 0 .../uncloud}/uncloud_pay/tests.py | 0 .../uncloud}/uncloud_pay/views.py | 0 .../uncloud}/uncloud_storage/__init__.py | 0 .../uncloud}/uncloud_storage/admin.py | 0 .../uncloud}/uncloud_storage/apps.py | 0 .../uncloud}/uncloud_storage/models.py | 0 .../uncloud}/uncloud_storage/tests.py | 0 .../uncloud}/uncloud_storage/views.py | 0 .../uncloud}/uncloud_vm/__init__.py | 0 .../uncloud}/uncloud_vm/admin.py | 0 .../uncloud}/uncloud_vm/apps.py | 0 .../uncloud_vm/management/commands/vm.py | 0 .../uncloud_vm/migrations/0001_initial.py | 0 .../migrations/0002_auto_20200305_1321.py | 0 .../migrations/0003_remove_vmhost_vms.py | 0 .../migrations/0004_remove_vmproduct_vmid.py | 0 .../migrations/0005_auto_20200321_1058.py | 0 .../migrations/0006_auto_20200322_1758.py | 0 .../migrations/0007_vmhost_vmcluster.py | 0 .../uncloud_vm/migrations/__init__.py | 0 .../uncloud}/uncloud_vm/models.py | 0 .../uncloud}/uncloud_vm/serializers.py | 0 .../uncloud}/uncloud_vm/tests.py | 0 .../uncloud}/uncloud_vm/views.py | 0 .../uncloud}/ungleich_service/__init__.py | 0 .../uncloud}/ungleich_service/admin.py | 0 .../uncloud}/ungleich_service/apps.py | 0 .../migrations/0001_initial.py | 0 .../0002_matrixserviceproduct_extra_data.py | 0 .../migrations/0003_auto_20200322_1758.py | 0 .../ungleich_service/migrations/__init__.py | 0 .../uncloud}/ungleich_service/models.py | 0 .../uncloud}/ungleich_service/serializers.py | 0 .../uncloud}/ungleich_service/tests.py | 0 .../uncloud}/ungleich_service/views.py | 0 .../vat_rates.csv | 0 118 files changed, 1499 insertions(+) rename abk-hacks.py => uncloud_django_based/abk-hacks.py (100%) rename {abkhack => uncloud_django_based/abkhack}/opennebula_hacks.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/README.md (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/config.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/hack-a-vpn.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/helper.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/ldaptest.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/products/ipv6-only-django.json (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/products/ipv6-only-vm.json (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/products/ipv6-only-vpn.json (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/products/ipv6box.json (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/products/membership.json (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/requirements.txt (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/sample-pay.conf (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/schemas.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/stripe_hack.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/stripe_utils.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/ucloud_pay.py (100%) rename notes-abk.md => uncloud_django_based/notes-abk.md (100%) rename notes-nico.org => uncloud_django_based/notes-nico.org (100%) rename plan.org => uncloud_django_based/plan.org (100%) rename {uncloud => uncloud_django_based/uncloud}/.gitignore (100%) rename {uncloud => uncloud_django_based/uncloud}/README-how-to-create-a-product.md (100%) rename {uncloud => uncloud_django_based/uncloud}/README-object-relations.md (100%) rename {uncloud => uncloud_django_based/uncloud}/README.md (100%) rename {uncloud => uncloud_django_based/uncloud}/manage.py (100%) create mode 100644 uncloud_django_based/uncloud/models.dot create mode 100644 uncloud_django_based/uncloud/models.png rename {uncloud => uncloud_django_based/uncloud}/opennebula/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/management/commands/opennebula-synchosts.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/management/commands/opennebula-syncvms.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/management/commands/opennebula-to-uncloud.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/migrations/0001_initial.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/migrations/0002_auto_20200225_1335.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/migrations/0003_auto_20200225_1428.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/migrations/0004_auto_20200225_1816.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/migrations/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/serializers.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/tests.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/views.py (100%) rename {uncloud => uncloud_django_based/uncloud}/requirements.txt (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/.gitignore (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/asgi.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/management/commands/uncloud.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/secrets_sample.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/settings.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/urls.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/wsgi.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/migrations/0001_initial.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/migrations/0002_auto_20200318_1343.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/migrations/0003_auto_20200318_1345.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/migrations/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/serializers.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/views.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_net/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_net/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_net/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_net/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_net/tests.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_net/views.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/helpers.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/management/commands/charge-negative-balance.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/management/commands/generate-bills.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/management/commands/handle-overdue-bills.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/migrations/0001_initial.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/migrations/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/serializers.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/stripe.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/tests.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/views.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_storage/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_storage/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_storage/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_storage/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_storage/tests.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_storage/views.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/management/commands/vm.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0001_initial.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0002_auto_20200305_1321.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0003_remove_vmhost_vms.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0005_auto_20200321_1058.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0006_auto_20200322_1758.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0007_vmhost_vmcluster.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/serializers.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/tests.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/views.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/migrations/0001_initial.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/migrations/0003_auto_20200322_1758.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/migrations/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/serializers.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/tests.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/views.py (100%) rename vat_rates.csv => uncloud_django_based/vat_rates.csv (100%) diff --git a/.gitignore b/.gitignore index 786a584..cbb171f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,20 @@ log.txt test.py STRIPE venv/ + +uncloud/docs/build +logs.txt + +uncloud.egg-info + +# run artefacts +default.etcd +__pycache__ + +# build artefacts +uncloud/version.py +build/ +venv/ +dist/ + +*.iso diff --git a/abk-hacks.py b/uncloud_django_based/abk-hacks.py similarity index 100% rename from abk-hacks.py rename to uncloud_django_based/abk-hacks.py diff --git a/abkhack/opennebula_hacks.py b/uncloud_django_based/abkhack/opennebula_hacks.py similarity index 100% rename from abkhack/opennebula_hacks.py rename to uncloud_django_based/abkhack/opennebula_hacks.py diff --git a/meow-payv1/README.md b/uncloud_django_based/meow-payv1/README.md similarity index 100% rename from meow-payv1/README.md rename to uncloud_django_based/meow-payv1/README.md diff --git a/meow-payv1/config.py b/uncloud_django_based/meow-payv1/config.py similarity index 100% rename from meow-payv1/config.py rename to uncloud_django_based/meow-payv1/config.py diff --git a/meow-payv1/hack-a-vpn.py b/uncloud_django_based/meow-payv1/hack-a-vpn.py similarity index 100% rename from meow-payv1/hack-a-vpn.py rename to uncloud_django_based/meow-payv1/hack-a-vpn.py diff --git a/meow-payv1/helper.py b/uncloud_django_based/meow-payv1/helper.py similarity index 100% rename from meow-payv1/helper.py rename to uncloud_django_based/meow-payv1/helper.py diff --git a/meow-payv1/ldaptest.py b/uncloud_django_based/meow-payv1/ldaptest.py similarity index 100% rename from meow-payv1/ldaptest.py rename to uncloud_django_based/meow-payv1/ldaptest.py diff --git a/meow-payv1/products/ipv6-only-django.json b/uncloud_django_based/meow-payv1/products/ipv6-only-django.json similarity index 100% rename from meow-payv1/products/ipv6-only-django.json rename to uncloud_django_based/meow-payv1/products/ipv6-only-django.json diff --git a/meow-payv1/products/ipv6-only-vm.json b/uncloud_django_based/meow-payv1/products/ipv6-only-vm.json similarity index 100% rename from meow-payv1/products/ipv6-only-vm.json rename to uncloud_django_based/meow-payv1/products/ipv6-only-vm.json diff --git a/meow-payv1/products/ipv6-only-vpn.json b/uncloud_django_based/meow-payv1/products/ipv6-only-vpn.json similarity index 100% rename from meow-payv1/products/ipv6-only-vpn.json rename to uncloud_django_based/meow-payv1/products/ipv6-only-vpn.json diff --git a/meow-payv1/products/ipv6box.json b/uncloud_django_based/meow-payv1/products/ipv6box.json similarity index 100% rename from meow-payv1/products/ipv6box.json rename to uncloud_django_based/meow-payv1/products/ipv6box.json diff --git a/meow-payv1/products/membership.json b/uncloud_django_based/meow-payv1/products/membership.json similarity index 100% rename from meow-payv1/products/membership.json rename to uncloud_django_based/meow-payv1/products/membership.json diff --git a/meow-payv1/requirements.txt b/uncloud_django_based/meow-payv1/requirements.txt similarity index 100% rename from meow-payv1/requirements.txt rename to uncloud_django_based/meow-payv1/requirements.txt diff --git a/meow-payv1/sample-pay.conf b/uncloud_django_based/meow-payv1/sample-pay.conf similarity index 100% rename from meow-payv1/sample-pay.conf rename to uncloud_django_based/meow-payv1/sample-pay.conf diff --git a/meow-payv1/schemas.py b/uncloud_django_based/meow-payv1/schemas.py similarity index 100% rename from meow-payv1/schemas.py rename to uncloud_django_based/meow-payv1/schemas.py diff --git a/meow-payv1/stripe_hack.py b/uncloud_django_based/meow-payv1/stripe_hack.py similarity index 100% rename from meow-payv1/stripe_hack.py rename to uncloud_django_based/meow-payv1/stripe_hack.py diff --git a/meow-payv1/stripe_utils.py b/uncloud_django_based/meow-payv1/stripe_utils.py similarity index 100% rename from meow-payv1/stripe_utils.py rename to uncloud_django_based/meow-payv1/stripe_utils.py diff --git a/meow-payv1/ucloud_pay.py b/uncloud_django_based/meow-payv1/ucloud_pay.py similarity index 100% rename from meow-payv1/ucloud_pay.py rename to uncloud_django_based/meow-payv1/ucloud_pay.py diff --git a/notes-abk.md b/uncloud_django_based/notes-abk.md similarity index 100% rename from notes-abk.md rename to uncloud_django_based/notes-abk.md diff --git a/notes-nico.org b/uncloud_django_based/notes-nico.org similarity index 100% rename from notes-nico.org rename to uncloud_django_based/notes-nico.org diff --git a/plan.org b/uncloud_django_based/plan.org similarity index 100% rename from plan.org rename to uncloud_django_based/plan.org diff --git a/uncloud/.gitignore b/uncloud_django_based/uncloud/.gitignore similarity index 100% rename from uncloud/.gitignore rename to uncloud_django_based/uncloud/.gitignore diff --git a/uncloud/README-how-to-create-a-product.md b/uncloud_django_based/uncloud/README-how-to-create-a-product.md similarity index 100% rename from uncloud/README-how-to-create-a-product.md rename to uncloud_django_based/uncloud/README-how-to-create-a-product.md diff --git a/uncloud/README-object-relations.md b/uncloud_django_based/uncloud/README-object-relations.md similarity index 100% rename from uncloud/README-object-relations.md rename to uncloud_django_based/uncloud/README-object-relations.md diff --git a/uncloud/README.md b/uncloud_django_based/uncloud/README.md similarity index 100% rename from uncloud/README.md rename to uncloud_django_based/uncloud/README.md diff --git a/uncloud/manage.py b/uncloud_django_based/uncloud/manage.py similarity index 100% rename from uncloud/manage.py rename to uncloud_django_based/uncloud/manage.py diff --git a/uncloud_django_based/uncloud/models.dot b/uncloud_django_based/uncloud/models.dot new file mode 100644 index 0000000..0adfba8 --- /dev/null +++ b/uncloud_django_based/uncloud/models.dot @@ -0,0 +1,1482 @@ +digraph model_graph { + // Dotfile by Django-Extensions graph_models + // Created: 2020-03-17 12:30 + // Cli Options: -a + + fontname = "Roboto" + fontsize = 8 + splines = true + + node [ + fontname = "Roboto" + fontsize = 8 + shape = "plaintext" + ] + + edge [ + fontname = "Roboto" + fontsize = 8 + ] + + // Labels + + + django_contrib_admin_models_LogEntry [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + LogEntry +
+ id + + AutoField +
+ content_type + + ForeignKey (id) +
+ user + + ForeignKey (id) +
+ action_flag + + PositiveSmallIntegerField +
+ action_time + + DateTimeField +
+ change_message + + TextField +
+ object_id + + TextField +
+ object_repr + + CharField +
+ >] + + + + + django_contrib_auth_models_Permission [label=< + + + + + + + + + + + + + + + + + + + +
+ + Permission +
+ id + + AutoField +
+ content_type + + ForeignKey (id) +
+ codename + + CharField +
+ name + + CharField +
+ >] + + django_contrib_auth_models_Group [label=< + + + + + + + + + + + +
+ + Group +
+ id + + AutoField +
+ name + + CharField +
+ >] + + + + + django_contrib_contenttypes_models_ContentType [label=< + + + + + + + + + + + + + + + +
+ + ContentType +
+ id + + AutoField +
+ app_label + + CharField +
+ model + + CharField +
+ >] + + + + + django_contrib_sessions_base_session_AbstractBaseSession [label=< + + + + + + + + + + + +
+ + AbstractBaseSession +
+ expire_date + + DateTimeField +
+ session_data + + TextField +
+ >] + + django_contrib_sessions_models_Session [label=< + + + + + + + + + + + + + + + +
+ + Session
<AbstractBaseSession> +
+ session_key + + CharField +
+ expire_date + + DateTimeField +
+ session_data + + TextField +
+ >] + + + + + uncloud_pay_models_StripeCustomer [label=< + + + + + + + + + + + +
+ + StripeCustomer +
+ owner + + OneToOneField (id) +
+ stripe_id + + CharField +
+ >] + + uncloud_pay_models_Payment [label=< + + + + + + + + + + + + + + + + + + + + + + + +
+ + Payment +
+ uuid + + UUIDField +
+ owner + + ForeignKey (id) +
+ amount + + DecimalField +
+ source + + CharField +
+ timestamp + + DateTimeField +
+ >] + + uncloud_pay_models_PaymentMethod [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + PaymentMethod +
+ uuid + + UUIDField +
+ owner + + ForeignKey (id) +
+ description + + TextField +
+ primary + + BooleanField +
+ source + + CharField +
+ stripe_card_id + + CharField +
+ >] + + uncloud_pay_models_Bill [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Bill +
+ uuid + + UUIDField +
+ owner + + ForeignKey (id) +
+ creation_date + + DateTimeField +
+ due_date + + DateField +
+ ending_date + + DateTimeField +
+ starting_date + + DateTimeField +
+ valid + + BooleanField +
+ >] + + uncloud_pay_models_Order [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Order +
+ uuid + + UUIDField +
+ owner + + ForeignKey (id) +
+ creation_date + + DateTimeField +
+ ending_date + + DateTimeField +
+ recurring_period + + CharField +
+ starting_date + + DateTimeField +
+ >] + + uncloud_pay_models_OrderRecord [label=< + + + + + + + + + + + + + + + + + + + + + + + +
+ + OrderRecord +
+ id + + AutoField +
+ order + + ForeignKey (uuid) +
+ description + + TextField +
+ one_time_price + + DecimalField +
+ recurring_price + + DecimalField +
+ >] + + + + + django_contrib_auth_models_AbstractUser [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + AbstractUser
<AbstractBaseUser,PermissionsMixin> +
+ date_joined + + DateTimeField +
+ email + + EmailField +
+ first_name + + CharField +
+ is_active + + BooleanField +
+ is_staff + + BooleanField +
+ is_superuser + + BooleanField +
+ last_login + + DateTimeField +
+ last_name + + CharField +
+ password + + CharField +
+ username + + CharField +
+ >] + + uncloud_auth_models_User [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + User
<AbstractUser> +
+ id + + AutoField +
+ date_joined + + DateTimeField +
+ email + + EmailField +
+ first_name + + CharField +
+ is_active + + BooleanField +
+ is_staff + + BooleanField +
+ is_superuser + + BooleanField +
+ last_login + + DateTimeField +
+ last_name + + CharField +
+ password + + CharField +
+ username + + CharField +
+ >] + + + + + uncloud_pay_models_Product [label=< + + + + + + + + + + + + + + + +
+ + Product +
+ order + + ForeignKey (uuid) +
+ owner + + ForeignKey (id) +
+ status + + CharField +
+ >] + + uncloud_vm_models_VMHost [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + VMHost +
+ uuid + + UUIDField +
+ hostname + + CharField +
+ physical_cores + + IntegerField +
+ status + + CharField +
+ usable_cores + + IntegerField +
+ usable_ram_in_gb + + FloatField +
+ >] + + uncloud_vm_models_VMProduct [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + VMProduct
<Product> +
+ uuid + + UUIDField +
+ order + + ForeignKey (uuid) +
+ owner + + ForeignKey (id) +
+ vmhost + + ForeignKey (uuid) +
+ cores + + IntegerField +
+ name + + CharField +
+ ram_in_gb + + FloatField +
+ status + + CharField +
+ vmid + + IntegerField +
+ >] + + uncloud_vm_models_VMWithOSProduct [label=< + + + + + + + +
+ + VMWithOSProduct +
+ vmproduct_ptr + + OneToOneField (uuid) +
+ >] + + uncloud_vm_models_VMDiskImageProduct [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + VMDiskImageProduct +
+ uuid + + UUIDField +
+ owner + + ForeignKey (id) +
+ image_source + + CharField +
+ image_source_type + + CharField +
+ import_url + + URLField +
+ is_os_image + + BooleanField +
+ is_public + + BooleanField +
+ name + + CharField +
+ size_in_gb + + FloatField +
+ status + + CharField +
+ storage_class + + CharField +
+ >] + + uncloud_vm_models_VMDiskProduct [label=< + + + + + + + + + + + + + + + + + + + + + + + +
+ + VMDiskProduct +
+ uuid + + UUIDField +
+ image + + ForeignKey (uuid) +
+ owner + + ForeignKey (id) +
+ vm + + ForeignKey (uuid) +
+ size_in_gb + + FloatField +
+ >] + + uncloud_vm_models_VMNetworkCard [label=< + + + + + + + + + + + + + + + + + + + +
+ + VMNetworkCard +
+ id + + AutoField +
+ vm + + ForeignKey (uuid) +
+ ip_address + + GenericIPAddressField +
+ mac_address + + BigIntegerField +
+ >] + + uncloud_vm_models_VMSnapshotProduct [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + VMSnapshotProduct
<Product> +
+ uuid + + UUIDField +
+ order + + ForeignKey (uuid) +
+ owner + + ForeignKey (id) +
+ vm + + ForeignKey (uuid) +
+ gb_hdd + + FloatField +
+ gb_ssd + + FloatField +
+ status + + CharField +
+ >] + + + + + uncloud_pay_models_Product [label=< + + + + + + + + + + + + + + + +
+ + Product +
+ order + + ForeignKey (uuid) +
+ owner + + ForeignKey (id) +
+ status + + CharField +
+ >] + + ungleich_service_models_MatrixServiceProduct [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + MatrixServiceProduct
<Product> +
+ uuid + + UUIDField +
+ order + + ForeignKey (uuid) +
+ owner + + ForeignKey (id) +
+ vm + + ForeignKey (uuid) +
+ domain + + CharField +
+ status + + CharField +
+ >] + + + + + opennebula_models_VM [label=< + + + + + + + + + + + + + + + +
+ + VM +
+ vmid + + IntegerField +
+ owner + + ForeignKey (id) +
+ data + + JSONField +
+ >] + + + + + // Relations + + django_contrib_admin_models_LogEntry -> uncloud_auth_models_User + [label=" user (logentry)"] [arrowhead=none, arrowtail=dot, dir=both]; + + django_contrib_admin_models_LogEntry -> django_contrib_contenttypes_models_ContentType + [label=" content_type (logentry)"] [arrowhead=none, arrowtail=dot, dir=both]; + + + django_contrib_auth_models_Permission -> django_contrib_contenttypes_models_ContentType + [label=" content_type (permission)"] [arrowhead=none, arrowtail=dot, dir=both]; + + django_contrib_auth_models_Group -> django_contrib_auth_models_Permission + [label=" permissions (group)"] [arrowhead=dot arrowtail=dot, dir=both]; + + + + django_contrib_sessions_models_Session -> django_contrib_sessions_base_session_AbstractBaseSession + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + + uncloud_pay_models_StripeCustomer -> uncloud_auth_models_User + [label=" owner (stripecustomer)"] [arrowhead=none, arrowtail=none, dir=both]; + + uncloud_pay_models_Payment -> uncloud_auth_models_User + [label=" owner (payment)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_pay_models_PaymentMethod -> uncloud_auth_models_User + [label=" owner (paymentmethod)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_pay_models_Bill -> uncloud_auth_models_User + [label=" owner (bill)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_pay_models_Order -> uncloud_auth_models_User + [label=" owner (order)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_pay_models_Order -> uncloud_pay_models_Bill + [label=" bill (order)"] [arrowhead=dot arrowtail=dot, dir=both]; + + uncloud_pay_models_OrderRecord -> uncloud_pay_models_Order + [label=" order (orderrecord)"] [arrowhead=none, arrowtail=dot, dir=both]; + + django_contrib_auth_base_user_AbstractBaseUser [label=< + + +
+ AbstractBaseUser +
+ >] + django_contrib_auth_models_AbstractUser -> django_contrib_auth_base_user_AbstractBaseUser + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + django_contrib_auth_models_PermissionsMixin [label=< + + +
+ PermissionsMixin +
+ >] + django_contrib_auth_models_AbstractUser -> django_contrib_auth_models_PermissionsMixin + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + uncloud_auth_models_User -> django_contrib_auth_models_Group + [label=" groups (user)"] [arrowhead=dot arrowtail=dot, dir=both]; + + uncloud_auth_models_User -> django_contrib_auth_models_Permission + [label=" user_permissions (user)"] [arrowhead=dot arrowtail=dot, dir=both]; + + uncloud_auth_models_User -> django_contrib_auth_models_AbstractUser + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + + uncloud_pay_models_Product -> uncloud_auth_models_User + [label=" owner (product)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_pay_models_Product -> uncloud_pay_models_Order + [label=" order (product)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMProduct -> uncloud_vm_models_VMHost + [label=" vmhost (vmproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMProduct -> uncloud_pay_models_Product + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + uncloud_vm_models_VMWithOSProduct -> uncloud_vm_models_VMProduct + [label=" multi-table\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + uncloud_vm_models_VMDiskImageProduct -> uncloud_auth_models_User + [label=" owner (vmdiskimageproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMDiskProduct -> uncloud_auth_models_User + [label=" owner (vmdiskproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMDiskProduct -> uncloud_vm_models_VMProduct + [label=" vm (vmdiskproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMDiskProduct -> uncloud_vm_models_VMDiskImageProduct + [label=" image (vmdiskproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMNetworkCard -> uncloud_vm_models_VMProduct + [label=" vm (vmnetworkcard)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMSnapshotProduct -> uncloud_vm_models_VMProduct + [label=" vm (vmsnapshotproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMSnapshotProduct -> uncloud_pay_models_Product + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + + uncloud_pay_models_Product -> uncloud_auth_models_User + [label=" owner (product)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_pay_models_Product -> uncloud_pay_models_Order + [label=" order (product)"] [arrowhead=none, arrowtail=dot, dir=both]; + + ungleich_service_models_MatrixServiceProduct -> uncloud_vm_models_VMProduct + [label=" vm (matrixserviceproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + ungleich_service_models_MatrixServiceProduct -> uncloud_pay_models_Product + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + + opennebula_models_VM -> uncloud_auth_models_User + [label=" owner (vm)"] [arrowhead=none, arrowtail=dot, dir=both]; + + +} diff --git a/uncloud_django_based/uncloud/models.png b/uncloud_django_based/uncloud/models.png new file mode 100644 index 0000000000000000000000000000000000000000..f9d0c2eacf5bf2f4fc30a61ef30b839b4c93f7a0 GIT binary patch literal 408110 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%0A1-#t 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

imU(@jjq5myRmV&cDFNAD}Cf z!2mi~`+gEUS52oIW3`=~ICgyNx2HxI(CTf6ZWj-jEcs}$_JcEvU*e#kCN2get!-p9 z3p+C0u%Cm2Bc^P7J|Lt|zb&{QShG2lNQcRPK9=3>WHe9+%64#Zv8L_B+1R?!Vtm;q zRaI39t?*g`X&Zm+gFSTCR551_{$`SfhQ={!RvAmqUv~S}EiDxHEUeSL0B%6aak`h6 z)Z9sUSso0Q!_C#(x>X1Udpe{0ZP_YBk5lK)oeMwC2Do`Of(vW)YPhe{X!=eDwA&3b zsj!Gh-D8l)ln5d6+%6tTC3qJXmvV%S(Vn$+#Tbh+W$M(5d-qsS?c0*)!sZ8CoD5N# zA;4|GpE()jsG}paTUB0P&y5x?p{0BI80Ae({IHnXj*ik1Vyhysqp`z_HNPW~SM?cD zrAde;)xYDnjA8{p8LkI|T^kmVeKIhGwHvm~yF``RGi>JZAKENv4 z?AkSGc>3U>Ll58)T(CEChQAz_^r|iR3D_V6J-8K*9?d{Pd9AsVH^EoLY-xShVH=xy zfK~kws{gkk_>#LCZK6794-ggj!R2>=Zc~NVcf}AB9Q0J|36wcRfOP8UrLpJabXY?zG!VtMeqnsuan&}Qv2Q%Z+j`*Y2H@qJr|ySE-+|w z-G^`DFR$oID@B3g1h~H9s;UJR90GoyyhSo93isRCly}ZY7eR1vu27f&^M`4$m6(pB ziA(H)!N>#OUe87E)ohqse^@ICB-HLYUen{&PhIUDCV;+vPDVG%PwkTF+lxI^5RcDf z$Q^u8cKy17fTHD4?dk2ec239535FZqye6wQ)!?aG<`tBxMsRBAS|Zr?-zhVyo?)2f z;yicGoRplLS#E`vyJwOW5$8Fn%RwupW2S}3E(gOw$eJT%y9bP>9=x0N@v7Xo*>F0B zwtMzW7ZDLjOG^um=(E5dwmEQsE7MF)-7pho@{RZ4j(QbCbSziPE4}L*>b1vdCTH{P z`SYtOJ7cs>6Aqeeg2_D(LL5jH5$N0Gs59P?J=t6}pOcf5#qag_`sDIWEL4VBb93`n z%-8^g9|Uyj9b$CcyrBI2!6Z>#_wOhfy41~ zCH4x_)QR8RI(`duvQcoJ0z^)G-@b*QGyeczEeTBnm_{fdvf=8(or~^Rieft_ZQi_j zH->w5%gni&l*B*23 zox1O!8HWQsH1}|tBQIVILq`7oYIdoXk56g$ZVLlizxoO!3FuGTW#zCvfJDcchwjQo zevtrusfV3z$3TY9*pe*@(fUOhw<9)46}AaCf1Y~a+W!9jN`$0-1b9birA3jlk7enx*&v9(E%S)z$=@8`d;Ow` zAA;YUzi~s6PAG?<;yihI4p=x{#QykfT@eih9RGG4NOTjIV4)z-zfRS&PZC&`v^smF zA!Kz|Az%*xhIsGEla21e*B_*?(nS;hS~c-Q))J_GsviBd5MW;rmhwHcBRbr;n{==; zJFWyEo77G=%GsUFj<}ot(L#Emqc`6~4c+`g8i(nRu>pgvcb)dnr){42A;r*P_wH$k z#iy{c+TuKtYcer4)va#u(5=(Xc+R#GgT=`pAd6F|H(t^JkIv1*a{?88`D{DslwGLv zsX#efKi%XS08Bi5^_U*5JoldE0t(~7M$8a~Jf4Ic56)B)a;1VJ-xIp^=WLLR(Nc_+ z09D03n!U*7-K3(0X36NMWBK^;qY%nE-7-3IyI{`?@V`I;vVpt8oj|FQd3auh|Fc34 z2uw}_jaY4MEr!c=51k>h>QPlu4|g(>Hm}pMEO23gf*ppCrnR+Mj=+DMN8tn=NpwVI zaR?rOQwV{gy(DnvF8uFI=8F8gaE9=}Twuodojf@i*1&`7|E$c{_poKgP5}V{7+BMb8*x}I{jOiXeoK3bc>+G@JxUAr z5s~QP=*VJ1B|r24LqPdFk1H9W{b0i4{r9QQ3Qta+kEH21f-?$q3m}k)jnx*9EN^Hq z*&ix^aIPkOeirIoVn{Fp;fzDEEQ^379!CzxjzGmBNRa>EDgS!^-*MlJhkgN(K5|_U za=icGK}bZJN}5ULtA$+mQUbEbCx3ZeBnYsQ7x&*mQd*ia9U)~K27Icx`plS=jIGHT z9ZVa*y+`ohNxZXo*|I7;EY4COe2E0Q_}nGK=be4nn6i7mqT)<0v6VaPwfKMZ_iFa^(yViRE$_+k0eu3V9rH;(~_c^v8yWch7@Qh|xS3T)_^aqFe=_-hnnCo=|q z{$yP+pZOi!|Lk%FrDkwo7~G&#`upSDde_E;yY3bR9=+lK8dwnE;*!>4@X-|H9~fR6 zjP>S0!6gV_?mK{1RX{8y)Mu9H7}rnI7{8x;7ECNpq!GMAB|6h7IUWH>m(r236wYMX zC4qt^I-MvS+(*2nj1C9f_IlS~|D$~$rmy_dErIaJ9$q`8!$>XkT#(I`3G4N5J9(U; z0f8GW3E|~l%%8|y#_{UX=Mb<8y}nT|I5ptEOr(O>2Qfurv3oT@+O1{efkE&3ioEr=wJR{{(_ zc69{-b1o;-%9pbDAm#&%AgeF%`Ku1wWxH!v5VV6BkUOft!EiyZ@rxHPf+Gz0jm&bB zu2kpCg)k#A?lGQm4;f>#cW)?U-P}JnbArrnqw&23=jRNvFyt*U3V((`kC-2+{|pQd z>_ioyC6|G2V*(cG+vmsD^}-t&KVsyg`-#YqU)=Hmc@iSE7c7=K?(|XPb?;Yqd z#h`6g#2Up~ak&);F>@U11iiD9y(IqmBIk)8#A8snIf?qld-$uZ zcil6)RrjW6?4A!TF_)-YfInUL1RK(XT{t@%pqn@+=3)5lTAQ**nl|#YvqHnQXV>@;yK3-U8_H1)o@vIp@&PP^kqU}g~@FruoEqKo0*yuSp%opbzj~2W{)Ya8r z6nicPoE{FMy#RtYUJ(NY9FG`93mgbN6qa=L>ZbvJ(?*F3qe(Sj+`e7Rf^_0I7a%Jf zIX|4geC~B*c^m?b{+5be6YkdHOE`)}lsO`ojfo5hUa}sNSfDoORMWj!>gePYMkXEX zfC`wvcOO1Tsz=qHHB6T)($oQPfv4gO(GgbM-54CVUV3}*Qv!RwX=8n90!>myMCH35NEhewOxQr8~kdgy{$Q&z5 z42Ku5l|XEY7q;2rSKAApn}USP#nqJ?3v03Ak~%-KYlS04Cr(Vlt-Csh$Jha~OMbw0 zyM>r%T~a^2q%~iu&%@zsXz&u9x;f3`dpY%4ef>QIY|l_(zXm4^J=Iwv2I-lf`ev~Q zOu}-W1ZB5dX0Ot!RbnV$+(!|AhPE~=U2ql&&7kfw6@%-^lOsl#ssc&`{0*hAdPoec zk?k=D39=lx+rJa5qvy_Gp52r_Oj{HNf1wKHP!|YC*Snfs$U1C6c>-u=^f>j#jaC2_ zL|1tEQ=!3~PkcTM;w(|onQUx9f>j_4oj_$9QH9>>MP56w!2gx#zSt5&_9eKn0>s2w z22E|JSWH-nJ-i1`1m#_Qe>4$Bv_w?o*?m?Fa|qfkpz#@^EB=n8t>G2!p z=OUT=aXKJ?6?x25SRxu!iUUQ()$7Xv2_ z)JnFgub2zbhiXWW&W|y?>irD~Lh82G%}OKQdOFz|1NqByMgPScNDPePkp64_;ktL; zN89LW?bJcu(W=3+{SOXYISncHQ|og^&MG}Cxx;&-t#mpE?_$*@&VhfNSHukK*VX!4 zZ#BvsK8-cJ*n7kkQ^RI_X`H-iaf3>o@9d2`O45Yc{QfjA3+7sRx$VHQA1ZC@hUU74 zoPJj5Fn5V)=cxeuD>fCYw>;w58t!+}w)<6<$ce7&Im+3UYpj z=pV$v$eC~w0?1j+q)(4(|7a@MQ}eLy!AI?~iWfOkO9vC!x(;Iu=k1jj{ElDpYX5Xe z=>7uf@?_WBg1?flTn($b^I5j+Q>~#@PgAjv{rNewJ5Gn~diLzHqCj~6<_HDOH&fcT zV-C&Q4@QCx|s-I$k60zd-W!lSY`a6a4Kdjrib62v{)x3;d4yJZu zchWbjcIKOV4m*zQ7#Mw|?K%AYL-o+kCN0M5BYF#7G-qZD_j7PmbUE`Her-N;=7PV< z+b1|yU`mFDthIbE;eOM1ZdKsY_O?8ZK90VdV_!mQeJtp*=m!i94cUMj^P{)-9>PS@Z*HWXSKPqoooI5-VfqicgaNhuj52ZqE%nzYbh`#M2QF zFt$=gX_c7QU49Lz&fl!HCj!KeidVc_>cO{Gt?abp&pGdF()BeTug`uq%6#4xaThPz zLqnF_76>@?WWpoOPOO(U&D_X=sv0uzvTmWhPIx7%xd0qPIwqdwjtGTOzHuCRvp7|! zZ4@UexgxN!ZIXcWQ@Ph~vs$JxW+e_yW1Jb1@#~;ww}4n+q0*Z zG@i4tefL6k$a#3}&d!g~giUgRk8vCAmmeaeRSP$t7- zy4g1Xhn|J8F7JTO{>&M#MOr1wk{CEa^OxS1xv6xJ*Yw!8Wy$xw;Zk1_)xP0; z=Q5VcyMvW|A{URh#U3adUa#s{;MI;Z(s_#?;av%!j`ojL%iBY;qO#nVVbYug$k5>+ z{@I`(Rv8sO5d#_XPq`Q!#c6MEk91Jqo$n4tXq-lPl7bgaNj(!93hOtrvTU9hmuZyC zk2Zsa)8F`iX%oKo`w&M%!uG64{TM*kEDYduQzjBj#OgFxii!cjhC~<>xjh_cnmxjY^JvTMR@Fk3cE74B6pRf@#J+s_l9XE6 zG~L=9h$ZO8p&-C%o4oQmrZc8r_o)2z(UUNTLCz5yhdiFS2+f{j_bZTJ^}eEi+IwK{ zh%;{dWDR1*Z?8`2atyqB^SzC?PWq`i5sh zMqGPO8&!(fbg&o2nneF4U~UU7l3IQ#t8pjPb%jJE@9a`B za-Tx+Cl8I9qQn%N7laHm6^&LY64R6AE(xFx;`05?3RFA7B2o@R(djtIfgMmv!TFn$ zp>U@{w(NRsE7w`x-q*jvC-HKdTbh{gpltdt(4Hm8FU1UT!xw==LuD|aQG6o0LUHQ@ zVX6sfe+(i7Cvsa54_n%Cz>yWEV&Wlvf&Rv$Ck*5TZTbwwrU;MlAKKY`emL=Aiaoda zw23H#djh`GPZbgpvVj5{-_@ZY-L*Idk(Xi!BG=U-B? z0t^sI_0U7vsE8{Ql?9AFYEK7ZHZ1UA}DBR^WrI;pyz;`UQb+!wA`dT{4$T;}^0@GAC=>8P<1QWF~G4V34wcbv18;^Y6COe#Z3sMYJP7D)NT1yzW zY7<5#^6m0$5{p)ndhYY#g| z6xd|%sdC{;b8xij7!|1<4)v+J-ICh=e)0ADzic^Ll2TvazWQ1I&++wp4xafFr7wA2 z;|Q~wGX}-d-5~yT>o=_hTI}fPsHLF*wBQ{|nqP8%grdBD3NeHoCRi~2Tp`LtIDoe% z;C{Nr!5K3I?QhuP;MJ=Hn`tpK#Ai;16ISYKYS_%f5kLO8NbdZ&wD;xR?>c%%9Tr=z zn3Z_mXz!XedPU6{2lk)D!ZKD*{`{)@S&Di3lQo|n!nLCO9SHJz^P%l$9`3|b0V}7- zOP3>ib+S=Ada|m}onZqSf%;5t|oY0nYsi*ON16 zQ(NbxktPu9<_n`?C1hpEdo=I6=QubJer z`Mj~wR=?!ef8@T;eO02w&dzb>T%568Yu}>ir9q_$iu64xzOTYMGiJ&$~(sN>>eclIb%kq* z-%Pg^iHmc(e*Gdf!d)Y89uln6@VrU4yem_RUS{ZcMUIl z!OtCWEq>prFDs@wt$(EK@n+0+i;k-F40qkc!#ifVD9Jh-D#h#OP(sOe?QCiD0^Ns^ya8&!|lbcA50`23pcDD`?bUMeIM&i z$6fboD{l48R5q^x*wNnI9SrjO900n4B3E7^V@$5orh+B%iy*kpoEe{_VWW^yA%}?JBcSu01o{2t;4!HbHqQ7+7sH7YqdX4 zd|2D{SCzy0*%wo?pKI)X-Z+!tA}c+I^1TP6`-SE<`yFgmtWm0|9=*LF`n2zvXogam zF2m3H`m5w~=`&J~tqRh#o;kdB!@I^dmgc4D-Hm-O`#8%I)K8UH7TK6oP75k+oz^1y zHwLOM3szMvxGFG-aaTkAUfxFUPEEM{r|j&bJ;J@kA`52T^Q{uk&D%e7^$Cjji@wf% zr+hii?PBT1@5M_h>pps%iT0Cq5}54K?3%LebzrT^>CwtF)<%9~Lp~AF1L$T`7iKGK z^>h6j^bV2Wz=5}%eaF-^V;}2ocAlwzXWvQnMT&(cmvFK{#Nb3FukG$3>RsX&$+Q5# zm#oVHZpW8fKiC1g_>6O2ooAu$cx%5> zW-0s;-Si`3PzG7r=7=l1cG>-^2`RYcx?Fto*d%$5b=^Im1cD)w}J* z>8kl@Qi9`U`D*@9->%xar)&(Qi(?~$FZWMgv0}-eT@OQG7~V&}-7NgaS%Q&ddtJS@ zT2MgF@XNYQ`R{+;2~tY9_xzE2f%|thY|IZy2~J;2byK^Gp*v;P^(HRfq6h<0+IRT> zp33Fi*b{=)uBm;@!rDqvDC~88M(1PW3rUql zl!H)OTW8FBbJSFfvAR52nNgnHf5j##`}Bszf*nV{ds@!XnumcMLUZSKfIK-%B;6wT zOYRvY3n&sFw#Z0=*cK8M8)giML|Xn|BAsLhVN>rvJy$$XQ$rntOSjjjIIRKg8fQ>^ zMVm@xRn=6$tO{dm7&FCu^3L6Xl3L$Nu-D@?J$az4+zlue^)(7PJ3UZn0W?5J-#!|pBTprT%zsp_GGA<8 zz^2J|H#B06nL}~epzfEGM}>MuR;D=$6u5OsGm;X_6*`v{J@uc=h_DPj_Vvb;o|kb# zr9VuC!_IAZzO0||`3;wsm(sEsMIQZgzGyr-@ocAAcz?yLLQd`*OLF z#ipH$mMsg6jTJ?%C5Fl%n&$;^et`N1XIM-G>%k2E?V>3t z@;tfs{5dZ~>I%Gm%AvnfNu9^M96cE)aQZ6R+Jqt8UyUDg(7E0n{d{U+_hU7j$WCwQ z3HYO`3q$Y&B@0{lE!AT~%D~j7Gk*O18IYZ=#IV8Tsa2e*3%&hQ;2NKrvy`2IkZtRy zHqDk`Icp2YsXlFUXY3H!;huBQpQ5G3*&$Dxy%(szlD~*F0Ab<2wlZy0owjU#dwo}7 zYkbHk?~}#WJ`M-^o-bfnSf6Eoaq6|Izy4)65y!3<>JIf}xyp?&YsdCG+CPfsg^Ga0*gnhKV7tO_ta$d=}q zW?T2}_164d8YtAfKY98ca7)W#PtM62Kg*w=a(~ok9ca2GQDHx?*s&Q5qqnc7;#=ef zSd=3#$7%fGJ=W;N;7Ppv9&jyKCSLYv5jlagLnoFRzVAmlTv&@FlmTLUR>|)9($Z5= z#tb}ue9fIFS0jjKifSUyjxNMKYxeGygH!)k{?FQ;OB^WTKA|jn%UX9YW78tuXfei_Y9!4W;t@@kp8w^;ZHGMm*FSZr@PaL9`da(FBZ+yV*3Uhcau$~f&S=eRhxvi@fM{lfQ` ze_t=+i}ud^X$;EO8gE70Mt|=*(^Y5tHbXZ4f=2n>%Y2tC@(2bP@%p4ZiR=3b15OEw z(JYS+Y(aK)X+$XTn#D9m_qXmTt?FZ?wjTur2CnT+-r6-ye7`*tQA#oFYH#<$kRw~r znJi3mjb1~L@)Ql-?ChG%`0%^WcXaqm-th!cB+;yl8DE>U>gIxl+{8 zF?Mh})Z+XOZTrDmowA=c?U+zJ%RsP@oH^Gx- z3cuy&o|%0w^~cK}u48R0`z+K%C8ZdQnLAX&bIz7cX1GhL*VH`my8ah|+eg&o?;*;2 z2Urh;jF1RZDG*-JePZ%emt#|c<{E&7VFGegTBXss0?yCtAXL10*kpm(GKA6vg@r`4 z0tR*xT_=Bjecf(%3*zs5XN%E6mysDTWw_pMwfY2L&L3CVFEbb3_o3t&fxHH-JLs${-mpy zdH6wgito#&k*B9IL4pSqI97&bi5GuFLM$!D5ZDO~=&B0^0pAAnQp@+7 zSfQTxE+FM3xJSe~g1!4aw(#BNcu=&_jRKsP%hc4AMC=;9`?orwDb%d&%$YNS=MKCR zEL-w53@5UYca`^i@f1Vz{|J(*OWZGhxUz{+T(IPR|L}Ls2iNcK|Njk=TH3g7!?$k3 z3O~a{8lt@mL5Bhxr=?S00Z&(t&S8P!5`V4aav*~$FwHABW%q!k@?&7YAF&UVU_-}g zpiO-24>(v^fyW~VWMwSenzQ=ln$((il|j^)1N+0mn8C@p?)CE1cRoV6hy1E;^)o|s zFal~dzYE$bDL$h3fL;lw_eBy|TDOl8qf^9w&hfi{C3C3b{cMm(g7r^A1b{eCL+D;L zhNMHitK6lGjT{IGJHhu4M`p06vtd?@!P(fune4zdYaTr^dxMZGu&~fGpxRm*yk(p) zsc#pRZ6~4X$dc4}wWrff4b*OB@EqI*pW0uK!zeRjNGjB;g3g0TzQ zTeb+|1EkI^kUcQs2~%DLj^9lPpHc#Z;C!Kq6{49~E8VyM*QllWLJtZU9)AX8(fJ?) z$dCTYWKjMUS$nfzNJf3qp7VEaSqL@$Mo&VpK5J8k@ni@&aQpn}SrD#ZQ2;^wJ5M}^ zf9*ddqLGKUSpZHJd2?ci*Bkr)Mr))pdc_{atMLK>xbb7FD<=B*U%WUM9aS%^N>BJ; z_7*XgpjINwNJ0lZSf*Eh4E3XD_6I8RNic&i^7hZgY}PSDPYMwrvQh!75AF$ghBAjV zM4cW(kZm&K%Oh=Ot&1@p6j~&`-u`RI^^X>wdI_3DgS)52R8>|^#+p|WWgav!>_-gt zBLQQv_V=U1R6|V-B#2k%&Y5RNXT#s2<4HVx6 zx);k|RpZujgAB!>u1Mg*TlQ?>06N2qzz)1#nZ-^J*T5g-U??wLstdaX9+jCaI=~`7 zxw^YA{8_6DQ7)ikEFp~-b;OUuz4ra;f7)^N)H+EX{4!{gsi<)(jT}wdUEDnZmtF33 zKuIuUIhfz5-T=k7c8@kRf33>_W?O)c_`8_f4*CWL_h9O4zeujuNmGEIC|ELuc_L-^ z=&BvWBY_FJLw3t>ac!yGL90W3=vhbweVFmXkiVZjAVurI8(;K0A?QY8ACyFg7VngAJappfw=j7Cd0o18ZT10fm z$B#L|werJxMaEW->&4Rnr7ji*7>F$GDor|c6|hcW;5}8od9~>f#3=Y z1bX!(bnC3aXF<27{o}_#tX%9x(-`oGQ5ej<@7*a<1{N54tXQA|&G^xm`1PNO!0~kAA z>um)>I4Bi5=)$wExp3x;A4v9~YJrS;&cg>?f28MzGUL74+N?!fY~0-D;vqU{p%y5w zu4Y5@yA=aE-PDp|!x2H4s2xTed`ei22jul*?*^qpY(d3Doz@nFeAEN0(8IcpPvOO@ zS1G8SN{O0|oR^lABmjzfP>2uXz1@)iug#>U3I@)k0m z^tDi?VXpwR5BjqltY@SV2sY?Doh5QIqv7kHPi(47kCYGGc`H6Jw5Id#=aoDBxb6;A zG_-H{!NH-0Djuf1pbpU+x7ki?qCi)+0CaiPwlmrgGY{nGmb$wH$AUU;q%r><>);L zL3=T?BRF(T?S)~7>YN%NN;F_|(QU_FyX@g9Yq@TQ^6_V(fhoJOng_w~5awL=et5`x z&AMzf;4Pb^6;gKe`Ni-Ic6N11AVN}8Q%fH`TG`q<7ikP;mjr>I3rTa!PoEbnxM6A=E71<1x>9yeOfm0iv3wV=JepIAL{`=x* zEqdAc8f%h`=6%`uaBzXft2=AGKl{Fo-Dk67u82i$RzgnNBFZz2WQHCn`IuM4LiMgY zb_sWhbPVVaaE8#;7X?Qs=jP2d(5iD=2q**O<7GI*y_j464t>-JegnWOW{`uU-c|IO z5F@^`BlL!yypO|C6yb1-W3I<0SXZ>0|1v+`yvuAT@pbkk=drF>-!5}C)x_`p-2t4Q2_mmkltGn zQa`?gZ%4g<4p72nE)FTGEB(_b$B@8EIT~ z+fYa6!u$VA{HODP?{DPxcz}N(^Rlddxg)s%GSm`Fm-3@!3iY-*bI#p8AbPZ^3x)+4 zBM;h*6zcbDUkZ`25YqH;x$`L986yLYAr0eJ^b0*xcd=&-oBBz6(q41x0oF1rZoRt# zp!7WXaMY6x#i|&<6QA% z{VSqOQ$2shm3-m-ztH!+9RlDRXLjoJg!? zj62KarYrjTRNgXi1v+@E=90@NEiKA8RYD<&78MmG_N@J#>D=?THlv6s2Z!f_A|L5X z6L%*DB*Qa|&R*#4>+`_AqNeck^HWq8W;ZAQsMCJnydr_K3fF5q?s-^9OHcqG08e5$ zATYjV=*N#({9iS@q5o!^r%;im)z_V9ice2vUTx!|jtd|pR!(jshg3Y6c<+GNW^^?7 zk@!{c`0-Cc9!cxAET<1{`Bb;Hiy1LJ{+2<;r zVi&ceqFx$&liB-zoF_*SeR1D3FNmg@<&jRuOVzE*G(Xi96L<%u<%4}j_gE&^U+0t8 z;CmBWWSXU=X|_-2cLP(EU9Qc=9Gf+gYWFIS<_2AU^O|2;uV4GdhM*r`2WC!nJu+7j z`h2EFSg|lB(90{LZosN`K1!%&pnAaS*uYJiefELu{@EjRnFkNXOVM#uf10{>g@cOA zgOOXs%%q-bfX^0+FWk3yxD0?-X_Q3R-U6)qly7H_!-N4^RPKyb)%q`p!~!R`Tbce_ zc%Ol)+<{;fZRA~C5wW-$YfdXfIj4c8f!ifGBxHhrFOio27#t+%FL3kjqOU=shVm*Z zS;|p8IWb9w<_6v-X>L2R9L8?MZi*UK|ru0Qep*6$H-5E9@ys4yCF zgTqBRu|zSYsc(gKvzCQq0ughZF2A0@nIQ=`n|l6A~tZ!DBp~1?I^{ zkK)Z@_cGcS0BZ&FM0}?xPzqcYBgU5+e^0)}`3agJDJ|`8oPMO1Vuq%wswx?IsJ1ox z=VZS^f#XcT>U}>gmKGfFI0MCZ&~bTKUqM3pZ5+4sJ=~*e4XqD6 zlr=R!X8o>)H5p!zdw?oP(E}qebbd!=PTQeEum`Me$Ls~HcqOt2h?E8$=bp)LUY^Dm z)YJ~o?dI=7?eZ2DT|_^-MSopx-ea(3HEs>}l$1S?G*mek@>t{?`sdwoqWZw}8b7R= zcm#21vV`T{*GeY6?=gvz%pj;_Fi zv7sEZReDjthfBI115_jXRjms(c5Hfj0BVIN^Bdm6*uN5^%RIci3{YR+w6!gVAW`P) zSEWmr*wF%EM|*=j#3+`NsrH26IvnXw{w4L!q|yFTq=f!l_+xeHjkSF96FI-oP_^gl zwCv#&p(w?K2IAkrf(T*q+2$vV4Noqps_Fs|>1bUa85A7s0e8gdBPjvs1H`k1R~omR zy{1z#Y-iqmb%6HN^V{`Jjqg4d3;j$_ow##0IjpXGbl&c#g=NK+9C057g_}c_N4Cl+ z(vm%P(aa?AE=q(R%p2&>SG;_g0|??kz8M;dj+j&atW8G#^=M4=Fr z)fF=^jqEJw0A>36mD{LMyhPjdu{GZoZ9h^~#>>aojnMXYn5Tm{vZ{ybBu)g6@=3{W zqdjT?0bg#6D#KomYp3k1T5%_i0WgevLuSR+VqJi&e%9JPoO?>e^`U!Ci|gfc=NPc8 zOe`f$9RH9#NQg~*A*V3vho{mG8Fj-&sry1hxYLgM(4j+ea&jbaH1z}*M^vm!`cCh* zYs`r*u5(}^wg_EahxDjSghew~ux#4o5fsEExNb+o%s$-6_P1_foW`B;>%LDNuy&}i zj5NKZ?lr2msP-y-H?IB}?olRXMG@9!b&pSZv(_tX>HY2+&kN`~Z5L0-;%9@>>ZQm@wux_0U z`bEldMMZkbD;gXXJ8?IWy&B4I%vmM#>U->6)T$>C-8AIz@gX81o}gco6|wN{6I}QC z$9Lyb?=oWcu2mjZ408SsTI@{BF%BSPFx+4F{j7lg5dAh$SmM-sppRLdJ5gaAK#nZ> zgj~_p=Lg7M#GHuK((N)SLABBE*9FRF3Lh-1$~C|Fd#+1*hMs+K&CE4vEbaRNNs9Xz zxXjU0cK(PFM9T9!1TlN!1hLRTF4@*DP3C(>B>MnA9V(D5C}Q(ID0m996k`Z02G;?o z*yvz(5_b~RkaWDq`dNDqcyoDvipp+3Me zD=4Tx^RiUXyXt){aG(s$)xnR52LEnO4v`mEDynO6DuOXX<*KBvZl0!}Bfl`0=iWyB zO~?G*P@YT7ACxkguU_573PDI9n==DbW5Dsw?Q3dNX;D#jEUfD102G6bhh8BzJ9`tN zOArgO@>~R-=Udd{dRMMA@~Y`A+QL~6`_DJrg*8bQ4N3^V`C8&*fA zPNtVMLT8`6Nj&+jC~WG+x3^gp_CH4sq<7@CYH=|&2=MIha-;9|HRM>07kM>Siq1ps zw&=M~Vs%PMNjdN5nN5S(HVic_hrq(vVZ|CYP!{o0Gzjv-ILuQRMV9jXIUC1DW@b;6 zc+gx+<6tAX5nb8IowQss$Drnn1t~6yA*%m+(z+vI>Vdl4s(dK3y3ma9J9yAuu?9bW5ndEdpI3Wq{)L6Y#ibL$=7F z_@>52xhvi)9Lllqla7kAHb;s&cO$1l7fCSqu;$s9mBguz zYp|%zq@WY^a)Y#-HR2IeWwD8gp0K1G z65E9?0q6k1BZyQU&v8mIvdy1Sy6+)wab$7c4c@e5%1{;A%$ofZw&@7tCnUB6Rmyt& z_y;z;W|e>l85tQVSY!xaSW8Y|Ma8>tL~oOH@k5REYqXt2-4ScK+Z*G#BUm9s#a#+? zur5Y&py;bAry0tU^E&4<(|TL#HaBi#ryo{qEc%HU4lpwe80j9J@I`dxs~t)rw1~Q< z7q=Zb4kO${hnXLrs3D<1p>`542FM8+G8QeYQ((Ws%*?Fl#oFQA;U7OJNs(M`*_Y0q zU5_KP{&`s6jagC-o%6jTy9MM*JF_M*G|9k${5w-!TY_)>*CElJJjDMd9BEak#JK{uYFrnyJ3xQ^YJWSPnk)hlp8-s?)eg zEBeOfX6cDy7FknM)4U9EF)?XP&8+}!OE3t>Vzlj0Dk7ke@_?h!EconV|7yy_oDs+7 zVwd#T-;6Six2|qURA!qfHRr@pNW?*a`S1Dpy+`xacI7J!@Syk0C6^aU?S9iBK=FyQ zl~lW^(0Sgydqp&v8-#1mv*YgjAUL&x;`ZpdOG%_1k1pRHeU|N7(tV>q#_)%+K|P-R*dEX5088-Apv|H+f~QkvYwt zg*4OIJ>Mnm=a)DrC31C?DoMJEZ%bsW@IN0eN6u8&!hJJPZ23`SYuLN-yud3~i zepz-WH4Tr1j?8kLs*5px>E}N5fKmF&ZO^kCr0)e!kF;(C+w>lHFG+`cklRF9XKWk* zYTYnRH>}UA*@_q}l3M|sVB~8d0acLghDos}uu@K8K|?Wl7ydAQt?YZ(@{w`D&d!c` z+a-4OM8$#5;wI2pcEAtfc4K{!=T4c6U{znkejvwi)AJyQ)2BkU4C>zz23lMD>>cJI zgyg=(m1b;AdkicfbOV9do*;`T{LFNa-Ty$;Av`em!@_6~v=4%T8362eP1d+%YuUkg zn=AiZPA3>G=H5B}9+A@5=dP=!j{`T&i>nmYLoc8PV}5JjP2xWDL;;UOnBLWO`J^%H z)~(&pj{Q9-*-un;$(=lVHlV~%3{`NKFsWzT@1xUQ`8e`smeqTa6*1J5MzLi5aR$?G zy2ieQJw$zB^X8|Q zk1*^wN_~BC#xk7sm(=8+;@D>iz9C$6u}QICu52w!K7|)?m^J%2E!TNwg!6$h*T;S) zu3dDN2pGEk=8YI+GIz&Xv;pp}K{hq|XhIQmkBs<1P>a~*Cs-8r4h~#GLK{hXj?h&< zjR^pA+pZsGgUnN!M$Y<|?G6t{hpT30^r&5*CV1N(NZDuVFGWY%B_%tR`;GFaPfMGb z?L&mKA;;;+DGX3~`}ONKaDDFq6u)qux7ErHRQRhq-2U2ix;)OSuJ)sU#~N)tJh>? z#e(P#ziWwiU(NSYC_J2Btz5s?&xsHHmllB6NyQ^Jruc28$MVLAx`%Pn9?MHuEPgT< zY&Lg{u&c&n$kPL9exzp5pd#zRv7<_p(ac2#Q?ZlMSoR4*5) zOYrTH`O%g@>#G^SVy?Y{k|?k&Qq=j9Rek)c`^f=;A72&oKOXc^2}BoXmMyW8i%Csc znFKwMF$z6rU+|rQoB)CBD-K6?uKlz@6G;m)y1KDn+P8RiFI_h@yo=r-`TcH&U%!53 zLM3(8JVPgJ+VzHsj#S3*;9xf*FBHdGKH)~-KHAuI{}`@RQE~Ad2(~11A#FtW#U&)% z#kK>Kg4dEZNFBk27=Y8KPlL#OrrHp8imW4a;n4Z)8dhp!*Ks|qpqan*3+XOVSF36u-hA} zrGVfQ_Y{tweVJ#0HPJZymS1_r}7GLySWgD5^^uH+j33X}r+P3p~?H=~P?&CVVY zsFb(Lxr=)UN)DiA^7|MZCILq*AWIvN!eG6LiOK!&@O#J>m%4b74S?c96BoofxT8A< z{#a_44>1uaYA+NN7=7jwd=(Wm!P_vGQa0#`tp@OW%5hR1dQd2}sop1=-*n=6aSfk5 zd^Jtm4+rhdZK7x5{_yT;cHTeMA-}neHNswbf%SOj^;MLcljnCyH}jwT5K$JsILx$= zX~seMh7-8{N5`te&hu8E=WI=A$41ZG&amqA(``C*NJ&lqP^E9YDT@vNz02NuPl_}i zXLCF}8Bm^KRkWFP{9znR{aDJH8Ua>{n@i!))hx$_$-FOy<|%$ac|xu4Jwk=kt)0o! zzyjVqHg-FOaadkJxt;I8feoNvZrD0a6AlDfbw64%IQ0z-E=FmCSRhObdV9PM?@hyq z4IuOyJgeJ}9xW_8faPW!lO$Q{JIB`8Wke5I0!G48V}h^;;(|YjMO}R^N#rBP_c$Ou zT5iNV@#q)C8zEoQ9sYxlVwiI5S#uR>K>1CR_#KxY&ZEUIfkFlYbhr~fUdjk<72? zeX~l+0&2%riyh4zs~Ue2-%5|xK=hAYr=@ao*vp^4n&@7CSzlV!qi(OG?fIhgi^hxG z8;?A2UJAZzTi?#IL_>41A+}YP<6-NV{e|;>C{I^V#w7P1cRgW{8d1oIb5J+-^PSz} zbrp)YWjR$+&$g{@cD*K_D*1dx@yoJ=ZRHHNvo>(%t=)PgOwd-N)6cv8>=?`Qj<{fK zfwrK-hdxU$KKnEzxpj4;qOs|fD<1f^+|Wi{xL~8w31;dY)JLqWtb`Y{YdpOBC{T~2 z=Tm39Pdu$c4~_x@N~vD$j(z{TUmjapXp`5-*V53q`HZg6VQLrh0Ktk;tE;O=&-h_K z0JsC;w-z=1_0BgTyU%*+G5*DFkhoze*`LXC8?s z6k5ss%C0A!8oOebPMShtasoqa9z1%)H>{-f^wm`@w8P6AA;N@~GkITy6s_?w8XB4h zGlxV(p0SNE1wVf5Pe^t^&a_-+GL4)EW{VV-#S89Jd!s5bt!%1Xd?fb3Y``K{is5r#C&IW z*U9nT%ExRJH@e((uAk3V*`NPKB_y%yt@8)k1&3*F@?Tdi*EauLY}T<@)?6Vm+M1M- zT>f}>p#5a@e7j{-vCZZwXUS6aV^VVYmfM!UW=k8j9_Cbh-|%``NnNFM9fP^5>V=*v z8%gfbN{1I!UsboR;d;97LF6N5ViND~U$t@LM#MS4gM$KE(&I>h0ZB`uNKwgjV=~9L zyd(`3l{?5(trDCJc=qNEYk$8@JV!`yaO~5k9>k6cEeP08Bb=K zdI3R7qg#m|)J85YiI4o57KXpt#KK|#Ovpbmw2w6I-hs#Fs<XtARfhx zQUzKrj*rOS+L@!u40!O5y2?dgl(CFfH^b@|ZJ)Bg!}TYTv32gq6sn+M!_Av#g(H@O z)&;JO>B-vMMOQsPGOZx$xENG2I&edKmAHh`x8LtXWM`e4xNo=r2}ui0mc4FCrT=i_ z?6%LDe(QDSCKir4d{mbxrrR4Iduyi$qo$FH&^}rNlotp&j{`3@3$cr!^PDy1IDisN zyg2~lGd$g^k)ZPZ2;m39MJ?j^rN=Tc^kF@k`K07zz1Ej+lpheUB3w8GKSkm%n$hit z4uv?LDYZ38xqJi`1_lJh%+Af}qX6{J31kyl4@Sk)%Znyja^UjRAdpZc6_rwuC`wvd z=qL$g01X?$3m-&9t-}&p$5x(S)C@asY+7242>E~Xk-`>^ltd`S9CDDcKuK~O3c^-x z=lS9E9sD0QG7&_;jDv|CD}Ik>MCO?HeR$B(yXtXRlH`tKf3I1;EeELJC6G|DuZ#rP zNJ`eLT1GrL!uxBhu_d3Lm6u-w7}E=C1#-&c#K!Mn+bME~nFJ3a%2*Gv$bjL>5)^FW z+W~ixxN_9L-6(Ws5oULVjTeG4!Q7)T4y>UNUZ=;k<$6k5n%O~m<@O7WHpzcRdj|)- zaTN8U`@*u_#5WE&i-aly)pjo~c8wPf2?+rq!zZ|2svSsKUd2XSR{1L**8xH#ED5R9 zpt#29bB#Ppp6Z$!Xl~niAA1h)4AtEW{O3a3x8tPWaV%Cq*E?`9$zTYB$sKYz5mE`* zjSiHWh#fI*ZhVC+R@?B}wfLMIW?Ng^m+SB1YQ(p0Sp6#bIWJN41D?SiybU+`&+Az^ zIWSlY7A-G1LZL%4$!}U)6A#LNa9dib(`&r@wqn$@37NBm5TH`<-q_N><#Ft94EIbL z9YP0;pMLZ6<_Z+EM4%4H2qw4fh?4*f#2kTXi6SK9xg|qqs?b{(Hw+f#WKA8Cr9rY0 zG6!Mu_Bj>-2XI_GUx)Xk6&5?C3C@KC=)t*P0!#_fPP0hXn?Tj%8znG(?fr>G`XTe| zP`qBedwMJdl5LV&YJAvuy%vz&16w?wNYPOcrg#$)77`x{dPiC=660ew{}>u-$cyve zGXBl4!f`psW{ZcMsx;Md@-O*{lU1CI()Sj~?!tNWKR79e94MdkQJp~^{|*h$PmDmJ zqWqg{0(tmpQwDt|CJV+=3z$>~dU75n%@;dNt%iLpGdue|2IE#_9oAo5 zgunt>cV%{wko2LAk*O31)P;9l3N6u}8!<4`lVl_$#GsI#>?0=}?)ah#(oy3c`j&o3 zk+!?(W~B|(*b+{&N^uUj|Gz=FIxw%Am4tGEBx_*iZ1?{+FC^Ifm*85t4%Sf}P7o{T zY)jl$P-{g=Chr(wf1zKtHdW&V6$RlRUwByU?=w_>Ciq_Xd^T6RXCDy_5Ev&g;xg2Wwh zz9{-*aR5Lu7e4;&wWmkOUMO)~m;UTVE95L1Lya;PinKtPwmTa|ywCt(7R%QwZSHMn zf^&8T*?zl;+!%p3G*O04F4?6rHIk`vTUO%mwP2|;HWI#*!>*u z^rwt@YPhaJIzb3Q<>rBLuCxH)&5aG+j0J+kK0|25AfEp~9o{64M_1K1{# zQc~QJ`+*(Iyl>w=@akH7jKSrV)YoTr{yGvd4B|)%U@zp1S2%nwnVww^u~I37qU8M6 z%E=C& zjhKN^0&yjZ@GS@QS5n^E(EZGy*0Z^)r}x(R^wNLuOb$hTbfz0)$Koj0)QGtL#*+JF z0F<>T#PNf1d;9!VEV}Qmf&Nh!O_bolgC?6y5wr)^5T-n$&YWT&03rxoT1Sy{{?WR&SNC7sQ1`#wmcyx!w?Md|SYl8ern>qM~EDG19Y+Q{U-xuD7~lv?e_;g{1WK z)k=07uR&jh!8Vj*PP1-+#ZoWkt|#e+VB9Ih>{|wg?gc86D}Qe^k}QdHe#W(SV=P8s zk)-}i3_`o!Rm1_UBsGO(FB826!mfF&`O*MIkfczeQwCP+5WU@OPXON6gn#kAPmPW5 zA&;S z=3gzz%PHCVnnGKAmTRO<#wvZyM1K1DVh)c4O&v#*{r{Y>9KuGo8Tl9oeBnhoLXe#@ zqkE}vNdEul$WX~oKD`Kx2KzY)GuBA~|1mc)@g#Q@Q}*V6^LaBpIk9j0jWlQ!o3@^U zR9L&f{x-y>|nZS|94_o=MRqIi7!DrM=r7lfgDY z<6DM$>`8L6H)`A!6cba^1Bj=$cXT8&b1-`+(3jw|5wQ`)hFT4C&PZw34SK9&_fJiJ z=9%TWKN=bu-w^oj4wX8_RIjDrq`e{*?6ea@@uTFdYo|mmaE7u;a{jKkvobnx9swSYxWtASQ6>wHrOvWN3Mf6oUYVfMq@hT-F0mtn4ffZ!$P1%Ju0C0`a_7!f;NP((@QgU7NibR0 zp#I)Wu@61PZ2MUH%Pa5imp1m;U6zd!9wh$)a5+>>ahzh>E zvDpi?JV;37)@&P5}%ynbAW<5wjV3 z|8N{%{Q%@nYP@>G8bwY=NUuQYu-UY#DGeeow{n|$)yEd3z=g#GmXtEMS@O=gTF{zo zq!3jRnukn8MUsGsj@@i4`#YgFA#fO86%jfiNgP*A`NQx3(G39OBHaL5mI)Y=ta2?7 zBf`(WNi_}ChD?e63tcFVL0|3=71e0&7WU1)rUheCtZG9j3xF?#g6~4E4nL630>~fV z;z>{h;iv&;MwR5aM->Va3WyIc^ldlI#{gPWC`ieLTXh?tv>uf1(o=a5^TBVll7b(* z9Y^4**c-{W@j@n_?&YmTq9KI>8QI4vYf}{qTA$*yhqigx=C7T#czAdK=us#$C!=D2 zftzy(z5M8~87s(0nE(Zii$Tp6-S1ZAJvQtHEDxD#|D!`PtazRQb;suY=Kt(uARZ@7 z*XltzNMfByVQZD!v|Q^n(Ibo9>aIt;{68Pd%6^&Aa@hGJf`}VX959G_TzsD_v9{OVeH5C;j zJG&pM7O(M#>oB7{DK&M|7Oz;dsNK8!Y!+?M>nLE>7ny{DR{DCjgoicH&Yif>83Dw+ zTrZnWI~32$%e!4vG(7+7;R>KBf@W>PPzrimQ$P5uZd$8oqPO@qv4?S!>%}RE;iJbn zUJ8W8{sOd0a;|-x*}amS9l4JpXubr8gdvCL7jiz}XOMjH8Zqq)7edCXofpu(0?)c9 zUga%8U_~TIkej2xeu4@kU;KX?zi#BF+&=f6ZoYPJhRmOl4Yp~=zYM7VJ(wFhgxd`q zH(=ArH&|$T$SPsHhQ99#7 z{V=-ewU(Av$DkWeGQ=dPNjY5VrNLRCSRE`+qQ$U+*HsRWk2$ldr(ZJ84#X~nC!9h- zc{CuvsidvF{=$U|63%nM*Gf`QW1;3_O|U>qMLCW*J#3~jpU+>ugy&nA;iz+1`W@+W zLTSP5BZfQQkBX|E>O9fhZ*pT!kz;r2KJQjoPF+F+M8%8Io4HR&-&Wq{YYKLS`Ky`@XjD2RRwhOMnx5g8G7VlMbPtj8~zH zBBwc+JEGYK3#O2@#VjcEP5-Gzv5Wb(`xX`#4>0|=Q)b!$J0a)?^StkV1frEP%_XgXj5NZw5 zbcq`@VZnhZgPIjL=_kx|GBP#o#p5N>m1MbJtzm`2=1}EsXj>v8B7hfc*jo$un+v!* ziX2dw1CKVxC5s-L9}z+@a)I)~OSOv^ebEc7NXj=pj04pPj-ZZFvi)q$2QA*U1<7^c z@WSFJo`x5J&PxkNjxGLDK@@vD7CY=*;wV0800fYEJz%ojuykCO{#?W7rG*u!1bs2N z)Jga+?rQS<$-n_<==3luk=!K&phs>2nfyQqY_!F(&z^aa5b55JlE;oki&xPUYmk%K zY5~{1m2Dhx4qY!{HG%axj z1?)Jsr2kn=T>hrgafz)D+MXXW^Sc*hL+kP3DD~rQvwWs6Nb4*7km|Ghlai=ID*$To zFyBwbgmfm1C*{C~0E+DcxayMlrer~%-R zsa}wVRiH*l2-Sh57HjVX#tb`QT(5slSD_;dCf|tKj%EmaUVUfCxtZ8Ff)UscM8iLqAgnd zeUT^lE@)+m zsEeSrkcw=?T|ja*33>ssczj~wYH|W2!x^Id?ZkzMXZjj5BZ&MO!x6sWYZBEyg0lr( z7VJqTFoxX_Vi6k}K03pqI~DW428l)~9%ZwWfxMjDw<9i4!rnx#5w6Dvkm>_cA>>!o z3`%seL;1hi*?ljz zwDmeGNQ>Q*$-BHwAIe*b8`NY@(@pA33Nvs?5(^Iji`e1P(;u|mgs*`~T%OpfDEPrb z?3jE(-~N)vED8k$9`#$0L@+v%nO1swPc)2w#;2sT_0_rGtlU9AJ>1#W#!9Y9x#?ZJ zHRdb;EI?RlQP6p1;@)!z4TT4~>jn+K^-9o(JZP9-FB_FmaRogDT#lD1GW-RL1~nxW1s(p)xnITv z9K<`Y|Mg=7W~tqUgAr~E3R>Wf1S-_mxZhVos6cdMl$$6=QMr(SL-3_N+B?pxs`f#k zk;Eng@OPhJsAt79Z~~x~D1>but6^21Y8H~beCEHj0L{%Ok))nA1nx^Nq$B|O3BQ>}M;t8A0k-?AF8nZ&eq%+BS*w1aYAt{;Zq z=;A)f+PlJfWUi2@DILm{2Mt%UT2U~|LU(bIb(=BIy3%`2wGY)K<+hFd3nOt-_6?9iSp+@$^Qn9+<`DY(>L$Pa2mHu5+NbP6iv8&7I@EFLsyG zXcc+$sLBcByx-b~N-XbFxQdb%rXYnsz2F*(jc;{gDE>RbvULb;R@^mQm z=;g6G$z)OV?V7qvu$4;TJEo?kP2h*^G9UvXP6n{A!KsUlmsWb#YtKD^+=!HYn|m2a zOsg@}1~X_GAm;hL4I9`8OYOGV2tW?Bo-2|fdu*W{Mlp@x){SUs$vH#-34lvrI=&^c z;ogD0l43JX1DXVlzdokz5YZ)KBYaT-+?*L``5I?3>td=A4LJC)k9xhMw@xdCh+>Iq z1NOJOc<{pVjT>nPdEaa9Z9r-iqE;X33v&d<)}3~Vd9hGy zML#c$RYhTSO>A*JuY(%^1{nlAxFaJLfcMj8h9J9zHZpOmgUuUrYzA&*=(xJq9|`_x zaN|Y*Tq`KY`EhS4p+K&TT}4ez?VZR+4hIrgunt3s|3c+f@Ty=Xz5DnvS+gP#c~elN ztpsp$FH1EV91j^QMy9~x(afS8|28thFTAhz0Oe;-8Qp`qGWcac@!hT07&6Fe#fWzn z$~R2^=`g$|Q!|en3FzjzhZfM(ZO7~t6ePD36L-Oz^;(E20KW-}Y6|7lsZ$88p`xHd z6~xdW^!KS~_5TND9C-thXH-V0v2Jsn@cdbwo{F>SAAb6IuG)c%S73o96UssbF8iR9 zBVJ6A8y(+{jeP02MdCfsQ9)g5??SUO*H^fA1qCI{PNMiwto=Ov^#9giQ+?j$Va<4k4)GG;r5r33g1(_w`iK;na0es< z#6NphA-Jv-HnZEUaQ2r9uG_e6TP;AUyLwwZ2Z1Bch}PC&Hb6Cc zfZ-a|c+!vX(#vaV{KqF2T)slFJC)(O=!+ly2P{VTj~_PCy8xfc3YyaLw{LGB z!MM)PSP$f{yaNNT0jqy)%(r*15b8Ta9C}+Q)p(5#Z6SKIxw*U(2mHRxuwCPHnOJ*k z|3rJ3iYM0HR88?QJ%{G5vHt$Dnq{OHF*)vs+bhhtyTb1bMfdku=iXsKT4R+y)HFdB zbaSbFGh3_1C3YvB+c6?^SyS^-p6w_rK2jD=>#JC1{=Xkwk(B$|m_g$*N?6BrY=`~G zr$*0HjmlF*EIlpyME~sq7lG{e=m5&`(kjLLu6}N5xrWSxy`O|}Q*8yEPgXnDS~yJ4 zu#6?gFCE|t`ut!i8slZ(!Gj_)Ka09t2%xGT$}S;Pzu(cruPYU-MiU(li-5%p8^jP+ zlYR1I;|p`QAa}loJf#o6#=ByOP3K`$REJTQCk*XXV1?HBCoD~%=@hWot z{h$|}LL$vcd3o;xLP7@60hLj7*RzY3_#5D$Is^5l5X92RAC8GkhjG-v%&Z0_U*sP& z43BWXPra8!@$?->BNN}9?BwUqg~;6EnHhitt0`GPTRrt6bQstH(A@ZSY*V8^gC7lL z3TsOUZ2KALj#VyPD06nk2f@y0t!ruuIUp>21;Z~NVHDHa1@Ymg=PW|#m0o#xmvGx6cr11FWOZ(fkRomSTDFrz|l(zO*Kzdx;|iZXfc~;6gu$LCV!2 z`Db%{i;E?Nj2m`-$0c$GknPND;_6k<&>cXXZU`!tJPaWSa<2hhEwB09+#EWH(L7jd zD%#q$?8ltK|BPhSZ2`wLMo%N<(CBLZ)6`tKXJhED0h@o8_?kTzOJ}qeZ{@yvWeAnR zb~UkvTl+tUt&(nIa9DHkKQ0ma<|K~Z z`Mbs47G~4zdl>qrt)B2;dQ~k&7l0gi-iWey@7%q8e1eQ3z}8+t8@uC^aK9`%Zfdu@ zo89g(Q-}?5Zsb0~F*yDa`+CjVwV*L;Y|#LeH#NO#ngqUT01pLElZ2T5vcEO^bfZ%|bEBk0gNrBU!AU2wT)l(>@ z?T60epROobR9|*9U+nE8Qr9eWEMwzQQdBsau53yCGhOo5yxu+5_Q6$&X`PIilZ!F3 zX6mr|?SlPD_m3zZ{v(*qv`2*Z?A?oB?YcC@Sv2V5yLulZcP>+Kg!qX&!YF-vAr!&= z-+lJuMOTiUs9?t0#1{m))A#RA{=!;*YN+{c5uz3AOc&aCz#F1@CDH3piv1(UZcxYi zytiRTCS2Ko0OPQfoLW}HZ@HgL!^OlpU7R9htPQR&n6vf^j?RtKOO5Uma2XeInHU-x z$}#FZ;W907R&2Wc9`FQL_hWlkIhP-nsPq2(%-Gl|XpH6~wbZDRn%ZjITHu1B$1SYa z^YlPGME%d$aq!)t7Q#KB8ybQtCov{z88e<37Si-ms#W8PN1m z`HB`&Kuu0CgI$Yg8+hAu_r<`s?VmoE8X0d?cl?qEoPlUu-+^>(~2OsXLe=;SU*2QN@I*0R=@3qQ+J_ zIXO{&>aYpIDh)2Zcbkfa25)oQ*g{)=f@ps?OGfO>d8_72eb17uSEl95(Q@^dzVy^Ag|+$D}8Ym=s}DkeAAmzdKSsAYfmdE03_N8Li`HaLn0_> zoPw$MErxhLjb`9_>TG3acMAPF!iO?--%`OnhyMBrYB@3p4+Id>D+Mv*7v$K*zB(fa zCcqYuU>efP&$Qi$MnPN&IyzpF|f6jm<8>Z8(y!G0%?(i&o&OqT4IL^_1F3Z1L`Wj|pro>L zEg~h{gU&}rypF5+)*^t~`Uq)LF|%?3aS~2}y)EU(J3nTprmjPYvVsD&$2Wiuvp?bk zzxDO4M7^|g=g#c_KFPFI%*`a6KPTsY%j&=H**hTM#wNUKrs9`}PN@u%iHQ<_>`*zE z_49b#|9!Il8c%VZhReVaDQ8wapzS2Wh$c*5JniS<0p7zy%YHMlW`K@jV)--Lygl-V*Ma(iobAUR^cHri!99= zBoCBc%`#?We_)C#o8+Nu))DhS{K)-qFW#x~+2!@D+F z=81cb3;*^$BLax-jQL;fJ)^ikh}R^b8)ap(sk3XiMwJwp!|o5f(u1&aE4c14)S{9v zJ`?C=3MPF|y)EBSLR4el@^I`L+}<|q=-nJ-%0`edO|N==6m))wu^~B}SD}8u9&&tt zw)a!~sgCJht&}BJ^QbSP%gA|?sZ^+cGvlruc8J_;Ew7KX4b9(X^n9>fobesA>W~0g zzK%2$*an8Earj>R!nuK}gt*FI;L-x`9-om>2Xg3xSg;yb&C-kPTAR41?c3%)_88rW zd6Rr=h<8y>-q^fLWYt#uyp4>E+lK|^s5xFO?=myWv@L)L=KU0qn5)PTyBNHBC~bdC zL_{T;veP5ujV&!<6F-0I;!0mtuI0)x4`X)Z99aYWR*rwYLy@_>98Flg-m}$=Ikqp7Dyymv7*=k=>N$Sm1T~gP$nh&r zcf`#wJxD|!%mrh1vtBkWEQ6rMDFg{YrvkA&)9_&VG3Pm5NPN!wryVoygX}`)H+CAC z>Ovk)P_%J3;;<<7Twz{hATI(|sxL=|et7n~{v7iutNBL7rj65H@+Tk|gBT@thVlTV zSX<-BCUkZHQ>ZA=>v=mxCH2dlIU@tYJCZ}k8H8~7F=sI>F)ttn%+JDbvfxPT2H}kc zpSw&9xm!fJ2heC2Z#2>5*+?eK%nh3?w93 z8ddxbfby*>GaIGH6MpZ*99sw!FyU_h(rhoo*f2qp|3=1;CJS&bxpO=CI%6bV*TZdu z+f5(Yi5+azAeuUahn7?EHSiotp&TaLfPklFX8x6xr^l8@rSQErY}{D-*s>V?HA(OV z^fod&3jEI>8EkGfS4mDa4jj_qyOmG#q%N&^YqopsS?z_M=i^wo1Uf($jM8t5r?Fb$ z{olz21(%w+7^PWZ&rRs(S{lZd!xid@oAiA{FbzmaA}QHN|LM%TxnuA_DNZNI$mih9O zA;v3kO1N|>jF%)S%a*izbJ2-_#=}IT{}fV7F}xevoW!(sCC!VP+#_efNX`;9cCt_b*9{S9DwkHkPwWNF`^uYl7O)E=wjf0>INCl6j6tgpXB+Ge~zI@9lN6e z2ftps z3l$X=(V;*QcVD1^34+gS&@@34*A1OAD97s!;l{`gD=w`$49^8EzWjq0%C3U|L@5U1+R!3G}8;^{#L4@CnRB0Mt--kpq5LWSM?=o1e@!~j#0@FLXFB;zmI z$<)Mz z%m0rSmqeHW5~QLmO~ow7;zUqpRcw;n{(5xid2;ew6#jX|oD%l;2YT3x zXNMHdof}x6vIUvRUilLz02r3z1yF8c!ZI4YN>SDcBf#A#K;U1KIF~Kb0^JZv!B``+ zO9$i<4K^n)-=~(r?~J+QpH+2E2gCgI**oHtm92)o=SrVGLFe1)2P+XGi`(qgG^nh#6g7DilB;?~S@~t}289P_wUs_+*G~rcL?n5CF#J8v;F2X7D^yCkBSbwu z(A$0DOIAzUcp;0>{87h@!shsSB;)o7){syCuL2Sl)dKZv1#n6?N2+84DSJ zUKxq53dq(qYDYZGPH5N+fl)*qbB=&x{1a*xZW}O}O8K^Cg)g>=-gHCu-vQ)yVC4E+ zAh%5|*6}v@H#PNon+ajg!>z9A!59}MDhef4teqU9TIm)Oa=l}bFFy~S80sJn>$$K1@lcahkbtZ#@{FL z=2`j#$+JNWzVFDWQ{Wbjd=#0o#3qxbfRkEjYL`LFIzpaJ5EB9k0O#%n(AM30EhYi# zkif>NpVyA#@<-7;fGHk(x;O;m@7<%t^I1g#=XGaix9AVKM9$?Un?J@yJXOwS4p+;2 z)QYHj1#H_9XM~!{9n{c;@gEGhh)zD+dFWTf4Vks4PF3j75V^1?LVYdtG)p!+4U+JO>b55`GmCq zQZBu|`L^LnaCrJ>_q@b=s2~QCpG6;3xSZ1yX%fip$W513dTc|+p`T81Qw;-nTGsIt@ zSWcqlKy-FI4(ne-O}@|yrhRB+ENjY#FAQ(`guZ^~*6qciOIUsRj%+xm$E z?;96g#_|R}8&aKvf$=8JZh{t)$>mX$B8KVU{u(tET^{O4AN-hR<>N+}_Jg`SpT zm!k{Nvp&qx8}{pORC1}1pX{zQgz!2W<@$#oc5yv(EyYW+aQ$w9UrJtHo)DvCGO`<% zy>j6#8z4h6xCz|iR8DNkLT&-BTKmygEgFTlUd=Ijr5&(!`gOpj7)q-%_&$W(fP^6- z;-v}32Jp;lWx_;or%bbPR@cGJ~qyjshl{XWgRFFElc;yE4kUJ$LqR_-Ecya$qs{V6P z_9Xx(m)UNbh{I-;NFGmCj$aLx18?pqz{r3WZAX8&J11(z$0a7`9tmB;O5izg;Drpw z8N@H0LGY|b&h8xr26xWd-DC&+%txRgE|!pPT!ZTmd1J^ zMfE_H93ARW1g5b-CnO|ul?}~N@`dN2HOEFZAArGbY~Z5h`XvewAAm=OHAlXOeaGa~ zRO@St9pH(xueS!BlU&Tc1ZPI|7KCzEf!vrb8td&PfjY3+5#XY)Pxboa80X(xakKTs zEH6CED<$W;hyTmgK`x-%bS030gbN!7HMLySM9OE+z(7UU@{(PIh3+f9%GXDNBy^empu)1PPD?O`ts_*D%IulDIB-Z63cdJ#2Xy7XQlf zlp*c^K+)@oF%djy{Bf@}yY!s-V z{MbYSaAh8FU2-aHhiOwpBO^T^lUi5G_rJ6NnSxNfIba+-QJ161hNWW#sqDd+PE1Y3 zqs(AGV);~pAw)4rJ!2a**hHZ;^wO(+w!0UnqIOTOM)c^@=lPf_R2Q=wpALC5kULzz zF(!<&Ju&%m{$a?6O93C)jOYwUK!X`A47fgk~z-1#y_)5(8El= zquVbLa5?7|?w5G{6St*H^-3;D20z%%lN8T<+)MZHQk?!R%vb^(f8i8cCeQb=Y$eb< z2U`s32-pK^7L0MGHLof1u3gjH>{Xa6GO>J9q-O(;8n~Z_py} z>+(47$5+;uFE=lB+JcTm5kUiXQ<#+=OTMDDRnz%$*Nn4Ddh3=QEvvBhWlnF!&2xOK zpzd+a6kdI>29?+}!GJ`{Q06Lck_DTj29PhF*C!4vrRE=G({Gkl4&HMiC8^(9(#z9x z531PIqSY>+`Z)fV=4SW>Gd)n4E=R7WvX**}LORq0kR7`ZwacZlz**2(W z@L3FMk7I4W#HAvuyou+~p$mw|7QX4RCk#g|ui--Y3)NW=?XW-& z&K{IzD)_9}Y^fj)o>xJLdd;RVC@OMO+0Q>OZWbbvL)j`4s6Q__TcLKBR#qAsngld= zr*Y(N!h`I`6fLDqvaSL~ypsH5WDcBvZxzA*4D08a!tROD}d`h9x@)1Kndjz_LD#N@X*i!8J)s%m*%FK_Z!clLZN;Y z4W#vF1*dCx|LZS7?bs?7nYHF z1UT#WHWX?L0A|Ij*|o&wxnB$N9}R7b^k?`+1Egn1?|Qzk;MGMyx5cR2O>CG#VR5j zcHM2yJoud{XD&~7J;OGMlGfHoxDzfqnFIx~BE-gZahe6WX?!Y2IbCMCh>Q&$fdeX6 zno&A0^}ss3^YGzNbsguXhHedyz6$B^bF$8o^qj|{fIhsi8`s7a?}sZsGHLeu^mzNF zfx9pqLRvx%q*ESv)r;I*nsqu41%F43FW=)XQR zAT|@H3@+@6nHhDjYCZ|qEVMiip4{4&xG7>>Gyc<$$9O#QzUjyOPo6k`K1fXB1@FzD z-=tVYZlo@yXZX|Y&FIy*B;MNIZiS=zPQS0O!h^hW+@Zlr-BC|pPwm*WPW(%uqaBuE zg;1S@im^DqL@J)@x@{6$>SO4b!~XlpDOq|~CY%o0gYwl2TVGQwn8^xksX_;2#shdb z!Y5>!&9P{ZLK5;E4Vy(_lhgfC}}_*iI5}UCP4@) zIt~Xd@iS0>{gOJAg81O1qixny)YMoR^c3_8#PrM)Af;27_ye{{xI|a3{cd;2Fre3G z?B8urO4YAd2Nt?4_K){!w9WbMWnpA20q42mpXq98&3?*AVa;U5e1^S($nXAoO^g3uj)erc$HzT)`l z(>`xH8s|CY((Qu3YrT2pnoG~Xa1B&2uMdt>L^-*EM~gSCwsAh5PKOH2LhVq)JA6w} zCUmGU%PT6pP~vXn;HUxl87^YJ4Z;@#q-aq7)ri3HoE!>`95>2VbQzG$Xy#cOdXuA^ z4}Hj1aobd}6e5~DT`=l{nF5>u12@S&K~fdzR#7dTki(C#MvtMAG9ZWN5W(YZFJYCeRH_)&uPiGd0)pk7ET->2>J z$1v-9i`w8MJ@1yb2~>Bhehs*5r8OJ57~w)Si3~q&>FqNBamshxFQMz>-$VZkDow<=L$M*!3y>8Ho$e(bQxOP&$O zotlI{^Ug@PIB(YuRS+@FLkj;QD~qHb6-+l@JXsY!Jm+?a1)WDPI*;!Pnf-Zs2(wpy zsvdCsXc~Z<{o&@prXd? zv6C1DNCeZ;j*PGt*_&Lahmn4Jg&ak&A>kILU14u;4==*?LBZ42Wo7>lTW12+^SZA8 z43RR#N-~5prGyfTGB%Q_5Xn>$Wy%y45lTr?RK{pd86$J1P{`D1PRUduBJ=S7JX!nf zefIh9>pIt2o3-or`+nc|8SeXj?#G|h77f^=Qkp<)vF(~TJTxibz*vA;{oVWSKizmD z!>f{4SjM0Tl@F4hC$9aRzZ*j|R(BpwE^T*h-_4v+n-5JoA)D@4jO0IIs@Ij=7^T*Z z8}>vcG;h`FI#U4+8aAx!hDbW^?&SYe&d=Vzedc_aL1&7@wbZ7qHVPeC`ug?$a|02Ow(325+>Gug8HOljz30}g z6T(W0RoabqUNX<-(fa|be)UJh1RBvyU47=_0pBdH*;kcq;eWrF-@DuA)5Hx|+WUPO zgn+8N-=jzxw%CH%r#Q{Y6?bMIdz9|ArOTcbxxrx*+Z`C64RWscGra$cnK~yHEnMi; zF{xMNtme12R!0m#APyNFPv?vz(U0;&Blz0ArEPC@^%$%6{Mj?C>8!lHPmg@@ltKfe zk>gk2^wg5 z_AOeKwNLKr1%V!^U5lWG%am~n*_%K+$02A8NF}N!_?MW2bKKoWV;tdyRDXEd$gh*t zTx^?KJ|t8)m4+L_b^97RUhq9KPRnoa1yG6T@b}J{+6peT8Z(nC+*f_yxq0{QgEZG& zNefcELIg!nqw7*=50C6-OaH_l{wc_nf4_e9YOm~%;xDZ!HIFdHL4E8}_3YYp2$F-O zrmoB#^S9RlSHfTJUVf`v>ZC2aJ00ebyr(T*u)v0aKwL@=>JWnMw%*>=Z|o`7Cde7IIJH%zx zsw-JpdcKPQ;UzK+`uTB7Z5_%3Jr7OdvT`&~-*yAT3b^uEh3lANkoc}_?=`4T_@uJz zvf$?v{TKK3ahc{_*6G-5aNmtDTr-b5>m+sSJ|WGr$;-3gMb^B_f+yLh4}GgD4>*4p zD#`Mc=lj^&I*>)(j~sYf#Y=qXaBlvs&s%z9YCgVb<@b5R1GGlWxb@>ew>?prg{dB= zeT1KH&TEHe86iFJBK+q)q8yIR?usL7c_m|m*5t9+lI$f$4?YQ>~>8Z%OLZ1pYp>e7wPpRsG<_6o~kKgdaNCMqZu-M(|P2qvo2+pbD z7t*tPc$JhkV2)@sCIR_RPRzY;%DE|c0!``m@ypwXCpH0CDBL;Xl5N=Nm_Gg9QpEXn z7NFN;v!r8*>xh@-<;sKE&y`$y>*`g7z3)2czuMgv1^dw@I?aP(9$8NtHEZ_nghQ`! zgf9rYB?yU;Rj=ckX8l|ies@-Bdn+}2`Ls8$U-Mw=MN<@}%`Q;(1rKX|U|N+&g$G-g zR{})PM1$iV_C2y<>5kJO`W5Wcf)RQ;==awq)Y&Cb%Zjw9sHJA8{MKZ2eFwHAvbigh zg+YK(o?&;NEw-(}xWkq>5Z)GnJ{m(r$h4_IO`K<%&Xp|-T#YPNb{|#Zu`4QSA(p^# zIica<<}^6jv%2gbOtk*WRE+0~?Xwj6`g}8jG##<@c zGVt8f5ciT#n84Vw>C96>F>m1g*-8GnDcz#fXUq<)axG0F8edj05p5`%c=H8Xp?jE=c+s=6Jc$4VB>j|8=#DmT%?2@^p(j#3#t<7IF zl?q(@!8B=%$kLT=iHx)aG4OhSr3H1#Qwm_({JOrP%V9Xls{AdF54dzXxy&Mx@J}Go z($y}r1owNs7aUT6I%KMdhH4MSRq#Qa4^f_NN6mFku9sPrl|QQ|q&XZY`ewZ3m8_Gi z#el;z_*JQnAwgC7*P?f3ozQOZ|Enj3IPvhH-;Myu8RLHDWNGVv*T;u19!DQzo5~rB z%W(y`2}&^Q+H~;}hm(kM0k-;hZ7CdwErben00REIxiRDVcDgntd-JU3-Iu?*qZQ|} zabA|qsIuZ>=#qmsUp%D&aY23uI&3#(W|@!vyq{#zb>sJSq6R^|4VB=ef?eA->Wru~Wd5A&zbX@AN2iwgw2hs=Fow2oKmDwLBO>VFqvWJyQ+0ZiO)Z`GJ)* zE%hZ&FuG<6&Pk$?K@`?SpSb)mWaGx(-YWo2T9W%20ylCm+QlH@R?pVF@VTQSbnWsR z2OPhA?OFqfIFG)cazzC~=qTUf*IbA<b&Iacv{h;8C6qkdKvzdTd;0b3S7u0Kbd-r4Gw3HQ)^$Dc@m%CH_qdMFJ5KA- z4tB2;RX*Ih!#|I|zgJcK{c}erJ>ZYG_g$z>P|i=W8y?lzh}Xo}`WBiw4WRk*!UNf~ zeF_5f(=1L%@|4+H+NiseR&h|O9m`qW81$JpM#_w@U)Jxq7KC%&}OjGb!V?%ES`i<$dY zJ;C`Pvhk2W>r^Oxg2VYcc>ax9U#`ryc zMs}8#!a@}Dgi@F2#5QCyiSSy&S*0l(>r-Vn|FE*{Qc4BU`~F?zd5J1k%kSpV>QQqA z=09cclceZm!uTsNMSW#srSKQm4rD~B0}X#HblQIja!kodMa^PJT#mhQ!=24h?ocmb z^{xZb&(5&TP{@7=y24M7nzEA;YB6ugYUey7+u&Du(ul6ffU6_j^}48=0nRt@aH?f#KY3hh?Bs|7;T==9d>c-9fCM z%1Pon%U!3{77~rWge?>~6Di}>#>%x#1{Q-znb7Ww6pS&(D1r6Q`7GbP>X+A9k14bQ z+)$`aivM^}Mxcy=M{eBtwXMVaInh0eKq#b36M2MSSlDcdFoFw;cv53mR-<3;m;IiG zn9ZD-==}rj#oYO>J^7x6&%ySDe36Zi^il!TO^b>9XhG0{*QQ)5CED}f;;!aZ`>#3( zJJcinU2RJ5^v^}hF-cp4u;V>ib}1f(@J2b?Z~3QgRQ9uwxXupg@ar~$Vi8dj(-q|V z17hqugwb1wG%g0tnR`cD1B36N$@K`Y(Ii=i+cO1uYqv8FhUXVeN=&_wwI z$$#dGPP57smVm_!;T?MZtiH?D6L;;w7vG)72TB}`Uq3lNZOU)5b8xHPL43$}kC#13 z&PLPJZsIC%VF|CSQ+ZGE`~k2oy12m(f~X;~X(DnK|JX2-t@Kuqj|@#urvqmTRTw|! zFo@q;k2jA_Z2=ODptRevV~29|vh6WH4Xhp&4xX0I?!o8COQgi4)ccS-ya#?6=ExKZ zeh(k(?u3VZw4@9=yEm_C<4nT~Y57JX5F{Y30OVJYySwt|w}t30~8t`=}pJG5*)D&*y(f ziBAQE<87)e0pf(yG$t=Ie!Mk2Tn(q*#O^&FbxzbdxKV_MXtQNvYg?arJC-=?EuQNY z?|)>e!`{6e^!t0h^MxGy>wmDm$LD-@auW=Z64qdS8TIJNMkLx$OT}z02u@qTjF^Z0 zDW)Q6{cU<$_`WUkdOUKu0%aGYNu$xX!BUnl@~N11a>00V=}Og%X=Iy%qn|JLtm8#y(yI8?!v7_@Zh_&h87@H!|YQOQDLpQB0M%wnSQs^+Ki zGC+*J0#AEVH2wn}@_eSxnw2!wU7RRJRLk_%i6<*}V%s6m((nKVTr;lQk4*B{V8*?}P7T}ieHZ4E`6(&A zbY+`{SJ;^df~VD`if}NG+X`M%>*p0d7%NI}LMY6-Ol#8bY#1W;> z-3@a(TpOg@0`WXDKXFExSKj#?Di}=byL)^MdgMmGw3?3MX;~RHfH1y-kjU&6PnRl& z7}Dn{FN)g+MT}Mu;LSR10xFdfv{_1RFeIwU9zDU1v_D}4^hs2yefgD@!?(JK* z5+O{mE!`JqsF8cv$qdE4C50TE$`S8cXAnDa<^Mv=gN6+Nc|meg(>oLHA~-^%rV68m@di5UhAW%1mCkfwT+=U=2^L=&m_CHHtNT0)q-rO_#XV_Qz&7=e{T^&sU=Uh(N%%*30@IlpNv>U5|WX zQm4RA+kt>-$X9THWLxB>dF|#|AdaM<9h+1pqrKoGmf16A&U{*44!NgGZb>Lwx^SM_ zpYp%u0B<8{xciK5e*7kGCjvP=$RD|)MT@OtMmJeV_y6|EdH1p3=jdA4-a|}*UO<30 zt^K(KDLGaQN~eO!x?spUt4!6e=NbMW2SLJs#+t_Fz%w*9&xwnjbabxfY60>c9y31A zZOF0?#Sn5A9zTxa^|hWdh^b->GT(v2j74$ArLYo8sqic=6#Js*4alMx7f(H~tT% zKb8uonY&4!K83EWS(4Uk$txSW6St!DQx|`X8`to(TiLAZp*fImssa zuM#ID2S%xfwCR@tvc!Im<9|qJ(V3!@aV=^op z<(=S;$856fda3l~>8}-ZRIbN;uU|sMh%Q2%HsTppI|8jj;g703P2jcI9Z23iYOg(Q z^GW~1mgH*Mi3g8#TKv0?KbrsfpGugP^Iyg9@U!2J+u8~eL+M8H5}^)~-tLC=&uV3c zyl!I|`Y$a&_W#t@JGV-0P5zwerZ?r0G3#pk|NimeN+Ix5^0M*{X4&oPPx2Y`aP}k9 zd$qk*HJ==~&*7LZ%TgSnC?XqY&ETjw<7@|=74B)gg||YcRFN&fl18_V^^24>RpNqz zwyk4cdjHFhzNQY8!3JQ`1l`3rI{gFVXLmY|W^Hp+)Vwj>3UB1(gi(rM#Pbil{&$&o zK&lTGouz#3`o%b$)Cj34h8f1Fe>81F_xVwOXn|+bEBfTmPfVNT<=v(-sR|Z%1C$bN z)GY3OWMeC8-R82kTFMl-4Yc%YsV|b7%)pvcwcgyc9bxL%$0A3hH0}aP3zPruy51p; z$;;@pHyId~S~okPn&DkGf7hAQrz3|4_-8!ayO#n!zo|VMgqu)d@E&@flE*t|U3v0k z+M6$`En9xQs?_6!>%NSIJI~DLmqaGzxQfF!-74LuIhG_e-ldRN*doW7yXaL7RiUT@ z>KGoNsM7V?$W<1$cU+BTZzt|=ofM!r_P%M7A_)_XXf0Vq_g}zz9|6{1EC@wp<;M@k&TD(fHv+`d;E}- zeU&jDFRDcuBB20|Y<#Vw2*F}~!5C1s5OJ%2XNfOcqqM@df7bCEzPoFa4qs0;x z1z@;Tc9LT;i6E;bc1T)4BYbAGX=2~>alUM)lf5lu&qb_%*>a-3+;fo*dhA4f@Y302 zY9c6h$(0twxszP1W51bC2ZJwn^!#J-;Y@J^M)=tSrKI?;eHtVnn0&8d5#}heTutUk6bkYt3wbPdn?~ zn}|*gojkTjEfL!h!k9k%HFSGMo&h_!#1n#I@iIkbEiqpCH?AIk)z!X<$$tX+<2%-0 z-uHMwDf@!D04=}-a!Mw2Y>0R|s*VPLk~NQ=?|^9H_E<}D5=qyEc#gNjCjL=Y7)zkX zMpd3?(Y!||7p@icGU~T;-`|?>CM99eISA%Kci6?i;QjkH`0j9n1&yYCWvl!Z1#)i&qW$F{@~=V zyh2=2#qfd?%Gp!$rLQqy5D!lGT5I?8i-gREnXKEMfuQ3uYk)KG*$H~|Y^A)zRj?G0 zyWQ~kc7zd2|MXza%zU!$byd{>&T0%?@sGs z_w!0bufp9KMu5Ds;oQ(Axu*`3eaNYZiDnKCJB+K}a!1y0+?c>8EB_b_n)8xbP-*kP z=44Kiervpz)l7jC&*@(I$Xj&U_}@i;)U@9E`ZC~#wo35=bF_FuNTml?YrsCgv(?_q zmmgjE)0?7@e%;V(`DqWD%vxWv2K{pC)uMUBH%CLxIZtes;HIbZ%B5}V*82*3@sM_V zK5ek2)R^!%c$H=Y#~Y~O9SLU1@AyZJK`Z;q z>@~o>_gw@#|KRk=V>b^TlH~aK@{`n@BaU5m^)QhG_8hqE3Jqa!VgRCI{ys=Cb0$^lF zw*7MDJS-d4AT^Lr&!_BdetOmKfpAC?mNE1lP~}Zj1WZV04sw^VhqYyeYU~=ZZ2-+_pK z_Tq&hVj(j5(JYN9*i=D?mKAx)GAkkYIG@Q=&z?@W1oY-~>Dj||bn3mR#8Ai%pM3C7 zrC&!DWO6IBN%WZdMH7mU#1E+yhaY+tU8?r-(BQ-|YW`sDwL*97utwW&-Da5=J)yeD zh^Y(94b8sqH|hQ@B%xo#@n27`_nvz%l)P0-aq+xto8$a$9}e*;@9LC*fINKXZsSv zD-H0D0De{AbzXJdJ=yay9+xd9-nE%~+Ww#3pccro?4j#?<@3p?0m~!N#|irFu|47Q zfwbxt6K>fr4|;lF%9%)TFmNRr(8FVxPVZB;!-^xuKdo%ow1L+3hm`uRcMocU-^}0~ ziov>ZH;#f%ua<6jlV0CW@W#vuBqY`RIik(`sCmG>6-3)ly(f$BxXM8dZ%*_S_Zs}R z*RW4%2f1c3QKp&CLYb1}5#!c}e9R$sTlR+W`|S3tT(W2pW{!KWj&)l1Y2vcU64dcE zd#Ih2yX)hzh%qE{kU?(Bw>vpvA;hw9 z`GV)OLB4+dOIyvT&I>$vN>e*-?W4A{dn7kO#;AHvn1X<0SmV*BuHU|$g)g9m?cRw; zJJXSsQY?&{1}cxgydept+1+WA`#Hbq8U+7mSg&!Ly`Kl@)V%!pz5C02Xx7oSecye40D!qFASl2R} zwr4mB$|ebZI<5b+XckXqj!-GOkR)z`l+8TQnUu{`pK<2T;Ymir8yqONoI4SH66GqG zl*9T>7A|9&R+SP%=*-u%AVRo>n}0ufhR`|9H7EA!-rBg^?4NeNXiZp9(5=WK`5_(J zxA!G9g$Z|^TcV_AcWCMWO79s}-?-k-`*_5u#{K}o=n4EJGh?!EO7I%+wm)As$T_ZH zbd#9M7xzI(Fd3S#zTz9+pN-BjI&+QoeMHER{^ek4zz)QtOUoO3;}GyFST9K}YQ-Ilob4S5*1Fwi;PTQ|Bnai(Vyy&c{?ss^ItbK){U?OHC zQ%8TbY}u7RRyExgHF8uF)-71#KD|EM2dOmE=N6T7P|+C%xV8NH{X3H+P1Ghw_b9XZ zVv~k@Ik>&nn~vsR!*D|FhAz83W7}vSk%ge;cMly}xv6RM2~-N~?nD!3 zdM}tJ_b6JymQ(E1=HLrQf17l#8jK1TlI0RdZak;ghr3;W=-js;A=cqEP}jPZ9p)R3 zvjG+;L8mDgzn~RQpPK&u^)s1^6C{QLw<#J?3ZpmlBW~<7+ct{=-Yu^)_fr;?(vv3u z=*)ePww@w~XslVUp~eM5|Fzq_kwvvm zkF*wjP{P%4Jz*1p0DzZDpFO)Q>i=C=?wlI&Qhw;7otTI*9U$ZhlQ%j+rjCy7iS~6J znS7evhWOaYxZMNOflIKtuGggP{((6KrxwqWzO}lV>Q-C`hy-Sga`_I+Dvy{j)!^jF zYlHl>AYvdd7T_o>at#hfLV4N(jA5CVckYmW&3(o9DDI`oH(!x#sOtsA2-aI+K2ed<0R${|^1Fm9jS*X0Z z1-v;Y0pbz0Q~oVNJV0!V`E+hxUScDvR73Ek&Y0aYf0yzc%ac*KdPq~iXp;*~`nW?= zqab1KASp!R_mysmx;*yU=--w1Qr99&gc6+u@R5O;Z1%kKdUbr}8Bxg#gE! z)GJpK%sUvp+P4h8pu8eCgf8NS8|7U3uWuC~0EYp87*+W-bS0ck8GF>}88?OBzIIJI zOIYs@Fo2s`%tl+Evv~V0qDj!RK76IpcE7*W$4AVOmkZ7*BU}6aeyWm{O658xs>y+` z{lC52QUuHwL%lcn;<*!UdFwbDzd&u1qtr+A8=n~Z!|WBSW3JWoJeKPT9*gLUnnhfL zh=Si~o|s2L*5V7_`YRmxACxmi_QjE9gJh}Ey5H*c@SipS4cfJ9m;LD_U1c=}FaxtA z@udgx525Ok(aru}pKVbOwpVfrVon4SV=+})d=wt-{O@099!xq! zOZ)>mL2+vdPdVSDrD6QlM#?_b>Ye}GAV^V8Vl>Dp ze3faXajT7)#W&qITCOF5EBvOyzXz|1IP?{_g$~wxaqIuZE_j#fy!m=({O!2YNkuWQ z-Wxr)U;V6I>=Fv&S&Q?`dXxzembv_a zUN~F~4eHn8wM{(fxMf5FjYInJKePz6AIllLk=6FFm>c0Kye;%&C$lnJpLT3 zn=S*7fE}kPc2F}7x1>FX^PB;WMU}NKKYtux!Jn*cop~#_)+=PZfuzCO3qVq;n z=0U#1ZMv6o{@1q+{F`F5RKu8!cR>d#m$&oi$6a+d!FeDhz9`f)StW?{Z+h<_FCmJu z2k`A0lSsG0FcNC$^$mx?_q;#9y$OQ{uXhpoDt%@h#j{JAwCOt-BO>0C6Q{!Bx?=tM zM=4FHK5M-KZ>G|cw8zLpEr=A4lcQ+=9v0-ja!VhC6~$xOIqhxm=wFq^p{Y!5RtD~P%0uUb2r@X~GK5kHiZ^rHv6_qcXy z4yuYjxlMMO$XxSn7vt0Y%Ss+~{{Q0m8^ZOuwL5Od$z(DLDQc_e99Nbr#S(o{VE_B+ zZUGdaRR~^r`*3HMvNW5fmJSk>u<;VB=|9ASC2I~y)&?adCH;SX%mX;JUr)s2u~|~z z(L_5#b_0yv%ypu7(Lpu@WYLRt4xA*J4#bpye@!!JyWTC_u>Y@worjjyjGA6mLo+C& zWjA*8-Z$>Kv$OMt{!TBc%>M4;uT%eDSohe(KjIK3?H0ToGH2GvJ!XiDEt5Lk@tW)v z^vo=F|NgR{E^df1qTz6M(RFco9gN#-LGcj9K>^_zu{(AqRd+fTQIN3FyW;d#@nu8+ zsoBp$0}K|LZmmU??;q6pCiy{7OB4uwPKNZn^#_1Pr-c!fGc^OS*CBI{ZQt!HrstMn zfR4Po&%MGcSDaKv?ZSumy1>513M=n}=rRi>^?ubJ9IK`t9xCcEWD$ zuc4|h$a4(@_nnuRw`P8tAH3BT6MbsA@qxDLgi*0{+W2=7Ggh%uK)QeId|XGW3=k^? z<#lTsD=FzLO!BV^PVVVeku`c>LDl=Lb^=Y59c~X=@_y>_H@zWmFo6vGF-6I?7Ep*7 zX#-@9WX9{uli$B4B!2Hk;`i%!f1YNYxz?>)7v1r2d;4v{U&bi&6J(H3VKT%OiY<}fvqt!$ zfEeBNUK6}%A4(B9Gm$Mv)bXU|l*yAtZbcB5#nV?Zljh7Zdzce;Ua@AZm%yV}nf@UJ z;!S-il2-~v+$H-$&OuuO&THxV!Ykw*2$lWC0LP>#p6p1ry{H@JP$I zy+z8A5Y7+5e)p!UCI@jQBW4T+^xzLh&L6i^E+aCmJA8d4>t8588mYCp{l6s=@<0G1 zXHm#8PrHV?>BECLhia{RXrQOJ0oexf$PA`eMugnIvvkQb_ly12EJ~OHpfQNQxj6rBq(2S6Uf=MtOW;Q^f~kJOx~Aviv& zu^(aTt*IWb-MC@CuRz^PpJlK*+m80{(L-dp_S)vqn8QSmQ!}x0X-34UJX7pAk$I&q z(TKn-{j4Wwth63lT0?IBjdt$e!ht+jO=^^}*~jMIZ)RfR)%KDa;5b9vYC5laRslJG zU_vGp0lhc?UBIZptMcq;NS00A^yc!3!25!DpS2Z7&slyq(5ST3CSRRJ<0Ev?H92*SEcAda~oyP0Bq$42V zPEpNy820*QBBwMRr%hq_6hQ;mt>xq{JOSz=LpTO*&^wJdaNdUndu=<2T6lPX(-D}5 z;^JbPGGl!im*5QUH0pm=ryaKzXS<3_C_ciX62<@pNhKo%_HyxNqE zw+BI)L9H=3r!+aom2d66wRvKWE1Iq`N!#uiBxtH^T4!^zga5#F$H$)R{&kX0Yo-c7 z2AQ-T#LtpcFc={m!SwZmV;kUx#B``kp`+i%>|3`w8yeQg)R5EfChxqz(d)LoaW`If zhR(IY5+ba%`F6BF`#R`qNZ-Z;l%?G3jeY}1W+JKwaFACoK z1G;HUf^~CvT}I|~k3p0ad6qW9jNnlBJddDZBS-draH?5*un(SFZFU!xSXpjrb_Mu9 z147uB+*{q?JC7#^K;vw-a>c*q+va*x7Os>!Ob3ZcLMPCZcu4MWLIfbwVK% zhv))E4;>nKdM=u1&Pqqr^YCcNsS|7YMV z+A%ExPXaaZm;`wIw>p>Uk#-Zu+hbG4EBY6WuB0>3WJgBLb&r{gR!0IFmdDX>5WMpK zmLiv9Aiv4LFTOP3{)CK&Kl)7#y#AKHK-8ZrAPQwZiMoSU=MUbXv}IV*pXUMa+CAqR z;X*HIXxsDHbqo(^@3*&DIJC9(f45uyxor#QZ=Y-Z$a9Ll^DM2gZPS>wvp1M8N{JlV zIJ;xO)BAzPbdvPvY+X5bcsD)0efGnTSQmC3G<|y1`cX>#)clV)b&UvFJYdyp=e7a1 zTN>XT;q&Cs)~%;so~!(NW5D7mHy?dCduX2q4OXS&t_xeph{)h1W6FL*i1iLbG)d1Tox` zRp_~j!j!ZME@)h$6&BvpX#OJ&AYL&$th6UA42`hOVf%_|?%=dJcN=5xw5ZX}eTj+n z%*-ws54Af3=upCpq)9!k_*$NiXC*QOW0zxUXX!xCV^Dnr zn5yq;+B`Jv3jI|PQGhpv%)&=6CUmZ-MOK}5uo z!$fxUR@thp+Rw8xgH`9Cy#jUwn(MX?8!C|Ww(Z+5VofItj~6a9P`H2F z#ZIYiZQR?ef77is*}i>y#CX3|uU78c8NhEF2`uF_XO7E8bUo<`4JhhN9L>nqE7SYH=ZhD)Z~JfG-V)R^ORFnyvYnZk zS%RM2frvSe8U9xg&}DY7F$cWZVT*}}D94IqV&)8oJQh5D8*WaOI`NUR5S3*CvMZ89 z?^fR5G~Av%$6We)a8&UWMmi+NYMfp37kaWcBHlsmMUNeD7&vdpW+(x$a@hH{0b{Wo7Dr&qZT%K^rgxV=5z}EBwhJ`PGLnM3bJ_1BI6FJhPcW~& zPo6KW7$TWK&Y?2|{5C55y|B@m_$(U#otcM0I7@Z&n!K#B#JV! z6sbU8$XZJJgzSCZBL;okb@l~onL%nxQ4rvplMt(DI&^JsxIHS>X5gFd&ASWt!cDvc zOCdX)IrU2_i!>37_%W@ErEkB)&W%8-YN2IPf^FAm)k@Ygq8}6N|L5b*)IIJHgZMi-GftUb{R2f2V|DZILJsX-E zRI8dQtEi+dDH{XrT8FZLF@mh7NLD&LC*3A58)x79mcj;eZH2*j9UfcD>{BawF0?9O z_zFa9_H3!~HCThv)OXeI!4+j?XOFR}{sn7Hw!Wp%YSLqB`UOCfXlyirN@G~)#V#55 zH^U?UcHesJ$;Xc$PZWN&xwURTQs%z9P5xQ+rfft1KL_V)5-hM!kk{&|}+ zp>5l?p{t(I_p|L#cZGiX*Ii_DWe&7!Zv6TiKey~SLD?y8E$3Xd%~wBv`4TtKzqn6) z-{pr)_ZX}rEII>T;(#q_L0rd1xefD)1is;RvMR$&J8bChrL3Hc!>dnxglRt9LPo{j z0p6`k{5z#m%X1ReU1%NFv#QGa?pZ8ix^?R&Fm=KY6Y;@B%qzm1^f;YP+$K>vHb3o` zuTb>H4OJH{oK%fdq9%=w!fFTjf~^)Jb|e;fS^X1nWC(fm?*4G6Du=s$gbtwG!Uh8f3ZTD|?j*#`*;}t|PgzMR=IRi$Hytyf*&DLYn#`GI7;OvQz z0yGzxx_vD2c3-*v4Qvf2|+O6<^VIDU+P&izc4_ag0f*VnW|5 z1Ss?mP2j3zQ7*%Ua^pCMvT#;G77pexXM|VwxE)r7D_5^N)OQMkVYZuy3~cJ|bx6v* zzA!cKIDL9;eg-!{35n0%1v|+qH4B>>HZ*6u?K`@OO{XiH448F@PO&kJl~-^s;MI+Z65Es}+DOQr3?1ThWHRP3mLb z`F)6X3r$T;1!e)~Csd$slBMlr*khXeGMVnbaN&XiMaxS~3%8BzDksGhYpxCTmPwCS ze7C44?rf%HE#YBoh>Xv>k2%8>s4VBUdCQR>HW!rYp%3wg8YiG}~T^6cpjzLqcf;^%iyj#sy za?OM>2nknX-wP|L*r7w5^4-S_+lXe6eX!{vxdppWF3aAodsws86uz?N0VHTO2cZ@9 z9R*dZ&FDhuaHezdwhdU`2Q-1y+eprlof z*Nx%Bl#*K_@Gt`+uhdxNH6S;G%2{{H-#vPa?wYjDlLcdZ?TFYw8i3gwC@$F3@eYw& zRfy^AtNdmekKtH~y`+&(D=lkwel(y0%iw;>L?t>EC3aVFg!sUZbZ&f9t;)^Gp$lmu zI)(0^N5^&Jvugcz=Ol2o*0DNWRx7b(7^3Dwh(-8NA^oWyI|o?tAY%PWoo5}`Xi~z5x<~FKDr$ud^|aD9Pi|wf zv>|fMOz>IP8EM|^XqV_ouv<&fhU$E;L{El#*CCq`OM1!2kdRGrFH}_F5wz-0F((*F z;PkRPF?B9aH(>1SJy_HQq+S#RFElskG~m@?X;8PkysD8xpXrwa9Y&cCt(J-bdc)fG zLIHybEb}V-9xG_Sp3A3o-N{BELU#%Z{F$#{{tB_|B12jK|D29 zjxQj%exCmw1WGzOnM^Y?B&2e{eQ{GRCJ?{S^dE;2w^}!S{r5kcpMnMqi~2*`!Ym;IKhjZuX2H-o84i0hracQ465I%G`K*vZrx zva5nDCC*PozfB-Xo$M!0WEE~b(v4Ds2CE|@)%mKS);2SRxgV8hr!Dq z61#)ky%rvG*RqCVEI8E7S9bOssU0T!F+ACl3hp(v{xv1rwsIgRPj18is4jhZ(ZZq4 zhjnZwxVZ2-2($uu}dOx#yxP;Cb9LT7Y*Wm*Uj8Exxz40 z`gdK2_Rktg^TUYR28Ku*vjv3LsO!yIS4aXs@P%&E&b8q-`K9PK3LhS*t=F~dMY;#^ zqe1QE_~XlaQU3vvXPUGkBE^nNu}hOxijyWi+;N@Mb%B$n_4(R0x^;(9W=hF!?BSHc ziv~yTglN!6&=$Nsa+&fCdSQx#lS|5)@m#N(G}c-qt`!It*lv9A-Gr0nuU-}Ng(41q zCQ~q}k=D3bK`>b35F!;Z^eJH`EF@%9L}3eM|6}3!34HK&2n1^#n-kgWfh-pqxpN)u zOfks+;lqcEHP@?-Sq)M`7;%Jl#J9>1-Bjat0~?~D_!DCj%o8}D{%nrW{(PFxwx~s4 zYK#DmHOLT3wY5APZb@C{^PZ704s&S+v&$Uox)M$QP+fz+|IT72YXK}zk*#h*TpTRM zBkQ|*Iy!^tCCl5}-W0}&ve9aqtDY9?SX^CQuO6Gv@prqr#I#6Oqe{dOoqXNewJQ6X zkAj}~2ZE}D3>EmSrlxYluZx<>l*V1wYRjHRykG^-X;QUrUTcYE+kOuP8m&vBCdTr^ zDCwoD{W`I1JR_sFY?k~~`D7C)e8Sh|!-tw}Au%wpDlQt7GU80aQpyninp9C0A|p3C z131I+LA0tlm{YTnJZ9DJU$R=lM&~k)_Tv9n^gg_h0hni}o0+#)ZI2H;_c4q5zM_3g z+DgD4(A4(2y2>;ft69;6VX`!+$#IBL=R_GO#2<%*4Qw*kAg z6Ng1KB(k%}UB8FEzASHHi%Iy?AY%(7{*GdPZ_3J(lg8ND`4cY?=hygYO*_Bx@bTlB zFhaJJi~(*9w0gTp!8UA|eD`$?Tdom#pXHSH?r?0T07N3L16r{YYuE%G2UTVN2z7n1 z$`}@QxCPbZ399Og`T60?)WB4fl^2xfcNMsV|FAhZfHs8YVaWp}qSY>A4`ndd=er_R zuLy=Ks1+fPf9<7az&LFalh&|ISI~(EKmwFfdG?5o+J%lTIE%4}{z$-Cn(|3UawCv`7 zPkBkcK=LFEd6t*6vO2J)44jTrRu7+Lfze^V7Y!Y)lpMnS5Hkv?z(7XU^UzBH;0gGO zPL7TW0E2;7rqk0WZ|0}zS{Ur(^ieUmoICGxfXy=s;VG^cal1@xWMfMG#KkuS|GTw& zzy`E+PvN3hBkz%TOM;gV!Jj7fP*M6ii0bJwG6LB(Oz%mtsH>&E!HI40Ky@Ot5l;l& zbkDV_V?OiKRQ{l>A_Wk$Ul^WEfCoPR=GyW$5jKe#F$BW<*Y|tLHIll8i7d~o_VLy?Zr+p&DY9Ow%b_}IYYfJh==^8TkNX;4s5@vB!#RQ_O*u_T(1c8XLjK49+XHq3C$#dWm_0fBvUo0fIU*v>p9B>X$7 zeGXCB!-^i`DNXuyN#1t+cprsveJ%V9o9l}`H3hSjeUiu#aRx)yIg>eM$4_NnKb`)W z8feR@ZbLx1C{q*>yh3_x0H&PIHM@t8HgQ_OeFY>rW~lvSbvx4mrqiaiqnx@7c%s#8 zeRN`CrinI@Cf89SjSTT@Uj7&in1r7{eaAW}HfaQ5Nw(Z5)1HxY=Z-Ju#=e^+;2sL7 zMzpxn@DvpleN0!4HD{131fUneU~Tbq`3@I(2PU1*y|rK&e)WiIqd4Jv^?HD!k&Xpn;x~z94P{Cl0P`A5dy_x~clNrjR_(_!vTUEt z<5^wAy0c@u0es!3=y^Y%7I2+f}^x_;|cCLn<0{P`h-_xB-Yfn!*wlf@!t zSyL>gsARoMa34-=kV04T6pWY%mGG#;&}OEycbjVu;Yv^g)t@tG4sA%C^FKeeX6RKW z*ILDZ7%JJSN?F{-$jC_c(w`bQBQhc)Cp1xcXnWNQH*YqgCNbmi0ZL0GXP4}B8i|v5 znQ);)mH)}`v?LPZl9Rh4j`PaW_7JNnfoZ8HYmOk@;Ymid2q+mQ*Xx5b4xwD=(W7#0 z1*izsks^7_W8406P9-qgI?&a3A*Rm+ribf^p*9zQLU?DBW*kn4TlwKMS*`~?6k+M= zL5YEJs?*4i8)~Iw1???Bq7&I3ZKxfC7f$3q81PJ3J$yAAkR&c~{s99S{Cmz%sj7tzh^feWz{Bs@CPEf! z$>vrn)%;rE+tNpg?+~KPQ&9XW3anbX+HcquT0*@xZd$;wd}kY5+vH2*?7FCLkPR}Y zci1(cmt%QJE$&qy>OpuX;Cu6~f_6eOD>Z3iMYQ2;yTg{sjHBSn!vE4@A!;2!J7_eN z$4iV}yLxr4$ZivB!}Z|Oqbak(T|ETZP_&d7oXly$+`FOo$5*xB6UwgLxjTrxvKc@J zOo|up3cosBM=c{eTLsY)*!!zTr+P^MOiHRxmLhjog$z{+W0R^1oS-?kka#Go1?N6I z-YD3hC}*gCYxZYQ3R&ImZQBaf5hVWZF(aDV-v6i?G_be8^^9&d00$_t4Q#_2g$?jI zPl3#hvP`rV$*3aiS5sBh!4oR=w$aVT5LP5)&;s~TwdzK9hz~Ky6pwz!N-tf#sw8j~ z*IsfUHD@&clk^UZwQ>76-b0(qNA|%CT$ibt zKASmj-bf1-kM_FGnUy^5x>a8W%(V-)uN`udP_=?3`^qupA0LVT?YsV`RH=h{}v|> zZiEDbsp+1z0487);BEXM7F58VBw_;|fpeHXD+Jyrxb9vStXvQftC(uvkR4-$1pac& zYSK+HzAl!i68@ELgu&AeQvwf@W^9U1OncB51B9u}#55366IEt3o5Nwq8(9(?(W^#N z#WPr2EQA8lZy}zmf#$KwNuN8(cb^{1^`3nd#w!O!9Sr3ED0+pBK~3lI0h zB~GS+;S{qlNm+oge-V>7?uXOTE|Y&!kpjt*Pbuj6uf()t5)F|NvM07w?lgsuHgyaa zH9c;Hjf;f}V1u=7+TgfuI>#j^lZwC!?bBSFO8u~r&nj%15Z^XB&ol!RXozM0r70(j zLD2n-8YZr7A0M)dEmRywVEzmdm>_5A@BL9pD2QIYN;M@9Y1Q`?Og?twl* z_wLzCl{B12_zv1N(b+ExZ<4a#wS;GUCqIAxC3Cy6Ha5P17rG8Mjzq~XVJ|` zu7oJcSx7KtAitu8O1|_R7jtu)5tG?=Bs+&W&-u$)=9?Sq2eK=4A54iQXZuwWr88Kp^sK&zm<-_;P|WMw)%891VCx_*OM#6ycw=q^bZTlu6)c z@x0(yLQ08+4ll8{Ipr8)#EU2oBr$cTU0Fl6ZmypnOY%f1Q5V<*MD^-_<{sh>M@NwH zu5g-SDK7DKXve3TNf-5c^aubd19TJ5LV*ktJ8H*Ap50yB6tNL)xg02D1+L0U5{-7( z984RU02ytc`{-m-)|&iHWgQ7RD#HY#6u|^uE~utvBOYPdD>+%z0Y^*loq?3*RvfD@n32-O#LPfS$O~4Uw zGxFuJXkpd^cl{EuCIOYnMzG9iT4yN*EBiK@3d$$Z2K*QueFI+eo!s1~R7>6!)oQ9j zhk${gGTunhrp-1#3bto|j_5JJAa$i=C&Wj7v}?R99=1m`X9V%W72XbaFo)SK z_bgM}9_|xQq!}^ROh<<@S;$`y8KLtCtL9zPy{na_M&?Bxo^!W5i$vdto*g6ylv~cd7XXVFEhacM6};)}md*85$6R6^K`_jL zHZBod2M|8v*pw7yI&$VETO*Z{c8M78ID57-B^ae)jKs_lrmgl9fg~hwa3%OVVySFl zVLigisy_YLMS#+p1B$pq@K&O3rH*0O4>kLaaVpb@4qszeA{!t^O&Rt{7g zuK9bLGOS4r>@J-M3%xfn+t8F*pkmDc)r3?=BthHiQxy>_-p^3>hwV!=0Mi?(hs!~)x*vgCbs;960S zU4V(}>G{sFXkn}U0(nC@Q*cXK?1@zmip1oIJHSi+sI^Rt$?1R+B;%H}EnQ2GUx`hNK~slo|QiN{Z|q^A}APBpPhx zQ+bJ!wTb59)Q9?tmpDXMU*Dfw#yp?wtw!w^3pWK86#J2&1tNlWG6)J;i^no;<=0LW zDFG-wa8RL%@aI+uY^flyWu)jb7r(eM1j>B(pjEd_8r!N0?}@3`^y$-i^$iFTsAw{% z9qCS}tt_0Jv?{mx8EL@TNn6&qO}y@$C@$hAcuH1E2C-0 ztWbwm>QpB0cO7DDRbve-?g*|!hCF-u@)B(Uf$xeO$C#Ll(!dDHA)C63U%w7!IBE5= zUG=*5?%iDS65PXkiX)*}M2bN@uX7ABh?oQPWG6cCF<&NxhDHkol9b-eetG?=_twNn z&67jT6O)*@j+01bT{{=D6b}Lp$A5%eaOo^GW{4wiNXT7BeMp|DMRl+OalUvgFk=js z5kc$CdocygkRCNlE8Y}&f)&kv^F_w3Tr_KrF71<1Wom7LRF;6k?@G+t^!HT#S5kQg zRMoI`pN4=Ma(%|xl^IQ$LQEoB?pgV*E74dStvgIn9WxT%O%efO8DT(hJkUa)Iz3f` z!jKpDrZybL z@(1ec>r<_k@^qQOSA;uP!TY5D@=w7OueCS`qBxTw9B2h=ynegR$XUa$@D19auC`FJ z3a$qAzil2}OFZ-W9yQq6lCliBp)r7vJ#CYuffa>UeZ!e6JPy%idg zO9_x8I3)mjEHRX6)4}v#m4WR#@lyaG)19xXsjB)i_*G;OSlfU?lJxTq0$Jo^&|MZJ zZ4Kx2xPrbG^LM(Z?2Y2-O31KBD#OLs?6S|;rQeI7*mKo6m=#|@Z~+&aX>xti4kNeP z9v;=oNdY!#Vd}Etm+QNum1(Viv^Yo~^;p7wYR2C6=Z>AL{@Xk?A~MnxT9#WXA)Ke5 zWin)6OcmWN;7cQa5S9?)jS^jBO{?;&Xv)oQHeIkVc4t{Xpbk2bc(-0}_ z&S-2HXmZW+%`U)19}5ZBM+j~g=u_cbGHZAjE$hpK7#^oo*!<|w&`<@sG?s8njz+3b ztZCaFs2F{lAHh~3|60TjyX`841qIE42&~_`bV)V=4CJ|9Kt5AUz=0oZ1_PD>*ym}r z&!F=7NQ;lB^(PdLYQZ*4!hi4JaDw#x(EFbWqk{h@~sY^3Z+ta z2GehuDBGGc-{yz3g-WgafMnJj1~3hk!Bc~cR$hE1alc*;S|Jr@=Tc+M#x{ikmzzxK z^aKr%wBA%=``_4`Hz)(-qa2|qGbP&6q`>JMSTgpwj`hR+DR<|cIR- z!?N?o^Oxq-b`Gw2n%)~~o+cIrSHioFmEgwbt_m>=zgLmdLf!^-TxrRr01e#M1qv7T zCLzHmf2GTmd7rqq^zCh5G5sRD<`pjR5lWsutqsC0?-U)8LUclcB99EG+;b-?Ne!YG ztJGhGa+=~@x-eNsB}f(`M^O9v{0W9MO#x+6-mUaE)jIcYb+_O$BSwz2g^~UF`{rDV zOEb7Hh4y{=sB-~oE6^!he$!|W_FUR-X_JvB3FHH}U2raC+W6MndOFpXFj)NLuLsnA z3chvfHx{N%p=j0PI=%DyOiFW=SzH9S7cw&~AMD|y)NzINvkNO%+JA4o{Wy&t=mJUv{6kok%LX4;j#)MvFOl^2=EtpI5u?pV^M1OCdz*oc=5eQntIhfJkRBEhD4QC{LP82Cl!3OdJxg#!b&ma)!)}_nKvp>Zv z`zPLC!3LRtO;?c^wdOoOM9$|NoC;yrYxQ88lAmmKKZ;LJWK`5mtc3JGyieb*&pIwT zTYf&8|LNlU8f@?hx+amj<@0;(Mw7;mS6{qr*(`;7gxUL?;X$(p9eHp8Hun~|VYY_z z4JhO4w~u!kWZpwYKM}#M^N3Fs=Q=+~9NgYfK69{vI)1hE27DVI#&=(H*_cS$aEyt3 zgHI*LJMwL)9qtoaH54L4L*ErHEpW@ON*fzC^fJ7&y_eUi8xPC2O*s4e$2Onyrmw!sWn;JUH=*N;=CpANtj%?LI)&eAluC%P3s-<}zC_r$MtI+3)@ zhD^U4U;4(O$hMUqvYR6eYX^f@_T)42^7S5DhJ8Cq_&-9>>y@c7DRsJ{O2dZX^QYx} z@0Gc?M~8wIIK>cMwj=4!D=eHzUwH$o=}F5&Jyr&#Hrew%%;VviJ?v4$xiM^DV`?Hk zMdasCb~*iyqN*?0lYXnpU{7k1&x-t8z%%(~XOih8jGvX1T;vgLGuK9vk#B~wctkh# zUfl4+Yz{~siP*ry z!{a#I&-N2}2EV`j`r!Oof6kedk2PoF8!uXULfs1*&R{k-9zXth_W7aFd)X@q+k9W< zk5Lcs=KGPeMj>U83p1x6r8(OTx;u|Z{a(0cO>NG>=-=a?5P15^0#?J-gD-#$LF9dg z4$p+PyTrWG_J|0(h+iwaj#>4IJXXkL4qQ^ESdZl9z^*eocj8*xy{2{^R+guu+V_}SAhdwsm&J$cuIzAZggs;} z+HYU#VA`DnE81G6LEXFoln(G9mgLAqY=>GhNKa3M4J1jR=qAW|+@(`nwxq7U3(vtD z!n!j*R$4J;RxBedV(fxwmf@!<5FTi^AzM6~e7BWz#&26o%k}(wlkeu-pYZfgpTX~< zA7^B2pgUq!PPQ+K%2WW&l0)EsGB@3M#sBg1$2QN`?1M!B!d?&9yJ-A=^||IPhS{xJx-LTd zZss`j8g=GmbQaD{$DM_7CH-k&QUz3R}BVPF7T=BMCK>lJ|gLQ2=+X~IEx&9d+|3#zxRJ_KP z*GPGZ6eci1G{r>D;^I>o8CC*WQ2h&R0%x*j)29Af<+ZkC1@)&7=F^HKD3$k&b$TylBxPPd+WXYG9zZFATe_XJ9)urVm@$R=>^x z@Nlcc-d{fvGbZNO?kzUu6D@~mg4U`>erYm%w9#}hGYGTOnRvy9?r!Rouzl-rQTRTxe`1G%OqdGW)GD=G>|V)ednmHpcmis-sKUrv+w<=0^s zV|TCB5Vb)&wAS|YiakLf5fUqzZaWX>&ABxFjda4K*a<}!W1cB|t~&D_s7Gc$vZM!A z-{ZxEvFFGBUD~MeW5v30XeBBnP-KHMZ6vF)0%rPnU-n)t>%faWU57AQ?%rO1jStHU zu`AlN`&wKc^ySmFn6);Ks*vR${oCW{x^L;s5C<*)pjM*hD(Jz^aa*8K-ayD=#kRTy zls%(>FGS&rX@l9m9@BH(QL$2}S{MBJs%wDB+7GVv9bsCeK*193F4i$xEi-nc>fTan z(V`9&;=LdPbz#Ji^Px#i$$xWd_MZc=@MPWwo!F*m@p#8kTDt*$dECMTW1og8Pzq?% z=fd<(4AP*E83sR-=-^oqGPrCvl0jp$=rjU@EtIjmCR);E5MBlZAKWs%SB{MOb*il*O{t~>iPrZ8e z9)+4T#d7jC_1qI`g#&?s?fMQFFq70Xh6nQL_u8)ok|F5^_67x2Ib4Le{fLsMNXLV` z3;%^hJL~d|X19kR)m)v~IPqW0%UyaqUaxp{m1E(&4L0cLg1Ro-bis3%Gl48BiE;Se zKYc*|e*K=HoUtF;{kiDqolQnuL#g!wQ&Ou*NB#Cy0r(a?y_LHs*MX_7j{acA|B*=D=uQ^EaQ7dbWE(X|Cx z(d8u!U|FZA`Hn9~`mXMqNid$#KK|g3id$+ImIB|EXDkH1^Qr}1vuiV7s7;M0vIs(+ zZFlbMSto^ww3tspktb2VY|nj6=k#=khS6L5?+~0anl1^q2%3o;VN~f*#h;A>gg+jb+5N5wuqIYzFkIw1Q=7-REU*voRv-XPw}%m8#0q!8l~m-^Xsnj2*h zdVO0gLwDc%_}mb5cgfQwq{d)9<6UxS?w69)wgxM@*ZMC7_ZwDQqb*DREx9l^sbZNL zG`8VdzQ(c=eiq!jB+{S3pE4D6DFg_S>;|7~)Tg^oo~R8NFyIODa$)aPTeOgt0xVkw z7MwJ@POTh%qcfw+J}gA#DOc(97M#6sVI*&0UfK20NnMoc)4gv;(yCGGe)X-7k4fZB z2n#1CJ;y5G;?G`_7DNuPy%0!8FY#9=>N zdy1}vH~R*`v?8&g{lrj2C~pvzyun_+_;s>&`}JsG_T$P;S#e-JQeewTL~@++1a+Q ze;+^i>$ijY-<0MUsdG!MM9HgPKgwDm28kp-OKflj>nj|+d|6HAOC%rgze z$Nd+CQ!=eA&8tJP3R}$1yeMkaB)=;m5ov470JFqTxte`ZQ-NJAPsX-phdWH)`yUs; z%q&MQhj%!V&sItV7g<#28y-aFmY`A_oi$z;)4Z`#w^&aodFUn|!3RAk-bLOOvRy5Y&?TF$D2A8tK%gjp&jDrZ3K;=P% zttK4oRtgJdtVkqBZB8&%L?)A2*{S9&q!(WRMv|qRAIQE^)--1BzDkDppK&jLg_=)n zsKSpG?)4Q)#wwR0dC<7=cD=N+)AAq~?49^lfBdl}a0-34Z@YNFR5 zeTP+|VSjvql7RcTaZ)3YEb$ZG#+cnYv8ILsy)5s(F7bs9uNGph>+#PiYwL%T{a=>6 zagi_{5A&g4BdcRbT>}IRlj*JWf{e&EK$#V`ID$fND)+?=X}tC7{5Cr4Vy?WsUSr|W zqY)hrPTNxRc+c*s3cX&9JH3DAIRCw~=S-huYB%8BmV={uC0st*srCBl8!itGIldvp z=ZdG#m1C_ICj3s+%(-+ae~x8IjnldN^wbVl-APOwu=KuL)3!c9r*KPlYu5Dsp@@;+ z)^Xu)IQQs|39VTql^Kcz>z#Vx{$~3?5qSogIUW%)+RaZX42%qO2Hju5-t>^QoYPl8}wgp}1iX z8I9P)<II-;ufVyr=@9N;OKT-$wipPz!q{n z3d`LQ}U2-&xTbCGB*^Dko68ayRm4r z>{-5N1y2MjS^#Vo_=e$nCiO%2u~7hGEG`5LjSyNea&cbbbMnW5KZ!!s0f_4CnOFvPGLEFtwwf~K9&|&S@QT+DH^4IdQZLEB-H==K zGXCgkWcYpg05*kx)D8#~p8>&6JW#$-_z^BQ*IZl-c`+_6e&-?~w}lJAm?12wp+Qrf z`j$HhU%Q2o|N8X^=2p^=(2nLaNn?t(gMM91f3go$zi9t}M%wj*pFlxJeM=PiqpZDt z+~p|p@l3)qcOE)q4v7IqOCD^*o2jW=m;pAbYgBZipSM!H9r~Qqf+oZz`r5>llzMcS zgWdCW4>4Du?2j$WFkdppOQ$ubqyX;Zz%89RMol>5ci_NN*CyKg4;|{^5jGFn03$cC z`7s-kdII7Z?KbO;vxq#^N1VgP+Sxt^w9%`+&Wy4<6{QzBhM~4h2?$-ZRw>k!E)z%C ztc3rA?jlSNL*CCSR@@?|UK;={eM;=go&U@=|9xzWZSt}Av+{|jA5lWp#Q~j-^x6H; z|Jen_mdfu6EKGKU`=KjO^rMxG;*^ADb4M5%KfSV4lMSAssNmx%89K3zV|zZ%IpBRM zmRXkK+*qdaYjaGy=U#Q0KHZRKxUPIy0V&%i%Er_(yUJ|bxOd(y9d`7+A0%GDVDReb zGn+13cK*w$)`n`=K%}Ke0kP!#gs`3+wL8^|+tH|QE!g)%p?HV}GW`hqIa-sK8GG;D zxib;^CS&@=MW25G@>?)ysk?DK+{XNrWx{_l9NPGckH6BW;U}`2sjFMq+3hWEuF+mp zu>ifmaTjY1 ztxM5c!01D?i(~E>stFCam*e$&95mbT@wBq-5!lRZ#MHG#kfKbqm+xS!usVx%D2m^hqybc6c!6P`Ug=u!1MmjQkT4N&$f!)_c; zB5WvZFi|4_P#(2SZ3Lw~;}pXa*;YuN|M$M-m`|$>j|79K!F1PlfgO{bXE{3y+lT`H z9r2PmtSq_MUz|V5{d_XqYMWUNr`d277$U!heId4IbVPYbh#MMiC_djubX(;FP`=@e z9zk#d4rbUhowyWb9Wm+Lamyy!ZS~jem;R;4;vfa&(>SV6SyE@KP7e26F;HUMFM^S5 z7@@FpT<=cAFsuSm>Vkt_#HIivl{~lQ+LXqc>sn)HL@yFy=+bUUCMFaN;W{;^5OgP7x5kv zyhnxdJw@LO{KOghkg)b<_lt>qtFAL}pe7bvv9Uv8yGHS8c{D|A`_U|q5BzfIkSgmY zXV%QtfPO^#$ZsF8e9u7jwPniKugeGnntBu=EMlawF|*oK7zq!!C~qSU8pyVtCX1{eK>$rWsTJJ+jWr7y08eXNl1>s;=-n{W_BSHzp>Kzf#EKgBIu2CUPL`vyJ+3F&qMJh*}-y!fa9An-#S7 z7S+b=msk7ql)fx| z$>3O13yWD4d18=C!yCaHpDn&6(rq%~#JVyqzOuYGAG$DY#g4+VA7EL%n6lrau}yWCK6v@JY(QC_22a#Wxh$(OQ$1B@l+uy>gs3o zM%M2FwP1tYiF}f%C|gp4^8UT)FR*Ch+cGRaNEpcbW0cNH|`7$ zB&mQP2ZpRT-Ka#$6vGPBK8Qssi{&nD(F}-AB<=AJ?%eSu4lIG`ARc~;7f%Do7c~-& z3_LYR9)87RH1>eB=@b9nQNU-No_dJ1ntsO7;OUqzEydjmu5Od|L%Y2{;=P1p&Mh)3 zN*^6R@chvwYuPXCLk`iEN;0plaK!>ZP<7@QQMx|hW>UZi3nSo|$@Y-MJ$z0nuz~rQ5lcz~o60#g$p#BP>!IW?fbYVSZ%1J%j z`dtnO2AYl;(-~oWYQem>Z)TZt|3%J%fH zc=YfFDK=oCy=+0>y=(ro{O(pV8Q`!X!dK!-%b211<5SuNqr4-cv)~t`0vpX%f@Bb_ zIdYw`LLkI1mTgOWZPcYL12vOX+!&;i&moqGbtY$YDSH2Ck&396tY7qN9TwlWI&<%F zlb61e$k7yhMZgm(@=@Vb2*ks~)Cx44lB`jq3L#!F$3L1q7d=edUs|>rHl^Rb$s5NY zV*_xNNb<6Ubr~ho;(npwX!L_`FjRj_*%P(YxK24E=nSaZVI1}6rAmM^&QQDQ!Hn*d z-`&l#mAxde1n8dxL@!GQ&gy`n1h37Mad$SgZ2zYbdwVxzd8KJnI-W%Cz+mWad``y9 zk>i@@`2zKIg`b8sH~mtBMvVxZoAFil2S?bAau%VUS@48FEeULvy>36;pfu_hORFHv zXXj>XtqL*qF?h%hQ-+2&Q#KN@5daxAFoEr4*#Hf#W?+JxJQxZyIa87qTXmgZtK>w=bm71kJ&8$GltniK~Pb8qZn4W1QOfR{~Fu@3y$GGC?uS6_+zCkH}66G-?5Qa_+P@3-A zwM#}-z?NY~jZj7(AK1P6BYvv>h_lg)A@OVl-j5ik95QA$52xsnH+`6_cSF*`VUx*| zFL4tvngXRXORg>dSA4n2)dFpiKl|MNT8~Owl!jRcoX#VV_nOa|8TIFt(s+Ro0~&+loJDjAh(XhA#e*gcGKCw;Gi3lt2`anl#ZlK;pW>c`2)?mM#c2!8&cnBpiAR&_lALR zjBz26hsJNl<@ZWo_2aV!+^jxekJF&pk(bAe?eQ*Y1}}_CA1c~U!0c8iY4QdBkT2L0 z$c)!O0bmK%pAItv+-{kk=zDdR0bLIXes}`efR?HjY(?`S@q>!K?rAXN`>2Gz0 z;40|{wUt9qVZ;QDiClWoGDx4anR&iTiR;`)YTzZ1lx@vz7D+bv(AY5CoL=M{Ib!n*|0)i1`<&UG}Ad8)uIJroltC} zaikk{iCB+Nmy9COP~WnZB<5k+b4KL{uED=KViM};ec2bxSyh00SXq6L^zP3_VMGkg`7o_?oID%3`GiaF$(`GE0*%Yu)|TK~7`c zA!);sS!^+dJx^$`md|e!W)?skCxcUHuQ#b>1koVu5@rJyTg<&zR?K~T`o7e=_wF6z zcNC=z!YJTJxZeG4V!#KB(@)*tfqCyZV^dS#;?9;ML?jp76^Rp)Q3Mo|p7s4*l@(rhh@!!X25x_!#U1aLdFzaGa*m*pL^p^Sn+sLOf3uC{jdF zUq`JG0PF2&O)1ipTs%#EW9N^lA9|5~fWRdYoUlf#8?lJM_VnzuTJCX2u;c_E6Eq#c zA+E5Bwe^82m3(ui7YMWIp=Bi}oScU9Y=oY9BtHiGwuuz6$XM+!Zc8;dHEMu!{b?up zo>IDqDmR!j)e^VXhLZ(qD9YU*a^uy3U0G^NW{gDtI)R$Y*>d8<(flZW5w5hAjg9b6 zskbQqtq7O@0MNWMnRf|UE}pFVQ`4dZGM;*?#WN~#CIOwaw6?%Y$)s8pDHyoyls`0_ z5mYwO?551*B?)XvJujFqycCDvv{6ArSM`xz7?h23pKn~6 zCIyAd2$m_-*Lwp!2-r|s0Fi1NIazjfnFv`gbX>I-7B?Mz0+Suk zzE*G$rxDnfRBY_l63Fuw+dr(Q)}m&roj|4|I;*IujfO?kqUGP~lQNoyuA3rKD~41+ z;vmk3to-s-X*6EM;hE4#r(9j;OI0UZ&V-@ax!2>!N|n(Z^{*V%kjx^mm^`w?G-Tjg zEWCCb!kbLoz)(oLmV$wrR<{-g>wXn!-eNvZ=e+X$sY|RI_Fj>41tm{eua=&eXd?|7 zA3=~BqC&(2l{|IosMee`02#y}L4( zaUqj~K7Un6BG_T@a|`AqB7&`)lw2IBeCf7Eoq7D`)>A0g_Xpq(;WV+#vY2@{Q5L3T3Tk;HwB#q zT~#P^kIQSOk<~|R*Xn3g`U_uk?f0qjU+B`Qpj4?hRgcMJ7^ zkl#F{-@C9v@^b3?&SD@8g(t-F*EZd0_vivv96-j#9>u_iyT&omUz_t$PZ|RXe?Ml zJ`OkI1FaYS8G3&Fr4yY>f5%%+>EzYgysCxYar36F?3HTLw$Bu91W>sMzJB;{Qm;sz zy>}l{KEuKW4IAe1J-U5W-xslxd5@5ga2%2oPW$7$V1eZRqTF}`poA1U&A)AnmyW*s zs>q8Gcp!+9Ew0;vCm2f6b#q5t74+Y_Oay_tY9X&-z=- z(L&JgZG;m$4WyMwjUa(-;P+(E$^Ambh24m%TqIB{8)%QAeaZ4Ut^6(KT!MtVeDnRo zp`xW^8S^*~i_6RN7CAPGNtbVLnzgWiK0-#Pmf^UY!I7hc5J^;NAfSDT-rl}_`x=SA zEGzE0U8q|2=nCM+yL@qd?PP+LVr05 z*aK4&P|c`uE%F9tQ(_9P2KPfqviSiMkXRz5?YnzlgFvl;a3kL|2AUU@q;#~>K*9UF zOJ^Xb$^Mj`TZW^|W*ESU5hI=Eakxih0C6^ENI5jR$F>{*ZPJ`<69Jxcd+*>SjFcej z_aL#N2+Bb29~`2Q+JTcDvJJefi2#7)Z8>}vchYJ#qIeRt6MldwuaR^(^m}U5ot6a} zdV@G5_%CSG4ALn(G~QIik>gf>5RwCnT%7axM!n>5`6qJTaeF2~>_a$ZQQ<=kqIE_z*)Uz>sqWt~#|$)7U|0;#Awi!vmCfKNf3L;rYuO z$9KC%FE0veAYGXI)}_m0tqlgjguri41o%gaoKa9BWCk|tHWzp1BZb@pz->+j1zA7H z(*_N`**9%yD4*%h-Me(JLiQwDo6(KP))|e~m?J|E@Uc%&)vd^X`xbnMVM92{y5hZK zRZS;EDyLw(LoEiP$8)xsQjJz=q^eYZpoaD9iyxvmGxCAD;ft9p06YoACqCb4e zk{CW*`>p1>*Ph1fyld(DJYHuL|0laPW^AX34P5YhOVfs#kWGXV%EFCRV7cSJc?x_9 z=1Ag6Ce|?&gFvfbu~NuBXr4C#S*&UW5&T>PYo_~_xey!;dd20Sdg~LNP*?JzYKnT$ zqWYHQ?~(eTt}VSEB>>4Z93XyLXJCpXBG+Jk0s1NldGIp8dm<*BY2SMLCi+IqLh5Bl z8Y&=4e`{dwnk2!kZr>K93qCK%W`y2H^}hZ4yU>(Ur?V%PLfuQ_ZaH)Rfz+S~Lz@}& z+hw0#ibIhMmiQjkrO8QyWz1nDkCU4T*(q2r0ZwvV^#45}zYH9Uo&A3hn|N0FqN8#462h4QOp$W$uYP1icmIKT^)YoYKjAa>mcuiJQxa_45hLltj9^S7A3lHy0 zBPT5`#}dJeM3-FE4hcMf8RK$k~Xp9$ANG zj%#dKL${yRz2$`SbX|@WgF$fJmZmY7PvKL8bA=GlqRy?$ z2kMt~T9L@I*M?lkxmiMv;1`UvJ9Ut=qp%Vj^>5)p=$;J%ENe9oY=v(`hvrOi-?;J3 zXV2{2PFEL)N?{CmeEJBFmHuTZ-waUHr;jwPq!Afl(QQFtl{qd|0i8B>kua$l1a*%evFC4_#{lox34o|X|MXpA{mo9lZ zQ*S4Zj9xbPq7+4P3ne_sw@GCqFY zTB<^Z&(o1$gcnD3?&#iA5G31z>K3?u>7i}k7Y)})_Q+=SM!Iw|QPGp=P}(iyRxUGV zZi1)D1OhsK5cQgqj+*Smuw5&>Km<65ktWOo-mY=@J0hT#Wb-jWRP0zuofNxL7leBR zBnjNqvVA=caxURw9OM1cG(|IFVeENY&)mmAyTram`Mm7obMv*D=Ug)nFbjyBa8C6~ zu4DJLK?k#=`u6|Q>2yx)IQ7RZ_qX`c(9W$`B`~&~red&Qv=|W9dGGbKC;B5!9;nn97PKPiURiq3 zK?vEM+naUbi4J?V;zrU_?*e@VO!;B4W>mMEOsLfXEdSL10duT6`6u? zp%#&Kvb%-8Z}=PFOqq4ix1^t)W(2zRfZ~z#WgYk}9Cn8eN+9}w%B=YW$MjMP;Yro_ zUvp-yd*ILkT@9!DiM`80rp48LyVh~|y-)3WKD~M*q^ePxeRgEpVawfx#aZ&+nj9JW z;GV5sujW&3J&IC3XXxF){@dQoW zA9Uq10fYG?z&A4sav-B9aX+Fa2&uOB)<~@rC*@8PQO{*!Anl99tcH2)Z_5rceLO=+ z2)#n3qGTcXL|?pU7uZe9lez!roYf_zkm6*<1GsRXS~d0F?nVYpdr9E%+PwM11KqhY zFKDFg6BEe_0jKh7x`{d*{QGzT1jILt<%{skP^2;i&m;=V=LFQ9K4(tk`LQHUST z1ql(X5I!c5e4T*<2MW;r_)O53GwRicLF0&0T-&C+_=g}RBI$OoLj#Q?d+RoC4JT|v zqSGsWm{gpcggz)$(?=LW;i9|3wQ!tURJRo3yX;kiQipS#q<1CehCY6OH#sd?I#__> z)g^Hvl;75uWF*ego2xDfYwa;;X zuT9q^GKU;2mzhSpyBXlcEK*&e>EofcuxV+IES=nSkX^BeMFaxye|(_G09I(jXuHKi8L931k32<= z7t9d%;&ukEwz4Z|e@c~Jie>^%X=hApDccOnyIL-W?Xe4))p=*)Mv))M-?N|$W}Te0 zn^C{@`0)vFgox8AH*b!tJ4fP_hOu{N0VGtwwVZiVQGIFSq=J&XM=L@XAsbZ)Ac);C zq$4&kLegjR3_A-G`E)mvznxS*;R!+n#nEsOkZtZOMZHZ*7U)l8VRS!j13Pc?@|a-J z>*j}N&ulWL#Uu3JysY1j67rh+~20AgeBZj(B zfQW23O|#^9(&y9{R|@$4*~^7sDj5!P|4FCaMlp!+EMpP*^d zSSPaX%>4K@g^)^ft+2n}oMvMW_6z1n5FVByT5&ThBvx4DP(=g`<`;XzE-EDX^d`Nf zJLk=dsx-9<97nsr&PSRlE>+A>J^9A!-omSTZa;1=ICXG9y$)}#_4O#ypEu#mowrAo zf2=59Ge0nXMQ+mC3iZZzwYPU$8`R4$ZAtf;1GipqHTd>kQDd74GZy-L0^dPu4}2sY zRob*Mr@1`xtb8|URD%Y=R3WmtLg*B}4oWQ$5s!k_TZE0YnNNQKQCEcT^_G7a%~?2r z6f}WSiWv!w;&PK%PhIM2dgbC7e;?GgEpn^JiD@7R=irW#$av!7GxY9`H5W@IPj-9{4(1tOprS$o0%N z&Fij~dCg#eo&TfoOXrsIsKSgC3(i$#tkutr(%xNf+tCGI_r_nnpY=BAPpcnQCD*oZ zAEt1iAZ_n;f9G?m@$SzJ$}O&WEBQZW+Ol}*QZeikN}Sjqs;dWuFZl@Q)l6A=Jrh;< zc+yxgNv9LEx@tSZM^hU%40Q*BanUg`GF%1%r?Mo30+< zPfGiGjNNI?OvjD03;d=CJ6kmKp$Z6_f&)yf8(LSp&GSC9_Us)W|H?5oZsB(OF~vg; zyQUopiMXsb>zZBs<+$zJ+kICb`s!40@7?7$n_P^$eFITjA?$}g&(9_B#vtNI=uTh+R^8Tzd z*GIl9cs}Tg-jPe2ExN^hymYljUan)R=fYu;Gp3bZ(pNY1i7(*{(DA}E6gnBO-zeCD z5Y#l?!N5wOrvV5gB4@|=8$69G|Au&sP`DNKtLC!~S{JLXjZI`7j*!Pf>=+i@h4k98 z*A#JXAW-0f5@{2;>%UW++DOo$wt^`GF~!(pu0E4P{+h!`NFGdIO?&GCqY1!B2fht5 zsI)AoiY-9_FZ)W!wqj&kz$dxeS`$uhW2>5IC>mQ)nPx!1+X>=*xmc9%;~3ykaYmg> zcAPPDuGyJ!?Tynayxv+|e-+lU)~e5+U-!uLwvHOG=D_6^ic6O+Qwh(!cU9LU z?MPl(Sc$^Ki`tt4H-DezoW0{&_Piz6J~#O~-Oeh^s>`Fea^=A1=R0*>RnOqX&z%D| zdu0TVY@;=4qEi21HNRg>Qx3T7ZJz7+`o2c+Wxb14*=t=5J~uwK;Na4Ng&HjtE5HAa zaXx3Vv5{u)!+(2g{8(9_v*Fk{MztH7bE?( zE_$2y&UgCtYJBU*m)ti5nQA1bA+s`E#^@wjb{lr>GNg|WD(s(`nF-bjy>>tFu_X-= z+jpYArx(~{)~o_e_qCSMe{vTzgnJA}oK8y!(PKyah0IfKPjT~9$}0t)O<@Wz>^dCK z1PU(oCl}Bx@U3NnLdkL#kofqR12+I##KMYQHJP**7Dnhz*rRKpt}L`Vc9mrtvXu2; zhVNjsBi-852odmQ+Dr>KoD~y#j}Wr}t*Ff8p!2TN`fV?sw503zq3X9z&RA;JvE`X} z`*LzZq8l5<_3PaB+1>ba;}+?CAKLrsvanlyvoHV2Y}fW+zoe|>J?-j!|6Dw)#4@h9 zFG2sbO^Fj z2CYqBw!ZLWol{!xPtT*q4V!Co>Q->3rdhpt_wL_YH2cN2Lk2l{UoM&V#dz9*ktukrqBdFkRJv+v*1zW!-Ca9#GFa|O4`LnmJ=Xt3<-bmKGEV?P-9 z6!p-b_VU33ixQQd#&vd)PvLq>A^I>;lD^ zY@#3izTska?qGF}q>=B{l)~cRIwR+60CPudIu{zJo;G zq00fx1RJG5qMbmaHi^oQFdT4tR`%QrXUN25exV)@?(ECdsKW=_>Z5rfxKY_u;0)cUxZh{!0JUg7z=dN|%P4pIKlVuCG#SQ_Rmm?+=b@ zNk`kq)flw;XUDWl>&l|?*S0cI7_@iyO>^Z;d8ubUkq6r;Xr>kH$yu@W)}CuM3#GVqNmRtUVxBRol~c zDdqV$S5?K#W*k=(eBK%HLb&0>cCHr*+P@PowmCNe#v|Xz;I1Jtlqh~DVZ+J~^!`$o zF`Wr!&>~DPDX!r95xAkm=F^|iA&NB9e+@%kgPmh3H2aDY5xZZQ@ouMdllhd7X2@R% ziVl#JJ(CQ{&esbz%@)X=ZdbN>%j@9&?!yi2u6_xReP{exb_ zwmL^AX!<)=TwA{U)RTRy_SjXmvLCeSb^7iqhqPhtRh^!O7go;KyguyE&95pON{b&J z=yK+M-Z|60g)Ii`eb@NV_&YZ??5=9ndiKk*bf?5f#R9W4%F23mCZv2bUK{6a6mFrj zc2PICd5`ketZRSsroW$4w_j-}+N$zOAq7ADI+pfwyNL?dYy{}p$RJeWF)l*VdPGl;*;mXf99%@C@9x|Pw9au_#CKLe~I76hZ zXrg(q2VKwv^R!)-l0bd`i`24kwggJT)}tbFa=l5O=+BtolcsxdlCgy-9}E3;cOMzl}| zg=;~1i<~OP)V{k;6Dk&iY_VKagrYt4EilR+bCf+EoEl=#?27i@{8IO;mr9avJFJ~E zCAXQiigiwt1H%`a>K?HkwIgtK4Nt!&3w&3v(0H)CiNg8HG}Vodf5j(%J$1NMaoFq1 zjrC5xoa6LhwDxJgj|VQeYq-tLN`7(g@cT>c7c7aeEKlFPh6+CA_my7ff2G~paB^$a z@3$VO=K61H{ON^u^7|{jCO$ru7P)nB^L@?F{eHf&(YFstgz=rLDtm^m`MtYz>Hzb` zy75;_{%HD_zIt7&?&@#1UfkPO_d@Z*ud7x(aJ+*BO6W&N=hgN8EPM}ItoB^9e(rj=P@emtauN~!hjIMD-n%a@DSua<@?(;UA#=;pXY=!WkALu3hs>bNiW5E`xe`wL!Bu4iz;2!E)lCqqt*gvSB3iaMSu zYSpJJ1$+MZp$sEU{*E^c#aFN~=S#oSR$0R<;w*C}+2;c4fJlj4i8s!n$VDeVc<`xZ z%4i4$!`)8nOy_lSgMqvvCSU5shM&-EfYOK|d;0ZfHbG&{UbstOMr)|FL#{Agdp-*5AvLo0S}D|w(3GRyIFZsmGi z^|ZGy^3BIMG)w&CT6KThwBCJVW;sq@UNZdJ=V7nX7dO1-_aOybYutVNhsPt^T~aLB zvEtOxGW*w#J>Oodn6hbQ`Rmm366!P!)tRaa^9{~!zP%uN(7+W=PAeSqzL(5Q-kj<2 z^?L6qcbp4{rZ4CDdom!?JpsiJEmY@W- z9sT-H?Jee8JkI@|73kRNwq_f$sl|Z!u zcOM*TR}m}*AA2$Vc7j3HNq9|(#}GBbQIjK={1Y5 zY9maS=rYIGv#*wQ=-O5EZL&F83>P8^- zm-@F)`loOGE>-_=;(4T3$ZTIPvw=36Rt3$Ac337wOq;RPdcSti+D%W_+!}B$=Dvxu zzWUR%-wrI&*>k1n@<*>_CRcYJdSP7Lxr=$xr;5tYORpcCzkkZh*dzWX{`t$Mmb`a6 z{r=vq@~b%|huez30E(W?e2ohS85@taV)fa*-E&^;(Tf*VQ4JCadU~92pD;~5C>+9F z5l)qe2*ber{AjXk+}T4z(yCQ1SYn_xk&<=U`$r$cX02>B(Z0jMqq@)Z?nTJ zjDOUIuGCR~+kwm2ac=t=Q`&Ecs1p+%J%TWC#kjU|dJrT(S^TGD7i04Z7NrrgpNO^# zv~-cQ1>q;zAB~BzD!iQKg<2T1wubP$*R%Y~BI6d=V)7N7B767W%Iu6^RTl6?mB7UR z%O-tgy1uCWZ=&@sOGK;CQnj z^nrg>Aisp|ZAn4nElZv3n~GvS!1s>~zp=)~_wPcCJiF+%A%FD^8TiS$Sxp&4zIyfQ z^9$$W$Bv2PDSGz8k%7$}%}ym84H&Quts?iFn~=Wr&i(r%8I&^G7IFI;SYR;45*7CA zzdsG3^JzhwE7It)!=A)AzwkCAQ)A<@!s!g+Jm+T>@K;|R^Bz*^JV4bu)cyFzN|Nwm!YhDVRWDB zH;CwwfUeYk2)bT;xY)RhG&SL|KGZdJ5?VR( zy^yCGFba`*7bLHBbLwp1v2Yj#!jusO+n7JC{GRXsd-;wyl3w+HysAY7+nB&O04xm4 z^WC%BhsVS;BL>T^0w*2;5+4{EMDSM4e2*#bvNoT9`W?K|+xbiQ=l|Zp;$0g+7=(Nm z912{etZeyaS84R{5LGjrC@5!PIC(y_Q(ZfQ;V&2a?~5)OvBPGG#E_<@Kb z)9jsrf!fQaG#gEe%QssO=R{;t!2FCEDu^=aFj)o*E(Vyie21TA7&n;t3uM;}J+)x2Z(Ylg$o0B^oepJt%53Nr`MkXTD z6^$xAH?6$cd>leHFhpJc;mLY_LMQf?c~3-~o;|5bd56tWH4JSX@4#*{5ifQfDzFQ6xDVwA<&v7V}jNc_yc)B(h1coz4BW zoO97+Kv1alpC{l+N#Ft45_xhrklE^2tn2F&F-PzRt`x9P6V&@;Ig51(Vx4=jF3U^0 z#8=AD7H8tVwT2$!!_VhZ0t~l9LNse>Z|xDMHk*YRGCzPo*nmtaZYC5yqUFMt`KGFh z0t4}sV^zU|LVZl&%tHJJwjVs(y%SPy8L3DhShq`L&myk#W{sn+X$)g2#)Z zzZgV_y9Z-g!6)Sx5iSe4U~J+3vR&9ey+(l`CBEv9D+XVAFIVK=G^i}~ALVHE7AA7? z;lWGAPZruIyDI~VOIz34x4A>!7p1bta`(uUH6aOX#`upjql=Muc#Z*)Pk6K3cp)A$ zhj(UyN<0K1$G`Ns6WN78}98}xXLAKqkLI(3_FLDtIo zF2gaZqQ#V@oNe2-wf?vtjQtk#DPaQ8sv=r(Dcvty?cc>biIf2`Vq%NTVm-Yjd}39}ofpgiWtZ@gr4>$)k>r4l+&G zt{$V{X*721xNmx=M@KIXZP%OKakk!Nez%#`vg1$3eEf6v{MkiF zKliq4czDxjwUC@|D=+NsZmxLD&cVTt#!bv6?CtGEWgRw1Iz%pmmuHAQ+!wJ=i)IOB z_T0HQkokiLd5clsq5B?fMyrWH6hutXRp(>Ey+YF`7*Rmt51W?D+}ynXy}SGM%oUBb zU&6)lBC``;aPPH{YNo87#X;{x-V_}gE+TJxHqxGgTE84`iocMnS6fRfz!_}H3Tpq< zQbv2?*Cz#mo?h!=?q#$0yonv%)LgO8fibT-8{f!y_|R$2dOQzBCTzMm{;}#uwXmId)Vz_?hIyP1kDMXIUo!q$bc6UdD zm%@=Xac!P?Fn?`4^HsrygIhQ@P{G?XBfJHKt&46crG6cPsRjMq%$d{qn}rBPh}YZr zZl4i8fLz>We7EK#w3*Y^7hl15TY@n0kAZF?=FFm5%^emgIBJs+t`3@y33TZK!riDT z(OLP=f@g;HI)vPES-4Qvxa8(Qx@w`~v3T z9@BLrQ>=%&_5sXcYACGxncuRf{rSSk?+AjVDJZZdw=U`om?-12(eaT!kC3CUv`B2x#r?6Ey+uOqw zjhIeQ+Z`FCXyo8<p~@; zzwyA&D@TuqU^dq0KqGh#`%&0~(*t?imy%fDZlahM@xgT#y&5i<52r)A25_H6aNI?P zij?kIFTQ2!j6Mu*L^){c)n$no2V)3cYv*V$dS9bRn<#^MzybfVzh$WNob?ecguV$~ z0WzRADWaqMI59r8(GtH4(y-{yAcvEH-%Hi6$MNDVD?cAH3DFj{;%0h%{*1+v@$>iZ zCQvkZ@(M#IelzX3lWw!FRzR_L+ga=sv7#Vuwy2v!Ap3gb*1}@ZQbm5Z;(L76g8n5x zU7lleB7`WVFOC52|0#wI~7IsuP6JZ0)(SX3%eM>Rv`I)E9bB4HM(2VyA2sV`v10~Gp(+XY&hFwkNDPpC-Q=}FZ?gel*wmRF;K&xH5 zHfL>g;gjlznUy+6v7M(+Ye(c!ULnH9DE<|@EJ_jr@rDmCT2fM{aAR0Y_~ANr0#Q}0oY{O#VFlzh zKT-lFJxlM;?fMNn9B{0ZayyR<6v0~93M0`k$v8v~~UWG1*`6m$?#EOdns1rP@Cs&q+yAR$BRaBZT5Fx&xN%^-K zN{%evs2QvqO7heEJfwpLlO0y}MAayUBC>s0jiff1Ld=nLmFXT?0GG&n4vc8!( zud5wrl}j1QWI&IEK3?}It=yJqf)ObJ&) zA`#_kOaTJNh_cdrSf^T_k4Eq#5;Wh*vJ|o|@;ki2%>dn+r9H;+&@EJ(yPp!FgEKO%Vi@UFYX%?~1(s4M|`tp~3SC5IZOXs&r$W%2YyFbY#TElS&tO1J|!kXO!o`gKPFoszH$>b zcQi8sbW@?`=GE!y+FA%*p)~U}-~&KuIxah`ii}lj*3_lSu8ZI|vMbIodqhl@SShq^ z20eMm9nAk+VS!nwPy&df3ZkQh;&&1obc^99L$^|xV;-G%J&45atSFh?xXWwg|QLlY(U_xS82nu%2T~!un=+YXt3piqhp0>D=a9yN% zM|Mgaa>#6#3fh0=yGp}Fe^g1wkjL3Kq9t>1DvnoGQc_Uh*!C{JY&>>5eh^H^HXr~V z9Qtsn*myNHsPF`_38j<+kxgV|vj`LgQkZytE-`k!x;seD9`V{O~dbbH)3+ts=#OG8)wdI{j%RZZ-WigXD4Zr1F?rNzG0F zu8Uw1MQww22OM@47tbY2yrSFp@%J}jxJ-I{czWs(Om@T+8T!-)jM*;^n6UC+8Ml=# z_0bHv332Fp{H{yKknk^`O${hz=?_9Fv=B-to}?z^KE6$`b4F0a>C+;V!MTtgnJUT& zbtum4>eHpC9pbjg)QVrMcv4G;Tf2_{ZwyPwF#NitvSQU5wqU`AHUtN22$1LGwS=P95uW<%1Pj;^k-;#vPdp;pX;XyJfB zs^3td%QUK~{*$l+lY+N-%a%rfTGF;;csUwE(BNeEIU{ zP39^H?{AiUDI{u*(J3Tui)ycjN7bzM=3!RTmF+qwrabhD*HvAy!GO(*E z^)1z(#D0hXd%NjSi81W(CFJBd{QVr=1I*ZdZO(P3+XfrjObr8zwl;MIg-z_Q;uD4` z$LjLD;Bs`r{0IO`MEf9uo|?5TGbJ^p)+RASzlOVuj;|m zoAWt~I#T9<#tLbn*Obeiv}&Lu%19>*t@75K%E=KVN}=XXwW7lka1wKHS%OBmn)EKR z+7RswQ>u?x$GK*b2FC(!k+JH@A)qHyl>*{|5|YT0yui4lJ*w8prUPFQhgcH4G@X#h zV)wYzE-dQOwQC;oyEnLE>4;cD;!~%7T6sKg1TC`k-xm5mYYTaXt$ayHEsq5Jdok`( z)UulOim+)y5CBKPo33W3PMw6NJJMVAB$gSxjPjXQ-MV*Q&*21#%IYHKd5G-?zo||y z%Kcpgs2f1L^Y5|>P-m=dLnM<631%{!c*o`gzRa81cD_^s|vDXf6*AmKL4RHSuY5PS3 z$nz32H$)|i?;mqwWU*{YbuAdOcasF#QIR$wlr{nYGNRF1)|M(UACo;WsiVDgX=Dv~ z2#lqQnMuoRe5o>zU#EJW&sQc5Ik+j_RJJborVng-6hOJ z59%}UPC?K@(KlqWWsrFp6}CjERY!h&t)l!Qw68s`TU9tGW?&swA2-9`MjljBs==_{ zP5(R{O!={y=ZSQZSg^y=XGN|CLxD9K1#YN$v74gEpJh1`wmk+fht>#!vNgrQwbx_c z_OQ3vIjPNwY0B}E-~-27h874%)eN)pg>@Bu_Tjy(N00I-(4hV$Gvh*Sil#|Vb}a$u zWKl}GVwU^l&T-);o^`6hEmc&kZnTVh`(Aul!>2HR>GI`-V@j#cyepcqh@g{ckjK+* zd`2Dlb*R7o20j}99xE22*9Pcr`6^30$he~bHMBiqts{GLLJ%d?RHOJTye0mDgzry( zH|9W}I-r!sv@Fbf)SUp%#^xP&1Ule(RV_P2+J6IEbtMDlAomvx-K&byG{+t+%W@ue zCv%2tLnIWct%^@w%DF+cC3X$$J|Ktm$@h1~)4>CEbQ1WNYAEoMOsGHTNGlA#ZXyCw z=9N2CotVd(`g-1*{x|ofSH_*1gsua9DP0g;$qf@6R@IRZhWRHIaA{7c@hOe}xB#n5 z647}8_-{l_BX)`qW?($4u;6xsA0{T@EMusH>q&_K0DU>D*5Bi+l6Zi8s==l3iMaA_ zmirItWa!a}NedL#{pd-j!KHgLx!!fg98u^1kX3)xQY;PPTJwZ>{$S9d+U)MEfXn7jomoI0k)=C}4N7qWO zD?$qf6OOzzr*NW0YR(=P7Z({5DR`o;FCJ2mixnz!L}tZc3dL!2jhtt>xbz>?_eQAm!1KAh%O{2(PZ zDu5^W17C-nJ*&jUn>8)imnql?UMrmyyCk*w<%CWzcOC3eCRs9i;desB=oUTfXPVFH z5F`JAV#@uyXmy6)~;Qf4FF&(yQo_I z``g-1p4?;EJ=Kq#-zb{JC~7ZT>{z21$(>-KxN=z#uP=fruPxYS6yVIT?8Ow`;dr6H z$bGOH5u^ZFOXf>a;LWyN5gP)p9VPw=5sk3LtGO6dLyNkyTpxym8}mb3K+e>cFQ>XO zlHuqEM8VAC-#(b)7i^ikKZqw-{mtEGSqF zm5B{>Uir}hly_0osEN*j1|HWFKa8Eq5wHbLFLuO$(V@%kJC)xCqmg9gdvkiZzW#Qd-cW zLNyX8YqnIh*h7OBy9!bLpVv8=d+z)Ge*eG!|92kqcrZDg^ZC3#@AtL5Ua#xA3|yNq z)JEoOLAnTOD1amnyw!cF=)9A(!jOI;>)3T?v(Ate%3#yCF!(hTTm%uDg5)F2q6>C>rQvLqSRC*!=Hox&`7yD2Hohgx{b@CGSpeR-52Yw)vhwlyl zNWY`#rFQKbZYuw2IhQAudqrjA!qPX|GP6XHk(G6keO|ovVa96tb`lFF zp7B^i_QXI;0We(V2yW)fp0JA2@Q0*BBqcEN1%-uAd?pM$hs`~eHL;Nex&>@HVHvq3 zMMX6QeTYEiRTT1+$O^)WH?llH&j7#6*um?Z-&v7>e|tlaz_ zE6mFZZvC9HuGd;ZQ1PA=N>)TIs!Y9gJ|uZ{$rm)k1;xb|Qc}2_MFVHuXf}M*s6lwv|#9;$ZGTNZVzOjexVgdpJ4O z%`#XQ!ww{u&k5N$x^9E?yjJP&TFrLElg)K|$(aEOG59pF^5s4M0V}Kq>>s-5%=k3A zZU4k_5`0caJ_JPBjwYiryBc-QAaZOO%Y!$U%H0J36*%xVty_C-sQDe1A7+&Wt+8g! z8VM2cDDm z6Pai&EjRPxt}*Hx!COs2J^0$TJO$V%-1pGbc{#985_dq9GugKl6;wU>a)~?G^h0`$ zJ7BDdU1S*Kd7$iUL%+NgZw5$Y3c&?XQWRbhb@ZrEQ*v*V9xw}`rl_+pa<%5q`@YKX z_Ia@PgBD#ROhr{T0|x`h8*V**S;@xokQl?oM?3yD{~9FEI}*PJSTD(U#Qw5;-+%h@ z;!IGl^FB9=FTCm6rOQNkc}NfgBe2RP>vBtDqQ{LKxv|_sKeafkC76Xs6}ShXl|W&n ztv%h28{@CY2W4oVjG-h0l$Z#Sneo}r&s)H8nHPMDLO$3X79Ge^rYth67=zKafB%-; zqUh6LyBPAmksSM|?dc{G(40K!O%k2)PL0}y?R3VR`)0Huu)a?QC(TotcKF2C+U^%Y zAa)*HT~)CZg|=Ce|8O-dBqhq~1d?LyA=IfBgr)5UiIbTr2Cg|0jtRO*VMl#LPstAq zVaik1Ie*%}nKm9r?X)q`(ocv56Y&esH7W?tvs_BO9)=LW^9KGVBJASj(CHg zw&rsrM&eLXZM})L8ftIfN}BD$*)S!s?cx%vBg9yUKPBR&dRby%W?|u`8XyvFM*sKn zNT9TDxJ%n7R|R-Eq&?7mH}e~kuh4&}d>tWl%YIAh5#ClK>I`jG0O`ALI!7yQKR^G3 zms79=4O;Y>lfsj6`}B4O5|_$n1IHbYdpnBmao!L9`H~Q6mB`Y{RS`$A*>Y{gd6mHImb9{ zV3qZhsO}j9T+b_b<1vr`o9wJC8U`xl3JL!=2ojJLN}QyPlJ+95U9K7Q?*bw2qeqWK z5lQ#WBQ56-PYVKB?G;yYYv|m$x>WV9LFP6m@2r*#0T5glr6VPqGvP`EJ_j|B2La12 zNihvnB)R_d#jRarbo6C#*5g6XOKBV#=O(CXI&Bmg3WA(~RQg;;t$w&E8u8v5->7t9{GA}7}m zF@5af)UaKBTAUQckg_w5qZPunb)?0iW}OA>+-V}=dQdx|x6$GCzC7q|qj(2U8Kb4I z{e|TJ-A_yHL)y_r3|`2DuGi`3Ii8#GMer)eIB+W&i)slf6)k=8y}k0HwW z3M(j%9QI2vYm~w@mK`fep}s;49cwAwmEN5F(jBi`i(WKq@zFgd0>V@Gua)0SWd z(Om|-7$^vyY#7-Rna(tA^>P2*u9G0AJt3EXi>wQpky@wuF|3(64=4r4yFiu!?y9mOKwR|hUAK}7Pt-O!h5lDj7j<#Ax>YigW9xuQ8Wm6 zyXX2d5j(QuLB;9*~v-fy;HE;*!Y_HDO#q{dP8i>Q|vHI^98b}p6m~x(1x^2 zm^gnw(6G$76TAtQK?J_oX6dIVno+Me!OMC4!#q3^6A!{9iTqqKQO{MU$rcWF>xP>S zf{C~}s19pemko~oB5z=(=n$NbL|KFgLHPtC@g^h)MGI@Zoo-V?yA+^PyzF%3Z*U9I z)(&!HNq|zWZu&i797mO4=jRqJwqTapb?jK1QnmF6$e*VNZbuZKeY$hNo;}{c?);Ai zHTr946EENqCPtbN);R0)JRF;iv%zf2^g^u70{o(4Fh?GI$n%DuL0uv!Z!!^~c)wk~ zp!%&{E@?+=?)UMza0@|D<`{?R(rA)#qa_9sn`zoPBrG*7F1 z()l6N1c~IsPu>{Dc(H2y{LcORGa;;bi?q`PUFo*iQN7;59Z|-IY4+~bi%DNUq0WYN z@2bkEtimN#8C_2Va8O+`RlC8-PC^~T7^uIwIg91S3EGU_!xjTF-16J-waAA#6p zb0Z!OnrZMWX*+Wh!ago%GW^p#oBGd82?SLQ|E{2pkf6tiyFHdc+Jm5i0=_6)9XCzF>K*JE+YtB^mhm(Cwx z=L!XY9nJ9Im7&hR|FLe}bqHe!lWRkwumU7~H!rsSnZ{V9A4eZ*NR(IOu(Jp15ju+h zG-_08(>~9AZk~bz#SemA1}0Sa^y;(tA!DL%9=TnWtmZ%45FfIG;WjoPhGco)Ax;$) zgGN9kp)~Q(KR9n_0HGyuvvuB%_d81>0Oymne@Gjm;4Z#(Xjz8M5Q2dnfx++z65ygm0XS4BMCJ;JpAQ{}y{L`MH7F`d26|qorUHkz zeWG!$BZuZ3P^+|L7SSNYCnjSZ&BnN=`am5*mk$`Ah5L~qIzj`2ytVDw^A}VE!r8Nm z-zk_fl>&c^0q~{;#my6MYqNNa(B7W(sM(8Y;T8y@N{`PRkhp4CZdGPanj?vT89w+t zo}8R??#0;Kah4VqLiMvzM0F0PkV$^=sk~euGGsIxqN88W{DNZ}(!pnjq%c%K(7|L2 zUUR0%A&Kc9AJDf2DlT%6A$b%p?DX}msVG`IRGl4x-);v{`_X@I^#U%Cc;edKJ9nm| zTN#>4oF9C|5r7KYAql9$t2$bOAt7KASecB7-P;;_x6=gddj<%<+rlR-jc}d$3b|M~@CC@Ktd%!_&hcJW6Ld zOzh%IurM4*7-}EJt|l5)8m5HRtp}+V_5QgBjhq1m2)TYzAR3r7Xuu=7Mk&0A<~^2w zM^sI6sZdyS(G zd~+#nX9$yZ4MCFz=Q=mWUPuA;DtY?A_ClPrqu}rRl%?-+&W@jhL{nA|@N$=?DcY#<-)Wk@@q=yHSJHLDT_`$$4ZR{pb zzSCu%+aSgY1=qUXbM&G28v;LYt@OE_P8*2rTE&^l3$0>2$=W>rK$Bj&P^j5Xy1H|B zR@n&@MiPuklUgFwePV!#-Vtp*jnB0kM&VqG2Onolnn6;JEt%9*bM@DEsa8`gWZkh$ zQe^?P)z-j36P{H%jX@WacGkv-i;P%Cd2*;oL|QMNQ|-$-2(NA>>Ujmqj8t*Ilj@eF zHXKH}K&Dyq)U@dCJrNK|F%)i>{|NPU-(R&Sla^@$aA@LoG1GuGlzk}rC%mgD*!8kO z;H?QJCSD{J93vEOM+X{ipO=2?eEzP{zpV=lxoq?TvxUW#6?xSnv66Ky>d63Vu>^|2 z)H=v8YF*u^rtZc+@Emo&Qn?59BnE2~;WV z4kpP&);j{h1H88)EBPEDsmcn0Wj%bTC6An$K+?gjv=3{89E1}$xD_)%ddxq%2?$Fj z1Bwh+(O~x+nEjEHCS`K$8qBu&6rwUsvmG77QYGqzf<;cCXxA=emiX-Fm8tc!fsTrVg?NFK8#Q$DdOT(ATHvZw4pCTa*_V)U%4k?n32yNRooD441xK2%2 zytS;s6W~vgE8+Q&kv~CTLM%|~^ImTlU#>u>mJ~O$p}`1(dwHx#SgID@2hEvI1=q4y z@wo^98*nN>qNC5vb#-x}oMJnXwgV}Jcq)d#Nqk3ZS?WciMl{hxg$UU6*`wwLsuZ3g zDpM%X-+@N)A5q?gn>Xu&rs%$(hCh+lPbQb4v3^9D0kF`>xE4P$_zs24p=tWhY4 ziIc$-0r1w6j8TCwr0veZYB3X?Aq?OD?zcna>Gt%25Fhe&IZ-*b80t+bCg5-B7hi{l zGpzOCEmO;b5HDdODd?EAr!gp(z}`4)nW8OElVOEyEIQfTdMHqVkK3S)eiuv&eawcJZ||NDJBOdAu9%ncrP0alMVPVge;q>GRqqeF$n zPuOpI|29x5WU4POco31???$(!>R^DOuCA^;|3{Csp{NyJFvg{0>p>7*q&0}FQVA0k zFnA=b#8;A*r4(+miAJ-#lEP-b-J1eXk_{Q4`s9MQAv%0>D|7R8>`Ppn`G7?N*I<;q z2~~wQ5p6vKPJmV={l_MQV%OC=PR^5*&M#Xp+=lOHg%|52 z=7L%0$US8U1mXn@YmJUf5tLVOQe(C+h0pcy%D``Xx#HlITVzM@AQcO@^Z55E`@@t1PGOIxk69Jlkq4rGbavso7U!*<5hi~mN zXizKi#~~E{C~{X+5LHS!G*zMNUR=BFhr%J&uIG~{ z))Ys1)xMtI*T`sa+nv)vx-is(DRn(a(&@HPwa6^#W;vV+FQ(b63`f>e+XT1o* z;^JFX9v3Kfzh|$ZSH(`;cBbiQCC~uzICmqd8*s2 z#6QN22+@zaSohGf3-=Y;>75Vh4?h2Wr-L_XiY5Q@1l3s-;sDV~vZS82reMw^=`OcE!@Nia3r z8-&O00u!7DlNwtSc4gDxa}~*1(d{GKaZCmjv^-pLfA0H|k}T>V;!rDNZLiLXb*3;I zmu=?a64AeZ|G)C{Lr@-dz3hqDLzC~v30{SToY4Q>`p_*MH4yR^LYUEDAQ0xhy(yK5 zlib{nA=-jrJH&>hMQ}uL@KCa8E0Tv&6l5;^Y3x~f27PM?%7ib6pTEpKW)n3UCo*lC ze}&#lXog67#+x>s5RHz=e94kB%V)*!4Ngq%R)2!?UdV1by6T|f_Wlghbu#b-g5+?3 zsB6?%N+TP}ndgsmnb(&($Y{%5(|hf6)Nk$^7^qkH)O{fb)6&T)OxvTN&4!H|akyiN z+{bTgsain&9~$c_c3d>3*qvp)OzKcd$MOq?w@+b)MEgf399z_R{h%{VoAKseuikNec4uH2Ek74tsO_}Ys_kSPzIdSDy9eTJ< zgzbzmn2izvq4_f^#u3PMeZMrHM=*7y@^XcagZIZ>1aos5=LeB-pJq|N^0uESC z(OmoN?&A}9Y@Nod7ENJTb3|Q*&ZId0uemoY62s1yoUn1)fA>q^n--eFZUPhiR?Ra z`SMdtNa?~Yx9`|7l5P=TpBD4xMW3YyhG|p7Cwc=siK9<(vs-@QqZ=c%AhHXlJ_yEWO8j&bH zQwa%9Rh1QGKDBwrYd*fKL-^ffjEnKYzkVlumjk=yQZVYIY02v!Oa~Gt zcJsA+>^w}RGs6L`TB`d=Nsnl?C#XiklLYM=3WfgSJx#l0b(@|L=iJ?tgNtR##Kv*{ zk5DMiPKaQSQMF%w_qgloJh;HQbiBm_vdPS_ZJzHxi@e6lDgw^5=;7&;{9K<3&nVl| zIZrykxdKEtCpTiYSuo?Ut=Ys39r-;pv98JLF7^hwVFwSU%oykr5cjx~Cs!WVR2M9; zbJwn87)5-GXstz7_Rq?q{+gh7ydGQ4Ja%_ZpV_s)OZhAlEy?YQAjxmmuZb1gO|m~) ziBHY{%On&~kthDeb=^7U*&FrweStashY(G>AT3k9T|UiY%7xOO6*{*4orBnX7&Vti zXHLalz?mepXT(XDK7DfT&QN_ZkG)pzFvSiX7|*dv3p=~lf0h$R` zN{q~bmOuUdZ(f(z2G>+58RBVZ%A5XvB`oh`qi9`NwYdrC$(#TFFqL8c_U?HD<aIa#*KI_a z?yv5ZCSIzo`r?ee`tC~rS2gx*Zlc~&jxa|FLHPvs3|}9PF}|V^7&)-VzOgpPrmvA6 z_aG{tAv~x}w+32`ac&us8AeBduZfopXtF?G;|QxS=}}W`7XZ{vAP30R9jN*$ugmLw zSPA8WhY#B?xW(>ux+6{lLu{%fiw&ik84zL|p`?2m?e>p@Giox~j6NfG?~`^$BKMSk zM?#Q`a{n+BF$$2fF)ViT4_48HXadGF`a$WMU54=@`tj)#S;0 z1C~GEXS1NM@vlM8UtGxSZ;(Iv)WSokFtP}laXuFxse>CY*9-V%9a9|VPBHiW`Zg3p z%ao64vPsD}2sujede`UliNwS4={Y#8m;!SvtHaV-78-9)+AN>sNHlsLR z7)%|@UC)lLod5@dIhe(qJL3gU*OLhE+P^;%58WBTA=6-saJN#I3_;@k3KZuW2-x7k zUR-g^m8rx$7Ys!A=k6X)LKII09|}wy@o-8fGg;&im~5Nlc9owKRt7-n-^-Wo8KqCR z=(!-pL2cv1^DuH&AB4wE)kk7*$n-w@85w10syuS!h)D5}m5o3fpT&c1S=O^zy%Trm z6ftKB5B%4YCv8k^l04#JFFGRtHN8dVhZwM{#4$X<_~+sZ;H2mD*$=`DC)&rEk^{L3 zo_KrPGr^H(&V2QY8JFPu+w9qdWE046m7zfHu7L7I%!(MfT|0`;9HRtXk3pUel58|* zes+W}wY7geeHtu1fHt22+Bcfk4$r&d^{G)1d3zSWb7ahdAs-rf^ypaXl9W2)Hn{8f zoEw?_`Ia&x+T}&jHbg>FrDMEJ-@n)4smG6UKDMq^hfO-+?0DiUb21A$tR@)8cne@_ zX=WD8SGm#yzD(mSrynNZREWhit0%we$&2fhf^nTOk@e~5(`}Ll^ z4U)n)ARroOvXPR!AYxhy} zJagN6V^-`c4aQ|h5f|OfJxr5{vYyn5bTTwNY2*6bDFC(8{d^8eY*QoJofJcD*QsCs z?2J=tv+r~T6UHl})84Ti;Kfap)J+RvPa~qS1fMzVN3F7}*RGk(nRAq8`N0H~cMUI6 zz7{_}Z=mTdP~s9QJ&MW4%f@~5TV!fR9$m60`RjSUrMPYy@xVh_;4?7G{ec{--}!&?xC`qNXqjPG;U9S2=Xne?d0rd{ma zqtpw1`M4+N0>9p?*R2_6_5>`@pO9vRWu=RHXH$k~`Lbxwii$iA^j1AJ%rZus*TG3) zrxpyL=pxO(uQ|b&F^vQ`BwA&gwr|g^t9RxgSISW8^TIG4D7O51%vgJ2TuJ%OIem%=;4Fp@Uk|lG zklbS{Dp~>(-%AdYh=Uj}5lv)7fN!cJ+=ioHAItn0vhr-1AE zcSvg`e4!a#Hl>L2N?v=8clc}l;5C)-b)thqFpK~EbeZ$iaWONrl2l0``BsQaY=5(zl#m-#3>|G*$fw2 zcB%OC{~t%2KFW3hO##C}ss82tLc?Rfmuty*i%iFsp(VG$CS2mZy}jvK@#q$m*@1?+ zG;)Z(ZUmm(rE_OVh0I>`hJnuEAH~jj$F%q!vja}+gvgX=#U~~vMy3|WqjdJW+_U*s z56Frb*J%KN*Ow9}OIh*3I6bW927B%#9~AEpbJ@t0|p5B|dg$5DO0!P~p5kPxT2lTYuxcfXciypAIBrTAiZ8X*F-h#s zeYJ~mo2zyu8kJ7cKAwkS6X&!OkFQVB`>VWU`#$@}kudu8`fc;;>5qz4ve59{Xsp zZ!E0}!#}tWV=}ic?8k48jwVXvEiuR)A~8}l4vG_@hpG53 z{4|r%fnlCimC8_JJury#PnzR89x|1WdWF+ElA6=v^xDmvR#T?zqhM6Wbm4*pnZQjh zs|L1A^Vzv`#&!{3GIC@WwR!DryIp<~N&wBv%YI{V8~MvLLm+7Ckh9YVT3cEkVg(osr+zf?)~3uTi+38|-#Rj+qVYN)CKvFT(F`g>e8g{-&?vI+wC`i@&ar>P6jh1c zSxv!FkY>M}Dasq}IDTNOZO{9z{+cjAqWBeSh7pI+P-&i?gL=QG#0RNOocqkKk4cd_&V=TY38Vj-IQt7(+{+`)nEGRj?&^?EXCQiziikxT z#1Pb2zs$3@S%Co~t@eIr=W$u0=(is}n9rJZSjQOy_x|I@J&}>c?l)V@LsAUR&sbJ@ z37kTaqG3LHa@fU-!%5TZ-CH5?G??r0VcyZm|Jmm*D49mo%BsgrKUag6s>K_c+D#lg z_78&BSGhH_zIOE-g(bz`!%R;kMG=?F<>{m26Eo3d%LvLZBp;x4@?S+oHnUG>I>j{= z)^jA*f*><%{ZEbXFv~4nCoX!GHY@f*@CLthBfl5#jl}qIdk8xg7^nlFl$hz&dGF&K zdg1S)B@xc1Z@8@#6Jc4LnMf)>lVQvf!%EfmO1> zS1*bFmM1T=by({7e{jU7mV4&NH5|12!c&Wv@4)Y|^Rqzo8vp$B!7r~*PtZ#$QHuE@ z)GgT5G2qkPKEraC}1rJH~Q4g7j2a4rN2^b>bCAmn`O>%6L3ag5X#PWX1fT$M-( zPykEmw%u_Fjep|rYSo@D}>2zBFp+b6@N#GDh z5sND@HMvE;`@TrfCJ(ye#9W_TeR0h@sA3kcTCa(kNq&47u@nOy7Y3vO%y)}$pait8 zYljXaYppwXcSiNMI;~enLe2NGMLG=_V2j>Amso8vUA5z#GKY#s7j^gm1b8?a_a%F) zzi+T=l{0>Btf|?VI#&5Lm@7+$5qo(lWQF~D&fEx)Mr?05+xr_55}#nyPxS%0H-Ug@ zlji|L+*nIX7YDSpWnVL}pDbjk2!{>{h$WEJkV`R~ihgej=#N*kitgIc*?+6jn-%+l`UC-t>AZ=^!-6h%LQ z01}!wqmV48)5cuQyZGvi+uLIZ^0OT}2qR2kxZ@~$E#)@v zrAp&f-0UIxqa`6>RfZDPxpLR!Ot&U%D|D)i{Pm3$&L<0Tmgy~6%4aYl)Ht{jhsnX= zgA8aL{N;mtd@JL{COija%jA!^<*xnvPa}Jli%JTFI-rrFBtio|+~^(ubm8J&L(|f| zLzcT-mbN~)fpDlvn-h+klC;!|k-HIC2j4n%ERZlIcfr7HPRIzlFhvMMCPVG8cb)b1 zv+lOZ8T0=o+Ir}oYsL$h(UDzljmr}Pi9jJ6A@(bT z)@X0)-wz26-sR&H!l`)n{JGt`NBQay%mo|)JOV$JOR?Q9wkSPHa4@daVxc86BPtLG zgF=UqWNh&cNnbxK_}m|eD?ardU{x{JAqzVW{n!yPjtT{1=~DEDtInlcT~Vgfdn1l6 zDS)t6*c5Xj0Pr8Z!sDshjQrXWJM8F3oGGz*2U8d^3$mD!Yh7cUs5yniIB;R$wg75F zd=i(wndeDRcy)i^fTBS3>8DtepaGQP!2s)VYpC*AI$su+^}r6IJ2Hn zR>hhh^d=vB%(`hJO!>ctNup)vl`I1*0|-m zmo%TGoQ>g*g^|67bC!^ebt7bJ+_0g8=bzC%wbDEd@*mb2+rd2Q{|KmfyqTX=x|{yh z;^1^aS^`G>--Ue9C<|Kb9wRG&rtlAehZT=I2+lQh=vqu~H}Jc0{zj9OCHS@?z}YNf z41JP<&!0XS^}V$*)v?!I!!l%d|AoU4IZ4@XM1+p1^u&18;vuz`yU$JEp|k4!)sC=b zME8^a199oHFYUR_h+6Jj|7C+-)-=Q$c)$;8Qjv=>kq4nfpwV56Q^<6;u-FO+{xb7= zEt5V1aRS&!kTNp0bxKt5hA|^_3_i^4wT}p^u*ah@1XkpD&}tNz?b2b;)M&CUig%N4 z{;H0!oLZ{g;k;w;WV*;Aus%M4fq{0*p422^u^|LCgW(b13Pl-47IL$G{E+`Oc6>lz zf(Q^4x+FMp0?TTA55f&18OHE*51_dCm7f=y-l7>IR6;bmZ!y`(&#fE(nq_b}4fvDps}P+D5;Qx>+~2-<`8$a@@oZzmEDaO*<9at_jM)l}2w=Z3V?A&3b+I zr#*k!TJfMcQ5-d+%xCB^+q;HhaFR6uBI)UH$(2Ws-i);mqNkRH=cfy;fWoAnwQs+R z9y1a7Dnqg0503Rk-!sK^CE+K6rE3>1pVJ3Ysfcm`GSlQqGlF;K{}=4zweczF zvMQE2IweHDk+3hZs`5+e8_%k#0H_Ik^^=!BPx1yCazd|}oKRJrZPZcWH*9HFwLN^> zb#jyyH9cdb-EJHsb`MYm$+NvpUy4#N!AR)toArM&973>9S95>1U( zemYhO22mh@+3MA2ak`}f9Wv+bkCiJ|`fF*ce?yT~R~;o^JJ*zXwnd9jofDbnj{!dtvBLNm!GA_H}(cO9?c zO6&q1oPka*8(oBiX?~~$5U)KW*MIqQbyR$Ch#C*u0bU$WZ|+s}xWIatk@rbRO^M!E ziL>bm7&}aT5)@vw)VHqhLOHeEgy7&3l#$SsBTtPGJW+K3zFk*yVTD1Z3Z%r8D*K$; ztyUgHjD3YzP2vxM#M!a4z14&fKy@t@D(9pRQ4s0L>Iy0<%%S6zpH~q}c|2ZIJB!g< zv%S6dPbWo4bbGQEJoAxjk@duh2hr)ezj;)VxUH)x_7r2w=+1}Hr`|+$hk>A5ss&(^ zFzXesmnOr3R81)R96rsOwq1OJPui^82x0^Y6SP8*_dR=ZV{YajT1JV_y!kD9&qD8e zV)eq8i8)mSq?nU^kN{;{W~$?6rvjCo7`)Dkvp@!c3AT`SE5eY_&2|dwVu^T#T`{j0 z=alA1uCJG3{K5OPro_D+lj=+6dc{5~X5g{O-O3-nS$#w_E(k4YWmoV zX4|)KCuCZ^@PXhCq)8I8f-0$y6ZEKXNxRW4QZ94^>Z2Hsk;4H-*`NcZP(bKlOUvz*Nv_?*_VTdlhD% zR+@f;rZiDg`K`n7g`&haY;#+Ex=QzKQG=_|P5CWS$Sy`klM!7_LIW;|(3EaXqU@6V z)@~Si(LAz_^_?b zdY+$gZfM#9K&sNavjd%pxFmHz3(}KIibzP9ESWWVoGcxII)Kr5A=-cY?GPaOV&tMz zqO3-Y8s$pwm~!|(I!G*ty}j60-pbA^%cWcla4))dmBFG__vfB;1^^^R@ef$>x-Sxs z321X}UFxdivTbVb?SwGBn$;7ls@bYl3j(VqvpL$UTD z9^DwFFewNjkR8xpIV9(*0@Y`HpFG_rTi{h*#SqO#aA6PjIzIRTwE?^L*bA%jqhyT5LCB)k!Blx}`Z4>Dpe>*`T|zRK zUAhCH%fq;ZahvYhr+fG4oIBgf>uu~&5(Zh{-uA(jfs3C1AzZKUYK9l<)~N%P zz|Pduv*z#?`^OGU_^Q7iJBl8AJjqH~aqsTwo*&g`bym7_HxWArA6fL=NyjT z@stte@IhQz+9N%EeG5jvS^u#L%jV9wM7}1*z&h=?)+OX;uZrz$Y+RD-pXZp+N${pa zQ}qFxb`i0KLJ?-o-7o~E3${SCWTcIP0#c_Vk=4XwUdGwox_3g82P%jw3kL2NC#?)c zYXf*VMzA(8T$D8MiMnWQBAcJ&n3Us1bxi3~A#y&PIUc1w3K>)C6LwU@t?}Fc#=!8t zUf$c7?Jj2n5!{h97X^81^OVEnv^+dMbCkrDO4(Y7jJ#)#v3{O8sm+x`xj3@LMt@#% z*vY&(%{L*>n{6x4o>ql4O@bI@C~wBG{%IGuTkUe)daK|Zuc?nPT@vR>Dq2TFBb!)D zbchv9ToMAB;W;Rbiny}t5|2P-_|o)@UH!$q{{O2EX90=jVtR%peYn~0Uc!P9q3cCN zMu?RI{Bi{DSImEzdDEhM)00=mCJjW?H4)fKFbhd^c+*L&xlZVIn`&r@a{d%&1@g$JT|Y z9H_M`{yScrT3;2?xOKsK@M{(Mt*Ulha>$fC0Lv>bamf-V^=cvIQ0H4lBghUPt@>yS zTsIbUUg!pbjSg@gv%S?~Eu=`MDm#)gjUC{h6Hsc#L2mKv#mKOA!$5AOmAHHo zuKDwbe01L~Bv>NcN}5X%AIhG z0)7$pm`gsxhlut_5-}i#oAouqE;$_U)UDe~zb_&U!NWGzofblA%~dCTG0|A&+aSHW z5JaH0FUAz$-x8kpgvTLm!a4zghS60oyL`E$<6jfXVv2k3fRzKgvt86*{kT54tDmgi zpU7$jJuVk`4%xA&^*oBgk7UJlPM-BN2a`0vs_qxcfAHD=CO`&&o@8U=hgJpdMB?H@ zhYyox4WojPB>R-Z1DuF0C>;~O_ku!{qVN6sSIhqk*>Z>93d{c`TSmdDW6DwpgB7~v zh&8(C&h#aWSlH*^b=Ho@C$|i^)dM-8FqSN9{Y8^M^!^_e%<2`HxK15|4X%itnOa{% zyZ>aYzcmfTNJD+W>t+Wm-?+BsgA>R;Nn%853SM8x_Vqx@W`J^H6^BF_O2zQ22dB(M z7Db9JKwn;E*-7Bx972kgO}fz$yIQ6!#H+NyBs^zf(8i+lNJewvPM6(Z>(!Fo00>8f zD?uH57B!8LkOs&+1CLr2k4wvqLA)75`DQo-VA-EUowhhA!NUkkH5)ZrWZAbLzLCn0vZIYM6eT{ol(RYhTs(pFF81q%mUanl=PG|SaxdB?f>yppmJGi8SvBvd==j)v z7aqpVlBYfJ%gf#s1eLcbQ#N2;HU4KDSG7HM*!R(+IY3gkzwTV?!l$#1+q4-X0gk)7 zuS&bfJfeGv?vbcA3_!M5^1t99{dCjM?wjAE*-Pt| zs3*ny6X+0haPXC(pq9jK_a}4Dy1c1-&GVYwM=rgqDBb zd*LFi5GrLs%i|Zi8T5_Zmp>-vG~MdRTXOwatvg$quny5zg79CSQn~vocL>-6Ymhr- zuR1{XYT>QojvwzJ3nk}r$Eg$n%HE!P^5J35Qz2J;X!>`hW@*7PaTk%LB?bNWl0B0v zhI{>Q;g4}@)T!_6Kj>8y`-MEeoEVb!i9{=cY(Oa0&@H(#4aV-poCnJUR^XowQwwJ* zcr|lzy5azdV5^?`+j_jp&tG)O-{3=GCvSs6-}C&kA)eZs66cBdzap;Mcs}T_LM;yN z9&@PF0mZj)fExM41$VmM?ml zp*E|$=|`2z+xk259GVb8yhUM!<~~fc1og;aVr1bz-utuqy(uu;2N9h_fLZq1pEi2z z*errYz}Vt{ytkR^y&8K7bzn0@Da+1LDJIFZxn>UQw`*`IdMST(s{qz5B-%JlY;aor;BqADi&kd4Bn8hU@01*PsD5rb!@Z7xV@?(ZnPi*2E)b)M5|jA)%!iw<+8@lK zVHpu&cMZvOs;YKf!sF)NCEGN<;X3S!q3Rk2uTlY>g9rE$UC?~KfZ2-#?)xaAj;FIV zimHD7^dIj=h1F3~P6RhObT)LJZ~q_i=%)WBpRRwO;^Htc)zM1vHBxe+42AjXYHVzy zjQe-N^Qy1^ViA=(&P<6QL^s1w$2(qCdQ{{sVu8Vd!)4ba3)`VHjsZHrQG0X{2%&l& zE_Ei)_sxD;QDn2_`l8=@?Q0qEM@GD>HG-qj$`VB4bW|Hne(OP(@AjskYbRdBw|VyM z5q#TLb8)z$i7uvAd?M{WGr_dQYDljT$y=3~ci9<(&T(W4XV6FomBAks^Bf z|0eCSi<7^mU%4Wv8Vx5M=ASN(KCNR)X<{bOB(S8uDtiE!B*+1MD00&!lN8Zp>e+$wWO`;7q2~NZGhIpnws`%E`?CLpc3Eu!SI9OGiv)VNlh%)51h<9{0+5 z%jJBR!XLU&)cv25EsRuziY#3A4^8u}ORhf`eXy^j+ip0}UwzM6`pw-j@S=H8>LA#G zU@%o)h^LSsexdJy15qgTMKPxoSv9T1uFUCk|HW4g6(Ye#=X~YTr9jyYQ%!L#_rxt-nl z=-)U%f5KC(Kivy5h7ho23-ZY`M3Uz!maWg6y~NAAkg_p3!R7-EO-CROWCNC!A;J2x zaRtW%36}72`+fZPKc@IbB|;#+qJo9Oz0t~Q;q=uD7ea2vC-k@O;A6^j6eS9pOIdUl z2GZefD&2L%*y1xi83;WJZti5@;f#zA8mc`o(O#93xz+7Qj7h>40kMQhl{Ucw*^^kl zewpFr@q$8vGFJ;}?@Ppk*qX5?V{lMHien&W<#lQW4J9!uw2(yTkb6t+bTB2BB}TS{ z^Q6G3ep=uld!%c-bXY}a0!w8m#WICi zd5Q#TBx;U&t;6?uC+3`=lsS}yN^_Cl*bZM7f#2r>yLUDP{Uzg%T+ECZWIr0Zd8&I> zlxnK{(@DWq1TPWY6>r>T>gfloZBuY?`iq{A z<(5RivU&UVrFS~mo*Di0*2kp}7I?4zus=@-52ZfSEdYyoaxUyDwv7@!8uv{5-b{{! znr{pB@U$EPE>QxH4q`Q$C-(mF0F{7eynJZ8X>B(-(LC)e4{hggJ};LY83@ zhrsmYERT=>ti~WrBF8o%LSFo%B77ariV{9@$*tRJC<^aMh(MBC2xKh+ILYz>a9e2A z?J*V-M#q#|qh(K;BZfncn2l8T*~^#y(=xrRHz&uar=JXk~!p(#n(OE?t!v82o@9GpJ zL5*_PfE7zf!w2JocIgoRjFATe>I8SPqSy#UwE3xp&n!+jnqty&J^JJ5U8Lw{Pj@MY zafU#OuuDq3wp%w6DV|Q>c{VibJVM#C$XSJ%&;g^I`fD%89XdpFu6Wb*{!6R;zrZaH zw;tKa?o0yBm)duJcd8z`3ckqDz2UFlv;aD$a4F`S8n(AM?l1zQoadpg{#L|nTbC(2 z_0|bM(X_oihe^#k`F1>boMyMl83mIS4e7?RoQVb#mMrlso!6@0lCdHuBzKcwoGU42 zr%1)8OIBDAX@V4JEu?+azFxnT0Onm~Mh_;(3RM%}U3*gUa4-))CUWz<55dx2$;_NY zE94yNXuydqLQQ^Q!sV!@-KR0(_<&^eJjzb?XIBb0h%*@gbJW6;x76_CO#*v4Vw!2N6?Z59<_iBFhPG zOKG1xIZwYq-MZ#ru2BZuJ>qp8Q#t_1`{-1mhdQ;mlyo$X;fRRypel-8`I(#xCJk^y z=xYyu{h@s<_A{Jax+$QphK407mjKt-l>_#|T+DuRN@q!lO412{=GeOR+uy5>#1 zEJS&lV|Z21?o1dh@2)}R0cJ}#cZ*XCe-jh~49$4?`%vKv*=I%2c7l*ebk^&)YmIQ){RQBmV$7|$ZGC@tp_yAIn}FK2Ee?WDT_cM_bB&uG(A z3^4u}pydcXUtjp0X1?zai&`~lau`Bx$_vrl(*5eE=SIg*G0*W>^kFylqwBh1@M;J$ zIhI$g`?lu>dHp<2?m7J#D4;cm;TZya;2l?TnJ$xp*JmDA^k1ju5JoGRN9nR^(P`pT z!P5j>mE400vo5xYaRMotn$a49Rp`a(+Ck+!nrJ~~5gpAT`Z zeDwBhK`~CGqwzc&%OnqXDYrl(>8rzf7hqNwy17j>F}boR&DLRDUCk~Q%jVojPG8`# z=vNeEw&Q}>$ErdKDd_ED7W#WjEiH40E~-0Hy!}D7ar>`BOd#hRQ{F(kzhpj0OaeN5 z+*kWnB%DK$G+jV(L{jQBw|T+sBiqFfAZdj=G7+ekO{9rjmpegwVaqj`#5q5iv^6=f z7$!uts*o3*jE(OlR-_Dy@hqXEr;QYUa=L@_4_i`hv@4kcOv?xz1i$|bNBS2(s95y6 z(f<9(_IvJ3qq&{U{w5T6sO<634Ry*X{k+p$?c7pYcOVSuY5ap%);X%{U_#OKjU4rz zm&G&It|%jD9a_#1_N6PlI!o5WxXF%r;66i@aWgK|TIZv<{Paz+YE@Ed&3)`CB|ZVt z-Ib|Ac(vAAAqOQ?;+%wN=(q|}sS)A}L6jJ>qcU&oTT37|IFXi3QQVuLnbLGcrk&swnrYQd>Dmw( zGZ_qY^{-mQJMv1&Q{D8ON1R!L15I%kQd~d{bX$%ap_wYg5-A@8j(j_tchJqXQa(yls?D%6&nN;xe2AFJ$(LQ3i>*y+u+eoE;o(G|hGM67VT@qIc?CgNuU z;99t&oshfrXk9Ax7=WZbjjPGLda8$f%#=V6#fk?FYMvDrM@q#uq%DD-m$f4u-bDBY z2Z1aj@UcHC84?k>a{$2RyM9(K&cCc7d?C&5+54&+_Zs3P0c4+!BoZ8navIGd=+=?p zBUnBOX*L6HkCJ*pFr2_;j8pX&@Qu9hY_Q{}jvPsOW`HKWjO(aAQBiNGY{z4rrSOb_ zI6eNEKo)(@Z+fdUD|X+jHtOHM)w6bp9F8+#Sov^#cR3rpq?(28 zW8Bco;vn%GceaPdu$5VwO?%D0*^P&0y{q1)cPP23u7ziL_WJd_#nn;Q8Hb~)V;Kr9 z88&ijbDCl9!e$L4dhc4hc5OhhH~XSdDEZ(sX1*AW*juIfrAri@0UE>!O^f zND$#jr_A7ysq2~K5lU#+Sx4t$0Vuk>dMH$eA?=f(>BMgm3J?wZtoz>6_q!blgjhw4 z4ak8LZcBHSko?D;Z_Mhga>4v^VPgq9ye!^OW+7pw$Q7j|6!|rV!UT)IN1smJR7d)6 z+@ODcP9G{qCzGds`|@R%KIN_E#v+L0t)-qsYVD+i=*^iA+t|aQ3nfaAo_4B)!lo8l zHPfX}gJeoXxE9Bz4++Uv-Gkz>rYd0wwL#&NEh|9VTUX@E5z|hcP<@P)sfn>9gP?Z) zQi0L3kvLf}8t^lf@8xqsd#jALnV2l1`Y|~7>^0xl#*4hQ5POSpL#Wc4@()&_FSzN5 znsplE@_)~qq`r!`n=@tSm>nQ+lMldu+)G+V2Wi5-x7MJGwMeBg@rR&Dwris@=@Bxu zP1OQQ3Izd@VHGi`gPZr1qyn&rd;z+3Wa_!PQVc;ozmz9_vnXeVs_yE72((e8syaqt zoUg(p;k9r5ju@>7nQoQ{t0OErvzG05b+@%)vF7}uHkCVWs(eH}QRhk14xHj`?%Hv7 zs{I5-|I@-Yne1#Uh+yWVw@M6}1tC2sShH_VCT52!aT;81K&9X3FJD|=KR6|&F_{Y# zp*<*`%bEJa}D@4hTGIP?7Kj1SL(}wQhndP_t z-uWZcznMjxZNPvaU7Eg-n(7L-Z~J~T z&aZd;wfCta)m1A(YpeE;^(?w|OFgsn-GN5rHwQPDbewh~Qj92Cw#)@PZU}aRwz|ci zBZfPq)oQY({gmp@X}v)GIZwgEtr2L^{7~v|TFB?f(-5gVjSNPP8d9g{{xm$m^4DJ^ zj)JWb3nO(}M^h2`$%HUCnMed)#;eNX?UF*7G(SCkF>!d8${A4E0*LI;!3 zm@47sCDAmMAJ)C+5C0b3Nq}Lb;88+z{ zv)_gSyw1cgfFsMJzsN%t9h(R|JFNF)zbivo7H9Mm!YL!?DL-G@D%(egG#J2fko7F} zW>sNxCT*|-Ipn1Al>aW=CU-%dP&lxy;qJaV*rnK z)$c#1f`4FG@HpO=H5Ek0T%C0&TB~i~i7)JBDVPxXBSxUN9L#x?cwXDlM#^TZrhQDh zcHk`xTqoQj$WsSbTjKbJ?MGih+rw->_Y*neqaYP5;+!U7oBHRjB0#o;E&~Q@O@?Nh zyxLZ|tLMy-GHDLocs5|2AlIzmIG%(B5S5%b*RAZ7>wF}MTDpZNy(%Ew15jrQI|N@p zC|=!;NYSv@6uW0te|=|?4JV15;`4BQuRSL)-ZXJ?p-HAlz{LJ7AnI2Vqn#};>WU)q zqDOzG%1cZ^?}NWyy^7)wnI)(Ut@mrMFhHwo2l|~9On_}j#<2*D_aF{A{LJzX*FXUn z#gej zvpxINg8YqySQ2e9v2+~h+OCr)m#&>X83QO0HHC{JTIg(?7d`?O%yItw7&^-QZR$I_ zeoruR6l41K1di=6mns9@1jAC@EnWv+9By{?%d}!f0FUCAE!gGmG;Oeqv9pCtQ!-Dp zV+FD<-Y5;0ERKxVLbW`MSzD5{LFkw3>~8{gy_nyT5>%;1tX9KPHzJ{nfNt2yR@ZI9 zCbp4}y0)+Piip`+N=rpSLuy^(#r>WvA`LLUR#8g9el2eM@=Aq7yb@%&zwIR`Cs4J&Rc{d z($8#@;ZG)JpqG&8ZTNpuv2c)xMD~R;x2mEvq!_p+hTt@eBuSDY+9S8RUWD|X;`ZWG z7hV2z$u2r>Ok8b=(~oi|R%U)=;SV3jc+PFKlkAMU|M;w99}VWt>Pg>(5hF)-0sR@7 zsy5q$uic$Kdoku_N10U3h{})*^&oNKExhZYYW@X<*R`1%b+`Aw9%w*x=s{nf(QnJA z$WriGnQX^w6otcM7A#yywDGc0Y7;R0Yxq!Z6*ms|kCr?5MH%vN=-i1Cm zTXlpHgGZWBl86vW)IYwe+BD>Q%1~ND3-kkUxoqu$D$ttyI$n@+?rfk+P=GN6vl$G&Zfh!RsK5cZ==RHE@3YXHW04Es z3*+buqLNx-q0sD5LhN5)gvrWg=|y{|Q@pe|=hbmziZ;;P1&;RZ(%zI>^O3=&AAx;X zzyWiM=4=Gtq*=L?;}ESD)AD5e&ILF|x%q>Yv|K2mGiK8VWGToaejWWjk&O^JcukFK`we3x z&BbRHqk7RBa8~V6s~&tLs0|k>6k>`ovrWcgZw%=D;@jQUctp{Wk?8j`>C_UB0skRy z4@(xw9Sfp}CyqwWVNzbaB9}6dA5!Pz!%&Zk+EKKp(8SL$ssFH~1ZLgQbICDW`T0F3 z)`xCra91>S>BQkDkE2DGy&RQZ7ZCi4Vy}Wyb-^pePgj5kZQ52{(iN#euy%~ZCneV7 z)7u~sVgUj=0Bb7aU=XX@(*4Sjw50ocUmARxp6Cj2x=!U?N3nhz!dr2<&M5xr- zRPd*rZAWBr?DpJzw-mu`mSF9xzkYIz+J8^B2yAf{X=kIA_Mn$g>202+?ML}(mov9- z!+wv@`@}on9?4i;D^OJqkpROaxP|18e^&Z^UQv;pOduNPQBuH^n0|2Y#he|}ZP^r& zf$W$`cR4iiAa8x4)!JpgQ=5?KBW~C5stS_Zl(uzJ=!SeJjJ<@Bnchw+(Z%(U{+jdL zwr>0IfUP7oYle@OwVNVy)U9b3MrbxVCo%kCb$Ud8C^^KYKAKFA=>iaXHUFE9Giax_ zajAahN*5dEd2Xi-T{u3akRYG#DdQF%opbj@At7&03|-_c6WpE*PgNgBuk4K%4ObIq znVgqc6iw~iJIB_L5M1p*)Fi1aS9#mApiL*$*%}7|mPbO#;{~`fZV&d77>Xr$G{|{YX0$20Cz1`vB zn2%$Qj75_vDwPHtX$#FIjfP64qEU0WIHIsMAPqFnk!F=S&6=eN%`??5&F`~zocsUZ z_kH*0o_p^(NA2J6{jTp?&wAGLT=b_tk<6z>D^@@aO4bJ_UyTD!l}Wp!I{XqfqR@DC{B93A%Re8c>^nCTAS{rN}r=i}=~V zKM+0Pkt^?}9=V*wVV{u7vVYy~A@Q1-GxMsds!(Go-*0dkJwliuQXOPN21W-PHo7%^ z|Mq2n=HMq&N>Ttd`0HPH)~9q8&l;QXCfeonar1g|(GFpTgSk0Lc+)94OwhJ184EYz|Ti>N8RErr%N6bQHm8!JopR5sdznDN0P#Vf@YQ z?R)@|a8v*vnTnWvY)tQV1a_hcq_ikuMBR^fz#(FYE%FJ9a2PXr!M|0GnV6V($VV^| zX-shN)DN$_!Y;noEJmJ;Xi5GPz>xgtrQSnMKwKW+jFb|4cwSb8NL?9W9{A|LW?%Dc zn9#{1SkOTVi8_MSM9$z^kqD}wqC`17^M4f32`3iF(u<5dh+2yx82VSv-`_6B;}C$NevS2Mmq*+y^#0p>JWm?z3|ASL-ta?kmiV!QhU3>?n zrfigEmfL)q)5RoMBmqe9Ns!Ii%ZZIcie5O#7@&<{^Q%wEjgbIFaN|^*{merzUPgSd zVRAx)sJyrjH8w!Qk+4_PqMn$d70^J%rYb~vy%B*m9NSpaZ!wL9LCsXabz+Pef^FJ? ze?>gN`A4UKYByhmaDav7028S(3c?UE@o6;&lZpXKZs#9gu3l0wgn#ECu8ATEX>mW^LPbdU2(XP$UzdGcQm&8-j!Nct6R!!WFmD0cMN%ZY}w zu&_nFHiq@o;PZXS3fvlMT|M;opMU7*A=bohE40f2H}PJ-eFJ|gN_RkS#O(1SF% z4WL{;mOvv^?3RF}i^d`MZW_p`{5j+#a*6#$u4Ac?Iv`GuiFVMz=fue*W{-|n-xej}O zAmt;qI%tc_Af3QXi=0HMi{Jqxw7c94>NKd|t@X3U~8JJSIrsk7a9-ne*#e z9LoEUGNA(yhnacQl_K0g)j2U@PUjR}Bn^JMdwPcb*DvwIAV1Zopw*z6UO{1^oF@Aa zstInS6Q<<6{wbtEL@L2l@jg6i8d5(w9EVW&{HnRFzv@d*KcaEg6v9B08ALktcELP7~WjdX*>zMWR92oQKc zk1ANHJH_XmJ%&85JZSH9V#v^f#~)#Tn6nAh{}_Q4*N&}D+Xgl7O?qjh-H4)>4s)P- zM!1qxYFNTKgnfqnguiberUT%`On)io?+1hmNAp1bw(R%R2%Xjb2B48mbKcV6pLf3Q{?uEY z4cZ`*!)KV&5)&5(^ob1s0t3yOBS-%hY4=>2_#${W9zO|ZZl_-jV#R*EP3Ia|k_eAV z0BGozud=#pG%zSbtBY84?9aOSY;M=t_xOi4Fctzwx|(*zM--?m4A7f`tq57S9pXju za4o%ruxMdDk7H&u+OU8O_3{7dQ_4eiXu=}2*+|>@83ZMo-`{yn3yAV^QX~e}k_Q;8 zZTB`aEKQT@r=cY~I93S$Y^LW-U5pO9<*^D)PWIUrqe-P5J3I}Nb{~5Cs61s4GDzlb z@8;Lm?JdGM<1f6ek5jK|LpKSZ5L4P(`qL>UA++E%vzyKIB$JJZhoMBvN;Uf z8zQqh{4P8G!`2HCuw@_J+gcHNh?GWh7yz-@Pu}6#V|j2}Xh<6yb?TF_h#G4$oTA`f z8Wh~4Sq`j6tWOYeiLZ${ID-Q}V>(AJJGU%N01=P0kV%LNU>bWh5(teNm;f!*mz35_ zeMJ6EO+mTW)P%wr5`|(2hphf3PZ8P_}qc`X8xUi!|4rXxjc;G3UBtf}^=_V8kaAmS!>#kU?&;|R;s%8pV7KhRh}cnq zZA1#D2Dwc1yW|cEr5h?Xn%HnN3Qa0~qZ8$yR<1379-J;>a;M3D^mM&tyK!S^NR!JH zkBo&52}&(MUdnrYt?pV06vP;qDA(v0|ZLcDh2ybU0+Z%l3L%eL$LPVP$7% zW_hX!m)XaDolBgyEmY4=yK6ma9>N`Y`r-!8hRDF@l6kx%FP*!8 z7Y{lg-O^s{s8s1aZ%$J|?A(hPYC#5E4A)qvbAuf`{@E@E)bhPF?olK858h3D;wtn?&jp zFO*s>De_lXEU{2Alxe`qsgr{cyy=!7y+)vMF{BXu@R5Pc*H*md{YWo`0ml+5|0|6o z!3Q*c)q&|_;g&wCl=4_Q{yA%;C?M$G$74x@#$z9atLqZmiXH7({T4Z@#Tijov#Tl` z%)iLERtRb+`MHegz6@7r%S`uZYIt;uyFK`Y0W;)%L`G%RExNKN=btEl@Vm~e$ z&0slvTHco^vi4)FL%o`CcrkD1u+)*72)nXMap&PnStH*aGl!3FIl@|HA~_lIJz!I0 z>p2@+rO$2mqdGKoJ8fNp6E(KAkBhjBC=|L%erK!L;iM6uEP37h`@*I^)P@0R4;ihS z#|Wt9#Lq>c-Yc$>{P(rz?%w6T(r2qe{i+7Wx6K-8F-=l?Dzj+ZcKs`dx@Nzs z3ZtTb8Ccq=d~`1y(RgdZIXu}alwqJcaC!82_Nat^>N(jXra#&S>!f)YYb~^6A8pCt z?bG#=s!tVv`B7ss_0gb;?0bMax3+v*9TBP}(iYzMGS6#$aEC@zT;E%Ht>V$F5=A_f zjBl%CFD z9CDRC==$id-dsP)@QEKgl}i@($TK&^>Wr;c7(LC9%*jZa+`_;+ z8lk@`JLfj%jlMCO%2(uKr}p~-*J12|2%ew>@S+M=4H~a>?#qj={GTf<&s$cy znv5mA-RnOmKJw_5s+iUXRpvptoy!C^iKsJATMShly;kITG_T8tA=*=$tlQb+qx5>2 zLK&+jWk9_`bXT&oMRU=E{aKB(r0l{pGwU@1&Q=Q>7jE#+u+>B?QnVeK6qeJ@ve%hm z*?cED@y6q|wZ0bqSM$7iMpaS_Qmy+RSe`CAnOXccSF^fqXXjUsXa#4_Vq3F2+-8z} z!6H31n;1`bR=1lEWVX8ew23HxQPhSF_qm#*%{@uvMAy&pAHG8U={7tfh(!YxwoJ8JtLciEX!P@dqU^Q`A` zyQPio6Z770A*~->PG@hedmfhFtf#WYibqZGOXQu{3;}uBq1FH^c64zoXBU~JZou~P zPCiM{mT+5A25{Km{q4O-lqsr2s!Lfgn8~X@`F;M;ayf%%+@kY$hPVU2_DY?dZ7d<( zPtF6kr%7pC)l8VZ7yN zdE^bFioEbAPYv>7Ztu)yF#B5H1D5S?<}93a@ITbFEoPYwr%BD7wZi)KA^#Hob5(CJ zD}R^px%*EIt$iD6ws^mmyb%_o_1L)6SDGa@ann=z#`)>bMyvKabh5-vM&mb$yYxJ} zbaahlnEe!oO3v)?GLiIueVd$WMS(wMinGoYpfDG)xzNI33$(DI0U%-~AA&9-2_Wj} z3a(afD!|c%n`#%TQtEe-?5UmIVARapW60aWk#L%#-2W#txV?ZH%`*JS zpy5eg_(ptj)M6Z~K;VB2B{SZ2>tp}}h>9Og{}AFJsq=en?A}5&+GorhMF5+t>_T|cS$WZ+oSq>nY#=vO&3Jac{IxI$yle+WZ_mW~t$@#PH!Iy&v zi*=*v5X_9RyJQtQXPGxQ*Nxr7+QNyat*e5TaaDvkWS-8yuiAZ8&7@=BXYmhKMG;pqFp2M%^VF$q{|z5ZNE%h1}pCmSdFH5KAUj_Nt@)`<7z3p{yB z>Q8H-!zxl*k9#jKSaH0OF(D;AUoYDGvvy;YO-!p(=PsufK|X&F)$s|DWG66(oWUR@ z`XusH3rb+lfWl4sEqLbK5)u=dw;!g9IA~n4Y`x2Ezf|D<=Z8tgU%Db&qclTR#pavT zD>b|OTfqAfhBg0_M(!{Lfl*F) z6nY3T3#ER@AV*ah?&F)hZmFfZ!S#gAnGdS1aXOV>3p-#BGwHxIb@t?3ig)~>Q>C>@wDK$_{8S;+Nv67oF!*SWLUE7wmy(U7{_n-BQTI_ORjb*olerr53*I@4Ev&S!#e3Ev0 z-tr{PJ>`u&bI+t}Exwblslb96^4NZEL`i3o8lDIG71A1YnThHG!KO1QkD~=y8ECi5 zrNSxcLhvJ#`vQDlI}X_Vz9O)SMZR>7esyXtR`8x^z6 z9HVrvR5f>WJ>DYSUGaBl+0w1vV)@1xO{c5Yr9QbgtUJDn39qPE8~011*Z=Hm zh6iWnN!v>W4TrVwo!-hAF)ZB|`=dwR*;zmQ71)w0o)MuRw+F|QYVGUY&TFk@mhQx_4CWi+KnT#xHZi;_iZl2^x%=QPJ`!5 zQaiQkuBS&2Y1#Rv99$_TCT>#ko`bT^=wyr1E2}hEE}cEd3@4s^YFJ&nrFha}S?b!w z!KGK+jSpw&KE?JBoi)UJIZ>Y3xTk&PYOd9+>b7==jN_7KV<$2eTpf?<@o-i{LW_ug zXgPlrp0&7Z_fwm!JvYW!SM7hKHRz=#-?-a<%8C_nVlQ*gTu0uX6AvGs>WRLPb;(3G z<6=Fp-hqWDPg%F*U1^Q#Y-)?3JHaZfuBqm0JAKc+c}s-%>&>0@m(Ps%Ii@1-RL&Q- zI5c8f;UPV7(<_8Ku$xglKE#=_q4!MPtTL8)tgC7N%eqglUCW0njU#$2hWS-yiZ&Mp zs0>>*F}b6TMwm!nX|Hw7^IC-U%WU=I=a4N0wQwbkwynnk?i^_D zcfanDWGHri;wGirPY>)VZ_%?b)$jM5BR!O{$tivN-j=pmLw5^Uaxb;*g`s*42jE4-YzDiajt->)0wvIIM|FM#LlY>aRTdPCA0B?% zTAF*qNEpX#R&9#p%Rh(K49|`9D6aYB&~%VXYEe|f)_{s^B)DPo%xQZcTE@FU;;w&| zE#hUQv*NKS0a;6*fFlh`&;`(5u zTF9IKn5yk23Fl1La0ll-y3D4QuTfG~n|Osf;^OME`0P?{_Py7WLje<^SvbOfCp!UI zY7BOLUi2}(`Q{~zY9WJ4pTNOQ5#9P0@%z&H4l$Dt>z;N_#^GV|rgUZA=PqW`?x8ic z3VkbqF&sqPt$OENlVqLWns;~0HHz%w&CzY;Zxi#HI>I()UqJ#S$6VdjttRBUs4i<& zWWH#8wCc;p+f+y2`Ae_yi#}MZt*yqZp^F2)JK#y4!`LfUG-u}5Q)>M&-?VKq-Myc> zxXvmHd@R0HS4FZnYNYp~4(D!HclsjH=TW@aCe3*dbm*#ICg=tJ$aF1D%dR@3oEew5 zl<(27UVK@I@C`4%%YLskoPu37ptzrNnTLVGq3ZS(6_$hXw($(h`38mehOe{M78Y*{ ze`V9v#V70(>+^6mS4G;1oy`TzBa15qRgrPGj|NSj{Zy`Wb4^w$^7&Xd^UF5UEf*ay z+P%{R~bi)(pst|Mof_0>w3Y%zNm6*ivhrh1W} zQ)T3bMQs?IBAXgwFo^tWAcDQ4n(g8o+!!LJxig)J$6r~|>F37X&ByIGzZ~At?U4Jj zUf1Wx1rPHo6|6Cm2~69-W4*2`;?U$|-$?d^%RXk4{$!POPS@d0{<~de7gq^-={5R@ zr#H!WOWUdj`?|)+i^k{H7ae`uzpS)9zm>I4L^gEpxLRw8?M$YZgy{m7eYi)Imq>nn zkL%oyahKue^-h_|-0d%(=y5Yp-$v|z@jC;CwEj72nmjJ6Cv}%y`B3{>=iEk3)dJvei}(eT*wM`G=?$>&SaJZoG()mi~XS`Z!XK<%^ zc>F}|+B^f+*Xbkn*}7SMQu$&HtzxaZuN(ucyu#1wDXc{!r(5c|UZVO|ALLE}_5%uY z&X(zNJ=f}dQ{Cj$DP}n~sGB^pywXP3rE zPj))k(|IT4jY^J<@W%a1ANsSZMfHvw^}l!4-kWB5+R&i=<9!wJ^MlW}>ZS>bF4s^y zAMF*sWh(a^eiwuVm3I#_aWwu2Hh|&5-_H5jH+qN?CzZ&^UA)bu&cP!lFE&cDx`GwX zwA>T*eh?5QD&_y^y@x65HVhfQHs~_T8!ujqlZZDswQYR$DzkB>t;MVL3}t;k)12Nt zsS5sD(m^`c20JH8#+SA=9GzSvx_6Ru)W_lZ;PXA5{`|XIc82>`h1DvygGtSQJe~Mq5kI&vdT;Ef5vOj;PRpfY z29BcX$=t7RWt&xX=!qOk7x8zU)mv$DL4j*LsrR;1Ctn+9>CU)A%&>z+6%!5d&9(`< zd3%O}O)B2VmrlBtY-Q}_n$E+J{>#EU$&wP5rHreH4lG^l)fl|NA2l+)NZcmII zTiY$>ptv@EPO*yZjjL5z5#~+03fn-p&gAUj{3fTMbM#0_SY38U@hw(vNEROyETGQH}k<7F^%vd|2wyGk}m%FyK~CDH!qFt-ca3!o`T=Uc$b&P&jW%y@m%Ej;nT=L}s!LxQf8@(&;*na7%f}_?!&&jy& zlM6}~W+b=AhUstZ*8v9O^nG$EH6w`K1iN&U-(E#n2&-U>4*33{?MR5vZz>BZVj zMRUta7Be`Pe3ai@yh&i=PL(3hB8ZJ^iL_%mHnmo3zw}>Q)}iO};lo1R6wTz6oTP10 zj!CY^;uo%dRL`}BzAmaEqtUvnAaWY*>g1l$Bijl`!uiEDKHN~4r@V0}edJ`LxWTIG zL%Zz{U!EL}8r-xx+2v)X^=nSn{oU%VTXmjCKDBtRF(f=xf?MLwb%>pH_p4d572b8+ z7Uy$f!UwL0E66b)R1JNomS1k&QG4Gse$|DxrnJ*Ksg~{OD`(9~PrfwRytQ$ZEBj38 zE7$0Ebrri}jP%~VTdVxdvC(Gam6*NoxAOLWBb%15_NH+IH>2I9&&Ryzv+7akTAL{N z=H1#-{7csNs8CB=OZ75``LR{Lci%#|KYpQ;EQO z31O}~e1Dq)qH9|Oubx<8c#T6*Qib=U>-!3d9ZBv} z@9E$g`!`)^(Xc=Ii!TH8<(*DU4sxXcEW|$d$YYZXY`}QI!<+iZWE~@y<+~)cg;oa? zjuQ(!r+pTA0!grOh-g-T>qU+8#9y5(nXG%%4Ze$U4~6>rdYWpjR^q6g8K;{Wg)xgr zjG}>KAtLd!v&ymL@B6rz?W8OWrtbh$#Bt_wm@g*LJUw4$tLo6K^Wi$4U(3~ z{F`eekKw)L`XM}lE^kVF5H!UmBCTkwo-Nuw&?a8uP=Mx` zyP6>e0I>`UorX4ZbAL3jzy0`hb{Q@yAKE{-Hw}!8E?KiiH>7`#Nk1mIKzj!lOk)AG zw6eFsw%6Ru@2*5^F`CCWRBc_HFJ<95;bZRecKf{7U4L-o%|tH%gIlcL;_~-0#uCsX z_{mhp&T#W1CcR|e`;DYEkA8H4r%KOXfBnUaakkL!rguOjg$}Cu$cmj_71{=j9!GNj zA_E_?n*#OjPH|$+`L}ANmi-dFwrv7qYuHcCEpo$sT1SgPBmT>qU#%2OSaaJY9L>j| z8bq=-`0UVsH1F?Qw`dp#Mh0YN+BtKdAVvltUUdiDQOW~gtU*rA;OoFm2i^Z1vMRK6t9lTOWJre6$|`6&W9WcAE0y zUwTg8ui#PLQ(pPv2w8P{d3lAPu0dR367ULGh3GltF%>r|B)WC$Q9lv0Qrr&<=GK@N zQvx+VO7YPpG#m#~vd51QZxu^`HZ;b^mq%vNzy}P#d3oQr3{3Fx@p0}r`ERmi1D8Uc zInn2v-4q!e)Wr+d!Y7)js;mkvX-R|lDskdyK!sR9&?V9Y>4fM|+%?_WORo4I)MM`l zj=^_*e{jUyTedV+m?m&AY-5U%j1CMrA}D#OkT4NuZG|z11O?-FUAvooBz>>ZzzI4v zkh0v=K4RQ23fd7eGaX#qC-6vNgBT|f24V#f?UQ0KEZc#(K9SB`v2f^X5|te-Zfj5* zNQnjmNL3YnqrHF(;7*#PS%pD}{=x+{=Fbnp5P(!Ci09niQd6tGs;MgZfLSZh<^1Yu zO(J!6leN^fYX={tSq?5QFU}WmS;$`QFI4XwHQY@5G!e7|C{mGiDdM8yQ7EsO!fTYc z23QXUwyVcpZl8z3%5Ri(126#vZ=m89K?&rpm_&gVio^8|@MwlB{YQU{b)f4l`=~Wo zA7K7nNTRW9jGx&nEh#TzHnp`yw6(Q46>k&N*gHPesSo0&aO^Gnw#$-C~%tcPqK(;c{r)5~LZ;@M7N( zGJ!yw15{om{4ho(j2X&Udl+9(Ar2YVJI(0sUnt2fEuFoulO#aE`GAxmjkaSNVi}*& zv17*q(&ywYsDU76Q)j0D=1r*y4JH!Ns%>2HXwQE%F>-wP&q_7i(e>nlfLqNp)3j-! z78n7c32-7Gf?oRoqkCfIXfe^@1|`EU`THa=$4%Km-q-j8;Cg5E%yx#%JLyuu?WGFw z|M20NwYookW(Fz;#P5Ti<|dH~>d(>@E8e@!;NalEt3x*XT)N7jHZk5%cj1FBjW_&CgyiQIg7;EgV2|Z zlhTXrs+&E*+Ey4KsRf#b@IPnr$N4*Pw4S@+1o1jrg$16!1wDksN+p&DX#!zRsNkQ& zN?)UZeIGPW$OjoxNScY#>1K#%M591Rz0j^YY5R+ck|03m_PM!q?bys8V2ojeYd81^ z2GRg%qWPF~LaPC~W2K}vMKJ<#*)ZqVF`jXXj`5TikjMrdUsP;&g3w~O=ad{@UBO^l>17~*^v-dbstM<=+i#a$BX+l8OWu$$x&s7fnGMb78r3Jj8 zPvZJ};j;BS0^Ndd;4p7c+xw3Z+&0u5T*myhO&wjE&55p3=?m;-N<)!#4_Tf7p_QL2#!Wi9;Tdg^A_N4C(6X+B3RSNXQyr zKq^TvI`)C4K&61iudfSwR@n_o?v>gvs)04n> z5vJ6t*uR~GazC6xM12PLa3a;fx~F`L+!f{_2BQQ~JrcVx651pVU$1Ybfi7Z(k^nhY zA`uO7CJW#_V$g!bUU?ZH_+g;MYu;-`=)K_&Y-|6t`1_uvdReu3gGFLe*;&4g*y$v6 z0wdcE7^mzsw3%9Z+gGs@O#>P$UaYX^gzY>Ig96;hGo@&$LHxD$kaf(!kk}XjNVBEH;T&Or$7+Thb4d3 zO9yk07#m5dLKbbt7=sxN#KaiuWIdW-Y50 zJ=!ziV__C53I=xO=PkYdji~_)9NaZ>G=4VZF`b*}S0MUn>a|u4S-#21p@B{&6{TQ$ zEMW;vJ+(tz?59R1rK%VMj8#ydCbMAqa^DSWO`jZtDYv@2vHn7wHR!+HjgB^bQmg|G zDRG6NFzItSb^4N&%>GsD?gS@f8LgN(eJbyex!-D;Uy=K=3E)85ZQs=>S+s z^l;3GS7x(5>jq!r&HO?iujhN!H}>;a`e6q1T$%qX^@n_kj>d z0wbu}WLCCd2ZHENTwv46m=a9=szoaEkYNDE{qA6Jp!%m007p>yc8HF9Ej#Jz^0x^E-^V#R5JxbX_FVR>hvu)61@BL!-b8rL|u4+nMq{7LAN3F06`OhR=g^9YZ%deOO6J5U`=)yRNe7ExGNoI^85;E&(un$C+0 zdlv~dwPB8o*kFILv?*S);S@s?f3k?Ko@@(UO^Q#6gEJ4aLtC{)?4u*2>U5wLQ;P7imsyV^DuG72W^+DCul$jE6x|(-d)fg zfZ4>8HwM{|-K3O~WrM7Rg?HEy9_XSB*}^`_ZP^YMB2yD*GREwuXq#-)sm}K9{QiB1 z#2lFVN?;rVr}0KIx+_dZ=Focha!qm^@K_?$Is;T!-<~}zolC5 zgBxNzK5l*Pdx?Ww4LT-KN5#axpIXMz!;M(326rJ+zd&wSOyan#_CXqMgW@60&ti0F zAbWCL0l|?7#$ZA&fwUOvqC!kOLQmu|(#d|WNl4yBB0&&`5;R#MV~xl$X+nPKQl8h_ zNo^8)MC%r`>i*xyLnl?=;KsC_*|HfiXrlW2GZR#-}qNtlP5&>MIbV2y$_YOF#Yk2LiMF->>vt2jqk#v||%=p6jt zr%YUQBH$bLm^RLT4JNstF=((H;ftoT_ZWO_@d}Bx9Jyv~_Q!q_p<_V+Ab!<4TpAj@ z{~SO~8QfEmNk)^MRdk(!>MbOu^^rQtWD)V2Okr?L!W>016HshS1>aiM30-OQKnHhpv zo!sp=|E!wYYKouO5S9cbKB!~u(--m18uLs3An@|FwVu`5l~*7AxSO-fuFi7H_2Xu? zyJ}m=sS2^R6S0HlOl&eRScl?Y7z+hzw2$65ll{wo9ch?>6&QGqtdmM-vYh57ds~5t zx3w>6PK7E0oOXx{q*)DRt2ukZL(FV~Uww*$j*mOs#efE=W!RblHu z{R}th{{@QX#Uf(xgPKKSjuLYePm2XkHfBFGv-Q#nLlfutM9<;SkZ|Mb$w~oX$s>|A zl7hEBu?9Les}L^P|FE&`b5M-m1)&jJ(ahpAI0=0-Hm7ggupv*o2Lhg*0k4pCXrN-c ze%-BRRrXH~T9lk20a!;-rY%{uO6xOcIY-{rY}P7%qs>P1og8o4uC3q37&&bd5|;HX zYL7`(;+i4H9}B4;0qi-#`G$JmiY2`5pO$Oy&zRL-ZN4{3qfBS^=39Jc-)@Ng(eBu3 zpB*R_Ie040xnxZKSZYU=rN;Q@qRR1Wo;>_~!y%t`^;d3lv}+GdaP4^IEuZpjYf#MB z)}IGe5BC%rXS)arXyxUqC@xE}`R0@)r^OfPWdZ#E@F6pfKdnCWdjG3klQm1LxN6>K z#t8}ST#|h9j!n&np1lseX}(^z!a7z}TdH)H>MRi%G@FrZtk*Q}NQcOW$bO?|jWRLm z$?ZYe3!UnvmIL}@f0}=%q)2}=rT9o_QP)!K{D*h1Wm#{y!f`#xk~Z z#O=SwH^wM@Y&^Yr`c+%kL-#c`Irj`N_0P24t2e$RG-OmjH-oQ7qO+*CV9_h2JWC zUyyG8;u5#;^YG*%-97PW(#SEtGCdD5JF22|n&yce2nz{s?+Tq8rIvJjN8Hc*;xJ#x zMk|kEXtc2O4;is1W|J0{k%~%^J-ytF;~CutVW$Bo*3Wr=heI&6yBG7AJ%0 zBJI47>2=?m$7u7x&hHR^*kjOiHUp1Qj`#be+by7X|O?9UMzu zmj7K`UXtQ5A!&X|eQY2TzfC#z!373WIX-Nu!^9;P%%+TFjdQii1{y80JD$v%#?1(f z*V@DociN*=*y(uYQ$&?&b;S`I)7rb?H`9fCN(y#ZM9F$bp(|0DZYKTd`<$tKoFmAI zq98{XNt?rz5`Po)@T2qYqYBSik41VW)FjtjkUMp0W^VMw@MSJT-ZK?kLO=Ak#(0)X zFDp>z(a?~rYUB$w(yq97zqcJt6FFx6nDx~KYZX+BZGX#qpI3e{l0~2uY ziV`B>#`OkTw;+RCp8I@IfTK1N1}Nmd?QOyZd^6p^=8-=1sDP%Dhyeh*P^9!rGUlN` zQO@=1HyZo#{XmDnaC3s)6qlJpi`_twfXJ2#@N!@nK@Z;L?!ZX z9tQbQ0jI-oR}3OEq30MvFT-52{P(Eyn63tINr!3uf1$lv5|EkPPt zo_hY&k^4fSADqOTLlayUfywIT#sTYT8Uz=b*BI9GbEHCvox}T~s*1(b8a0|;ba-p% zDxu&;og0k%dj8@RoY%=UjMNz8GY21Vx5M_J6j%%kkV_;Ed71zM6s-Da-wfQ&Jou=9 z2Jg$3;y}7pr8m*HoTldh8WYJEQ(rm`%N?&#LXG2%R2Xn=q+!4$dnlwflns+#1-|F3 zdH$B{!KW3X+u z^{Z{r(15BT%@Xf`MuVg$#}Z*cg+5!*M@y;699OZLE?(Z8u%VT)FcGjE;xU5qppI2W zYAP=dcrro(=1&%&BwK;0-Qj>llsu&OO@Iczn;f!m_6qRvF{U74HFT4DvPR8KhtoRY zWJL4U)E2^RlJXkTDkSV4%F0T}zO11s4ivZ@3xhCe z!DaFRW+?V6g(%lc%sk6Nm0Z-mk=fMM1<}N{reeQvWmZ#H{6AQ?Pk2Dh%KsdTn-j66 z73(3!#Q2lKa*U6l=3^{SQ^?SeLKxahQBJqGpfW;g7?{iM+%*B!m-Gf`664l}1A1W< zfpQ60v_W1`kq^2IsKsE(86&?e$eMZ>)O+AwdG&;uG&thG-F_|2^TD;#&5`Ou1lQl9 z=+(%9o`+L0A!SbfevTLO3CPDV>;taXVd$QTVKp{UC!1e6&`^r2j7fx@iqjwPS}%F2Rg|WfPwTrKUuu#Fcpg=$wETI zpfFOp2IWj1ydO9=z8G2J`V+?nVA7l}gi0a|nFUff1t!&6cxD7_EtDL%jq2-A>%Lxn zq^}e~q1Hm37P6UVhJb|xO@%zj;*e3;nKKcEW@*h;y2&H&3VCDnAY;291(GjPOI(Ga z&Zeo-C}@}F&A#L|K{sU;PSZ3WYp&W?MFO!bo9rK7=R#m(F9ITeg@a|H+QGdl{hpk{ zhXBvQgIY1NWnT&D2LiK%WKw^Dr)B;}$C0)^aV{1;3Z|8`dBvz?YrpH5Z=!tCn%erQ z-Kp=LqPGowT64`FOj7F&J$sH@fRyy{4%Rf@W5z$5cO345rMX@I;>e35sSC^&Qi%lr zYhBuXc$8&`?PL~;!jOb8qEx~?sazn@b7&_}uS(axc`1}h4k6d2heUeR6u}O9uD%25 zQ>lp`r%;YAcc{mzrKo3$dN!tW5MXcTmF2oL20c(`*I-P|tb`MnA%uhOvjh2fP(e&; z`4Dn$OKT0tzq>w#N8aZQ9%Z1F`;ZZ2O#1UN{3>0$v z0?{nUY4p2}U7bO9aG>+x{X%i~{)vny&*_rLOAS9vHsUCP6ev)>^W=&KNFRTtB{^x! zkXUkkezMf-iHV`?RxNdNQ5VCHqZ9QVLyq8sH836AB-B7ssQ#&I?DjN=!%bRF2nbkg zJfw|4`h9UqLcCZdAc_xb!|bXsUvq#|Q@Q{VO0RdL0z^JgY6;tdb9Q#yl+CqjECW>@7AK75p66KC zpv7{@{@%Zq_d1&0!Fwhglyt48C#-c@z}{>6QNM#|p>NapECP_ShO#n~{P!L_hytoW z{kyb!@z=EY@Pv6#V^_q9U3fM*uBMVz(|No|4^Y$u@oIl^0dB5*$rqS8v}I?$jGP@ZJN8>&~;y|8=vQZ;zV2#*5X z+<&2e@1r{A@=%M>;QH1;ASKo;ZE5Syn1rVx&k^ObvYWq;uUWa0nqPigjXs+2RO8lk zU9+Tu+GkCB=Q0Nhb{3Pm2hE)&#YuGQ{_Mh~N+Q>j$%2;1BUB2x$f{>8SdHP)%dqW> zLU)VIqmZINTx(0hO+c?vXab}0OUWA4)k3eWaiD?l1LD2NuCDaB4wM`uqpZ%{&4JH) zQjQ4{R8Fpp5aHs7lO+t+t{7fux^oaIL)e}gGaWmejxEK3+mLrbEaeD9!kMZe4(<-I z$#G`DfSOOPyd?1!Pe=`77(ifNcJUtQgHcfwz-S`ez;9~n#T(ghQ_zt>K!7{RREY?+%Qz4GJ;M{nD3sl6dKLA z@cl7Kz2ZhqkO>gxXrtFtJ>UFc$-35F;)vO3Sz11&VS7Y*QjtK$o(sVgB*P>a1PqFV zDHv4^o!A;taF2?DI5SWsQ?A|C!8Muxtit^>ImY%f7p+-iZhNy6xnkb8@86$H%wDjd z$f^L4uhBqv%}}}-3Nk?@XVfw~pzd_Tx(g?@2M{hKg)0DL3IX1o__IRu53Q<7nADwu zl!NB_5R$mc*XQW!Gcc~P21_NF8+kxoK;R_vCi?d{99CeR%VI@%;4L=- zA{8}HnPiPY)^R?PO|-ODp*`~sOrqFP%Z6+C5pBC}j1h`*uPolvf77N0U2P8(jVmE( zD-8C8;cm!Lz@4k|DY57_3JX`r9XTS5)@5IL2@zJMfn{N-YOiTL4|^$)tev| z()`pT-_)FAP-U6x6no?IfKGBg z-2cOUw~olms~}CFzA4*>8koL`?>w=yIyDd#Brl{oSRC5M&gHQ~8PGY>XZPS$eW2Z? zuTF+bs=B|qG(h1Fr)4-TZM-hSoF^IJppZTtUPB52(MT(f`Oe^z!@K3t@snJigZ>n0 zv5;N}LIaAgQv8D0(aj10f>WAhl)c*LX=A6MmfJ03(*B>Hr@f^{+ykk32YSb^Y&Ek^>+1k3b|(UR&V= z_`28|&}X=<9KHChFY+%iYwmLeA5ObJq-U>MUVQUwx=wvBQZ8YMeUZB%xrMsI3w%K8 zSf*GYnZ&CH=qe$h#b%B*kaQK%DY&TywI4J(eL6xRP>TF5=f-y0n%#Z64h%_Pu4Sn5 zt;-CkwnCM!elh1JPgvS2 zh&nRQB#1gCHhxqCmy)nagg!jhLrhSy$X^vy^64gPA7Y3`MDS9%2CBt@qMb;~0G6DAUeIyq=wu~I))tv5tK)^pF{LKQ ztccADyr2x+&wj~?ca2kQoM_lF9G}bp^_H?Bbe~9EjhoZ~4=E`4B8x|hJ+M{SJeFb{ zH7!D@9gD093bg6~_Q~2L925Faj)D6a2cj$btGvsJg7)Rh zBZ%G~Ac1U&1&Sw~CSI>WO#|x(?VhZrXQ)-$2e3RNNhXgRp75NL;9TpM(`u*PV?2z1 zY({RlFfO@`Qurt=>04K5#Ve&hg!2&&Ej$JYTpa}!4EWUt2y#Q)%ZPqp1bo?8pkD7^ zzFc3NjJ~p|AEjoL7@%nphsunMU&vFb-^>vi0@0FeI?5?G#Q*Ryu59bSttTlq8tBx= zpfm~?Mk%nVia4DNiIa?#|D>sMY(DV6Ed!V_sH}1s;4T*;3y)f^=cH6DIrI<`#Eux3 zftkTiC=B3(%JDl&hiN%NFNF;p#Xs+Jr(Q0OMqO%MaJN?*1y%rXf+Y*nNXwj>Y)Bho zN3#SHGS62oZ8^qKkK7CrAZe!ZniNgr0&%qxYEXSbJMY5kf4AfPY=kzUQ z*)K|QgiU##%_Z%kjl2xU2SJivJIG0nuqMc~P;G@nL+$!4bl3lbj7;)sK`)cMvKY*H zuW!FSdB?hYw#yXeF~!BJMG0$KkoP=Vz$~|?hX~k+K1nT%RIp#Cil!4IDU=8{heXg)6T8G+ja0<@YFb(h}jk&$p7_o>?ey>`kyVgUc4*n7R z7TLPBd9`dRov}Tx45apF<()GWxBq9g60{xkZ)&+Iu12i@^7YgwZ}lvkZ5)ca#9O3g z0kHSyhc{(v&A`w+8Wq$3N3wq0T-$KBINwxLvc2Y5B0^MO}$xA96!>X7TI zrHTR6R)Ov!+1H%u~&du_J?g+(H zq#I(pcN3OShAf`!VtrB%zupcJ)5V zUQ(4Vn*&JK=_Yi8&7-*p{NfNO80R+PNJjA^&jiQybX`3%T*BeNGOpY!%>*llo~#@b znv)UWKFcw;YNtZh62=Q8)Jj^pOcdO2wJf;~D1DG|W7f_h47L+&31~!u{s$nW@7uS8 ztgT&H{~gX^gr=UG+tcer)bNri^7X*B+$lC&(YAR)*%iqWiTwcqjFDfxq*)v924^lh z3Yq1WKB_3|Y<_$@fgJBJleB?jH4|mGWe$|uW+Lz*MMFcwJH>PV z3&}ksaPrA~E^$>H@;)b4pzjVnPr&qi-ySmSudP~Ys|R63%C#6I3J()xov-PNdmu_` zeScMge(zwow9evHtD;FwWYZ>DlIp^?B8!ESzKa(xwyHZUF!-L3SS+K?K%#to67A1_ zVpb!P(K1zT$=V(2EDWW*<|3+^Qh;I`VJdE<_4w6pX_9G$I+iEO58=Q@lN03q( z=4*g5G*2t$Yj;jnIx%E`Xh<{!nlpeT8~Os?5o?y{0Yr$A{~ZEtBxa6&)SQJ2i-%Tg zrlSYgs z5lfWJmHhyG))2dhEGQx8?x+*@(7?_zzfRPw;;uvv<%}_!I|=N@!%8-vY8dk_{ZEZK zFG!mTi<>AQ{kWzl%$1rvu@J?Vto<14RY01Gvhq#50Rj)NU%!48TC8^-JTT$jw29Ld zgygdUr#e%?8F>wEyh8zqq-VNn)51y^dad7-s3kTDKQY#Ngt@JuhqI4 zc$vL(?(x6MEnkE-kCzG#3-aIueVT)JKLU&Z^g$WQz_nEo^2yGbY~#uG*RS=Xcn@-` z3Q%!PB@KZg5EY)xiGeuL00A=uwHhErO`;c`Jo%Ugm>5W_tFVb!D2}ymA$yE36aswO zUoH)&=6(4YWjx&6#Aw4i^5q73s2v;M2LU4#=|B`JHf9Kt6Jyk;?(XhS(=s7N4K>~; zRDywi-jvo0&L_4n4qs)kTLE>Vj6Je`-MSZ_!jRLEVV4(5NWgT|bz(V1HulT4hMck2;H-MdWn$Qf z9GojfYVl~3;!#%}1=)x$0Ip87ShoOF;ldcsD;wuBbx9O5Ni7sfZQS;^Og^z|J)x#H zx;97p+RBnQi*~&HO?Am$r3}Lbk-HaG2i-OnQxLP>S@=}Bz*JTzso=Rm%IZFGHBKLu z1;$syLN)HZ)Z8ijm)ySJ48*ggN9?!q^Y$BF$PUQaTqNZf%Mvr<`m?qnQ~me@WdGqn z0GE7n{7K#_!9%u}!GC`~3cnnmxua;nlws;>CNNbHu&w7PO`hg$kdg9 zT#`cySphPiVH^4HKEDrXe)Sq@jP4jLy&O|NMCPve>uGXwC8k*{wMt&aPV*_ST8ttb zanRuYP^Flr$6Yb}QA$Vh%P~hE)-F7V>L8HTd48wS% zd+d7l&X9Su^L`;jVb^mkSqS$v0!RR9lz`6~57-Ci)HU)YhFj!&(TT}hmlABdj}u@K zGVY+Hnuc^T5@gO-eIHK?wF6{>*GGYpBfO$eHn)S?{4^zHTg*L~`)?YiqST6xxvPSy z7yaiG*nzJTB_tPzZd2Mgh-{eAl6^U~T{#YMxnuV6{q*E(GR8ZhVqMcSZI)#x$~f7B zycMXM!jZvoa!sAR9K7e2ps9U(R?IWE(hzXlWe>{mDZ&K*#^c|iNGVT?q<<-a*i4bxo zi5>$cU-|GC8(HxqTf1>X}Jef2&6USBLD?@6a9TZe^TbeV`5}$ zpA4N5r2i07zcdtx;EDOi9tSC5VKtVUi0irA&pTmXh_z9?*ZUkfpp(V-;DW3(+Ko|T{tk9(Q(Ob@)K9|?GD6(C`1ryLk0 zFIlvd#o#ab?wzcp!+;_}c~5nVZvR6}9Kk2|7)%oY_NgI~pFVYJBhDryqmm6)7X&QO z#um(>;Wq5oy2RctDMuU=Wi*3^GNf5-ik9T|7e$-2cX&cCe}GYMQS;{p2}~ma=YMqO zH=cb6s35sn)%7&x-X&9=?=wny(ZclHp-`jQX zWo~0T{R)As<$fQ=kuUgWm&{8D!6w(S})w8rJf2}UVN;)5rX53@=D zxWMgbAQ4ZpxuYWrp|CEo0m&@eF%YyNa*{!+CeMTjY7)raSa#$94^oH^f>Jn05yp^% z#r7+QEnhj(7-}RVt%owrxO@q7M3O~8rdc!&9LL62^0}PAMY5Q}?<$K16=k@CZ z9Q8VI-5)KA(rGEy1^E|~fn+a&H4%w?;=s3~?0xH2T9ns@CG52;Mg4ftncv<^K<5!L zZFOLA-X`AJKt2c1#E!yUV%o9-qX<-6n4(@LiZl;n_Uzf{^Hcz!rErRgnZHm+D59bT zh8-k0cbU-8D$t*Rd$kdQ)G#?=M~?(X>55QICy0qWn()lPTE2^6VUVJ|{|{T=0hjas zz8~i}HV4_MP*fT+Li->*m9*0m8nlcC?ZUyalBbkJMT>^CrDeCJt!=bZ(Gbo5bw5uz z=lA>np4WN(ey=Zi`i%Gce&6?XU)Oa*M}#sCX~f>8OP3P9O9KP}5Y?*xbFmgbqk$Ys z_`0>V^-W)2-_F`2fh|*cTvWlVtU!HA%oEm}shk|IzRE@O{f7#rFXAF$Fit&DB3$5^ zW9qp%78bEsyOXiwm8rFL$vts1!7SyEam5|kk~`eO$)>*e&t;PV=0x!oN% z=9WKmkfsyZ5HX5rWB+{NvpVcVJ5a}m0*{!QA`@>|wdeQ7eb}MMC57=!+hP|g5c?o8 zaZ`T7Qh?})%9Y6QC=I71q~!bCnR&YX%W?Xl?3DTX>^CNjVj{n4>?;sPGCI445px?( zO_D}+1W+(eIyo-xMpBaf$MmIP26&!Ww#SEbKU7FFbc;q}Y>8G8bj^l-7$FOdnxs2YI38*S zv5dejXi8Yj%}vxKDhYd*2?D*L&y+K)%_m66%9D4$HI{<7PP9SY%!sZgym*nbJLN|W zGF_@{=A8znOUy0Q0QMhWOu_G0VgjRyfXBQ5X2GCcM9^0whIdFwrCDA1$XY;RnE8}% zi_J-C2MwISM#ONT!#zpqkG8Ct-~w1y5=f8Nt1GH#1oaYjh#w!F@NeE+n4B*C)OPdV z5ZCapSwkD!H%6-K&JgHH`O?{-JfgNjMA!9oZMeObk+M)6gAeuxDk&@b#Kgn^G8673 z=oy^nv_V<)Mu3ZL0->$uO4do;XT-jk%);AxV} zMunO3qrlg8VZ3~NTyCakMMZ@+(So20RkwTYA)RMnK%V7jLjwhA175EN1c9G{GcJib z`$$AE5CyX#cTwC%Wnohmi{0Qh(yzlpCuxK{@Ey|jvD*qVPGvUP?#(kl_=`C-I2Nx+ z_5PWGy(>p;5MuwV@fNH}Es`$6Zz)A;#K%Ai4Fz3({PDJ~Z3U6jnktC7U|Mw=u^bfu zE12X^FbPDVK^kiYjcH0q*eExX9fBwS#X1x0Iv$-^w{CRVlpZj}x1+!3JM)Bp_m9ke z`-^i{5S3B%>b;#gE@x7ZzLUi`Z3>)p`KRWf=6OtJ9hjTY3M85KKIxx8n4?*h1Dz?D zi0~A{!0O${Txqm5f&NKJNmLMn-);#}vn1Farse0qh}twbK3|?{3j~#hd{GaOMt`EG zKuio~e9%xqZ!LNzqI9{L2H7UUX2fJttB?;a?1U#_sz43LN`wW2V z7;2#yipz`wa!a)q>El40YlV_T9UUq72SSh^NtPVgb-Q*e2&RnZcw2|{$h#kyt3G#t z@*1e>*X-8cdIvc+z%3or(KL1tsR6mJixoVPfB3xnFV?PH(thPj%y2=JUHMm1jclOKP>b)Enz*N zt*YvUDL}y}k}*|b{2!e83&7) zg!K08+qY%wbX6A;p%CejjuA`>h7rBO8UX=;eVtg3TEW{?e_$L0>Gp{!hT)1dIvH6b zU(<-~iF_~)X%c9D5OO6!S=j?P>(h^UoT)itofK|U4^0V4MZcNk_b3fvP%|+DajO*ut1TU<9#uJppoq@WuPF#(4W9~ zfo3%0NToXg(k+_b9RTLYLo`WfxD%m|Nl+`-h#$M#K-mp?O8AziHcgk^fNueO)w?d; z^A8lttoh|Q1L+6@PJ$dwFSL6f7V*)ZHtJz?km-fb32q8 zZhZNw-oGcl8Z#6Nat{-I(0j<~5!yN*$=+~x^cREDB>(f(t5<81E0=CP@xgk^{DFWh zs@ITg`|&}4kY}=C;fE-8?I^IHRXQrA_?`(rI_ZZJo?4(&yCC&`h?S3Y67%6XXCgVM zM0wi;8Z?S{hB_+bw?T5T)XpWgWFV{>CiARbi)D>EU3B;EY?o_BzFN`y)#$`T)0~e% zeR=p*P?O&};Lck=I3B@QC#bV`AV4mmO8FkUEE5oCpW5+pKckwVGb{YS=kX1Dlv)_5 zs)&k@jCPacd{rw*dv_q0!zel?A_*UZxc3+@Oe|m!cJ<#T#mDDqh#P1dH||7W?PKZF z08ok4?BZ>qa|;_Y7o`VAj#F``sF){H(`cXBWta6c3hi5XAgyB1cNdu124;_6oE6LB zTNLw-luBI1lsQbFqsB_YIpqqWEPIl*$Kf!XRDZyelhHMSq-Si4nm~C2K8UMI<)F ztIjwQkp8yvZr2uf+*%33@jXoPqueS!DXFl3341dsyf$g}oyjp)gp?b!0d&PN!BsVy zPGyP|Y&W5Uc3jWaWAP``R3^yvo18B>KNG-3Y8^ayFfMh0E8Lkt8MsUIKJV%^gQ;wos*|nQ}Jsg*PZn7)xW7c!ge42gE zo8!%UxZXfiN<_6bG^*h?KK{cT{7c4j`N8?MtES?}(bd<_uc+|7KgDM{#0u@O`u1)d$~S`oRRAE1Y^n=C11tj9@~v20w`z^M}aVD zghA}jU=JFjMn>V;0>!e!Uw1(9LX&+-j!wrJ18`TQ?9WDT>4BlQQr!=*P{4Y+)U*$E z1-&r5e~Fq?fI#qMD-5&!Em@C2^$zPe0l8csI_gSH>s!jP*OFDN|2Nj>c}w@ zta-S(1Doz_sAT=};H|7zB`si-nT}nH`AD3Xk%P9OpjF zob)l*7@|2%@1#EfPLarEg!7Lo2*4^9*af=WHAs9ZNMWMFp>s4njDei;9oj*{49bZC zbWoiS718SwcSJ7Awn*;GBnz@~kVUCuirGED7`$J&?ofP)s*)t#6n>yDGKl*}-Oc2>E{56`F zv7UktrvwY0;{&wiqfPBA8bym&cob#>lwSBC)G0CRojOEhetbAbf*CSz1H-LjpuxQo zFKZRBNOUb|9}>Dw1H{n&gc%2o0K_!NLc?sFC1E&K8SaLCnfAp_=)>M#f9NcQclb__ z9~Q~*(E|vBBna&qJwlSnehwTcYWE}Dl>^G*!?A-HcQ&oNw-x*b;Pu4HZh9Z1WsXoA zj%z2P-5`wS!jMLEU{j&Mv-S--hn%}k0!pzj@5M2*yk5c#;4Kmk>V6W~f*x4~fFDx~ z*~YSMgU;X`mTNc?Xz}rtvJ(G^Oc%yT0^;J|9vP#C!oL79@uDX1R8+L<&uGbT7m_~yiwXx>-N0Js!I^EDxh%CPhxr%TQ z5=9+wA_0Z+GXO^T-MxF4&-CZEf399lw+^Hy4>p~BcfBFg#MCqlG$008O}^q;!#zqP zK>RFfl4DqNy6G3_Hn5w6A1o zS^IV$RNW78n9~4X$ijkX!ejnxEBLz*Qb*c(A{}6H2w$hpb7ibiB@`>0FmR|GyOuoi z@VzL|$wLQ%{g&jDb?_R&v{bw%T~8x)6m5#7fO`OS(7+SF+*~NwgkaYXaDw0vTnW`; zd~LV^Izd80jFW@TNwv^qSnyUVklIpbo+x@aA}?RPn%L(IDInyE`o6Z5oT4%GHvaZx z+u5w0qBx(8mtS6_dK*#$Ww41f@r3(;ZzA1Ux?;M(4K!hd8{{CpL)^MRcp_Hre>x+F zQ}@fj{DvYcIDxRpyN-VlFTI8V3_^{Q*(jkG*Ds6byzwkWS|}NljWSZ#sf1`mFgT;VE%-iNalHDH-Y})3ALFy7onn{ zK2T<6=8PFLN?=XG$3Q#&9dr)SAYTYT;Sp~#hR!<;lLBL$CQoC>Z}-g(%}QoI>9+^( zliV0ELr{yk$>qlh5PRt~A!118VEekN>gsxXaTgFP$dmv_2CJLWIl1RK+KA9EQX)hu zPpk`(1L^yj$Mnrt_HvmFYaYwBpRs1O;sk29_B41 zeO0Z0a$KH@iIX^$Fy!P}tAKpm0Pq4!@M&rKBqq4<7QrH@3}7t|U`vo0iNIZa!(-KW z1uvA9wFt!#&_xEC!oC?9u9L!kQ{|j665HW=$e1R#Wk9SzF3qp5u5R5b?@7fxVYKMD5{oqo_0xY+hC}QQ4IgQ!km#cO z3ygzT=YY#M1yY((7Jvc)yPJJ+90H$F@pJIb1uc^QlE^C6`{__@4&qo$g1?>&NKpui z<3H*_)9G?%V$YPg0P%4G#@z=kKr7rqXh`k&hsjFwq^Y@(3*EqGR7k|82H^VdH$6K` zFl5g&nEkEszI1;Ir!xgfEUe9@d7N~XVwT|er}hH-N6{St98rnYjgFH^2bka`Fsr(U zEE7Z}3>y6P^XFMV{{SZt&^2+%KfJ=o6B95R8l8h*5|G{kLHL~`OcC1}L>p0p>!5>x zxQyL~0`1etwWhi{5=|6eIdDgaQP>YUT5W>isn5+@#+_si#fwYgck*lDtH*~qp56u~ zF{KD#cMvm3LSi^)s?R3OygeLp_!v!@K^UFGBP)ymOynC96p~eBQ!4VDW7gI&U%!4$ z3xxB@K<@(f$eQ5&ILYy#Xjb@BcxEG|t1$pzN_`@L>#z=S>UuQg^8V)TRDC0Ogzm)d zAWtnSG8pP;fK!bCDisJC+O!4MF(}s{GzsxiiN&wNp-o zHN0!fbt1;#zz{ zxCfCKL<$(R6rqtI3^at=V6g(u&5gcFiAm7Kc%d3Z91^LSPoAgbuL!kbV@pc}qA!Dq zBNxF4178W@UI97+*m}OU@yi#d>fGBt1!^zs&cR15JsXbux5)Ii2qfrdCj?~c7s-8l zwSY#ol0a|_dO#v;BF~-itxllo-?;Kz?>UemHCjrjTSAO+&j7sp+e&v6)H{22*uhBx zyJ(6!;;K1vIm$fw%uyzy;a>vMyl7kO{nZSr{|%n_^5Jb@Rw;vtM9OSiJ!sAS-3>-S zPtdwa;7W;)XX0&AyY&EaQ$IdB&nqg;A>u<2 zgvl+Bi~{(^ac))MoJ2CON&X|}5=a6MaD}i;An`c>WoF2!pDPxRZA3nX{xNwq($WX8 zux4MN%dabKaPF27G0HT+U*DEG{pik}J3X7&fn8p0y?=MoytN)1UjGk#VAbCjFXn@e zH*elN%Fl{Vs{WCIq>~2il4u@N;W5)#;0DO-Q3%!FEa$17`sU0YvV39iEDQ1qK}rWA zs8OcXiTOSXD2yu6d)RcmUU~)qG4_Xl5qp`T17d1y90C9xf%YN z{mS8WoK=j^R|czONg<|EO`A|0n^8~NdBQ12cj?eCqTXk9#nuPKm@GOuoCnauX&FD0v`tZ zLhvv!&6o2wl7BL#1Y(9rLFgHdHy=F`fK4DNd3>yUoQj$Y25eQWO9alNe~ao=j_CZv zv4kcYd62pwFRwh!+|3^URe8eeLXaq^GO1i7OaY7k8d$8atRz?>2rSwpSd63Hg_L>X z{Dx5d85UmcPfU#$29gB@$=#oOf{IG$f(u z69#q5>v-v$_d7z9Vh!@ zcd&mwSfy%-9xklK2kr$*0`hUC-~5ITW|yF#lHC@XHx_nxHiY1=apK|i`4y|Nzh;&K z<#j(BySm+^nPk(oD_hVOXKFMAP|L@8KoegA z(*9FOBT;PO)y{9k0oh)a5JC1;LJb_&clg;bZCR5qCXjsf>sw-$bK{e zr9UbG#Ptv{5akamQ&CghPTU&WC0NNxXpx~1Shiw1tQ_H{N0~m(7%}3 z4p!r)iFuoTy(`AyE7KZtpxP@36pu$Q2)Qg^TVEaYoDZ~%?dN0!~(Y#|A4TakQJEpeSJsf#Y@E$tI zS%VN&H7^EEZ@f8C;M5ZfGdPE}NhY1JwKaC0R{I8V0-O;tlSZoyD(YkLTUKVE^Itq6Ft?-FoY4uE{z365Ox_4xH9szY9& z0c4R%BSP|#U2=+QyKob$+GAEsGoE*;1&WPzsXz*^3oH2{Ws*_i;ZF2!} zFzB+Fp#bA(-~|3m4eT!rm9?^i#0Xr5>I~6gsEI(pG+8Z5ty^iA&h(p zDM&OhJ61bA`>#1aAd1Ht2K8uSb;{phof4Bj;dM5+j~NnweD`1Twt=+j^!>|2()&a0 zS^Iic@P6GSik-op;o1~QG}h{4Qqy3a^rK%0{d`gD)|`8Q=pPaz0{Zb^Pka{Z4gSfdgv2TA zTzUeA5M(@n2tD4zM|}7oLGv=mTms<~6IW9yj{eV36TF87)=Y{^NzoyDBPa@y>ih`= zHWG)Sjg3tSr3ENCkQQKg2qwAX03-DPs}(TEhFqg*^`cn0>dDTARr%Pf9_M|~{qyR= z(13x4PEsCX#vwm?jlF%s4~)`$q;}X-jut@y<;J7muDdsyUwzRYvB?3fR`B`xts62k zR0of^v8p%ulg1kon)y7&j%@G;?zas3PNI9p;Q$Q5*+7(ayrfAa6eZaxZiMhJsvhY{ zh*DVyA{K2}a9Wxl&Ye1SD)80SMJ=}r&HR4bBj+_dGSW|S;?0{k7rCy_Vy&{^P@g7q z#8zSv0zF04=P+pl5)PJ2Ds_1g$r}cSNf!p4zzwuN(P`jZu|kA_mU=1TGAv)#xk5yS z)m03Ir#<8shFNZgWJQL@u%_+1RBI>DRy5qvZ851CGrlfEM*vA#$xaroYxhw)*{&U3 z0coWu07P`3+HdUU&eh;45M$kfz{*VIupU#04yCYjCu!RVK}L;y&GVcKvYKTwPON?q zwhW82uzRwH6|H&H>A0{Un?#o}Z0mklItYPTg9^bnZE&N}K@~+sCh|=xXX*GvIZOFQ zbWBXQGp#XW=hloWCUn?X;Ww3;lm_RizCmW&>?d(Y{=fS!149K|2y~&9I9S` zH0fZ&35=aJh0s*Jr*;ovx)(BRkcNoOWx?-ms9Qr=d+?!?v!PCC^ z0W9TdRS@Nt+{O$-GfQJKscc0CWr@R-6kNTv?#))XEFy5)ln5v zhUZMq(}?RdT%-^ChR8_Uj)4jx2FWp@+~5bM^bcTwIs^2&Q#kO;!S+DeFNlFI5M(9Y zu{1U|PCW-1KnkQopx-d@0C;hD^kt6tXaNf&5Fh2DZ^ZYB_bq636~&&s`RC5g7<55G zjCpye1eyXDT?ms)ppl+o&6QAfCq(LgU0q#2Xr(QY-RX(B6b&B&nV=q#=i8#Tj`Se3 zl!&emv@z$~-DmQ;kL}vjfo^KT``F#4{1{>cY4ec~2;z_;19O}}7*pKT5AQMBYL&mu zl@11dfyRr_j=+gefli4BJKY-vaa#)*x&{(tfJ1$(59oGCHGf~kgI1n@a4yt3!eT@jc~&?U<5ZO3%5 zC@Btr8mEp6C4`L#3n;S7iHi^Q@paijvP&U^4au_riY=N*K_WG3FjIF0<$ne6HZuID zUOw%^(6LVbOq1OClz+kmpp=+UaC0$n6t)cj@HBhrhMhE(0=+T#cL~9Qf_^EPbb!)M zlA|`9>bc3W1;oOj!5%omu_#-}n1UdMp5ES4AO{PVFJH$<$#{!Xab5nbs!qSa@}V~9 z>v8s2qJalxrFCZWQUMWoXn~l%gUU&iX#jO++=3$j)eUG~LDRqvZI4~3!UGQMaW>B7 zrwt+UX!rPCn|)>3O+IRv96T6Ivq>_3HV33ySb{T3#1HfrU{i30a#%EtC=LM+(gxib zPKPmwshC`$GIcJu53JL|tfA@n(%A`xLpYupbr$eoN%Y>AHFNO)$N2Ppvp1wTBIb}K z2xxb^#?UwuC#djt|U;j(B>nAGT)q2?h5B6@M*D4Zg(VU|gqiiR?3F^xz9 z?KLj_k$&vPv{_XPMNInLjsZDGeNC+jhnZb;i$P7pi6MPWWiWuobXuJXX&Ce*8fMP)9y3F@mIDEoDrfhub+B~xh=}jc2 zf8Z3NrfL2*#8Vtn1=cO~RT90G?5LR8`2+jWuD5wFA<2YVfnH3H0hDnP9N`hKRZ4#U z{=IN^-lNz8AUWs+@FJ<9<{X{9rm5Xu4k)Q?Fd49K?!}2ncHp4xv^aTslr#f0!|7TE zm31i6BOo~E$#bAQ!}84|&O&bR`A`7|eC&!FYde0TRTgMRl2^c;xx%Phn6m5~;?F+j^g zs}aPZbP_%H3ucU~sW$6(Q*^J>#VQQ6b_y~wG_VjtIR`plq8+i% zxS?uH4*8gL|G6Ps8yli-CK_kY$ro>4%(_h<3AAyao$XxdaTdH)9#FpjTBq{}32)a* zXlM}IchGDqI^dAdnxjXFET74TtZ*MlLV4f5|EcSmf&K!4 z`dE-ouvG_>n(*BqRWdKJn@P;HF1djo3uQ4gozZDuMj)D!kT=8ol0|Y zsD}wCx3G7`!xW#eE0Pe&-G=KJB?1(r0cA7M3Lsjf-vK8EIs-WaOR6%0z?mrqF2yS* z7VC0-l8+`8aR_!vhhF}Hcqx@{yTN_#{}G(y?rgMceOKcNqCTZMWHy7%2rDff^5X)| zrvZ5)5#@?xhm?VAD1!e5z+@q4FErr;uSqY#>>8|ZNb-jhd^WfA)|y#Zjpb-xkTovA zH**Ny(Rb4G{p@FoP^N|Yi}(?nt=_B7?`3x(6&C_h8J>$=?zt}X?G$flxBr~8pED4T zI|>09&;}6a8eon;T?TZ0FE8S2qdXeizD8)Us1!yZqBTRUe9{N{fb)m_1Zpg=AGNL$xoed6s_APAGT0Kr}_Gb=%V zVe_>u_3to&5g8r%cPi}OeMG9_|43nRfF0T9rVD*ZIAXa4UK-J5`6l*-5OA9xJakam zA`!;&QU~Q!2lN6|&dLa74^SRPVaJg~hG?51t8eas-b!xx>p5=MzT#EOdx`7^K&MOZ zDVKl}PSOaJ!{qD(&cWO7>0d-8rJ(wUQm5uKP&#!SHzFf^e0;5VBHQXK%ux6^(YQuP zS&>kI!)_*}jaP1u4V3>GRC>}4<$ue6irdetuGYhH@_seUs@go%n`Ryx`JE&9&rKMm zrmd|F)=}`$drP3fB_lFI!Ep)dkG&v5e7I-kB0icKr69?zS{oV`*4AHXREgBkwdm}5 z=iEnTBNZpn7N?Mmt?tj;!>7q}2yi#pt+dLK{G=U8BfV`D;#*Dq)-_*NU@>^x!?4+;rdJ$Rof8 zM}Cox+{AfuE0!~scJ2rSuS zuptRTx9OyX4-A*;Wmajvb^SAJbn=j<;}hS+uFdusfwKcgnpdziz?#nL?#UI92KL%s z85@K}9f6Wk^)W~A&Iu$7Tqd>0su##`5UZC3pp1VL3rbeqLz;#F%sh9^)-oI&<8kK> zpEhk(mx$i<`-gUj@XzAnNhr!LGYB)+^As*EusJ(gtFN&!-)1?}ET~NJncAb_&|6YE z;g@C(9JF>8_OJDRa6zoU&qDoVk>Xu5?_!M%~$Kp6YPd;%~#Ps)EKr zy9#fKIw&?Kq$1Rf55*`8fMrWSM z$BkmZqM`&8$qKMfj&0;+O;9t$$A0J$7oR*BOj=T~`QZVvWdaa-#L|OGqgk*TuN)zz zs8vAWE%CxYVz#JWcTO_7R`^#zo*gOPj`B}IJvr`TgG>;MMF9cUCyyxW}3!}&PZ9b3Vz(1gb} z5VB&m%YOqhvH&fQ{4J1rn4nRRBaV(H>L2O^lFAj$H+lG8Xac4NyzM=ag4)bxHP2Q8 zIVOYv8f0-v#d}aLN>dUJRudY68Pt-QYl?HE8Zvq|RLRK;2YDxyF#mzCJq*~aln0;; zpxb=-(m&G4DD5)fD@sFb(fg$fa}c0r6m%5JkEheGrQCQrT;d7M7H2=JiHky-O7lIS z5!kg`Fp-d4xHW133XcQL*Mh$5D2%i&q261_$!TK8$th1X4z#?<-yP%t_Jg0ndho{V z2frIbJ!m8hRWVoy8$nHxPQ+nn3E?Wup9M4KupJktJaHv1&F6&#p0p*j%B0Bnq(K3$fnIwmTu^0GdKCIArZgK$tVzAwi2TUNA z&vtw^`Z0+k$CwaHz7d>Nf2qSliuWJ4*oAv)_M%DMfPMe{!bKk%(%K)Y-Iu*gZ-QgE&;F+IF3L= zzd=+Td{hP43^gh6V^H=bfonh};Be{8Ab}6S+a)VHZ=8f48?6Fm*gFsx7lb9+E1O zoi;8d#y~|wK7W{J(^b2TIAv%|keTj8_tQ&brNpka=(;?O4BmmAyLOSgpKsN|65vGOg)eIZU;Ax@$&`GuDAmmx z8GYet0$vW@HyyqAu|gY9OLOFqe+NNf{X|sgs!iRM{d_fv^MRS`M81$4|3!QLxmk>c zu)tLsj}RIfsyqanlcT6Yz|1VTs5vEnyrYL>AWRp7+>vx44JTl(ciRe%#th>3;~UAz zp;+zfKQv+jk?=pDwwE!hB{vnXk!Wr4*$QDX7~4=qDtM%{z2C}&ap^y9$*8)dyB-rcNY zbS?1&~BRGV5zOxOEk}7eENu!vN|{97MB^XYmrC^r3bslINvfZyoSlb-?lo z9|7oF4!dNRJ->4}cEf$h3uqH0KNPs;gT_m$ga1Cv)vR~E8|Xv?vR(79AD%xno9=-5 zn2CU)$hCfrcW&_CZ%BI#;PTw8BmR$*l4v|9IRDRFl>gvxGzNr6jhi(pchLCa+!jj(Ygaw8O2vcKT&JD(8+afQVDqx@3wcjvSC+Nz&&EK!OIqfwTlDOa#b@9{4)DcK_~S zAP$w#U1>*k%EXJ+RHuP0xev*K*8cr0bqrq7hZ`?pz$<(UK7Jg$19h3M`Ut6I)E~x{ zgN*XLCbCQ_D?!!U_iJBxdi5>7!D{G9C!$*;1PMN(E;_6;#9<~$@ekmf#6&)o{8;*T z4$gT*t_M<2S`0^0ZLq{AlX!y=dMq$B3;6-oZx|}E?F4)f!3vwApA3%2C7QhXbeWg` zLDQuj!(YMWR1I}Sok=w`@`%O|q|%Wlxy<&4tSsDZ4nbR}-btkU>usLL-1^{uP`HXq zPF6JmNik$KN6v0gsEPsbQht{U9ZTz00r;IwrhSewVFKsm&Bqq;SoL7ws(egCQaKTG zIW173k_#@<>cdz+^RRvh)P!}L5O{$TsF=xg4C}y0UX;`$)PIG!B$yOgr&hY7yQ%ba zV3?hJOAGy|+(IR{JWH&3ODv%v21sg2pU*D@l(Eule!3LPh z-j#Dd0Ae<^So`s6iC4ku=#KYKeOZU~%E!lQBL@3Mo=vvp;t;ruP67=kvH0-p42x8K zQgtWRxH3WuLA<|S$E!D_C6>+`ol}3<@$N03(456@BwkE#0oM8Tjl3QP+aWM zirZn#u7%uOKR#s*R&LxYtT0n6U;EvYEe(|#*B#rcI|K9<4H~%)j#Q$9HYnwAW%TKi zhY5aS4-R!YMOM5vD#=*NrE*6qBqMun)a#8!9fO_v%Mwf;-5%WZPH8qefQv(lm6|O^ z9>w^Mx*KIaxG*B6$mEXF+-vNVzBcG~(!v$XLmp>HZFb5vsC(+X_GR+a>&6@W%Q6&} z+gsM}T3DCgUhsNPp>CRiWy(eUH!|M`%saQ8+7q+vNBbN6qWFW6$t!9DVh0ewl|UcH z8M4W)Rh5XS$;rvgP;?B*ft4W9*(%faAm2vYnbJWkYiqbcg`*fEK?E5(kuSO(n~B3Q zlf@$Y`XC`LSv$#Qp;m~C=}3#C`?iH*k8;-e@iFFnf6Xavb77fNMQPsNj{eHmduBVw zq#Q8kI#`uuQ8nZ8zJDt3B;{*xrI@`I5;8jy@iik26XB+a44=3oEu>le)KFhDIW#T& zJNK$}8rAMTMLbJF-`KZzq;-C*c5k0+Z&9uI*YUoh#LmC!^b?E6I>hD0PsZu>m8J+C zGg7v#UoJQ{I9tS(9-_*e*9()6L`3?;1dGemJ3r`{&M~D59UBs@!1`kk7%GD>3!sfh zbiRqyc%IrT+B2Xo>Hys#7ZiYU#5P1CL8NjpxJ<{Ng$F0aG>?m#Zwn1;%tnl!KmWPT zZ?nmBO@?|xM4(1kiD`dlvZ~1R5f#tZTz#dXVrBIy=PQQi3o)KLh$z;raT7)Z?$eW& z`=XHz(c(?%Da^3W?wg)wq_U`|%4N@M#gQU%?box$ zGy_^j>_@)GDA#afMd$|M4WjyyYs@zh*Uw`=yED2wtx85i(i;lI zmz92&i5YRXwd?3walW(e-t={{^zyaxd`im$D((9#W;06SY$Ny?Hx>O7`WT9XS-~f# zPmhmlb{FH|Jb>me%BrMBw8sdNCF}>rANNtUlhT^7F?0%z?UXsiiKGeO!XDaGQiY=h zsfA{mM&G5&Pjwpi&^_TJaP)^Jy>QMI%cJ_!lEbS`iMHS8Yl~<|wk#A|&d<Mj{Vb{5U%~Eh8kJzpngH!LwO4>MdEJiedSAuZwlJbk(ZIT!!5U zcE4A|&q=|-)KY;KT%^GnNHF@NR2+>%w5>5I_3G%x<@t(O9#rS#N1{fADYWo`FZ)N* z6m{5x!1D%&*M!wos95Lu$6!xCnl@s6aaF8=dZ|-U9ck$zIuY|7CA;*+ zG9pcPtfdf@U)^5MGmx9G&7|ymaM-=Eo7a-AX?xxZKV(>6F+6aNk6|>DxMpAPZ}NZH z0{{otDVNlNNZ=kKH6iIUJPJ1bR&r0k8Agw!&LmjE9d0R+-_ga$LGur>=a10eSdViL z539Jor(b-!uf263scE3j>EW5619N|*cm{3@L)2_)d(!Q;K_$)eOX-I>$1UX3j?HLD zQ{r)x;4e*{>Y6FL)7bZ|w>LaB60M@%|Jd3eG&07Sd0Hp1d8)@*8h?t(=3ah&I=4GX zsR;5ILG>gK{CQ`N7qR$h0uZ`R%35Aapk706ywr#78qDju#?~oIZ+dW!xW6)(*V?E@ z01-$(21A(S3pn93j{F{;c4v}Dy6v%YZMV0RuRkV{B>W!$10KBm;ykK9Y6R1i_Dz^v z0tBPxnU6p_!IdO34U#p4C_lw&3|et&0CmEv^O+s%xo|xEd)&Lx$76&^Rv8(gpW>d_>|+F&*&sZt^EHm11WK93E#cv&-?Ev#2c5V$uWC#G8*;D^!O- zf`~Yen(WAl?H>XW0BDRhCGw!^@zMA-A|P#L%U0wq*cGSzA}%l$5Hy*gm_q?_LL2yJ z)MN)pZ*@ZQ!x;LLN3mr{QHxBGzb?R>1yA6PRR5%62Or%Nt?yPMoRV9E#rwzizyu)9 zA(}YLSIpk?n(YZ#0^$rQC&*HrK2unHad_Na0^^esV)FO|7DSC&wD~FM|?J%#H|v+ z4rMi=gK7?m=>XOUeXOmk`&Ck2W*O1p-vtBe3;^(FZa0f}H)^*&z@&K)wzLY+w{dVh zq(luLf4Hz*e+uQv(iQBqlXq`{B|Dj8{XNGl8p@&iyQ{>B#Nv4#t8x?bOzhZqg1?`^ zN!H*0elC!?i`QzD9Aw6$VLcb6PwuPZ-ivp_3-JcZ6wFXgNHPM+9mE)m>Xw2+=I7Jn zm?+7j{`y((+X|!}A#MDM4fJL9vR3ZRusZFSGc0n9WvIxZD zD1a(Q%(LK7fzxf@p6@V>Bpa#Xvr}dgq=fry$Ab3#4!{X~1+rL10rSxFC;i-)ssySy z&_e{tIA~LLb1M6s!Fu>g<5!>Ur~ws}BwlC(bf9%G*f2JF#Ewt;Ah}|Lw}!Nprn3{Y zM=a2rT5tNVV1qNe3In~q05H--dx_@UP`3<{cujz|kS~ZZsIi9HcP&i?Of2~1C5%1- zepCYh1|I|Gyt!wg?G%$jq%%n^KURhq2TyZ|^!3a06SB6uYV~pCOl^;V>=^4gNej_q zYP<~SEMztB3bc_iDm}pG)~yGq$@G#jy<8V{Rh8@k)Y4(^SE93{V3L20?XcHi!8EW=FMIs_?OfSYpP*v#`11o(ZSq5Y46}}ouMcS9 zetm}-9}iH5Q}-40r1ijANMeh~3l%qet~bUFkq8FY07JucH;f=hpH3e&wQJ-EICYf$ z0bT-~JrW54mP#0@gYhyU1JO4#@#7B>(&(k@pcVxdPlQNPE}MUE`OoXhcThRX z!4ZEDY$y;r)zR!GNEV3UB%FI?#vtqwdllCS@(Yom2$F>Kg;*vyq~77(mZrNS4}B_e zygnL#!6+f`N90-$018nD05oL2efx@pTl_KO1BX5-KEMFmR9D6A@SjCX8L8K52xxn% z;U^0Y&ZhOVH^igurdfkiz|^r0&>m>>o9jlv$$bFgu3n~?esVN8ziFF>>cTxv`0J9Z+w=j#={p{$wyuR~R z#`ZIH^=i|1N4ON-sOaB&EXv@vzuDEVA2#-!{<`Sy{y}dE<^93mGeu_QHtLq|m=_dt z5{7a#XTlhzj#(1uUKOE#7P(3R;Ot7&8}Z9=4uJ* z|0NSKtH{qq;0H5k9>d07=$_d=nF#;xSvU2kFBaLKBIKQ6TUor(J<{+U2dY-i2gas; zyU*u2eV=~3Q`U)BOTXq#p=QC``X1L)Hp?7AmBHu3M|Nc9JR}lmE}VUMPW9l>7!rNQ zK(LL@C*8b>6u8GaeI#P^UE9nSK1SM{F;UBPmQyl{jZP&eU%qm^D1~cRPPgUAgQsIl zZ-idUvi7@sEvEi>&EAbqcdhh|jRha08#CdEz)F3GzR>X-T+et0SCTuSSe=f?aDsTP+Q z{Et0@S#6(&M$!^a6&$#CJi8!{>v+|>T*xdRqt9PH5z5z zbMsGC&h{IAusKAo*!X5-*jAMun^pcY3zjBDByD2EYIi1m_P#l2>-gYBgj<+zkp#nj z&ZPe3<}d%ABAIFxQgJ;%%7$qdRuzDy?1=$tjUNh_c`64-B(z&T;rO4 zjDf<>3RgW^^fhY=g*&@5d!CN!r84X-rG4)o*Y05jZg?%Q(yritY-~>z&+HGZ7&O%H z?>*CCw{Fn0TH=^*WW(;ePUhyV?&Y@adq&d7wp?=y$r>H0y~5;My!%KDm-yc4moBwS z*(Sab8B^Yy9#n#yTX-mCwu{aJ9XC-%yUc1;>)#soo*eevt8e}yuW0k9d|jc=_HeIf z{x+gr56<+ivG|aFDN^$(oOC!tuv^M@oms{iM z!18X1ii-G@q|$_a4pHs_m3=R}g6u!PjZwBWX-b#+kyIAZJ=J2Mc%*%s{ofTU}V{-oD{$Crq`|DfYZm6oSs;YRVc+;^i&$PjyNu8Y;TO_$HZ;b zl+oK<>8jmD0m1S7O6JW{J2XslzWC?lZM4nm5;>rDFy(aODg9gZwl4EzqO2H znRk9aF}rg(nd{4yhlx=EgDH-CuVB%SM0kd!?sIZvg){l*FIZWc{Jj3479*den@JMG zPqj{d`!=H?LBl#=Q>BlVQJ$5A{^0Ce8xI5wj0iERIpdU2SC&eRwcQ`9=hcBx^8{SFk03sc0xQ-9l)TlHM+ZWA?$&%bED});&YPmW_Ew2^uFHYX0j>l;6X~d`AVkB}j%{bxkr^BYk z)(A01_cYBJB_Doz3-dhjvlF^^Ql>#?fU#p?^63`28iP&WV*avuEatmP$E;|lS?54P zLQ?s&@5hfaT2J2|E^-#K>Q>Pb(O2U+?WQ!g^rvT7ceaso<(r0NC1$N*-KMsfxtg_^ z`+eQ!@TDzu-M!pxME9KJ*bUo1Z0OfI8 z^WB=;@$LC`LlJ%h$E=huuu(yeVV2^KLv?48Wr>0Fb)JgzQEJT4{;bhxiEF?0efeBJ z*x%}=lqDxNeD`n`f~m&4<=uI2s`cFOjA&FIZzy}erJ?jlhqigm8JQV%2|MmNpEB8& zU9P`HIQDpbY7DSDfyXscJuyRxrq<3lA#8lFge277F-#OZtaI5|c=%9K#n@9Vrmk+e zW&1SXD#DiQ);U%>D8Ix+g6z8uC11~P+*pX$QrYXU{n%U4!DB;f@-Nmi6MMhPCiPke z9@aOrv5g*z-MnGo;2|Xgm*I@L16iZnOENy4xtRKWOLu)-dZt5X{m|Fz3GRBy3UyC} z7(!Yv_+PsrnpHU(Ir+riSLZj-SXkncWt*_ezSHj$m*QR;bwWFrK=6zP-k6Bo$w0q(da-Qkl>IjVAt}6QCqEi>Ad?vw zt*h^yIl3J%7Y?}VT{_AJ(bYdC_NgU)4bHEO&8^N;JjRdNoz)AImX;fJr*wwvj$F)r z`$l`Yd(ES7`Ep9Or_L3;-(Pd|+wKLXmc}8$uWkD(!+I^g?lipirCNtc02|Y8w7rRQ z2>43Wf|j=?meC!YH>b$j{4-&5g430!3oLy9Ov&!o2`s|wll^dnIOGM$VcSZKL zSM&RU#ebQsk;~q$n0sD?sax4yt8G%I0MJaYy0@b`z(nzzD|>aX8!L*Q>-Wfr5jXNF zNdS39l;$O-zghlUhmwr@o*Vt%M)h71Gw)-jwtpI9N^}ot`8_gZ82{~acFS1s$JqYj z*HJosrDMsa)p9ZEto$JOAnBb{gMXhgh0;`{e?Og~Yq?T*H~6cM{^kls3Z_Z58LW7T zDhCy&4isZHF0;^6hrk1DeL;}w$RrBH!lc6rZ%P%8C}83pafSU0o9z-{tVncEz|YjQ zCM*Ue@k4+Rb7f>z-z8etDl_}kA`T2b(1_)<1~h2?N++SDuCgvHl#sdp=8Og|xszR2 z2i-SlOH@cWHGh)?;AR-JL{@8Wy}L|Js)f$Ol|_-<7IID=r&f#_GBqEW2r4=EME}`T zr`TcASY^c{w|edBqMZ5)6~L21-P)2bxzyjZxUOBJ$J}(H{uO7|wjkm5H}(qI^#L0i zUKelEN{iB%&WKkw9(!!O@<^AM730c{O4)<~L+u8?#iN28LY<#{?|+TB8Wk{q{sQ3y z8ELD)A@l{#TaBp6=p~n5{%UWrKP|BSwycoS=f0tRNAPzyg+4}#j>aTOe;E?)WMlX< z9u>G3{waUs5JV)9RHJ~wU`D4zDq_2{^9oPSj6_{Vb53Coi4j4Lz5{9QhC^5X{`>D% z)ZK5*(>aMySZU;Lh#pi4N;&R92Xy?)koJ>#ABuw`f@DQQ9CZSDmVW<@TMI`>X;;Y9 zGKsHsoJOlN?}W|wRGIOhA*ZA=WII-FvO{>>oV&93{(kj)`9p1`nj^84zxd$vynAuL ze01P>Bz98Mxzh~A;g&IRZsGe*Ufw%SG6upw?$Zw0zA(SzbIWK6B2}Y)w?F5Q{kP*W z8NE9BRnNt1jMwx=YAc;SeBZvQxgTfusd_p|)8fP<)iRmig9cyr*thnbBor7J?25sT zzwz#oHyw%;p0rQq$j@s(YnRM)eEGmKsA}Vl@_{fuI$5Jqjy=#iw^-fE9f;g_Zn?vb zJTl6GiP6IRr-5wmSq)M-p(+4C~L%X|D&OkzF8V;@pD`k zMHhZQpdo>kE1$P6ao9;*1<+(-<~CL7;z9_VMUYY8U77XD6?|l-4`zs(hKjd_c27AHw57FCu)$UWCHv^{* zooape7YNhjbV^Rs07Xbb%j5)u0LDW^HxQIvQY3ujFQ_1cQRM>>+CRf*Lz)gqoPxe) zIqi<{h#exHr|d|d2L3kGVqbSAqXdk?B>2n{m=3JY-}7$qh+;B8W1_~@MUTT%8ihhP zq(`y(OKTT9XeL>gD5Jkna-mKx!a`}oT0w{Wxjh5s!OXX!%+Tk(YdiWq{Q8?N8aL?= zm|q*V@Rb;LI^OW)heL+!=#INt%fbXRE*^Etx^6Gp&aA()LHhN{xnuU~L9uhG{Mq!q za8i7nigVagDe+<_+q@H$%$`4&9oe+;TK{P6bjhWT+6xtL-r#eM`?&m?;rEAV{{>Sq zkYYAgytYBKbZYO~BbEh@zwI(FY~7b=^J-JZ;P#n&$INboM-OgL+7@q9A2d2(>AKbZ za$s*yu^*3Td1%TX>o&CCciOwcyltb~>&Y1#ov*rBR8-tKz1w1YEG4Mn6)y){FV^X1 zz0qBYaQ4r5I5Uk0NDvd3-yCJ=-K@9OAv&@Gv*ow`-Cq2LU|91z>T}K;VO^q8W`kA(B48M4B-J zt5WJQqEk;)OOWN&O`c}S{M-*vE+Mf7se$b8MO|?ldJZ`+sw05BX;eRy&hd3WulwG!>{P03dC)*@tax5Cn#I+b@ncHReIZo(R_$* zBvkeFU$kMFRLqdQ9A;IJ90VBG-i?-Y58z>I>VE$G8M8AZzJ1QNMB8WX=>?-pw?LZ+ zk&Z*F@ub{}&g%QJ8_!<#JhSTH=rdjv^+)WMy6*r~$MyyI-?#ny4qA@yJoEv7*AQ=)jXzBO-cBdxYaxy@Ir3xlL?X{IS zgZ6dUl8sT@t`U`n1bs|WHZydybaLaJ0?iV(DGl(&$l|;x!C^pLM)H4#TB*S9OF@?( zU`gPvs>5)$ie$4*JZxrX4vC6wI@z8=h(of$3$q0yB2?;0)Kn$OPLb#~H*5Q!o}{V?m)9@1U;@DH z{6(Aw#H1W=r~rB^Y)x!k$bN!B5C)@YFEyUfoJ_E*rOXKY_hjWEIZ%(G2m(#ZjU>8Y zawOdOHfjtql2p0QuFt*#YTKbYx(S*MCew(Fz}>S9+-|g8ED$$+JLh_r?y1Vq(UmE} zo}i(9XeH9LF)OQhqqW<>30Ww1z<{)c5F4=Xk65GN9xB=10UsNuHiPuy@J~=rq-GEi z&489B_p)GO+@nWJ7cXkbUzJGR`FR@8igaT=t-!UFJy#F9pK zO0N)07}82G+glhYqYnxiZY~=t9KSYknr(`l>L}WtsVjah@nJ%$775Qi^CB9agH`SB z=s#)JTy7U$xa}j{BmVj4AD87~F+=^q5cWjj{p$+t_v*R}`rHc5`#UdPy!LAmm5l30 zIXF*M2_A3+eV}LkgnbZ)7ay@j1h$Sdo#+lZ8)yp7n6iOXQ)4d*ak7=oC_ZO-#Tq4Td0xC6CXsL~CWEm${{h#xdzZh>zl+*y^{1uAeQDNSK&V7Aq)=jwF-x{H1PJ{Z*>{sk@@I6m+omX<+% z1LgwcosDO}eSC-`C$;Cy*DTD(qYis)#`oJKEQ3CRW~`!arguPT5xJv6NJ3qd!S9_| zs?*5v8H8^dH*vtZb2G6hNzn%b7#fj)=8gr#Th#v{7gv}LGniy5kLi3ld3lN;K9M~# zA8D4sB_tm;)Y8#JF7ZQH zod+I~UkdomoY~G_?Rzp=*S>8VB^&08M5GJs{kj&)fY79yX#~SXaxZQg>V$B$*;akY zc#j^JD?fupW>-D26}Jr@ry@)qZZ3?<1}VZjx2GBuLTDQSIrw&hQ;z@e5ge?~8bjl+ zvANl|YstW3oj#n>G@Y8FzS0~MKF5aWs~Va(o)rLasN+cHV?e7=i8G4!59f%DO&!i- zxH)Fp)oe_gJ0K@D{@gf%kABo(a4dp;1;6sCZHp)9q0h(=l7{K>F_1FRxkR3*&`BvX z6CuEdG71x>b;y^Sud^y)H!hIL2XZUvUzv809zfeF{_@)+qbyPApV2|qK~Glqt$*Fg z83_pNmhe8n@4|nS;C0n!1bmd;meDmg?~82cz@3Tf@_2Eb-Y2e$y8k(4kO^)E&x3X+ z40dV9(1bsj`qQa_vxnG5G`EC|xX_xzd77X+CWrHz93bFS@dUqY)$!xqv$8SZmwXG! zXbsZ0cFYpg&0}2~CQ&~m=&h%Rti_?O*zEB2!>obaHhXtfvw@WvEx#K5oNjAuj|m@} z6Ri3^%RP(gSST~wq2t>kImf1I{P6o`%(EU`WOeAEX}G2x=ftheIQu6WZU@!w@0`w{ ze*DCT*B!mCXJ={r^TeldEk)jOasulQdF8N@p1r_sLL-?Na}$J7`diLPi1d5zA$NXC zGiS#IMF0K`xq?710eUd&yXgOTtn$Ef7W5G{)I(kaF2yrjC98PF!Ck9U)|Rt{!*zHFx`akdeTs@ocgL>t7PWZEQ@hk8W+(h8|Ch zBIkG(@{)M2GZsO4`obKdR2pT0e1M#{DOnS`=a}UgsqWFb|^HCZe(uu%y6K})W(m*dE-lQ?<2}N zU+PD3P5{BBybJ-B4^{p0h>3aW9Nd%EYz&&k@uUd!4dNPucf>N%o|<~}|CM$nP)%Oz zmTIl7W4#JiWzZT0Q9wb!Fk6clL_kGk2!hH`1VI^Prd3OyC`b^HF^VEG2q*}mK+sm1 zLya;CNG(H{M*$HCZyyqR?RxjFci(zh9bA}@|3CjZ->|>^?GJDzNuM+P);l7n24yG> zHj?N7L6n6;BP||SVItKCesU*Z8wT1ARLOuk>;=LlCY{*W&*mSA)DP-y7BtBe|vl9rb?E-Ne4lQp?|8w><>%v^{ZK>n5n zvW{s{HP)O!47rZclrqqijE(g^=XY-_yNKAJ3F#$>%|x4?#zA66de@hJvwgibE3RojHX&Lc<(kJT`|mY4+S>jV z_FaLVhYP7l&gY6bt+#ufU)#(99xO<0dmn<7)e(Xi9;gXo+=80_2;tzg!+ zRx7Nnv-2+1wc{@GL@?iFk@iFo85jTF`7b`@^Ubksw03{}46La`TfnFITU_c&?&0~r zuu0#Az$T?!+j4mCthCuDHi`c?Mv!el6VzYgo47c?k{lZzphE~E zJlwg)UGkP2Uqc&JOWJsbRj9hWE_K`c7c+iJ=k@e|nZI|*rT^+JJaT!Xi&3KC&LCIO z>jy)5cbWA^PIOS68!l`TySqwJRX@{S`dhNf3DPB{5l14L&^x?$rRC3ixxpH zumVDM5japCKd*Bt4d8JD?jR^#Aj#j)R_2(7n{^D$y)4c=cUx@2E`V+rZpRA~7A|jh z0h=ct32b}LEB*cXbXloQ{4Z^cA)mt6B|!?QXG>JYIvEP&OXLmIhQ9Y7iEVNG)tdjt zRW$q0OyrUlbeH4kjbL1g4Ge8;K`v4=Xh`|w?w01iuX}#d0;|F?(3c7@3_-iZYTmA3* zf+I()OML`xY-$`BR*mcVSeel1?q3t<3ymHIR+3$J%%>ebM^0r?V?Kn?HVO+1Z;8+) z|3p>v6lNgPqIO(A>B6B&o^HLs|FTujacN&5Bz&v8*moPov{{Ah`tz3p*!_HT4Znha z4NZS!c<^8t#7^8mX<}g;EY=k?wyd}&61WtJcsysG`E9za9eUBJu*k2VQ8EQFyh{4&>9>sgUiPeZ4-J8ziK37lgQI%>aMsM47p*>dtq`kHf@56$pL0rKY7R z0Lr^4Ftke8IrTSi(vxwMXg=1~xhZa*^TRWcQ=B=EDf1;ebYRIPlz zv^tNPOB5wq-NyqFfYF(?-M5g;-a!CvijRx?4n7t2Y&tu4A6BXmueUlshRKS(;EH)c zK_)olJ9W3l(|PTf7r{m!GZP<|7R)WoYQXYe!SY+6f55_RNp~2$1UJ}n>I7STyvg_7 zV#`v@&>_Rm6HoG!{w%Qv>YryF#MU}$cH;#T65H?l8kGUpu;u)Rg5%Dc_k+QLnZza_}MlpOM91!{U-VIYN#`Ac&5D)w){j{5<3v(v}dCa)qu`a=;9a zz!WaUpDkP^Hp>ha3KYXUgupA%G1~*f5{tD;0^p@xa(tan>lI}VD`19TZuzM6DWLl> zaiidx=M$@N5U8&np5)fdf6b^x01towk(}4%S9BV5Bzty%OF4n*}5L8&fV7i5PkMd zyWGg}QnrB`Ujxo2(&D@$9hQxly{z=)n+KatGRPQmDmTvc0m!#i;`{~9QjCC^%V4ZSFu(_IF6=R$BM4y70crF6eou$q2#rT(rB$?+a$bIFFr&Zn>*n~4%U+Rb?yoJI zVpWfK*KEZgmVFGpn4Mdg)4g`#6lANF82u4nUS95owr_$Rpg}(C?GlG|sveFAqYsZv zc2p8G7w0=%wnpB1UzOdu?N(aL)CP-v2Ygy8FtU?__JGUc>W^)CNERtHr~LLvVy~&4 zH_p3o<2{`Q>))I_H7Tp%29=Wx1;MlnKd4z+g(DS>M$p~{Hnag!@=GCm#2)qKHp)Zb zh#aXkxCX@L(_s$0E+%gaLiB2IR7Ywu{S}WKITDTN5NfT_`1+2=JsA}nL)93FL6c$q zyr5i74z?)d(Lhx_#+Wf(aU7k5WUuH`+pnpCYfVM=u3%Gs?LbK5)#cTpn5{bA>}g46 z0%rQQZ^a6h9O8)Ghzm1BU z`&2@KO{vF%aP%kMCu_z7Mw>o470WmNhRK_yWQ1aK&JF$9o7?C#fmMh&tWQnq+~vE3 zgbqiGh9jnW?KCS`(~T?}h&I^&e&eZkCZ^@uU_RG95~-56AqI1W*klL%paJoT$byL| z0P2V)$<~AKM$2Y=pBBO0-+}p1bd@v9k;3q{?#(5qOM|#zX2$^AX@iPW#myk`7_fuo zVb#p+rh=$o2p3}8IAHJ|szXL2dP;T)Gx1)1Ey|-*NM2n>^JZJY2)lwN8Gj7`VEQ{o-GA%A=*0cPM#ZjzWNnv z1C{Zr%BZCWXg~nc`Ta*6!Vy=X8AP<mN zZlbBc)O)-?e#OSUZ8y#@(?w&7(z(;8nBw=XoDF1%#A-2$ttR9Y-1A*rLzZci}U~U=kBgW?e+{Q35$sXjx@5 zdGC`Ch=?%Sh@bKsZ;qm@0s88MX;QxZuFMKC&!(`p0du|g-8BxkTnX75SE7d%b$Kp) zltROBhA1@1)SjZBW-o_am*&Tpq4GeiVn%Q?WeU}5;S?M?bC=2;C9kFTAaXjDd-%hy z5yy;heHEk=Ze<9ehH)p3IEqT+7L`m*O>ExjdQ-kv=w@){8Ez$YLs(Y2+Y+T$uvGGn z^;yK@b+xbpAU6A7IFdJwPi8*uPMyNmJmBu`j&we0$=aP#0Cm_H3&(+BZ^EsuLG#OA zZYSVF64Kx>k}=>_&`gg50bQx4rsm<=JZ!oH37{jiW;Z%LX-CIaCe>0pV)$1H-ge}= z`K=+U@-T&TpT38&m4gk{{z{NEtS?EhbM+evwya#CBN~QCrlVzMx<&b% zmRkzgPT8BcQ1ioXr^j7)rVuFK3!g;Qo{G|0#R28!Yb5?~m$&mf({nHH4iwoI@jaSW zAqFP@x|>I7``T#}Y8 z+fSm*z$?&Hn!7m3Ae5I?cb6bUN?%%Z_5Jb;3T8WJ19t`zFcLgZa_$ka3%dktqM zqf<&X4mWFuxNm}syp{wLB+P^mEZt2IuGgO;AL5`OL`Jnh)<#;Te3=d02^G^*6wo?F zLd|?c2OLBrf32!ikkr99107Z;TA=%9&VgTS8i-y4t z^nq64UyHf(Aax-ED_{&OL@_q6pN9+;n&}cVJCKi5qYfr4*xB_dTd*VU0fuXEtyYf^ zQTi3dGIF;|#8h@#PIn(wKseEhJaR783jD4?d|o|SA7it-Nw8lVa0bd$2MWSTdO&@5 zVdW)3pP|qwCg=pp9Kt04Ne~vh_4WyD@$6>9OoM=rub8U@7hxrX$W|mJJssM@YKPq} zdy|qSNd(v^1TTS)LJ4i)6&~i?lhP{0{CKNiqa#P!fkQZevC`Lzin4Y|%25nPV3IJG zM4$c>6_y>B71&a9Q)plBCtuWs>>d%yRS2b}bxMu0QDKW9?gaG5{m5p5A-OR78Z}O# z%SHt+<2yDXpG}5_`x{B0@JQabxip5f$x;8~Jb2 ziZ?V1T>7XpF_?q$f8t54E0Eo2OHIp=o+|*nUX!$-J`g}{vT38AyxaT1#9La7wyT?c zRtq{Kg}RNj$lcmC_f*SdUj4it97HNgT41%8KcPw={kU`WWS zr~B>1;1u&m%YE)a1~xAl&!azbP1o$`__=^LX72708a$a0tG!=@JEJied{2cN+bZ7N zd0lDRr?z2QkpA5>u>Z`%ipevwdEwu4X{v8}+@)urzs#x1?6yJZTWW8yK9y$~)K<7@ zpsvhMw%^t1ZQP80bEHVE0yp$UGks))H73CzU-5Ix<+N-c!M!xX5cU?6x35+D;i~Ii zfA#8uF)pR-+4BMJPXTMMqRFH3I*p}|NGxA6F4EPT8EKI7M%ndkbgaXGSh&y3pdp|m zr$Mf=Wp$3GW^DN5(Ndq_mTFe>ht?_o+V?RdG>+|n-$=(J9S398AMyrxYldblt{DWR z$_JPiNO|f_9m$v+wSez1SL4=rwj3L2;60qVy3Opdb^OVw>53?Yw%ZyLV={GZkqZad zFOTS0H|1qkwpQy^6j!h!l}prPj&>A<<%tx8Z(Ped5wfh(z zV&FaQF9z=H#7)5tix$&do?kHMv`;Cxv;;)`gRTA!+Vlm-O?gi*HGO>I!*Pt-oIm!= zIk|3yPR->Bqu4R~mpP`J55zKZC;OQL@t()4@|PdgG|90(TUC9))1Gm*MLhnNibUl7 zkb)T`rHP5&&Bj{D20#Rs#9yyhKnU6 z((*g*Ef{D^Wb%B923wtN+EXJG7X-br{_AS{HdrmpJ=ehLeOvHcrDUy<(QORkGJWWM z#5mJ5Ja@7;iY04y9UQ1*jJ9g$!UT$a_PD>|$=dV@X5R`8lZVnv*EvoZ=sfWoH(C7H9wZeaqT+$L!FW52cs&H6)oSm`Tv~oMsvpd*HHPfcatNv zKY8IYiW&+hGQ6D2CaIk(`!JN19pDo@Fj@PPCxgO@{TS3Vjah^=XLjX9Y+- zQFac~Q1|9GYcE!?|9Fj3*0(Ys!kOu}d-gE%qEjZO!qm}$G1cF1)?)YWPwj?Gn>6~E zoF)726Q1*CeZOyATjHat#XYP$3z&{v~qPEt*C1~1P%Up_|+!Lu#Ufp48zIk<=dy{7SMQWz)$Nkk#LvgXK zM-PkZGt_oYmxZQZ8|+WK*l^D)*!Sa&a=o~*@BMf6cTm7R;akw+jxDh3!*}G)$M1(3 zdUUpxlisJXM#cNw%d_=*Gn~ns%ip*hWJ^ppH_f!Q#k4wy`0T{9l+^Ld5BRYww`s$H zG4}07{}lnbd`nutTQ3V9e$Pa$>1yj@J5A=qz_d*Goda9u#+GKS0EFrxWhdr&D)OF5 zn4E>Xsd4($jk&R+#d}Q8NaeoNdY?({PEwpmbD^cLfzSiL%O43^UU0q>||fV_#=2)w{lZ1HnXsL+)EV29g<00Yp`nS2TLhEc8k5g9-)(l$Z3HQ9Gue z2OEhJg@&X){G2oYnz9}u49)#<&oU~7q`(XcqC{O}qNKD&LYW1UX@r`34LGGH32_MO z2)X&XICCHO^D~8sPlGfAOz8U<1S?0}Qc1uxAeFWdPv~jt37o&I6ySIiA|6=+ax>gp z8cC2ul4z0^f;1arm*8bv7+q+|zn;75@cu7kA9p5hqd(dXw8jtk;>)w$4-tILrhURk z>0wwbW!z*+%_GQJZJwCy{z~F8g8vZ$jUort11;7Sny6f$O_@|O&U2e(ks4SdgdM0M z&&9y;>!emeqCvtj0os)@{`Rdvtl)J5XP)CpLcAnFCCEf=x5M?NRrCuzfV|y7F~8w- zUbD-yuOzK;j|l0iHkSZvI7{Odc|Qvx`1%sjUE2w+My{0buCA?PSQG3B{6$iR1t2iB z(G~2%9r$cHz&?Wc>H%RW93SIP?Gd^$M)sA*UIIbBT+v3aX@qFv@I^V_4Ky-;C^U|YXqgMCV+*3j1p(2+TRB3F^#^st zA_ywm1vOAiZuv2>^)(S&#pxc##E)`itWq+&ys2bBp)n*VS(^EUJ-CZ4S>x(c1}U6`)n zKvJY0wHf3AVc_kx?YI1R$O+%IG-K?KY5sxN>Jz})hAr20lP<4QmIWJitIDF+-)Q># zq68!HsnPmhjl{o>KmQZ!h3yLFQe$rirn09?)Km?9;B`O9{#$4Jn0YVJ+D-~S1lm+l>ycz5ZmSZ@vN3H?xU zb&?!opc|@!bC7Dc8VuNJ0g}UA`rcHQh%NJJc!(C4%Hl6dE?a z0)P+#Z|8T!ILO;`ZclX|Wxe@hXcT`W^@^ghNj2oLGHbH|GxV0DbrsDt_Z-T0* z8(VW`3bi4d_=!uoF!8qUo?Rr6u@0NGS`w2&ju8&QPQb+b9}L-T$4wd?Jt|rDg(f$s z1eorlm)zM}7HBj1fBdwVR2#@T!4Nz(_FY>B0@^E9K)?b45!YkY%4;H4>fMiXw!?<_ zwyr}{8tTk|;%sp!X|>aT`hr(TrhJhK#M9QE?6HRr^Q-ZmR{&npj}@vw3_mT}4hPN) z68Jqw>~Ern%L}=9t+|s>Ni141&7ZL8S0e3d(s0Lv_5&On;UuNn5G130Zh z88qhnG8HM>FlWD9`;QC7OTl!i&3EdP8*-RTUxyhLC__38PTff`$fsv`Me?^~ab${= zics$o5n1|mz?mC?kP;hzDkRf1dow2R=rP(N&7I12l!#`(Y?a_@5a5DA?Dj19gd!dN z>~mon0}>%N7?>w9bH4UI@_%I%(e~jap!f%r_W@w(DGS`vLsMd}1SCVMCI_HGzL2FOxm~)(1#gdywW7sb(fvcs47NJ`BcEa!3fC zQfJ|5L6!-ym<$f29jU#e+@Xn47H1f3TvSzzC9fL4P;_&*Xd zOqRa1Oh`3*^xlK4`>lc>s{Z5ul8FCDhENiQaQHskITWRa@Bv@0YDz|iKA>JGkjJ17 zLYer$%mXlv8dK^77la8QZ>|JyzY@~;yPxN8Tz-vA$lRPz7aU6!>*(RA)lx|X6yB4K zpk(Spi6&c_HBlv#kUa}+CV{d<;9ng!v=VD1Bn6zmtTjM18)vJAur_VQ^OskS7mXJZ z*dC0}QjDkxr#Q2wJ@nOveghkhDj9d?I}~JD*5FcgLHn5?SU_)K94Te!Ky^V~?GDO~ z1eU(MM&Z)b59T$HE{m&>eX@Xtto zYMrPv7Dr!Wq77Ep`quc=ndcV{sB@PV3P|!d#&m63a-Qn^)1#$@PmmHDB!@K+Del*p zC^N7{lwyx-A-!tAl2qq^v1A{@DKa00?%C{)3Zl*f45)aIX)3GfXLf@M8-}jrc7UFx z=*3Y0Z#B-Pd<&WLqD;&eJ!J1)BpwXp>{b)9x@?~rjROObLngMy$Mis4a6d=vG zXt*`em3yIF6h>xUkh%-XeVQ(Q!{eMi*nd?6INi=*Vp;ZZn_zlIix ze%6)@jBg0C)QGBR$HG$r1(##i3S);XE#}wld$L{bdB@`3_P-W02|` zL9WRrcF!=M+&={&v^F5gG(_iB#>;zNA#|+7Ex8t8ZzD2zHR|$+V3-7|x zA+XvAZ$e{`^&~+f1C_C@N9kv^8P@P(Yp5>PJ8tIv_;1Xg>7z5p2}wkcsi6*o|K?90 z=WeQV=d|F!KLiJ2v>02T#ExXs!#8?jW>|b$#$)NcW)E|0dRNrlUOVnN1A4&hGJaCN zT9Dl0EnD>APdF|Ogf@D(Lj(g+1Pfj#Hqt!cuZ$F?61JFw02B3+fZ+bZ6mG%IaREg6 z`dEsitJ|kuUR&qKwg1w52`*S@(f=#sO9N=RO6;@RcP%vjFZ9@%KI1#9pZ*)ajhs;c literal 0 HcmV?d00001 diff --git a/uncloud/opennebula/__init__.py b/uncloud_django_based/uncloud/opennebula/__init__.py similarity index 100% rename from uncloud/opennebula/__init__.py rename to uncloud_django_based/uncloud/opennebula/__init__.py diff --git a/uncloud/opennebula/admin.py b/uncloud_django_based/uncloud/opennebula/admin.py similarity index 100% rename from uncloud/opennebula/admin.py rename to uncloud_django_based/uncloud/opennebula/admin.py diff --git a/uncloud/opennebula/apps.py b/uncloud_django_based/uncloud/opennebula/apps.py similarity index 100% rename from uncloud/opennebula/apps.py rename to uncloud_django_based/uncloud/opennebula/apps.py diff --git a/uncloud/opennebula/management/commands/opennebula-synchosts.py b/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-synchosts.py similarity index 100% rename from uncloud/opennebula/management/commands/opennebula-synchosts.py rename to uncloud_django_based/uncloud/opennebula/management/commands/opennebula-synchosts.py diff --git a/uncloud/opennebula/management/commands/opennebula-syncvms.py b/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-syncvms.py similarity index 100% rename from uncloud/opennebula/management/commands/opennebula-syncvms.py rename to uncloud_django_based/uncloud/opennebula/management/commands/opennebula-syncvms.py diff --git a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py b/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-to-uncloud.py similarity index 100% rename from uncloud/opennebula/management/commands/opennebula-to-uncloud.py rename to uncloud_django_based/uncloud/opennebula/management/commands/opennebula-to-uncloud.py diff --git a/uncloud/opennebula/migrations/0001_initial.py b/uncloud_django_based/uncloud/opennebula/migrations/0001_initial.py similarity index 100% rename from uncloud/opennebula/migrations/0001_initial.py rename to uncloud_django_based/uncloud/opennebula/migrations/0001_initial.py diff --git a/uncloud/opennebula/migrations/0002_auto_20200225_1335.py b/uncloud_django_based/uncloud/opennebula/migrations/0002_auto_20200225_1335.py similarity index 100% rename from uncloud/opennebula/migrations/0002_auto_20200225_1335.py rename to uncloud_django_based/uncloud/opennebula/migrations/0002_auto_20200225_1335.py diff --git a/uncloud/opennebula/migrations/0003_auto_20200225_1428.py b/uncloud_django_based/uncloud/opennebula/migrations/0003_auto_20200225_1428.py similarity index 100% rename from uncloud/opennebula/migrations/0003_auto_20200225_1428.py rename to uncloud_django_based/uncloud/opennebula/migrations/0003_auto_20200225_1428.py diff --git a/uncloud/opennebula/migrations/0004_auto_20200225_1816.py b/uncloud_django_based/uncloud/opennebula/migrations/0004_auto_20200225_1816.py similarity index 100% rename from uncloud/opennebula/migrations/0004_auto_20200225_1816.py rename to uncloud_django_based/uncloud/opennebula/migrations/0004_auto_20200225_1816.py diff --git a/uncloud/opennebula/migrations/__init__.py b/uncloud_django_based/uncloud/opennebula/migrations/__init__.py similarity index 100% rename from uncloud/opennebula/migrations/__init__.py rename to uncloud_django_based/uncloud/opennebula/migrations/__init__.py diff --git a/uncloud/opennebula/models.py b/uncloud_django_based/uncloud/opennebula/models.py similarity index 100% rename from uncloud/opennebula/models.py rename to uncloud_django_based/uncloud/opennebula/models.py diff --git a/uncloud/opennebula/serializers.py b/uncloud_django_based/uncloud/opennebula/serializers.py similarity index 100% rename from uncloud/opennebula/serializers.py rename to uncloud_django_based/uncloud/opennebula/serializers.py diff --git a/uncloud/opennebula/tests.py b/uncloud_django_based/uncloud/opennebula/tests.py similarity index 100% rename from uncloud/opennebula/tests.py rename to uncloud_django_based/uncloud/opennebula/tests.py diff --git a/uncloud/opennebula/views.py b/uncloud_django_based/uncloud/opennebula/views.py similarity index 100% rename from uncloud/opennebula/views.py rename to uncloud_django_based/uncloud/opennebula/views.py diff --git a/uncloud/requirements.txt b/uncloud_django_based/uncloud/requirements.txt similarity index 100% rename from uncloud/requirements.txt rename to uncloud_django_based/uncloud/requirements.txt diff --git a/uncloud/uncloud/.gitignore b/uncloud_django_based/uncloud/uncloud/.gitignore similarity index 100% rename from uncloud/uncloud/.gitignore rename to uncloud_django_based/uncloud/uncloud/.gitignore diff --git a/uncloud/uncloud/__init__.py b/uncloud_django_based/uncloud/uncloud/__init__.py similarity index 100% rename from uncloud/uncloud/__init__.py rename to uncloud_django_based/uncloud/uncloud/__init__.py diff --git a/uncloud/uncloud/asgi.py b/uncloud_django_based/uncloud/uncloud/asgi.py similarity index 100% rename from uncloud/uncloud/asgi.py rename to uncloud_django_based/uncloud/uncloud/asgi.py diff --git a/uncloud/uncloud/management/commands/uncloud.py b/uncloud_django_based/uncloud/uncloud/management/commands/uncloud.py similarity index 100% rename from uncloud/uncloud/management/commands/uncloud.py rename to uncloud_django_based/uncloud/uncloud/management/commands/uncloud.py diff --git a/uncloud/uncloud/models.py b/uncloud_django_based/uncloud/uncloud/models.py similarity index 100% rename from uncloud/uncloud/models.py rename to uncloud_django_based/uncloud/uncloud/models.py diff --git a/uncloud/uncloud/secrets_sample.py b/uncloud_django_based/uncloud/uncloud/secrets_sample.py similarity index 100% rename from uncloud/uncloud/secrets_sample.py rename to uncloud_django_based/uncloud/uncloud/secrets_sample.py diff --git a/uncloud/uncloud/settings.py b/uncloud_django_based/uncloud/uncloud/settings.py similarity index 100% rename from uncloud/uncloud/settings.py rename to uncloud_django_based/uncloud/uncloud/settings.py diff --git a/uncloud/uncloud/urls.py b/uncloud_django_based/uncloud/uncloud/urls.py similarity index 100% rename from uncloud/uncloud/urls.py rename to uncloud_django_based/uncloud/uncloud/urls.py diff --git a/uncloud/uncloud/wsgi.py b/uncloud_django_based/uncloud/uncloud/wsgi.py similarity index 100% rename from uncloud/uncloud/wsgi.py rename to uncloud_django_based/uncloud/uncloud/wsgi.py diff --git a/uncloud/uncloud_auth/__init__.py b/uncloud_django_based/uncloud/uncloud_auth/__init__.py similarity index 100% rename from uncloud/uncloud_auth/__init__.py rename to uncloud_django_based/uncloud/uncloud_auth/__init__.py diff --git a/uncloud/uncloud_auth/admin.py b/uncloud_django_based/uncloud/uncloud_auth/admin.py similarity index 100% rename from uncloud/uncloud_auth/admin.py rename to uncloud_django_based/uncloud/uncloud_auth/admin.py diff --git a/uncloud/uncloud_auth/apps.py b/uncloud_django_based/uncloud/uncloud_auth/apps.py similarity index 100% rename from uncloud/uncloud_auth/apps.py rename to uncloud_django_based/uncloud/uncloud_auth/apps.py diff --git a/uncloud/uncloud_auth/migrations/0001_initial.py b/uncloud_django_based/uncloud/uncloud_auth/migrations/0001_initial.py similarity index 100% rename from uncloud/uncloud_auth/migrations/0001_initial.py rename to uncloud_django_based/uncloud/uncloud_auth/migrations/0001_initial.py diff --git a/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py b/uncloud_django_based/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py similarity index 100% rename from uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py rename to uncloud_django_based/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py diff --git a/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py b/uncloud_django_based/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py similarity index 100% rename from uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py rename to uncloud_django_based/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py diff --git a/uncloud/uncloud_auth/migrations/__init__.py b/uncloud_django_based/uncloud/uncloud_auth/migrations/__init__.py similarity index 100% rename from uncloud/uncloud_auth/migrations/__init__.py rename to uncloud_django_based/uncloud/uncloud_auth/migrations/__init__.py diff --git a/uncloud/uncloud_auth/models.py b/uncloud_django_based/uncloud/uncloud_auth/models.py similarity index 100% rename from uncloud/uncloud_auth/models.py rename to uncloud_django_based/uncloud/uncloud_auth/models.py diff --git a/uncloud/uncloud_auth/serializers.py b/uncloud_django_based/uncloud/uncloud_auth/serializers.py similarity index 100% rename from uncloud/uncloud_auth/serializers.py rename to uncloud_django_based/uncloud/uncloud_auth/serializers.py diff --git a/uncloud/uncloud_auth/views.py b/uncloud_django_based/uncloud/uncloud_auth/views.py similarity index 100% rename from uncloud/uncloud_auth/views.py rename to uncloud_django_based/uncloud/uncloud_auth/views.py diff --git a/uncloud/uncloud_net/__init__.py b/uncloud_django_based/uncloud/uncloud_net/__init__.py similarity index 100% rename from uncloud/uncloud_net/__init__.py rename to uncloud_django_based/uncloud/uncloud_net/__init__.py diff --git a/uncloud/uncloud_net/admin.py b/uncloud_django_based/uncloud/uncloud_net/admin.py similarity index 100% rename from uncloud/uncloud_net/admin.py rename to uncloud_django_based/uncloud/uncloud_net/admin.py diff --git a/uncloud/uncloud_net/apps.py b/uncloud_django_based/uncloud/uncloud_net/apps.py similarity index 100% rename from uncloud/uncloud_net/apps.py rename to uncloud_django_based/uncloud/uncloud_net/apps.py diff --git a/uncloud/uncloud_net/models.py b/uncloud_django_based/uncloud/uncloud_net/models.py similarity index 100% rename from uncloud/uncloud_net/models.py rename to uncloud_django_based/uncloud/uncloud_net/models.py diff --git a/uncloud/uncloud_net/tests.py b/uncloud_django_based/uncloud/uncloud_net/tests.py similarity index 100% rename from uncloud/uncloud_net/tests.py rename to uncloud_django_based/uncloud/uncloud_net/tests.py diff --git a/uncloud/uncloud_net/views.py b/uncloud_django_based/uncloud/uncloud_net/views.py similarity index 100% rename from uncloud/uncloud_net/views.py rename to uncloud_django_based/uncloud/uncloud_net/views.py diff --git a/uncloud/uncloud_pay/__init__.py b/uncloud_django_based/uncloud/uncloud_pay/__init__.py similarity index 100% rename from uncloud/uncloud_pay/__init__.py rename to uncloud_django_based/uncloud/uncloud_pay/__init__.py diff --git a/uncloud/uncloud_pay/admin.py b/uncloud_django_based/uncloud/uncloud_pay/admin.py similarity index 100% rename from uncloud/uncloud_pay/admin.py rename to uncloud_django_based/uncloud/uncloud_pay/admin.py diff --git a/uncloud/uncloud_pay/apps.py b/uncloud_django_based/uncloud/uncloud_pay/apps.py similarity index 100% rename from uncloud/uncloud_pay/apps.py rename to uncloud_django_based/uncloud/uncloud_pay/apps.py diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud_django_based/uncloud/uncloud_pay/helpers.py similarity index 100% rename from uncloud/uncloud_pay/helpers.py rename to uncloud_django_based/uncloud/uncloud_pay/helpers.py diff --git a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud_django_based/uncloud/uncloud_pay/management/commands/charge-negative-balance.py similarity index 100% rename from uncloud/uncloud_pay/management/commands/charge-negative-balance.py rename to uncloud_django_based/uncloud/uncloud_pay/management/commands/charge-negative-balance.py diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud_django_based/uncloud/uncloud_pay/management/commands/generate-bills.py similarity index 100% rename from uncloud/uncloud_pay/management/commands/generate-bills.py rename to uncloud_django_based/uncloud/uncloud_pay/management/commands/generate-bills.py diff --git a/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py b/uncloud_django_based/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py similarity index 100% rename from uncloud/uncloud_pay/management/commands/handle-overdue-bills.py rename to uncloud_django_based/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py diff --git a/uncloud/uncloud_pay/migrations/0001_initial.py b/uncloud_django_based/uncloud/uncloud_pay/migrations/0001_initial.py similarity index 100% rename from uncloud/uncloud_pay/migrations/0001_initial.py rename to uncloud_django_based/uncloud/uncloud_pay/migrations/0001_initial.py diff --git a/uncloud/uncloud_pay/migrations/__init__.py b/uncloud_django_based/uncloud/uncloud_pay/migrations/__init__.py similarity index 100% rename from uncloud/uncloud_pay/migrations/__init__.py rename to uncloud_django_based/uncloud/uncloud_pay/migrations/__init__.py diff --git a/uncloud/uncloud_pay/models.py b/uncloud_django_based/uncloud/uncloud_pay/models.py similarity index 100% rename from uncloud/uncloud_pay/models.py rename to uncloud_django_based/uncloud/uncloud_pay/models.py diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud_django_based/uncloud/uncloud_pay/serializers.py similarity index 100% rename from uncloud/uncloud_pay/serializers.py rename to uncloud_django_based/uncloud/uncloud_pay/serializers.py diff --git a/uncloud/uncloud_pay/stripe.py b/uncloud_django_based/uncloud/uncloud_pay/stripe.py similarity index 100% rename from uncloud/uncloud_pay/stripe.py rename to uncloud_django_based/uncloud/uncloud_pay/stripe.py diff --git a/uncloud/uncloud_pay/tests.py b/uncloud_django_based/uncloud/uncloud_pay/tests.py similarity index 100% rename from uncloud/uncloud_pay/tests.py rename to uncloud_django_based/uncloud/uncloud_pay/tests.py diff --git a/uncloud/uncloud_pay/views.py b/uncloud_django_based/uncloud/uncloud_pay/views.py similarity index 100% rename from uncloud/uncloud_pay/views.py rename to uncloud_django_based/uncloud/uncloud_pay/views.py diff --git a/uncloud/uncloud_storage/__init__.py b/uncloud_django_based/uncloud/uncloud_storage/__init__.py similarity index 100% rename from uncloud/uncloud_storage/__init__.py rename to uncloud_django_based/uncloud/uncloud_storage/__init__.py diff --git a/uncloud/uncloud_storage/admin.py b/uncloud_django_based/uncloud/uncloud_storage/admin.py similarity index 100% rename from uncloud/uncloud_storage/admin.py rename to uncloud_django_based/uncloud/uncloud_storage/admin.py diff --git a/uncloud/uncloud_storage/apps.py b/uncloud_django_based/uncloud/uncloud_storage/apps.py similarity index 100% rename from uncloud/uncloud_storage/apps.py rename to uncloud_django_based/uncloud/uncloud_storage/apps.py diff --git a/uncloud/uncloud_storage/models.py b/uncloud_django_based/uncloud/uncloud_storage/models.py similarity index 100% rename from uncloud/uncloud_storage/models.py rename to uncloud_django_based/uncloud/uncloud_storage/models.py diff --git a/uncloud/uncloud_storage/tests.py b/uncloud_django_based/uncloud/uncloud_storage/tests.py similarity index 100% rename from uncloud/uncloud_storage/tests.py rename to uncloud_django_based/uncloud/uncloud_storage/tests.py diff --git a/uncloud/uncloud_storage/views.py b/uncloud_django_based/uncloud/uncloud_storage/views.py similarity index 100% rename from uncloud/uncloud_storage/views.py rename to uncloud_django_based/uncloud/uncloud_storage/views.py diff --git a/uncloud/uncloud_vm/__init__.py b/uncloud_django_based/uncloud/uncloud_vm/__init__.py similarity index 100% rename from uncloud/uncloud_vm/__init__.py rename to uncloud_django_based/uncloud/uncloud_vm/__init__.py diff --git a/uncloud/uncloud_vm/admin.py b/uncloud_django_based/uncloud/uncloud_vm/admin.py similarity index 100% rename from uncloud/uncloud_vm/admin.py rename to uncloud_django_based/uncloud/uncloud_vm/admin.py diff --git a/uncloud/uncloud_vm/apps.py b/uncloud_django_based/uncloud/uncloud_vm/apps.py similarity index 100% rename from uncloud/uncloud_vm/apps.py rename to uncloud_django_based/uncloud/uncloud_vm/apps.py diff --git a/uncloud/uncloud_vm/management/commands/vm.py b/uncloud_django_based/uncloud/uncloud_vm/management/commands/vm.py similarity index 100% rename from uncloud/uncloud_vm/management/commands/vm.py rename to uncloud_django_based/uncloud/uncloud_vm/management/commands/vm.py diff --git a/uncloud/uncloud_vm/migrations/0001_initial.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0001_initial.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0001_initial.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0001_initial.py diff --git a/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py diff --git a/uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py diff --git a/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py diff --git a/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py diff --git a/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py diff --git a/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py diff --git a/uncloud/uncloud_vm/migrations/__init__.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/__init__.py similarity index 100% rename from uncloud/uncloud_vm/migrations/__init__.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/__init__.py diff --git a/uncloud/uncloud_vm/models.py b/uncloud_django_based/uncloud/uncloud_vm/models.py similarity index 100% rename from uncloud/uncloud_vm/models.py rename to uncloud_django_based/uncloud/uncloud_vm/models.py diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud_django_based/uncloud/uncloud_vm/serializers.py similarity index 100% rename from uncloud/uncloud_vm/serializers.py rename to uncloud_django_based/uncloud/uncloud_vm/serializers.py diff --git a/uncloud/uncloud_vm/tests.py b/uncloud_django_based/uncloud/uncloud_vm/tests.py similarity index 100% rename from uncloud/uncloud_vm/tests.py rename to uncloud_django_based/uncloud/uncloud_vm/tests.py diff --git a/uncloud/uncloud_vm/views.py b/uncloud_django_based/uncloud/uncloud_vm/views.py similarity index 100% rename from uncloud/uncloud_vm/views.py rename to uncloud_django_based/uncloud/uncloud_vm/views.py diff --git a/uncloud/ungleich_service/__init__.py b/uncloud_django_based/uncloud/ungleich_service/__init__.py similarity index 100% rename from uncloud/ungleich_service/__init__.py rename to uncloud_django_based/uncloud/ungleich_service/__init__.py diff --git a/uncloud/ungleich_service/admin.py b/uncloud_django_based/uncloud/ungleich_service/admin.py similarity index 100% rename from uncloud/ungleich_service/admin.py rename to uncloud_django_based/uncloud/ungleich_service/admin.py diff --git a/uncloud/ungleich_service/apps.py b/uncloud_django_based/uncloud/ungleich_service/apps.py similarity index 100% rename from uncloud/ungleich_service/apps.py rename to uncloud_django_based/uncloud/ungleich_service/apps.py diff --git a/uncloud/ungleich_service/migrations/0001_initial.py b/uncloud_django_based/uncloud/ungleich_service/migrations/0001_initial.py similarity index 100% rename from uncloud/ungleich_service/migrations/0001_initial.py rename to uncloud_django_based/uncloud/ungleich_service/migrations/0001_initial.py diff --git a/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py b/uncloud_django_based/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py similarity index 100% rename from uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py rename to uncloud_django_based/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py diff --git a/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py b/uncloud_django_based/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py similarity index 100% rename from uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py rename to uncloud_django_based/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py diff --git a/uncloud/ungleich_service/migrations/__init__.py b/uncloud_django_based/uncloud/ungleich_service/migrations/__init__.py similarity index 100% rename from uncloud/ungleich_service/migrations/__init__.py rename to uncloud_django_based/uncloud/ungleich_service/migrations/__init__.py diff --git a/uncloud/ungleich_service/models.py b/uncloud_django_based/uncloud/ungleich_service/models.py similarity index 100% rename from uncloud/ungleich_service/models.py rename to uncloud_django_based/uncloud/ungleich_service/models.py diff --git a/uncloud/ungleich_service/serializers.py b/uncloud_django_based/uncloud/ungleich_service/serializers.py similarity index 100% rename from uncloud/ungleich_service/serializers.py rename to uncloud_django_based/uncloud/ungleich_service/serializers.py diff --git a/uncloud/ungleich_service/tests.py b/uncloud_django_based/uncloud/ungleich_service/tests.py similarity index 100% rename from uncloud/ungleich_service/tests.py rename to uncloud_django_based/uncloud/ungleich_service/tests.py diff --git a/uncloud/ungleich_service/views.py b/uncloud_django_based/uncloud/ungleich_service/views.py similarity index 100% rename from uncloud/ungleich_service/views.py rename to uncloud_django_based/uncloud/ungleich_service/views.py diff --git a/vat_rates.csv b/uncloud_django_based/vat_rates.csv similarity index 100% rename from vat_rates.csv rename to uncloud_django_based/vat_rates.csv