Compare commits
No commits in common. "master" and "0.0.7" have entirely different histories.
329 changed files with 117 additions and 18038 deletions
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -1,12 +1,6 @@
|
||||||
.idea/
|
.idea
|
||||||
.vscode/
|
.vscode
|
||||||
__pycache__/
|
|
||||||
|
|
||||||
pay.conf
|
|
||||||
log.txt
|
|
||||||
test.py
|
|
||||||
STRIPE
|
|
||||||
venv/
|
|
||||||
|
|
||||||
uncloud/docs/build
|
uncloud/docs/build
|
||||||
logs.txt
|
logs.txt
|
||||||
|
|
@ -22,6 +16,5 @@ uncloud/version.py
|
||||||
build/
|
build/
|
||||||
venv/
|
venv/
|
||||||
dist/
|
dist/
|
||||||
.history/
|
|
||||||
*.iso
|
*.iso
|
||||||
*.sqlite3
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,8 @@
|
||||||
stages:
|
image: python:3
|
||||||
- lint
|
|
||||||
- test
|
|
||||||
|
|
||||||
run-tests:
|
before_script:
|
||||||
stage: test
|
- python setup.py install
|
||||||
image: code.ungleich.ch:5050/uncloud/uncloud/uncloud-ci:latest
|
|
||||||
services:
|
python_tests:
|
||||||
- postgres:latest
|
script:
|
||||||
variables:
|
- python -m unittest -v test/test_mac_local.py
|
||||||
DATABASE_HOST: postgres
|
|
||||||
DATABASE_USER: postgres
|
|
||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
|
||||||
coverage: /^TOTAL.+?(\d+\%)$/
|
|
||||||
script:
|
|
||||||
- pip install -r requirements.txt
|
|
||||||
- coverage run --source='.' ./manage.py test
|
|
||||||
- coverage report
|
|
||||||
|
|
|
||||||
71
README.md
71
README.md
|
|
@ -1,70 +1,3 @@
|
||||||
# Uncloud
|
# ucloud
|
||||||
|
|
||||||
Cloud management platform, the ungleich way.
|
Checkout https://ungleich.ch/ucloud/ for the documentation of ucloud.
|
||||||
|
|
||||||
|
|
||||||
[](https://code.ungleich.ch/uncloud/uncloud/commits/master)
|
|
||||||
[](https://code.ungleich.ch/uncloud/uncloud/commits/master)
|
|
||||||
|
|
||||||
## Useful commands
|
|
||||||
|
|
||||||
* `./manage.py import-vat-rates path/to/csv`
|
|
||||||
* `./manage.py createsuperuser`
|
|
||||||
|
|
||||||
## Development setup
|
|
||||||
|
|
||||||
Install system dependencies:
|
|
||||||
|
|
||||||
* On Fedora, you will need the following packages: `python3-virtualenv python3-devel openldap-devel gcc chromium`
|
|
||||||
* sudo apt-get install libpq-dev python-dev libxml2-dev libxslt1-dev libldap2-dev libsasl2-dev libffi-dev
|
|
||||||
|
|
||||||
|
|
||||||
NOTE: you will need to configure a LDAP server and credentials for authentication. See `uncloud/settings.py`.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Initialize virtualenv.
|
|
||||||
» virtualenv .venv
|
|
||||||
Using base prefix '/usr'
|
|
||||||
New python executable in /home/fnux/Workspace/ungleich/uncloud/uncloud/.venv/bin/python3
|
|
||||||
Also creating executable in /home/fnux/Workspace/ungleich/uncloud/uncloud/.venv/bin/python
|
|
||||||
Installing setuptools, pip, wheel...
|
|
||||||
done.
|
|
||||||
|
|
||||||
# Enter virtualenv.
|
|
||||||
» source .venv/bin/activate
|
|
||||||
|
|
||||||
# Install dependencies.
|
|
||||||
» pip install -r requirements.txt
|
|
||||||
[...]
|
|
||||||
|
|
||||||
# Run migrations.
|
|
||||||
» ./manage.py migrate
|
|
||||||
Operations to perform:
|
|
||||||
Apply all migrations: admin, auth, contenttypes, opennebula, sessions, uncloud_auth, uncloud_net, uncloud_pay, uncloud_service, uncloud_vm
|
|
||||||
Running migrations:
|
|
||||||
[...]
|
|
||||||
|
|
||||||
# Run webserver.
|
|
||||||
» ./manage.py runserver
|
|
||||||
Watching for file changes with StatReloader
|
|
||||||
Performing system checks...
|
|
||||||
|
|
||||||
System check identified no issues (0 silenced).
|
|
||||||
May 07, 2020 - 10:17:08
|
|
||||||
Django version 3.0.6, using settings 'uncloud.settings'
|
|
||||||
Starting development server at http://127.0.0.1:8000/
|
|
||||||
Quit the server with CONTROL-C.
|
|
||||||
```
|
|
||||||
### Run Background Job Queue
|
|
||||||
We use Django Q to handle the asynchronous code and Background Cron jobs
|
|
||||||
To start the workers make sure first that Redis or the Django Q broker is working and you can edit it's settings in the settings file.
|
|
||||||
```
|
|
||||||
./manage.py qcluster
|
|
||||||
```
|
|
||||||
|
|
||||||
### Note on PGSQL
|
|
||||||
|
|
||||||
If you want to use Postgres:
|
|
||||||
|
|
||||||
* Install on configure PGSQL on your base system.
|
|
||||||
* OR use a container! `podman run --rm -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust -it postgres:latest`
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
* Intro
|
|
||||||
This file lists issues that should be handled, are small and likely
|
|
||||||
not yet high prio.
|
|
||||||
* Issues
|
|
||||||
** TODO Register prefered address in User model
|
|
||||||
** TODO Allow to specify different recurring periods
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
"""
|
|
||||||
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()
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
dbhost=$1; shift
|
|
||||||
|
|
||||||
ssh -L5432:localhost:5432 "$dbhost" &
|
|
||||||
|
|
||||||
python manage.py "$@"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# command only needs to be active while manage command is running
|
|
||||||
|
|
||||||
# -T no pseudo terminal
|
|
||||||
|
|
||||||
|
|
||||||
# alternatively: commands output shell code
|
|
||||||
|
|
||||||
# ssh uncloud@dbhost "python manage.py --hostname xxx ..."
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
# uncloud-pay
|
|
||||||
|
|
||||||
The generic product/payment system.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```shell script
|
|
||||||
pip3 install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
```shell script
|
|
||||||
python ucloud_pay.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
#### 1. Adding of products
|
|
||||||
```shell script
|
|
||||||
http --json http://[::]:5000/product/add username=your_username_here password=your_password_here specs:=@ipv6-only-vm.json
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Listing of products
|
|
||||||
```shell script
|
|
||||||
http --json http://[::]:5000/product/list
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Registering user's payment method (credit card for now using Stripe)
|
|
||||||
|
|
||||||
```shell script
|
|
||||||
http --json http://[::]:5000/user/register_payment card_number=4111111111111111 cvc=123 expiry_year=2020 expiry_month=8 card_holder_name="The test user" username=your_username_here password=your_password_here line1="your_billing_address" city="your_city" country="your_country"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Ordering products
|
|
||||||
|
|
||||||
First of all, user have to buy the membership first.
|
|
||||||
|
|
||||||
```shell script
|
|
||||||
http --json http://[::]:5000/product/order username=your_username_here password=your_password_here product_id=membership pay=True
|
|
||||||
```
|
|
||||||
|
|
||||||
```shell script
|
|
||||||
http --json http://[::]:5000/product/order username=your_username_here password=your_password_here product_id=ipv6-only-vm cpu=1 ram=1 os-disk-space=10 os=alpine pay=True
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. Listing users orders
|
|
||||||
|
|
||||||
```shell script
|
|
||||||
http --json POST http://[::]:5000/order/list username=your_username_here password=your_password_here
|
|
||||||
```
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from ungleich_common.ldap.ldap_manager import LdapManager
|
|
||||||
from ungleich_common.std.configparser import StrictConfigParser
|
|
||||||
from ungleich_common.etcd.etcd_wrapper import EtcdWrapper
|
|
||||||
|
|
||||||
config_file = os.environ.get('meow-pay-config-file', default='pay.conf')
|
|
||||||
|
|
||||||
config = StrictConfigParser(allow_no_value=True)
|
|
||||||
config.read(config_file)
|
|
||||||
|
|
||||||
etcd_client = EtcdWrapper(
|
|
||||||
host=config.get('etcd', 'host'), port=config.get('etcd', 'port'),
|
|
||||||
ca_cert=config.get('etcd', 'ca_cert'), cert_key=config.get('etcd', 'cert_key'),
|
|
||||||
cert_cert=config.get('etcd', 'cert_cert')
|
|
||||||
)
|
|
||||||
|
|
||||||
ldap_manager = LdapManager(
|
|
||||||
server=config.get('ldap', 'server'), admin_dn=config.get('ldap', 'admin_dn'),
|
|
||||||
admin_password=config.get('ldap', 'admin_password')
|
|
||||||
)
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
from flask import Flask, request
|
|
||||||
from flask_restful import Resource, Api
|
|
||||||
import etcd3
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from ldaptest import is_valid_ldap_user
|
|
||||||
|
|
||||||
def authenticate(func):
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
if not getattr(func, 'authenticated', True):
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
# pass in username/password !
|
|
||||||
acct = basic_authentication() # custom account lookup function
|
|
||||||
|
|
||||||
if acct:
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
flask_restful.abort(401)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
def readable_errors(func):
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
try:
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
except etcd3.exceptions.ConnectionFailedError as e:
|
|
||||||
raise UncloudException('Cannot connect to etcd: is etcd running and reachable? {}'.format(e))
|
|
||||||
except etcd3.exceptions.ConnectionTimeoutError as e:
|
|
||||||
raise UncloudException('etcd connection timeout. {}'.format(e))
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
class DB(object):
|
|
||||||
def __init__(self, config, prefix="/"):
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
# Root for everything
|
|
||||||
self.base_prefix= '/nicohack'
|
|
||||||
|
|
||||||
# Can be set from outside
|
|
||||||
self.prefix = prefix
|
|
||||||
|
|
||||||
self.connect()
|
|
||||||
|
|
||||||
@readable_errors
|
|
||||||
def connect(self):
|
|
||||||
self._db_clients = []
|
|
||||||
for endpoint in self.config.etcd_hosts:
|
|
||||||
client = etcd3.client(host=endpoint, **self.config.etcd_args)
|
|
||||||
self._db_clients.append(client)
|
|
||||||
|
|
||||||
def realkey(self, key):
|
|
||||||
return "{}{}/{}".format(self.base_prefix,
|
|
||||||
self.prefix,
|
|
||||||
key)
|
|
||||||
|
|
||||||
@readable_errors
|
|
||||||
def get(self, key, as_json=False, **kwargs):
|
|
||||||
value, _ = self._db_clients[0].get(self.realkey(key), **kwargs)
|
|
||||||
|
|
||||||
if as_json:
|
|
||||||
value = json.loads(value)
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@readable_errors
|
|
||||||
def set(self, key, value, as_json=False, **kwargs):
|
|
||||||
if as_json:
|
|
||||||
value = json.dumps(value)
|
|
||||||
|
|
||||||
# FIXME: iterate over clients in case of failure ?
|
|
||||||
return self._db_clients[0].put(self.realkey(key), value, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class Membership(Resource):
|
|
||||||
def __init__(self, config):
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
def get(self):
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
print("{} {}".format(data, config))
|
|
||||||
return {'message': 'Order successful' }, 200
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
print("{} {}".format(data, config))
|
|
||||||
return {'message': 'Order 2x successful' }, 200
|
|
||||||
|
|
||||||
|
|
||||||
class Order(Resource):
|
|
||||||
def __init__(self, config):
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def post():
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
print("{} {}".format(data, config))
|
|
||||||
|
|
||||||
|
|
||||||
class Product(Resource):
|
|
||||||
def __init__(self, config):
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
self.products = []
|
|
||||||
self.products.append(
|
|
||||||
{ "name": "membership-free",
|
|
||||||
"description": """
|
|
||||||
This membership gives you access to the API and includes a VPN
|
|
||||||
with 1 IPv6 address.
|
|
||||||
See https://redmine.ungleich.ch/issues/7747?
|
|
||||||
""",
|
|
||||||
"uuid": "a3883466-0012-4d01-80ff-cbf7469957af",
|
|
||||||
"recurring": True,
|
|
||||||
"recurring_time_frame": "per_year",
|
|
||||||
"features": [
|
|
||||||
{ "name": "membership",
|
|
||||||
"price_one_time": 0,
|
|
||||||
"price_recurring": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.products.append(
|
|
||||||
{ "name": "membership-standard",
|
|
||||||
"description": """
|
|
||||||
This membership gives you access to the API and includes an IPv6-VPN with
|
|
||||||
one IPv6 address ("Road warrior")
|
|
||||||
See https://redmine.ungleich.ch/issues/7747?
|
|
||||||
""",
|
|
||||||
"uuid": "1d85296b-0863-4dd6-a543-a6d5a4fbe4a6",
|
|
||||||
"recurring": True,
|
|
||||||
"recurring_time_frame": "per_month",
|
|
||||||
"features": [
|
|
||||||
{ "name": "membership",
|
|
||||||
"price_one_time": 0,
|
|
||||||
"price_recurring": 5
|
|
||||||
}
|
|
||||||
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.products.append(
|
|
||||||
{ "name": "membership-premium",
|
|
||||||
"description": """
|
|
||||||
This membership gives you access to the API and includes an
|
|
||||||
IPv6-VPN with a /48 IPv6 network.
|
|
||||||
See https://redmine.ungleich.ch/issues/7747?
|
|
||||||
""",
|
|
||||||
"uuid": "bfd63fd2-d227-436f-a8b8-600de74dd6ce",
|
|
||||||
"recurring": True,
|
|
||||||
"recurring_time_frame": "per_month",
|
|
||||||
"features": [
|
|
||||||
{ "name": "membership",
|
|
||||||
"price_one_time": 0,
|
|
||||||
"price_recurring": 5
|
|
||||||
}
|
|
||||||
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.products.append(
|
|
||||||
{ "name": "ipv6-vpn-with-/48",
|
|
||||||
"description": """
|
|
||||||
An IPv6 VPN with a /48 network included.
|
|
||||||
""",
|
|
||||||
"uuid": "fe5753f8-6fe1-4dc4-9b73-7b803de4c597",
|
|
||||||
"recurring": True,
|
|
||||||
"recurring_time_frame": "per_year",
|
|
||||||
"features": [
|
|
||||||
{ "name": "vpn",
|
|
||||||
"price_one_time": 0,
|
|
||||||
"price_recurring": 120
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def post():
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
print("{} {}".format(data, config))
|
|
||||||
|
|
||||||
def get(self):
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
print("{} {}".format(data, config))
|
|
||||||
|
|
||||||
return self.products
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
config = {}
|
|
||||||
|
|
||||||
config['etcd_url']="https://etcd1.ungleich.ch"
|
|
||||||
config['ldap_url']="ldaps://ldap1.ungleich.ch"
|
|
||||||
|
|
||||||
api = Api(app)
|
|
||||||
api.add_resource(Order, '/orders', resource_class_args=( config, ))
|
|
||||||
api.add_resource(Product, '/products', resource_class_args=( config, ))
|
|
||||||
api.add_resource(Membership, '/membership', resource_class_args=( config, ))
|
|
||||||
|
|
||||||
app.run(host='::', port=5000, debug=True)
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
import parsedatetime
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from stripe_utils import StripeUtils
|
|
||||||
|
|
||||||
|
|
||||||
def get_plan_id_from_product(product):
|
|
||||||
plan_id = 'ucloud-v1-'
|
|
||||||
plan_id += product['name'].strip().replace(' ', '-')
|
|
||||||
return plan_id
|
|
||||||
|
|
||||||
|
|
||||||
def get_pricing(price_in_chf_cents, product_type, recurring_period):
|
|
||||||
if product_type == 'recurring':
|
|
||||||
return 'CHF {}/{}'.format(price_in_chf_cents/100, recurring_period)
|
|
||||||
elif product_type == 'one-time':
|
|
||||||
return 'CHF {} (One time charge)'.format(price_in_chf_cents/100)
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_friendly_product(product_dict):
|
|
||||||
uf_product = {
|
|
||||||
'name': product_dict['name'],
|
|
||||||
'description': product_dict['description'],
|
|
||||||
'product_id': product_dict['usable-id'],
|
|
||||||
'pricing': get_pricing(
|
|
||||||
product_dict['price'], product_dict['type'], product_dict['recurring_period']
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if product_dict['type'] == 'recurring':
|
|
||||||
uf_product['minimum_subscription_period'] = product_dict['minimum_subscription_period']
|
|
||||||
return uf_product
|
|
||||||
|
|
||||||
|
|
||||||
def get_token(card_number, cvc, exp_month, exp_year):
|
|
||||||
stripe_utils = StripeUtils()
|
|
||||||
token_response = stripe_utils.get_token_from_card(
|
|
||||||
card_number, cvc, exp_month, exp_year
|
|
||||||
)
|
|
||||||
if token_response['response_object']:
|
|
||||||
return token_response['response_object'].id
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_product(usable_id, etcd_client):
|
|
||||||
products = etcd_client.get_prefix('/v1/products/', value_in_json=True)
|
|
||||||
for p in products:
|
|
||||||
if p.value['usable-id'] == usable_id:
|
|
||||||
return p.value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_charges(specification, data):
|
|
||||||
logging.debug('Calculating charges for specs:{} and data:{}'.format(specification, data))
|
|
||||||
one_time_charge = 0
|
|
||||||
recurring_charge = 0
|
|
||||||
for feature_name, feature_detail in specification['features'].items():
|
|
||||||
if feature_detail['constant']:
|
|
||||||
data[feature_name] = 1
|
|
||||||
|
|
||||||
if feature_detail['unit']['type'] != 'str':
|
|
||||||
one_time_charge += feature_detail['one_time_fee']
|
|
||||||
recurring_charge += (
|
|
||||||
feature_detail['price_per_unit_per_period'] * data[feature_name] /
|
|
||||||
feature_detail['unit']['value']
|
|
||||||
)
|
|
||||||
return one_time_charge, recurring_charge
|
|
||||||
|
|
||||||
|
|
||||||
def is_order_valid(order_timestamp, renewal_period):
|
|
||||||
"""
|
|
||||||
Sample Code Usage
|
|
||||||
|
|
||||||
>> current_datetime, status = cal.parse('Now')
|
|
||||||
>> current_datetime = datetime(*current_datetime[:6])
|
|
||||||
|
|
||||||
>> print('Is order valid: ', is_order_valid(current_datetime, '1 month'))
|
|
||||||
>> True
|
|
||||||
"""
|
|
||||||
cal = parsedatetime.Calendar()
|
|
||||||
|
|
||||||
renewal_datetime, status = cal.parse(renewal_period)
|
|
||||||
renewal_datetime = datetime(*renewal_datetime[:6])
|
|
||||||
|
|
||||||
return order_timestamp <= renewal_datetime
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
{
|
|
||||||
"usable-id": "ipv6-only-django-hosting",
|
|
||||||
"active": true,
|
|
||||||
"name": "IPv6 Only Django Hosting",
|
|
||||||
"description": "Host your Django application on our shiny IPv6 Only VM",
|
|
||||||
"recurring_period": "month",
|
|
||||||
"quantity": "inf",
|
|
||||||
"features": {
|
|
||||||
"cpu": {
|
|
||||||
"unit": {"value": 1, "type":"int"},
|
|
||||||
"price_per_unit_per_period": 3,
|
|
||||||
"one_time_fee": 0,
|
|
||||||
"constant": false
|
|
||||||
},
|
|
||||||
"ram": {
|
|
||||||
"unit": {"value": 1, "type":"int"},
|
|
||||||
"price_per_unit_per_period": 4,
|
|
||||||
"one_time_fee": 0,
|
|
||||||
"constant": false
|
|
||||||
},
|
|
||||||
"os-disk-space": {
|
|
||||||
"unit": {"value": 10, "type":"int"},
|
|
||||||
"one_time_fee": 0,
|
|
||||||
"price_per_unit_per_period": 3.5,
|
|
||||||
"constant": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
{
|
|
||||||
"usable-id": "ipv6-only-vm",
|
|
||||||
"active": true,
|
|
||||||
"name": "IPv6 Only VM",
|
|
||||||
"description": "IPv6 Only VM are accessible to only those having IPv6 for themselves",
|
|
||||||
"recurring_period": "month",
|
|
||||||
"quantity": "inf",
|
|
||||||
"features": {
|
|
||||||
"cpu": {
|
|
||||||
"unit": {"value": 1, "type":"int"},
|
|
||||||
"price_per_unit_per_period": 3,
|
|
||||||
"one_time_fee": 0,
|
|
||||||
"constant": false
|
|
||||||
},
|
|
||||||
"ram": {
|
|
||||||
"unit": {"value": 1, "type":"int"},
|
|
||||||
"price_per_unit_per_period": 4,
|
|
||||||
"one_time_fee": 0,
|
|
||||||
"constant": false
|
|
||||||
},
|
|
||||||
"os-disk-space": {
|
|
||||||
"unit": {"value": 10, "type":"int"},
|
|
||||||
"one_time_fee": 0,
|
|
||||||
"price_per_unit_per_period": 4,
|
|
||||||
"constant": false
|
|
||||||
},
|
|
||||||
"os": {
|
|
||||||
"unit": {"value": 1, "type":"str"},
|
|
||||||
"one_time_fee": 0,
|
|
||||||
"price_per_unit_per_period": 0,
|
|
||||||
"constant": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"usable-id": "ipv6-only-vpn",
|
|
||||||
"active": true,
|
|
||||||
"name": "IPv6 Only VPN",
|
|
||||||
"description": "IPv6 VPN enable you to access IPv6 only websites and more",
|
|
||||||
"recurring_period": "month",
|
|
||||||
"quantity": "inf",
|
|
||||||
"features": {
|
|
||||||
"vpn": {
|
|
||||||
"unit": {"value": 1, "type": "int"},
|
|
||||||
"price_per_unit_per_period": 10,
|
|
||||||
"one_time_fee": 0,
|
|
||||||
"constant": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"usable-id": "membership",
|
|
||||||
"active": true,
|
|
||||||
"name": "Membership",
|
|
||||||
"description": "Membership to use uncloud-pay",
|
|
||||||
"recurring_period": "month",
|
|
||||||
"quantity": "inf",
|
|
||||||
"features": {
|
|
||||||
"membership": {
|
|
||||||
"unit": {"value": 1, "type":"int"},
|
|
||||||
"price_per_unit_per_period": 5,
|
|
||||||
"one_time_fee": 0,
|
|
||||||
"constant": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"max_per_user": "1"
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
stripe
|
|
||||||
flask
|
|
||||||
Flask-RESTful
|
|
||||||
git+https://code.ungleich.ch/ahmedbilal/ungleich-common/#egg=ungleich-common-etcd&subdirectory=etcd
|
|
||||||
git+https://code.ungleich.ch/ahmedbilal/ungleich-common/#egg=ungleich-common-ldap&subdirectory=ldap
|
|
||||||
git+https://code.ungleich.ch/ahmedbilal/ungleich-common/#egg=ungleich-common-std&subdirectory=std
|
|
||||||
git+https://code.ungleich.ch/ahmedbilal/ungleich-common/#egg=ungleich-common-schemas&subdirectory=schemas
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
[etcd]
|
|
||||||
host = 127.0.0.1
|
|
||||||
port = 2379
|
|
||||||
ca_cert
|
|
||||||
cert_cert
|
|
||||||
cert_key
|
|
||||||
|
|
||||||
[stripe]
|
|
||||||
private_key=stripe_private_key
|
|
||||||
|
|
||||||
[app]
|
|
||||||
port = 5000
|
|
||||||
|
|
||||||
[ldap]
|
|
||||||
server = ldap_server_url
|
|
||||||
admin_dn = ldap_admin_dn
|
|
||||||
admin_password = ldap_admin_password
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
import logging
|
|
||||||
import config
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
|
|
||||||
from config import ldap_manager, etcd_client
|
|
||||||
from helper import resolve_product
|
|
||||||
from ungleich_common.schemas.schemas import BaseSchema, Field, ValidationException
|
|
||||||
|
|
||||||
|
|
||||||
class AddProductSchema(BaseSchema):
|
|
||||||
def __init__(self, data):
|
|
||||||
super().__init__()
|
|
||||||
self.add_schema(UserCredentialSchema, data)
|
|
||||||
self.specs = Field('specs', dict, **self.get(data, 'specs'))
|
|
||||||
self.update = Field('update', bool, **self.get(data, 'update', return_default=True, default=False))
|
|
||||||
|
|
||||||
def validation(self):
|
|
||||||
user = self.objects['user']
|
|
||||||
user = json.loads(user.entry_to_json())
|
|
||||||
uid, ou, *dc = user['dn'].replace('ou=', '').replace('dc=', '').replace('uid=', '').split(',')
|
|
||||||
if ou != config.config.get('ldap', 'internal_user_ou', fallback='users'):
|
|
||||||
raise ValidationException('You do not have access to create product.')
|
|
||||||
|
|
||||||
product = resolve_product(self.specs.value['usable-id'], etcd_client)
|
|
||||||
if product:
|
|
||||||
self.objects['product'] = product
|
|
||||||
|
|
||||||
|
|
||||||
class AddressSchema(BaseSchema):
|
|
||||||
def __init__(self, data):
|
|
||||||
super().__init__()
|
|
||||||
self.line1 = Field('line1', str, **self.get(data, 'line1'))
|
|
||||||
self.line2 = Field('line2', str, **self.get(data, 'line2', return_default=True))
|
|
||||||
self.city = Field('city', str, **self.get(data, 'city'))
|
|
||||||
self.country = Field('country', str, **self.get(data, 'country'))
|
|
||||||
self.state = Field('state', str, **self.get(data, 'state', return_default=True))
|
|
||||||
self.postal_code = Field('postal_code', str, **self.get(data, 'postal_code', return_default=True))
|
|
||||||
|
|
||||||
|
|
||||||
class UserRegisterPaymentSchema(BaseSchema):
|
|
||||||
def __init__(self, data):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
self.add_schema(UserCredentialSchema, data)
|
|
||||||
self.add_schema(AddressSchema, data, under_field_name='address')
|
|
||||||
|
|
||||||
self.card_number = Field('card_number', str, **self.get(data, 'card_number'))
|
|
||||||
self.cvc = Field('cvc', str, **self.get(data, 'cvc'))
|
|
||||||
self.expiry_year = Field('expiry_year', int, **self.get(data, 'expiry_year'))
|
|
||||||
self.expiry_month = Field('expiry_month', int, **self.get(data, 'expiry_month'))
|
|
||||||
self.card_holder_name = Field('card_holder_name', str, **self.get(data, 'card_holder_name'))
|
|
||||||
|
|
||||||
|
|
||||||
class UserCredentialSchema(BaseSchema):
|
|
||||||
def __init__(self, data):
|
|
||||||
super().__init__()
|
|
||||||
self.username = Field('username', str, **self.get(data, 'username'))
|
|
||||||
self.password = Field('password', str, **self.get(data, 'password'))
|
|
||||||
|
|
||||||
def validation(self):
|
|
||||||
try:
|
|
||||||
entry = ldap_manager.is_password_valid(self.username.value, self.password.value, query_key='uid')
|
|
||||||
except ValueError:
|
|
||||||
raise ValidationException('No user with \'{}\' username found. You can create account at '
|
|
||||||
'https://account.ungleich.ch'.format(self.username.value))
|
|
||||||
except Exception:
|
|
||||||
raise ValidationException('Invalid username/password.')
|
|
||||||
else:
|
|
||||||
self.objects['user'] = entry
|
|
||||||
|
|
||||||
|
|
||||||
class ProductOrderSchema(BaseSchema):
|
|
||||||
def __init__(self, data):
|
|
||||||
super().__init__()
|
|
||||||
self.product_id = Field(
|
|
||||||
'product_id', str, **self.get(data, 'product_id'), validators=[self.product_id_validation]
|
|
||||||
)
|
|
||||||
self.pay_consent = Field('pay', bool, **self.get(data, 'pay', return_default=True, default=False))
|
|
||||||
self.add_schema(UserCredentialSchema, data)
|
|
||||||
|
|
||||||
def product_id_validation(self):
|
|
||||||
product = resolve_product(self.product_id.value, etcd_client)
|
|
||||||
if product:
|
|
||||||
product['quantity'] = float(product['quantity'])
|
|
||||||
self.product_id.value = product['uuid']
|
|
||||||
self.objects['product'] = product
|
|
||||||
logging.debug('Got product {}'.format(product))
|
|
||||||
|
|
||||||
if not product['active']:
|
|
||||||
raise ValidationException('Product is not active at the moment.')
|
|
||||||
|
|
||||||
if product['quantity'] <= 0:
|
|
||||||
raise ValidationException('Out of stock.')
|
|
||||||
else:
|
|
||||||
raise ValidationException('No such product exists.')
|
|
||||||
|
|
||||||
def validation(self):
|
|
||||||
username = self.objects['user'].uid
|
|
||||||
customer_previous_orders = etcd_client.get_prefix('/v1/user/{}'.format(username), value_in_json=True)
|
|
||||||
customer_previous_orders = [o.value for o in customer_previous_orders]
|
|
||||||
membership = next(filter(lambda o: o['product'] == 'membership', customer_previous_orders), None)
|
|
||||||
if membership is None and self.objects['product']['usable-id'] != 'membership':
|
|
||||||
raise ValidationException('Please buy membership first to use this facility')
|
|
||||||
max_quantity_user_can_order = float(self.objects['product'].get('max_per_user', math.inf))
|
|
||||||
previous_order_of_same_product = [
|
|
||||||
o for o in customer_previous_orders if o['product'] == self.objects['product']['usable-id']
|
|
||||||
]
|
|
||||||
if len(previous_order_of_same_product) >= max_quantity_user_can_order:
|
|
||||||
raise ValidationException(
|
|
||||||
'You cannot buy {} more than {} times'.format(
|
|
||||||
self.objects['product']['name'], int(max_quantity_user_can_order)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OrderListSchema(BaseSchema):
|
|
||||||
def __init__(self, data):
|
|
||||||
super().__init__()
|
|
||||||
self.add_schema(UserCredentialSchema, data)
|
|
||||||
|
|
||||||
|
|
||||||
def make_return_message(err, status_code=200):
|
|
||||||
logging.debug('message: {}'.format(str(err)))
|
|
||||||
return {'message': str(err)}, status_code
|
|
||||||
|
|
||||||
|
|
||||||
def create_schema(specification, data):
|
|
||||||
fields = {}
|
|
||||||
for feature_name, feature_detail in specification['features'].items():
|
|
||||||
if not feature_detail['constant']:
|
|
||||||
fields[feature_name] = Field(
|
|
||||||
feature_name, eval(feature_detail['unit']['type']), **BaseSchema.get(data, feature_name)
|
|
||||||
)
|
|
||||||
|
|
||||||
return type('{}Schema'.format(specification['name']), (BaseSchema,), fields)
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
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'))
|
|
||||||
|
|
@ -1,491 +0,0 @@
|
||||||
import re
|
|
||||||
import stripe
|
|
||||||
import stripe.error
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from config import etcd_client as client, config as config
|
|
||||||
|
|
||||||
stripe.api_key = config.get('stripe', 'private_key')
|
|
||||||
|
|
||||||
|
|
||||||
def handle_stripe_error(f):
|
|
||||||
def handle_problems(*args, **kwargs):
|
|
||||||
response = {
|
|
||||||
'paid': False,
|
|
||||||
'response_object': None,
|
|
||||||
'error': None
|
|
||||||
}
|
|
||||||
|
|
||||||
common_message = "Currently it's not possible to make payments."
|
|
||||||
try:
|
|
||||||
response_object = f(*args, **kwargs)
|
|
||||||
response = {
|
|
||||||
'response_object': response_object,
|
|
||||||
'error': None
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
except stripe.error.CardError as e:
|
|
||||||
# Since it's a decline, stripe.error.CardError will be caught
|
|
||||||
body = e.json_body
|
|
||||||
err = body['error']
|
|
||||||
response.update({'error': err['message']})
|
|
||||||
logging.error(str(e))
|
|
||||||
return response
|
|
||||||
except stripe.error.RateLimitError:
|
|
||||||
response.update(
|
|
||||||
{'error': "Too many requests made to the API too quickly"})
|
|
||||||
return response
|
|
||||||
except stripe.error.InvalidRequestError as e:
|
|
||||||
logging.error(str(e))
|
|
||||||
response.update({'error': "Invalid parameters"})
|
|
||||||
return response
|
|
||||||
except stripe.error.AuthenticationError as e:
|
|
||||||
# Authentication with Stripe's API failed
|
|
||||||
# (maybe you changed API keys recently)
|
|
||||||
logging.error(str(e))
|
|
||||||
response.update({'error': common_message})
|
|
||||||
return response
|
|
||||||
except stripe.error.APIConnectionError as e:
|
|
||||||
logging.error(str(e))
|
|
||||||
response.update({'error': common_message})
|
|
||||||
return response
|
|
||||||
except stripe.error.StripeError as e:
|
|
||||||
# maybe send email
|
|
||||||
logging.error(str(e))
|
|
||||||
response.update({'error': common_message})
|
|
||||||
return response
|
|
||||||
except Exception as e:
|
|
||||||
# maybe send email
|
|
||||||
logging.error(str(e))
|
|
||||||
response.update({'error': common_message})
|
|
||||||
return response
|
|
||||||
|
|
||||||
return handle_problems
|
|
||||||
|
|
||||||
|
|
||||||
class StripeUtils(object):
|
|
||||||
CURRENCY = 'chf'
|
|
||||||
INTERVAL = 'month'
|
|
||||||
SUCCEEDED_STATUS = 'succeeded'
|
|
||||||
STRIPE_PLAN_ALREADY_EXISTS = 'Plan already exists'
|
|
||||||
STRIPE_NO_SUCH_PLAN = 'No such plan'
|
|
||||||
PLAN_EXISTS_ERROR_MSG = 'Plan {} exists already.\nCreating a local StripePlan now.'
|
|
||||||
PLAN_DOES_NOT_EXIST_ERROR_MSG = 'Plan {} does not exist.'
|
|
||||||
|
|
||||||
def __init__(self, private_key):
|
|
||||||
self.stripe = stripe
|
|
||||||
stripe.api_key = private_key
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def card_exists(self, customer, cc_number, exp_month, exp_year, cvc):
|
|
||||||
token_obj = stripe.Token.create(
|
|
||||||
card={
|
|
||||||
'number': cc_number,
|
|
||||||
'exp_month': exp_month,
|
|
||||||
'exp_year': exp_year,
|
|
||||||
'cvc': cvc,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
cards = stripe.Customer.list_sources(
|
|
||||||
customer,
|
|
||||||
limit=20,
|
|
||||||
object='card'
|
|
||||||
)
|
|
||||||
|
|
||||||
for card in cards.data:
|
|
||||||
if (card.fingerprint == token_obj.card.fingerprint and
|
|
||||||
int(card.exp_month) == int(exp_month) and int(card.exp_year) == int(exp_year)):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_stripe_customer_from_email(email):
|
|
||||||
customer = stripe.Customer.list(limit=1, email=email)
|
|
||||||
return customer.data[0] if len(customer.data) == 1 else None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update_customer_token(customer, token):
|
|
||||||
customer.source = token
|
|
||||||
customer.save()
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def get_token_from_card(self, cc_number, cvc, expiry_month, expiry_year):
|
|
||||||
token_obj = stripe.Token.create(
|
|
||||||
card={
|
|
||||||
'number': cc_number,
|
|
||||||
'exp_month': expiry_month,
|
|
||||||
'exp_year': expiry_year,
|
|
||||||
'cvc': cvc,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return token_obj
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def associate_customer_card(self, stripe_customer_id, token,
|
|
||||||
set_as_default=False):
|
|
||||||
customer = stripe.Customer.retrieve(stripe_customer_id)
|
|
||||||
card = customer.sources.create(source=token)
|
|
||||||
if set_as_default:
|
|
||||||
customer.default_source = card.id
|
|
||||||
customer.save()
|
|
||||||
return True
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def dissociate_customer_card(self, stripe_customer_id, card_id):
|
|
||||||
customer = stripe.Customer.retrieve(stripe_customer_id)
|
|
||||||
card = customer.sources.retrieve(card_id)
|
|
||||||
card.delete()
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def update_customer_card(self, customer_id, token):
|
|
||||||
customer = stripe.Customer.retrieve(customer_id)
|
|
||||||
current_card_token = customer.default_source
|
|
||||||
customer.sources.retrieve(current_card_token).delete()
|
|
||||||
customer.source = token
|
|
||||||
customer.save()
|
|
||||||
credit_card_raw_data = customer.sources.data.pop()
|
|
||||||
new_card_data = {
|
|
||||||
'last4': credit_card_raw_data.last4,
|
|
||||||
'brand': credit_card_raw_data.brand
|
|
||||||
}
|
|
||||||
return new_card_data
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def get_card_details(self, customer_id):
|
|
||||||
customer = stripe.Customer.retrieve(customer_id)
|
|
||||||
credit_card_raw_data = customer.sources.data.pop()
|
|
||||||
card_details = {
|
|
||||||
'last4': credit_card_raw_data.last4,
|
|
||||||
'brand': credit_card_raw_data.brand,
|
|
||||||
'exp_month': credit_card_raw_data.exp_month,
|
|
||||||
'exp_year': credit_card_raw_data.exp_year,
|
|
||||||
'fingerprint': credit_card_raw_data.fingerprint,
|
|
||||||
'card_id': credit_card_raw_data.id
|
|
||||||
}
|
|
||||||
return card_details
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def get_all_invoices(self, customer_id, created_gt):
|
|
||||||
return_list = []
|
|
||||||
has_more_invoices = True
|
|
||||||
starting_after = False
|
|
||||||
while has_more_invoices:
|
|
||||||
if starting_after:
|
|
||||||
invoices = stripe.Invoice.list(
|
|
||||||
limit=10, customer=customer_id, created={'gt': created_gt},
|
|
||||||
starting_after=starting_after
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
invoices = stripe.Invoice.list(
|
|
||||||
limit=10, customer=customer_id, created={'gt': created_gt}
|
|
||||||
)
|
|
||||||
has_more_invoices = invoices.has_more
|
|
||||||
for invoice in invoices.data:
|
|
||||||
sub_ids = []
|
|
||||||
for line in invoice.lines.data:
|
|
||||||
if line.type == 'subscription':
|
|
||||||
sub_ids.append(line.id)
|
|
||||||
elif line.type == 'invoiceitem':
|
|
||||||
sub_ids.append(line.subscription)
|
|
||||||
else:
|
|
||||||
sub_ids.append('')
|
|
||||||
invoice_details = {
|
|
||||||
'created': invoice.created,
|
|
||||||
'receipt_number': invoice.receipt_number,
|
|
||||||
'invoice_number': invoice.number,
|
|
||||||
'paid_at': invoice.status_transitions.paid_at if invoice.paid else 0,
|
|
||||||
'period_start': invoice.period_start,
|
|
||||||
'period_end': invoice.period_end,
|
|
||||||
'billing_reason': invoice.billing_reason,
|
|
||||||
'discount': invoice.discount.coupon.amount_off if invoice.discount else 0,
|
|
||||||
'total': invoice.total,
|
|
||||||
# to see how many line items we have in this invoice and
|
|
||||||
# then later check if we have more than 1
|
|
||||||
'lines_data_count': len(invoice.lines.data) if invoice.lines.data is not None else 0,
|
|
||||||
'invoice_id': invoice.id,
|
|
||||||
'lines_meta_data_csv': ','.join(
|
|
||||||
[line.metadata.VM_ID if hasattr(line.metadata, 'VM_ID') else '' for line in invoice.lines.data]
|
|
||||||
),
|
|
||||||
'subscription_ids_csv': ','.join(sub_ids),
|
|
||||||
'line_items': invoice.lines.data
|
|
||||||
}
|
|
||||||
starting_after = invoice.id
|
|
||||||
return_list.append(invoice_details)
|
|
||||||
return return_list
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def get_cards_details_from_token(self, token):
|
|
||||||
stripe_token = stripe.Token.retrieve(token)
|
|
||||||
card_details = {
|
|
||||||
'last4': stripe_token.card.last4,
|
|
||||||
'brand': stripe_token.card.brand,
|
|
||||||
'exp_month': stripe_token.card.exp_month,
|
|
||||||
'exp_year': stripe_token.card.exp_year,
|
|
||||||
'fingerprint': stripe_token.card.fingerprint,
|
|
||||||
'card_id': stripe_token.card.id
|
|
||||||
}
|
|
||||||
return card_details
|
|
||||||
|
|
||||||
def check_customer(self, stripe_cus_api_id, user, token):
|
|
||||||
try:
|
|
||||||
customer = stripe.Customer.retrieve(stripe_cus_api_id)
|
|
||||||
except stripe.error.InvalidRequestError:
|
|
||||||
customer = self.create_customer(token, user.email, user.name)
|
|
||||||
user.stripecustomer.stripe_id = customer.get(
|
|
||||||
'response_object').get('id')
|
|
||||||
user.stripecustomer.save()
|
|
||||||
if type(customer) is dict:
|
|
||||||
customer = customer['response_object']
|
|
||||||
return customer
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def get_customer(self, stripe_api_cus_id):
|
|
||||||
customer = stripe.Customer.retrieve(stripe_api_cus_id)
|
|
||||||
# data = customer.get('response_object')
|
|
||||||
return customer
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def create_customer(self, token, email, name=None, address=None):
|
|
||||||
if name is None or name.strip() == "":
|
|
||||||
name = email
|
|
||||||
customer = self.stripe.Customer.create(
|
|
||||||
source=token,
|
|
||||||
description=name,
|
|
||||||
email=email,
|
|
||||||
address=address
|
|
||||||
)
|
|
||||||
return customer
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def make_charge(self, amount=None, customer=None):
|
|
||||||
_amount = float(amount)
|
|
||||||
amount = int(_amount * 100) # stripe amount unit, in cents
|
|
||||||
charge = self.stripe.Charge.create(
|
|
||||||
amount=amount, # in cents
|
|
||||||
currency=self.CURRENCY,
|
|
||||||
customer=customer
|
|
||||||
)
|
|
||||||
return charge
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_all_stripe_plans():
|
|
||||||
all_stripe_plans = client.get("/v1/stripe_plans")
|
|
||||||
all_stripe_plans_set = set()
|
|
||||||
if all_stripe_plans:
|
|
||||||
all_stripe_plans_obj = all_stripe_plans.value
|
|
||||||
if all_stripe_plans_obj and len(all_stripe_plans_obj['plans']) > 0:
|
|
||||||
all_stripe_plans_set = set(all_stripe_plans_obj["plans"])
|
|
||||||
return all_stripe_plans_set
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _save_all_stripe_plans(stripe_plans):
|
|
||||||
client.put("/v1/stripe_plans", {"plans": list(stripe_plans)})
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def get_or_create_stripe_plan(self, product_name, amount, stripe_plan_id,
|
|
||||||
interval=INTERVAL):
|
|
||||||
"""
|
|
||||||
This function checks if a StripePlan with the given
|
|
||||||
stripe_plan_id already exists. If it exists then the function
|
|
||||||
returns this object otherwise it creates a new StripePlan and
|
|
||||||
returns the new object.
|
|
||||||
|
|
||||||
:param amount: The amount in CHF cents
|
|
||||||
:param product_name: The name of the Stripe plan (product) to be created.
|
|
||||||
:param stripe_plan_id: The id of the Stripe plan to be
|
|
||||||
created. Use get_stripe_plan_id_string function to
|
|
||||||
obtain the name of the plan to be created
|
|
||||||
:param interval: The interval for subscription {month, year}. Defaults
|
|
||||||
to month if not provided
|
|
||||||
:return: The StripePlan object if it exists else creates a
|
|
||||||
Plan object in Stripe and a local StripePlan and
|
|
||||||
returns it. Returns None in case of Stripe error
|
|
||||||
"""
|
|
||||||
_amount = float(amount)
|
|
||||||
amount = int(_amount * 100) # stripe amount unit, in cents
|
|
||||||
all_stripe_plans = self._get_all_stripe_plans()
|
|
||||||
if stripe_plan_id in all_stripe_plans:
|
|
||||||
logging.debug("{} plan exists in db.".format(stripe_plan_id))
|
|
||||||
else:
|
|
||||||
logging.debug(("{} plan DOES NOT exist in db. "
|
|
||||||
"Creating").format(stripe_plan_id))
|
|
||||||
try:
|
|
||||||
plan_obj = self.stripe.Plan.retrieve(id=stripe_plan_id)
|
|
||||||
logging.debug("{} plan exists in Stripe".format(stripe_plan_id))
|
|
||||||
all_stripe_plans.add(stripe_plan_id)
|
|
||||||
except stripe.error.InvalidRequestError as e:
|
|
||||||
if "No such plan" in str(e):
|
|
||||||
logging.debug("Plan {} does not exist in Stripe, Creating")
|
|
||||||
plan_obj = self.stripe.Plan.create(
|
|
||||||
amount=amount,
|
|
||||||
product={'name': product_name},
|
|
||||||
interval=interval,
|
|
||||||
currency=self.CURRENCY,
|
|
||||||
id=stripe_plan_id)
|
|
||||||
logging.debug(self.PLAN_EXISTS_ERROR_MSG.format(stripe_plan_id))
|
|
||||||
all_stripe_plans.add(stripe_plan_id)
|
|
||||||
self._save_all_stripe_plans(all_stripe_plans)
|
|
||||||
return stripe_plan_id
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def delete_stripe_plan(self, stripe_plan_id):
|
|
||||||
"""
|
|
||||||
Deletes the Plan in Stripe and also deletes the local db copy
|
|
||||||
of the plan if it exists
|
|
||||||
|
|
||||||
:param stripe_plan_id: The stripe plan id that needs to be
|
|
||||||
deleted
|
|
||||||
:return: True if the plan was deleted successfully from
|
|
||||||
Stripe, False otherwise.
|
|
||||||
"""
|
|
||||||
return_value = False
|
|
||||||
try:
|
|
||||||
plan = self.stripe.Plan.retrieve(stripe_plan_id)
|
|
||||||
plan.delete()
|
|
||||||
return_value = True
|
|
||||||
all_stripe_plans = self._get_all_stripe_plans()
|
|
||||||
all_stripe_plans.remove(stripe_plan_id)
|
|
||||||
self._save_all_stripe_plans(all_stripe_plans)
|
|
||||||
except stripe.error.InvalidRequestError as e:
|
|
||||||
if self.STRIPE_NO_SUCH_PLAN in str(e):
|
|
||||||
logging.debug(
|
|
||||||
self.PLAN_DOES_NOT_EXIST_ERROR_MSG.format(stripe_plan_id))
|
|
||||||
return return_value
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def subscribe_customer_to_plan(self, customer, plans, trial_end=None):
|
|
||||||
"""
|
|
||||||
Subscribes the given customer to the list of given plans
|
|
||||||
|
|
||||||
:param customer: The stripe customer identifier
|
|
||||||
:param plans: A list of stripe plans.
|
|
||||||
:param trial_end: An integer representing when the Stripe subscription
|
|
||||||
is supposed to end
|
|
||||||
Ref: https://stripe.com/docs/api/python#create_subscription-items
|
|
||||||
e.g.
|
|
||||||
plans = [
|
|
||||||
{
|
|
||||||
"plan": "dcl-v1-cpu-2-ram-5gb-ssd-10gb",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
:return: The subscription StripeObject
|
|
||||||
"""
|
|
||||||
|
|
||||||
subscription_result = self.stripe.Subscription.create(
|
|
||||||
customer=customer, items=plans, trial_end=trial_end
|
|
||||||
)
|
|
||||||
return subscription_result
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def set_subscription_metadata(self, subscription_id, metadata):
|
|
||||||
subscription = stripe.Subscription.retrieve(subscription_id)
|
|
||||||
subscription.metadata = metadata
|
|
||||||
subscription.save()
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def unsubscribe_customer(self, subscription_id):
|
|
||||||
"""
|
|
||||||
Cancels a given subscription
|
|
||||||
|
|
||||||
:param subscription_id: The Stripe subscription id string
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
sub = stripe.Subscription.retrieve(subscription_id)
|
|
||||||
return sub.delete()
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def make_payment(self, customer, amount, token):
|
|
||||||
charge = self.stripe.Charge.create(
|
|
||||||
amount=amount, # in cents
|
|
||||||
currency=self.CURRENCY,
|
|
||||||
customer=customer
|
|
||||||
)
|
|
||||||
return charge
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_stripe_plan_id(cpu, ram, ssd, version, app='dcl', hdd=None,
|
|
||||||
price=None):
|
|
||||||
"""
|
|
||||||
Returns the Stripe plan id string of the form
|
|
||||||
`dcl-v1-cpu-2-ram-5gb-ssd-10gb` based on the input parameters
|
|
||||||
|
|
||||||
:param cpu: The number of cores
|
|
||||||
:param ram: The size of the RAM in GB
|
|
||||||
:param ssd: The size of ssd storage in GB
|
|
||||||
:param hdd: The size of hdd storage in GB
|
|
||||||
:param version: The version of the Stripe plans
|
|
||||||
:param app: The application to which the stripe plan belongs
|
|
||||||
to. By default it is 'dcl'
|
|
||||||
:param price: The price for this plan
|
|
||||||
:return: A string of the form `dcl-v1-cpu-2-ram-5gb-ssd-10gb`
|
|
||||||
"""
|
|
||||||
dcl_plan_string = 'cpu-{cpu}-ram-{ram}gb-ssd-{ssd}gb'.format(cpu=cpu,
|
|
||||||
ram=ram,
|
|
||||||
ssd=ssd)
|
|
||||||
if hdd is not None:
|
|
||||||
dcl_plan_string = '{dcl_plan_string}-hdd-{hdd}gb'.format(
|
|
||||||
dcl_plan_string=dcl_plan_string, hdd=hdd)
|
|
||||||
stripe_plan_id_string = '{app}-v{version}-{plan}'.format(
|
|
||||||
app=app,
|
|
||||||
version=version,
|
|
||||||
plan=dcl_plan_string
|
|
||||||
)
|
|
||||||
if price is not None:
|
|
||||||
stripe_plan_id_string_with_price = '{}-{}chf'.format(
|
|
||||||
stripe_plan_id_string,
|
|
||||||
round(price, 2)
|
|
||||||
)
|
|
||||||
return stripe_plan_id_string_with_price
|
|
||||||
else:
|
|
||||||
return stripe_plan_id_string
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_vm_config_from_stripe_id(stripe_id):
|
|
||||||
"""
|
|
||||||
Given a string like "dcl-v1-cpu-2-ram-5gb-ssd-10gb" return different
|
|
||||||
configuration params as a dict
|
|
||||||
|
|
||||||
:param stripe_id|str
|
|
||||||
:return: dict
|
|
||||||
"""
|
|
||||||
pattern = re.compile(r'^dcl-v(\d+)-cpu-(\d+)-ram-(\d+\.?\d*)gb-ssd-(\d+)gb-?(\d*\.?\d*)(chf)?$')
|
|
||||||
match_res = pattern.match(stripe_id)
|
|
||||||
if match_res is not None:
|
|
||||||
price = None
|
|
||||||
try:
|
|
||||||
price = match_res.group(5)
|
|
||||||
except IndexError:
|
|
||||||
logging.debug("Did not find price in {}".format(stripe_id))
|
|
||||||
return {
|
|
||||||
'version': match_res.group(1),
|
|
||||||
'cores': match_res.group(2),
|
|
||||||
'ram': match_res.group(3),
|
|
||||||
'ssd': match_res.group(4),
|
|
||||||
'price': price
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_stripe_plan_name(cpu, memory, disk_size, price):
|
|
||||||
"""
|
|
||||||
Returns the Stripe plan name
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
return "{cpu} Cores, {memory} GB RAM, {disk_size} GB SSD, " \
|
|
||||||
"{price} CHF".format(
|
|
||||||
cpu=cpu,
|
|
||||||
memory=memory,
|
|
||||||
disk_size=disk_size,
|
|
||||||
price=round(price, 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
@handle_stripe_error
|
|
||||||
def set_subscription_meta_data(self, subscription_id, meta_data):
|
|
||||||
"""
|
|
||||||
Adds VM metadata to a subscription
|
|
||||||
:param subscription_id: Stripe identifier for the subscription
|
|
||||||
:param meta_data: A dict of meta data to be added
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
subscription = stripe.Subscription.retrieve(subscription_id)
|
|
||||||
subscription.metadata = meta_data
|
|
||||||
subscription.save()
|
|
||||||
|
|
@ -1,338 +0,0 @@
|
||||||
import logging
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from flask import Flask, request
|
|
||||||
from flask_restful import Resource, Api
|
|
||||||
from werkzeug.exceptions import HTTPException
|
|
||||||
from config import etcd_client as client, config as config
|
|
||||||
from stripe_utils import StripeUtils
|
|
||||||
from schemas import (
|
|
||||||
make_return_message, ValidationException, UserRegisterPaymentSchema,
|
|
||||||
AddProductSchema, ProductOrderSchema, OrderListSchema, create_schema
|
|
||||||
)
|
|
||||||
from helper import get_plan_id_from_product, calculate_charges
|
|
||||||
|
|
||||||
|
|
||||||
class ListProducts(Resource):
|
|
||||||
@staticmethod
|
|
||||||
def get():
|
|
||||||
products = client.get_prefix('/v1/products/')
|
|
||||||
products = [
|
|
||||||
product
|
|
||||||
for product in [p.value for p in products]
|
|
||||||
if product['active']
|
|
||||||
]
|
|
||||||
prod_dict = {}
|
|
||||||
for p in products:
|
|
||||||
prod_dict[p['usable-id']] = {
|
|
||||||
'name': p['name'],
|
|
||||||
'description': p['description'],
|
|
||||||
}
|
|
||||||
logger.debug('Products = {}'.format(prod_dict))
|
|
||||||
return prod_dict, 200
|
|
||||||
|
|
||||||
class AddProduct(Resource):
|
|
||||||
@staticmethod
|
|
||||||
def post():
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.debug('Got data: {}'.format(str(data)))
|
|
||||||
validator = AddProductSchema(data)
|
|
||||||
validator.is_valid()
|
|
||||||
except ValidationException as err:
|
|
||||||
return make_return_message(err, 400)
|
|
||||||
else:
|
|
||||||
cleaned_values = validator.get_cleaned_values()
|
|
||||||
previous_product = cleaned_values.get('product', None)
|
|
||||||
if previous_product:
|
|
||||||
if not cleaned_values['update']:
|
|
||||||
return make_return_message('Product already exists. Pass --update to update the product.')
|
|
||||||
else:
|
|
||||||
product_uuid = previous_product.pop('uuid')
|
|
||||||
else:
|
|
||||||
product_uuid = uuid4().hex
|
|
||||||
|
|
||||||
product_value = cleaned_values['specs']
|
|
||||||
|
|
||||||
product_key = '/v1/products/{}'.format(product_uuid)
|
|
||||||
product_value['uuid'] = product_uuid
|
|
||||||
|
|
||||||
logger.debug('Adding product data: {}'.format(str(product_value)))
|
|
||||||
client.put(product_key, product_value)
|
|
||||||
if not previous_product:
|
|
||||||
return make_return_message('Product created.')
|
|
||||||
else:
|
|
||||||
return make_return_message('Product updated.')
|
|
||||||
|
|
||||||
################################################################################
|
|
||||||
# Nico-ok-marker
|
|
||||||
|
|
||||||
|
|
||||||
class UserRegisterPayment(Resource):
|
|
||||||
@staticmethod
|
|
||||||
def post():
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.debug('Got data: {}'.format(str(data)))
|
|
||||||
validator = UserRegisterPaymentSchema(data)
|
|
||||||
validator.is_valid()
|
|
||||||
except ValidationException as err:
|
|
||||||
return make_return_message(err, 400)
|
|
||||||
else:
|
|
||||||
cleaned_values = validator.get_cleaned_values()
|
|
||||||
last4 = data['card_number'].strip()[-4:]
|
|
||||||
|
|
||||||
stripe_utils = StripeUtils()
|
|
||||||
|
|
||||||
# Does customer already exist ?
|
|
||||||
stripe_customer = stripe_utils.get_stripe_customer_from_email(cleaned_values['user']['mail'])
|
|
||||||
|
|
||||||
# Does customer already exist ?
|
|
||||||
if stripe_customer is not None:
|
|
||||||
logger.debug('Customer {}-{} exists already'.format(
|
|
||||||
cleaned_values['username'], cleaned_values['user']['mail'])
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if the card already exists
|
|
||||||
ce_response = stripe_utils.card_exists(
|
|
||||||
stripe_customer.id, cc_number=data['card_number'],
|
|
||||||
exp_month=int(data['expiry_month']),
|
|
||||||
exp_year=int(data['expiry_year']),
|
|
||||||
cvc=data['cvc'])
|
|
||||||
|
|
||||||
if ce_response['response_object']:
|
|
||||||
message = 'The given card ending in {} exists already.'.format(last4)
|
|
||||||
return make_return_message(message, 400)
|
|
||||||
|
|
||||||
elif ce_response['response_object'] is False:
|
|
||||||
# Associate card with user
|
|
||||||
logger.debug('Adding card ending in {}'.format(last4))
|
|
||||||
token_response = stripe_utils.get_token_from_card(
|
|
||||||
data['card_number'], data['cvc'], data['expiry_month'],
|
|
||||||
data['expiry_year']
|
|
||||||
)
|
|
||||||
if token_response['response_object']:
|
|
||||||
logger.debug('Token {}'.format(token_response['response_object'].id))
|
|
||||||
resp = stripe_utils.associate_customer_card(
|
|
||||||
stripe_customer.id, token_response['response_object'].id
|
|
||||||
)
|
|
||||||
if resp['response_object']:
|
|
||||||
return make_return_message(
|
|
||||||
'Card ending in {} registered as your payment source'.format(last4)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return make_return_message('Error with payment gateway. Contact support', 400)
|
|
||||||
else:
|
|
||||||
return make_return_message('Error: {}'.format(ce_response['error']), 400)
|
|
||||||
else:
|
|
||||||
# Stripe customer does not exist, create a new one
|
|
||||||
logger.debug(
|
|
||||||
'Customer {} does not exist, creating new'.format(cleaned_values['user']['mail'])
|
|
||||||
)
|
|
||||||
token_response = stripe_utils.get_token_from_card(
|
|
||||||
cleaned_values['card_number'], cleaned_values['cvc'],
|
|
||||||
cleaned_values['expiry_month'], cleaned_values['expiry_year']
|
|
||||||
)
|
|
||||||
if token_response['response_object']:
|
|
||||||
logger.debug('Token {}'.format(token_response['response_object'].id))
|
|
||||||
|
|
||||||
# Create stripe customer
|
|
||||||
stripe_customer_resp = stripe_utils.create_customer(
|
|
||||||
name=cleaned_values['card_holder_name'],
|
|
||||||
token=token_response['response_object'].id,
|
|
||||||
email=cleaned_values['user']['mail'],
|
|
||||||
address=cleaned_values['address']
|
|
||||||
)
|
|
||||||
stripe_customer = stripe_customer_resp['response_object']
|
|
||||||
|
|
||||||
if stripe_customer:
|
|
||||||
logger.debug('Created stripe customer {}'.format(stripe_customer.id))
|
|
||||||
return make_return_message(
|
|
||||||
'Card ending in {} registered as your payment source'.format(last4)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return make_return_message('Error with card. Contact support', 400)
|
|
||||||
else:
|
|
||||||
return make_return_message('Error with payment gateway. Contact support', 400)
|
|
||||||
|
|
||||||
|
|
||||||
class ProductOrder(Resource):
|
|
||||||
@staticmethod
|
|
||||||
def post():
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
validator = ProductOrderSchema(data)
|
|
||||||
validator.is_valid()
|
|
||||||
except ValidationException as err:
|
|
||||||
return make_return_message(err, 400)
|
|
||||||
else:
|
|
||||||
cleaned_values = validator.get_cleaned_values()
|
|
||||||
stripe_utils = StripeUtils()
|
|
||||||
|
|
||||||
product = cleaned_values['product']
|
|
||||||
|
|
||||||
# Check the user has a payment source added
|
|
||||||
stripe_customer = stripe_utils.get_stripe_customer_from_email(cleaned_values['user']['mail'])
|
|
||||||
|
|
||||||
if not stripe_customer or len(stripe_customer.sources) == 0:
|
|
||||||
return make_return_message('Please register your payment method first.', 400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
product_schema = create_schema(product, data)
|
|
||||||
product_schema = product_schema()
|
|
||||||
product_schema.is_valid()
|
|
||||||
except ValidationException as err:
|
|
||||||
return make_return_message(err, 400)
|
|
||||||
else:
|
|
||||||
transformed_data = product_schema.get_cleaned_values()
|
|
||||||
logger.debug('Tranformed data: {}'.format(transformed_data))
|
|
||||||
one_time_charge, recurring_charge = calculate_charges(product, transformed_data)
|
|
||||||
recurring_charge = int(recurring_charge)
|
|
||||||
|
|
||||||
if not cleaned_values['pay']:
|
|
||||||
return make_return_message(
|
|
||||||
'You would be charged {} CHF one time and {} CHF every {}. '
|
|
||||||
'Add --pay to command to order.'.format(
|
|
||||||
one_time_charge, recurring_charge, product['recurring_period']
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with client.client.lock('product-order') as _:
|
|
||||||
# Initiate a one-time/subscription based on product type
|
|
||||||
if recurring_charge > 0:
|
|
||||||
logger.debug('Product {} is recurring payment'.format(product['name']))
|
|
||||||
plan_id = get_plan_id_from_product(product)
|
|
||||||
res = stripe_utils.get_or_create_stripe_plan(
|
|
||||||
product_name=product['name'],
|
|
||||||
stripe_plan_id=plan_id, amount=recurring_charge,
|
|
||||||
interval=product['recurring_period'],
|
|
||||||
)
|
|
||||||
if res['response_object']:
|
|
||||||
logger.debug('Obtained plan {}'.format(plan_id))
|
|
||||||
subscription_res = stripe_utils.subscribe_customer_to_plan(
|
|
||||||
stripe_customer.id,
|
|
||||||
[{'plan': plan_id}]
|
|
||||||
)
|
|
||||||
subscription_obj = subscription_res['response_object']
|
|
||||||
if subscription_obj is None or subscription_obj.status != 'active':
|
|
||||||
return make_return_message(
|
|
||||||
'Error subscribing to plan. Detail: {}'.format(subscription_res['error']), 400
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
order_obj = {
|
|
||||||
'order-id': uuid4().hex,
|
|
||||||
'ordered-at': datetime.now().isoformat(),
|
|
||||||
'product': product['usable-id'],
|
|
||||||
'one-time-price': one_time_charge,
|
|
||||||
'recurring-price': recurring_charge,
|
|
||||||
'recurring-period': product['recurring_period']
|
|
||||||
}
|
|
||||||
client.put(
|
|
||||||
'/v1/user/{}/orders/{}'.format(
|
|
||||||
cleaned_values['username'], order_obj['order-id']
|
|
||||||
), order_obj
|
|
||||||
)
|
|
||||||
product['quantity'] -= 1
|
|
||||||
client.put('/v1/products/{}'.format(product['uuid']), product)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'message': 'Order Successful.',
|
|
||||||
**order_obj
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
logger.error('Could not create plan {}'.format(plan_id))
|
|
||||||
return make_return_message('Something wrong happened. Contact administrator', 400)
|
|
||||||
|
|
||||||
elif recurring_charge == 0 and one_time_charge > 0:
|
|
||||||
logger.debug('Product {} is one-time payment'.format(product['name']))
|
|
||||||
charge_response = stripe_utils.make_charge(
|
|
||||||
amount=one_time_charge,
|
|
||||||
customer=stripe_customer.id
|
|
||||||
)
|
|
||||||
stripe_onetime_charge = charge_response.get('response_object')
|
|
||||||
|
|
||||||
# Check if the payment was approved
|
|
||||||
if not stripe_onetime_charge:
|
|
||||||
msg = charge_response.get('error')
|
|
||||||
return make_return_message('Error subscribing to plan. Details: {}'.format(msg), 400)
|
|
||||||
|
|
||||||
order_obj = {
|
|
||||||
'order-id': uuid4().hex,
|
|
||||||
'ordered-at': datetime.now().isoformat(),
|
|
||||||
'product': product['usable-id'],
|
|
||||||
'one-time-price': one_time_charge,
|
|
||||||
}
|
|
||||||
client.put(
|
|
||||||
'/v1/user/{}/orders/{}'.format(cleaned_values['username'], order_obj['order-id']),
|
|
||||||
order_obj
|
|
||||||
)
|
|
||||||
product['quantity'] -= 1
|
|
||||||
client.put('/v1/products/{}'.format(product['uuid']), product)
|
|
||||||
|
|
||||||
return {'message': 'Order successful', **order_obj}, 200
|
|
||||||
|
|
||||||
|
|
||||||
class OrderList(Resource):
|
|
||||||
@staticmethod
|
|
||||||
def post():
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
validator = OrderListSchema(data)
|
|
||||||
validator.is_valid()
|
|
||||||
except ValidationException as err:
|
|
||||||
return make_return_message(err, 400)
|
|
||||||
else:
|
|
||||||
cleaned_values = validator.get_cleaned_values()
|
|
||||||
orders = client.get_prefix('/v1/user/{}/orders'.format(cleaned_values['username']))
|
|
||||||
orders_dict = {
|
|
||||||
order.value['order-id']: {
|
|
||||||
**order.value
|
|
||||||
}
|
|
||||||
for order in orders
|
|
||||||
}
|
|
||||||
logger.debug('Orders = {}'.format(orders_dict))
|
|
||||||
return {'orders': orders_dict}, 200
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
logger = logging.getLogger()
|
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
log_formater = logging.Formatter('[%(filename)s:%(lineno)d] %(message)s')
|
|
||||||
|
|
||||||
stream_logger = logging.StreamHandler()
|
|
||||||
stream_logger.setFormatter(log_formater)
|
|
||||||
|
|
||||||
# file_logger = logging.FileHandler('log.txt')
|
|
||||||
# file_logger.setLevel(logging.DEBUG)
|
|
||||||
# file_logger.setFormatter(log_formater)
|
|
||||||
|
|
||||||
logger.addHandler(stream_logger)
|
|
||||||
# logger.addHandler(file_logger)
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
api = Api(app)
|
|
||||||
api.add_resource(ListProducts, '/product/list')
|
|
||||||
api.add_resource(AddProduct, '/product/add')
|
|
||||||
api.add_resource(ProductOrder, '/product/order')
|
|
||||||
api.add_resource(UserRegisterPayment, '/user/register_payment')
|
|
||||||
api.add_resource(OrderList, '/order/list')
|
|
||||||
|
|
||||||
app.run(host='::', port=config.get('app', 'port', fallback=5000), debug=True)
|
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
|
||||||
def handle_exception(e):
|
|
||||||
app.logger.error(e)
|
|
||||||
# pass through HTTP errors
|
|
||||||
if isinstance(e, HTTPException):
|
|
||||||
return e
|
|
||||||
|
|
||||||
# now you're handling non-HTTP exceptions only
|
|
||||||
return {'message': 'Server Error'}, 500
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
## 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~~
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
* snapshot feature
|
|
||||||
** product: vm-snapshot
|
|
||||||
** flow
|
|
||||||
*** list all my VMs
|
|
||||||
**** get the uuid of the VM I want to take a snapshot of
|
|
||||||
*** request a snapshot
|
|
||||||
```
|
|
||||||
vmuuid=$(http nicocustomer
|
|
||||||
http -a nicocustomer:xxx http://uncloud.ch/vm/create_snapshot uuid=
|
|
||||||
password=...
|
|
||||||
```
|
|
||||||
** backend realisation
|
|
||||||
*** list snapshots
|
|
||||||
- have them in the DB
|
|
||||||
- create an entry on create
|
|
||||||
*** creating snapshots
|
|
||||||
- vm sync / fsync?
|
|
||||||
- rbd snapshot
|
|
||||||
- host/cluster mapping?
|
|
||||||
- need image(s)
|
|
||||||
|
|
||||||
* steps
|
|
||||||
** DONE authenticate via ldap
|
|
||||||
CLOSED: [2020-02-20 Thu 19:05]
|
|
||||||
** DONE Make classes / views require authentication
|
|
||||||
CLOSED: [2020-02-20 Thu 19:05]
|
|
||||||
** TODO register credit card
|
|
||||||
*** TODO find out what saving with us
|
|
||||||
*** Info
|
|
||||||
**** should not be fully saved in the DB
|
|
||||||
**** model needs to be a bit different
|
|
||||||
* Decide where to save sensitive data
|
|
||||||
** stripe access key, etc.
|
|
||||||
* python requirements (nicohack202002)
|
|
||||||
django djangorestframework django-auth-ldap stripe
|
|
||||||
* os package requirements (alpine)
|
|
||||||
openldap-dev
|
|
||||||
* VPN case
|
|
||||||
** put on /orders with uuid
|
|
||||||
** register cc
|
|
||||||
* CC
|
|
||||||
** TODO check whether we can register or not at stripe
|
|
||||||
* membership
|
|
||||||
** required for "smaller" / "shorter" products
|
|
||||||
|
|
||||||
* TODO Membership missing
|
|
||||||
* Flows to be implemented - see https://redmine.ungleich.ch/issues/7609
|
|
||||||
** Membership
|
|
||||||
*** 5 CHF
|
|
||||||
** Django Hosting
|
|
||||||
*** One time payment 35 CHF
|
|
||||||
*** Monthly payment depends on VM size
|
|
||||||
*** Parameters: same as IPv6 only VM
|
|
||||||
** IPv6 VPN
|
|
||||||
*** Parameters: none
|
|
||||||
*** Is for free if the customer has an active VM
|
|
||||||
** IPv6 only VM
|
|
||||||
*** Parameters: cores, ram, os_disk_size, OS
|
|
||||||
* Django rest framework
|
|
||||||
** viewset: .list and .create
|
|
||||||
** view: .get .post
|
|
||||||
* TODO register CC
|
|
||||||
* DONE list products
|
|
||||||
CLOSED: [2020-02-24 Mon 20:15]
|
|
||||||
* An ungleich account - can be registered for free on
|
|
||||||
https://account.ungleich.ch
|
|
||||||
* httpie installed (provides the http command)
|
|
||||||
|
|
||||||
## Get a membership
|
|
||||||
|
|
||||||
|
|
||||||
## Registering a payment method
|
|
||||||
|
|
||||||
To be able to pay for the membership, you will need to register a
|
|
||||||
credit card or apply for payment on bill (TO BE IMPLEMENTED).
|
|
||||||
|
|
||||||
### Register credit card
|
|
||||||
|
|
||||||
```
|
|
||||||
http POST https://api.ungleich.ch/membership \
|
|
||||||
username=nico password=yourpassword \
|
|
||||||
cc_number=.. \
|
|
||||||
cc_
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Request payment via bill
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Create the membership
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
http POST https://api.ungleich.ch/membership username=nico password=yourpassword
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## List available products
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
* TODO register CC
|
|
||||||
* TODO list products
|
|
||||||
* ahmed
|
|
||||||
** schemas
|
|
||||||
*** field: is_valid? - used by schemas
|
|
||||||
*** definition of a "schema"
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
db.sqlite3
|
|
||||||
uncloud/secrets.py
|
|
||||||
debug.log
|
|
||||||
uncloud/local_settings.py
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
class UncloudException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import argparse
|
|
||||||
import etcd3
|
|
||||||
from uncloud.common.etcd_wrapper import Etcd3Wrapper
|
|
||||||
|
|
||||||
arg_parser = argparse.ArgumentParser('client', add_help=False)
|
|
||||||
arg_parser.add_argument('--dump-etcd-contents-prefix', help="Dump contents below the given prefix")
|
|
||||||
|
|
||||||
def dump_etcd_contents(prefix):
|
|
||||||
etcd = Etcd3Wrapper()
|
|
||||||
for k,v in etcd.get_prefix_raw(prefix):
|
|
||||||
k = k.decode('utf-8')
|
|
||||||
v = v.decode('utf-8')
|
|
||||||
print("{} = {}".format(k,v))
|
|
||||||
# print("{} = {}".format(k,v))
|
|
||||||
|
|
||||||
# for k,v in etcd.get_prefix(prefix):
|
|
||||||
#
|
|
||||||
print("done")
|
|
||||||
|
|
||||||
|
|
||||||
def main(arguments):
|
|
||||||
if 'dump_etcd_contents_prefix' in arguments:
|
|
||||||
dump_etcd_contents(prefix=arguments['dump_etcd_contents_prefix'])
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
flush ruleset
|
|
||||||
|
|
||||||
table bridge filter {
|
|
||||||
chain prerouting {
|
|
||||||
type filter hook prerouting priority 0;
|
|
||||||
policy accept;
|
|
||||||
|
|
||||||
ibrname br100 jump netpublic
|
|
||||||
}
|
|
||||||
|
|
||||||
chain netpublic {
|
|
||||||
iifname vxlan100 jump from_uncloud
|
|
||||||
|
|
||||||
# Default blocks: router advertisements, dhcpv6, dhcpv4
|
|
||||||
icmpv6 type nd-router-advert drop
|
|
||||||
ip6 version 6 udp sport 547 drop
|
|
||||||
ip version 4 udp sport 67 drop
|
|
||||||
|
|
||||||
# Individual blocks
|
|
||||||
# iifname tap1 jump vm1
|
|
||||||
}
|
|
||||||
|
|
||||||
chain vm1 {
|
|
||||||
ether saddr != 02:00:f0:a9:c4:4e drop
|
|
||||||
ip6 saddr != 2a0a:e5c1:111:888:0:f0ff:fea9:c44e drop
|
|
||||||
}
|
|
||||||
|
|
||||||
chain from_uncloud {
|
|
||||||
accept
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# table ip6 filter {
|
|
||||||
# chain forward {
|
|
||||||
# type filter hook forward priority 0;
|
|
||||||
|
|
||||||
# # policy drop;
|
|
||||||
|
|
||||||
# ct state established,related accept;
|
|
||||||
|
|
||||||
# }
|
|
||||||
|
|
||||||
# }
|
|
||||||
|
|
||||||
# table ip filter {
|
|
||||||
# chain input {
|
|
||||||
# type filter hook input priority filter; policy drop;
|
|
||||||
# iif "lo" accept
|
|
||||||
# icmp type { echo-reply, destination-unreachable, source-quench, redirect, echo-request, router-advertisement, router-solicitation, time-exceeded, parameter-problem, timestamp-request, timestamp-reply, info-request, info-reply, address-mask-request, address-mask-reply } accept
|
|
||||||
# ct state established,related accept
|
|
||||||
# tcp dport { 22 } accept
|
|
||||||
# log prefix "firewall-ipv4: "
|
|
||||||
# udp sport 67 drop
|
|
||||||
# }
|
|
||||||
|
|
||||||
# chain forward {
|
|
||||||
# type filter hook forward priority filter; policy drop;
|
|
||||||
# log prefix "firewall-ipv4: "
|
|
||||||
# }
|
|
||||||
|
|
||||||
# chain output {
|
|
||||||
# type filter hook output priority filter; policy accept;
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
vmid=$1; shift
|
|
||||||
|
|
||||||
qemu=/usr/bin/qemu-system-x86_64
|
|
||||||
|
|
||||||
accel=kvm
|
|
||||||
#accel=tcg
|
|
||||||
|
|
||||||
memory=1024
|
|
||||||
cores=2
|
|
||||||
uuid=732e08c7-84f8-4d43-9571-263db4f80080
|
|
||||||
|
|
||||||
export bridge=br100
|
|
||||||
|
|
||||||
$qemu -name uc${vmid} \
|
|
||||||
-machine pc,accel=${accel} \
|
|
||||||
-m ${memory} \
|
|
||||||
-smp ${cores} \
|
|
||||||
-uuid ${uuid} \
|
|
||||||
-drive file=alpine-virt-3.11.2-x86_64.iso,media=cdrom \
|
|
||||||
-drive file=alpine-virt-3.11.2-x86_64.iso,media=cdrom \
|
|
||||||
-netdev tap,id=netmain,script=./ifup.sh \
|
|
||||||
-device virtio-net-pci,netdev=netmain,id=net0,mac=02:00:f0:a9:c4:4e
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
|
||||||
# 2020 Nico Schottelius (nico.schottelius at ungleich.ch)
|
|
||||||
#
|
|
||||||
# This file is part of uncloud.
|
|
||||||
#
|
|
||||||
# uncloud is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# uncloud is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with uncloud. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from uncloud.hack.db import DB
|
|
||||||
from uncloud import UncloudException
|
|
||||||
|
|
||||||
class Host(object):
|
|
||||||
def __init__(self, config, db_entry=None):
|
|
||||||
self.config = config
|
|
||||||
self.db = DB(self.config, prefix="/hosts")
|
|
||||||
|
|
||||||
if db_entry:
|
|
||||||
self.db_entry = db_entry
|
|
||||||
|
|
||||||
|
|
||||||
def list_hosts(self, filter_key=None, filter_regexp=None):
|
|
||||||
""" Return list of all hosts """
|
|
||||||
for entry in self.db.list_and_filter("", filter_key, filter_regexp):
|
|
||||||
yield self.__class__(self.config, db_entry=entry)
|
|
||||||
|
|
||||||
def cmdline_add_host(self):
|
|
||||||
""" FIXME: make this a bit smarter and less redundant """
|
|
||||||
|
|
||||||
for required_arg in [
|
|
||||||
'add_vm_host',
|
|
||||||
'max_cores_per_vm',
|
|
||||||
'max_cores_total',
|
|
||||||
'max_memory_in_gb' ]:
|
|
||||||
if not required_arg in self.config.arguments:
|
|
||||||
raise UncloudException("Missing argument: {}".format(required_arg))
|
|
||||||
|
|
||||||
return self.add_host(
|
|
||||||
self.config.arguments['add_vm_host'],
|
|
||||||
self.config.arguments['max_cores_per_vm'],
|
|
||||||
self.config.arguments['max_cores_total'],
|
|
||||||
self.config.arguments['max_memory_in_gb'])
|
|
||||||
|
|
||||||
|
|
||||||
def add_host(self,
|
|
||||||
hostname,
|
|
||||||
max_cores_per_vm,
|
|
||||||
max_cores_total,
|
|
||||||
max_memory_in_gb):
|
|
||||||
|
|
||||||
db_entry = {}
|
|
||||||
db_entry['uuid'] = str(uuid.uuid4())
|
|
||||||
db_entry['hostname'] = hostname
|
|
||||||
db_entry['max_cores_per_vm'] = max_cores_per_vm
|
|
||||||
db_entry['max_cores_total'] = max_cores_total
|
|
||||||
db_entry['max_memory_in_gb'] = max_memory_in_gb
|
|
||||||
db_entry["db_version"] = 1
|
|
||||||
db_entry["log"] = []
|
|
||||||
|
|
||||||
self.db.set(db_entry['uuid'], db_entry, as_json=True)
|
|
||||||
|
|
||||||
return self.__class__(self.config, db_entry)
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
|
||||||
# 2020 Nico Schottelius (nico.schottelius at ungleich.ch)
|
|
||||||
#
|
|
||||||
# This file is part of uncloud.
|
|
||||||
#
|
|
||||||
# uncloud is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# uncloud is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with uncloud. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
from uncloud import UncloudException
|
|
||||||
from uncloud.hack.db import DB, db_logentry
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class ProductOrder(object):
|
|
||||||
def __init__(self, config, product_entry=None, db_entry=None):
|
|
||||||
self.config = config
|
|
||||||
self.db = DB(self.config, prefix="/orders")
|
|
||||||
self.db_entry = {}
|
|
||||||
self.db_entry["product"] = product_entry
|
|
||||||
|
|
||||||
# Overwrite if we are loading an existing product order
|
|
||||||
if db_entry:
|
|
||||||
self.db_entry = db_entry
|
|
||||||
|
|
||||||
# FIXME: this should return a list of our class!
|
|
||||||
def list_orders(self, filter_key=None, filter_regexp=None):
|
|
||||||
for entry in self.db.list_and_filter("", filter_key, filter_regexp):
|
|
||||||
yield self.__class__(self.config, db_entry=entry)
|
|
||||||
|
|
||||||
|
|
||||||
def set_required_values(self):
|
|
||||||
"""Set values that are required to make the db entry valid"""
|
|
||||||
if not "uuid" in self.db_entry:
|
|
||||||
self.db_entry["uuid"] = str(uuid.uuid4())
|
|
||||||
if not "status" in self.db_entry:
|
|
||||||
self.db_entry["status"] = "NEW"
|
|
||||||
if not "owner" in self.db_entry:
|
|
||||||
self.db_entry["owner"] = "UNKNOWN"
|
|
||||||
if not "log" in self.db_entry:
|
|
||||||
self.db_entry["log"] = []
|
|
||||||
if not "db_version" in self.db_entry:
|
|
||||||
self.db_entry["db_version"] = 1
|
|
||||||
|
|
||||||
def validate_status(self):
|
|
||||||
if "status" in self.db_entry:
|
|
||||||
if self.db_entry["status"] in [ "NEW",
|
|
||||||
"SCHEDULED",
|
|
||||||
"CREATED_ACTIVE",
|
|
||||||
"CANCELLED",
|
|
||||||
"REJECTED" ]:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def order(self):
|
|
||||||
self.set_required_values()
|
|
||||||
if not self.db_entry["status"] == "NEW":
|
|
||||||
raise UncloudException("Cannot re-order same order. Status: {}".format(self.db_entry["status"]))
|
|
||||||
self.db.set(self.db_entry["uuid"], self.db_entry, as_json=True)
|
|
||||||
|
|
||||||
return self.db_entry["uuid"]
|
|
||||||
|
|
||||||
def process_orders(self):
|
|
||||||
"""processing orders can be done stand alone on server side"""
|
|
||||||
for order in self.list_orders():
|
|
||||||
if order.db_entry["status"] == "NEW":
|
|
||||||
log.info("Handling new order: {}".format(order))
|
|
||||||
|
|
||||||
# FIXME: these all should be a transactions! -> fix concurrent access! !
|
|
||||||
if not "log" in order.db_entry:
|
|
||||||
order.db_entry['log'] = []
|
|
||||||
|
|
||||||
is_valid = True
|
|
||||||
# Verify the order entry
|
|
||||||
for must_attribute in [ "owner", "product" ]:
|
|
||||||
if not must_attribute in order.db_entry:
|
|
||||||
message = "Missing {} entry in order, rejecting order".format(must_attribute)
|
|
||||||
log.info("Rejecting order {}: {}".format(order.db_entry["uuid"], message))
|
|
||||||
|
|
||||||
order.db_entry['log'].append(db_logentry(message))
|
|
||||||
order.db_entry['status'] = "REJECTED"
|
|
||||||
self.db.set(order.db_entry['uuid'], order.db_entry, as_json=True)
|
|
||||||
|
|
||||||
is_valid = False
|
|
||||||
|
|
||||||
# Rejected the order
|
|
||||||
if not is_valid:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Verify the product entry
|
|
||||||
for must_attribute in [ "python_product_class", "python_product_module" ]:
|
|
||||||
if not must_attribute in order.db_entry['product']:
|
|
||||||
message = "Missing {} entry in product of order, rejecting order".format(must_attribute)
|
|
||||||
log.info("Rejecting order {}: {}".format(order.db_entry["uuid"], message))
|
|
||||||
|
|
||||||
order.db_entry['log'].append(db_logentry(message))
|
|
||||||
order.db_entry['status'] = "REJECTED"
|
|
||||||
self.db.set(order.db_entry['uuid'], order.db_entry, as_json=True)
|
|
||||||
|
|
||||||
is_valid = False
|
|
||||||
|
|
||||||
# Rejected the order
|
|
||||||
if not is_valid:
|
|
||||||
continue
|
|
||||||
|
|
||||||
print(order.db_entry["product"]["python_product_class"])
|
|
||||||
|
|
||||||
# Create the product
|
|
||||||
m = importlib.import_module(order.db_entry["product"]["python_product_module"])
|
|
||||||
c = getattr(m, order.db_entry["product"]["python_product_class"])
|
|
||||||
|
|
||||||
product = c(config, db_entry=order.db_entry["product"])
|
|
||||||
|
|
||||||
# STOPPED
|
|
||||||
product.create_product()
|
|
||||||
|
|
||||||
order.db_entry['status'] = "SCHEDULED"
|
|
||||||
self.db.set(order.db_entry['uuid'], order.db_entry, as_json=True)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return str(self.db_entry)
|
|
||||||
|
|
||||||
class Product(object):
|
|
||||||
def __init__(self,
|
|
||||||
config,
|
|
||||||
product_name,
|
|
||||||
product_class,
|
|
||||||
db_entry=None):
|
|
||||||
self.config = config
|
|
||||||
self.db = DB(self.config, prefix="/orders")
|
|
||||||
|
|
||||||
self.db_entry = {}
|
|
||||||
self.db_entry["product_name"] = product_name
|
|
||||||
self.db_entry["python_product_class"] = product_class.__qualname__
|
|
||||||
self.db_entry["python_product_module"] = product_class.__module__
|
|
||||||
self.db_entry["db_version"] = 1
|
|
||||||
self.db_entry["log"] = []
|
|
||||||
self.db_entry["features"] = {}
|
|
||||||
|
|
||||||
# Existing product? Read in db_entry
|
|
||||||
if db_entry:
|
|
||||||
self.db_entry = db_entry
|
|
||||||
|
|
||||||
self.valid_periods = [ "per_year", "per_month", "per_week",
|
|
||||||
"per_day", "per_hour",
|
|
||||||
"per_minute", "per_second" ]
|
|
||||||
|
|
||||||
def define_feature(self,
|
|
||||||
name,
|
|
||||||
one_time_price,
|
|
||||||
recurring_price,
|
|
||||||
recurring_period,
|
|
||||||
minimum_period):
|
|
||||||
|
|
||||||
self.db_entry['features'][name] = {}
|
|
||||||
self.db_entry['features'][name]['one_time_price'] = one_time_price
|
|
||||||
self.db_entry['features'][name]['recurring_price'] = recurring_price
|
|
||||||
|
|
||||||
if not recurring_period in self.valid_periods:
|
|
||||||
raise UncloudException("Invalid recurring period: {}".format(recurring_period))
|
|
||||||
|
|
||||||
self.db_entry['features'][name]['recurring_period'] = recurring_period
|
|
||||||
|
|
||||||
if not minimum_period in self.valid_periods:
|
|
||||||
raise UncloudException("Invalid recurring period: {}".format(recurring_period))
|
|
||||||
|
|
||||||
recurring_index = self.valid_periods.index(recurring_period)
|
|
||||||
minimum_index = self.valid_periods.index(minimum_period)
|
|
||||||
|
|
||||||
if minimum_index < recurring_index:
|
|
||||||
raise UncloudException("Minimum period for product '{}' feature '{}' must be shorter or equal than/as recurring period: {} > {}".format(self.db_entry['product_name'], name, minimum_period, recurring_period))
|
|
||||||
|
|
||||||
self.db_entry['features'][name]['minimum_period'] = minimum_period
|
|
||||||
|
|
||||||
|
|
||||||
def validate_product(self):
|
|
||||||
for feature in self.db_entry['features']:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def place_order(self, owner):
|
|
||||||
""" Schedule creating the product in etcd """
|
|
||||||
order = ProductOrder(self.config, product_entry=self.db_entry)
|
|
||||||
order.db_entry["owner"] = owner
|
|
||||||
return order.order()
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return json.dumps(self.db_entry)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
VERSION = "0.0.5-30-ge91fd9e"
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Nico Schottelius, 2021-01-17
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ $# -ne 1 ]; then
|
|
||||||
echo "$0 target-host"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
target_host=$1; shift
|
|
||||||
user=app
|
|
||||||
|
|
||||||
dir=${0%/*}
|
|
||||||
uncloud_base=$(cd ${dir}/.. && pwd -P)
|
|
||||||
conf_name=local_settings-${target_host}.py
|
|
||||||
conf_file=${uncloud_base}/uncloud/${conf_name}
|
|
||||||
|
|
||||||
if [ ! -e ${conf_file} ]; then
|
|
||||||
echo "No settings for ${target_host}."
|
|
||||||
echo "Create ${conf_file} before using this script."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Deploy
|
|
||||||
rsync -av \
|
|
||||||
--exclude venv/ \
|
|
||||||
--exclude '*.pyc' \
|
|
||||||
--exclude uncloud/local_settings.py \
|
|
||||||
--delete \
|
|
||||||
${uncloud_base}/ ${user}@${target_host}:app/
|
|
||||||
|
|
||||||
ssh "${user}@${target_host}" ". ~/pyvenv/bin/activate; cd ~/app; pip install -r requirements.txt"
|
|
||||||
|
|
||||||
# Config
|
|
||||||
ssh "${user}@${target_host}" "cd ~/app/uncloud; ln -sf ${conf_name} local_settings.py"
|
|
||||||
|
|
||||||
# Restart / Apply
|
|
||||||
ssh "${user}@${target_host}" "sudo /etc/init.d/uwsgi restart"
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# For undoing/redoing everything
|
|
||||||
# Needed in special cases and needs to be avoided as soon as
|
|
||||||
# uncloud.version >= 1
|
|
||||||
for a in */migrations; do rm ${a}/*.py; done
|
|
||||||
for a in */migrations; do python manage.py makemigrations ${a%%/migrations}; done
|
|
||||||
2
doc/.gitignore
vendored
2
doc/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*.pdf
|
|
||||||
*.tex
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
* How to handle billing in general
|
|
||||||
** Manual test flow / setting up bills
|
|
||||||
- Needs orders
|
|
||||||
-
|
|
||||||
** Orders
|
|
||||||
- Orders are the heart of uncloud billing
|
|
||||||
- Have a starting date
|
|
||||||
- Have an ending date
|
|
||||||
- Orders are immutable
|
|
||||||
- Can usually not be cancelled / cancellation is not a refund
|
|
||||||
- Customer/user commits on a certain period -> gets discount
|
|
||||||
based on it
|
|
||||||
- Can be upgraded
|
|
||||||
- Create a new order
|
|
||||||
- We link the new order to the old order and say this one
|
|
||||||
replaces it
|
|
||||||
- If the price of the new order is HIGHER than the OLD order,
|
|
||||||
then we charge the difference until the end of the order period
|
|
||||||
- In the next billing run we set the OLD order to not to bill anymore
|
|
||||||
- And only the NEW order will be billed afterwards
|
|
||||||
- Can be downgraded in the next period (but not for this period)
|
|
||||||
- We create a new order, same as for upgrade
|
|
||||||
- The new order starts directly after the OLD order
|
|
||||||
- As the amount is LOWER than the OLD order, no additional charge is done
|
|
||||||
during this order period
|
|
||||||
- We might need to have an activate datetime
|
|
||||||
- When to implement this
|
|
||||||
- Order periods can be
|
|
||||||
*** Statuses
|
|
||||||
- CREATING/PREPARING
|
|
||||||
- INACTIVE (?)
|
|
||||||
- TO_BILL
|
|
||||||
- NOT_TO_BILL: we use this to accelerate queries to the DB
|
|
||||||
*** Updating status of orders
|
|
||||||
- If has succeeding order and billing date is last month -> set inactive
|
|
||||||
** Bills
|
|
||||||
- Are always for a month
|
|
||||||
- Can be preliminary
|
|
||||||
*** Which orders to include
|
|
||||||
- Not the cancelled ones / not active ones
|
|
||||||
** Flows / Approach
|
|
||||||
*** Finding all orders for a bill
|
|
||||||
- Get all orders, state != NOT_TO_BILL; for each order do:
|
|
||||||
- is it a one time order?
|
|
||||||
- has it a bill assigned?
|
|
||||||
- yes: set to NOT_TO_BILL
|
|
||||||
- no:
|
|
||||||
- get_or_create_bill_for_this_month
|
|
||||||
- assign bill to this order
|
|
||||||
- set to NOT_TO_BILL
|
|
||||||
- is it a recurring order?
|
|
||||||
- if it has a REPLACING order:
|
|
||||||
-
|
|
||||||
- First of month
|
|
||||||
- Last of month
|
|
||||||
*** Handling replacement of orders
|
|
||||||
- The OLD order will appear in the month that it was cancelled on
|
|
||||||
the bill
|
|
||||||
- The OLD order needs to be set to NOT_TO_BILL after it was billed
|
|
||||||
the last time
|
|
||||||
- The NEW order will be added pro rata if the amount is higher in
|
|
||||||
the same month
|
|
||||||
- The NEW order will be used next month
|
|
||||||
**** Disabling the old order
|
|
||||||
- On billing run
|
|
||||||
- If order.replacement_order (naming!) is set
|
|
||||||
- if the order.replacement_order starts during THIS_MONTH
|
|
||||||
- add order to bill
|
|
||||||
- if NOT:
|
|
||||||
- the order was already replaced in a previous billing period
|
|
||||||
- set the order to NOT_TO_BILL
|
|
||||||
**** Billing the new order
|
|
||||||
- If order.previous_order
|
|
||||||
*** Handling multiple times a recurring order
|
|
||||||
- For each recurring order check the order.period
|
|
||||||
- Find out when it was billed last
|
|
||||||
- lookup latest bill
|
|
||||||
- Calculate how many times it has been used until 2359, last day
|
|
||||||
of month
|
|
||||||
- For preliminary bill: until datetime.now()
|
|
||||||
- Call the bill_end_datetime
|
|
||||||
- Getting duration: bill_end_datetime - order.last_billed
|
|
||||||
- Amount in seconds; duration_in_seconds
|
|
||||||
- Divide duration_in_seconds by order.period; amount_used:
|
|
||||||
- If >= 1: add amount_used * order.recurring_amount to bill
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
* What is a remote uncloud client?
|
|
||||||
** Systems that configure themselves for the use with uncloud
|
|
||||||
** Examples are VMHosts, VPN Servers, cdist control server, etc.
|
|
||||||
* Which access do these clients need?
|
|
||||||
** They need read / write access to the database
|
|
||||||
* Possible methods
|
|
||||||
** Overview
|
|
||||||
| | pros | cons |
|
|
||||||
| SSL based | Once setup, can access all django parts natively, locally | X.509 infrastructure |
|
|
||||||
| SSH -L tunnel | All nodes can use [::1]:5432 | SSH setup can be fragile |
|
|
||||||
| ssh djangohost manage.py | All DB ops locally | Code is only executed on django host |
|
|
||||||
| https + token | Rest alike / consistent access | Code is only executed on django host |
|
|
||||||
| from_django | Everything is on the django host | main host can become bottleneck |
|
|
||||||
** remote vs. local Django code execution
|
|
||||||
- If manage.py is executed locally (= on the client), it can
|
|
||||||
check/modify local configs
|
|
||||||
- However local execution requires a pyvenv + packages + db access
|
|
||||||
- Local execution also *could* make use of postgresql notify for
|
|
||||||
triggering actions (which is quite neat)
|
|
||||||
- Remote execution (= on the primary django host) can acess the db
|
|
||||||
via unix socket
|
|
||||||
- However remote execution cannot check local state
|
|
||||||
** from_django
|
|
||||||
- might reuse existing methods like celery
|
|
||||||
- reduces the amount of things to be installed on the client to
|
|
||||||
almost zero
|
|
||||||
- follows the opennebula model
|
|
||||||
- has a single point of failurebin
|
|
||||||
|
|
@ -1,485 +0,0 @@
|
||||||
* Bootstrap / Installation / Deployment
|
|
||||||
** Pre-requisites by operating system
|
|
||||||
*** General
|
|
||||||
To run uncloud you need:
|
|
||||||
- ldap development libraries
|
|
||||||
- libxml2-dev libxslt-dev
|
|
||||||
- gcc / libc headers: for compiling things
|
|
||||||
- python3-dev
|
|
||||||
- wireguard: wg (for checking keys)
|
|
||||||
*** Alpine
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
apk add openldap-dev postgresql-dev libxml2-dev libxslt-dev gcc python3-dev musl-dev wireguard-tools-wg
|
|
||||||
#+END_SRC
|
|
||||||
*** Debian/Devuan:
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
apt install postgresql-server-dev-all
|
|
||||||
#+END_SRC
|
|
||||||
** Creating a virtual environment / installing python requirements
|
|
||||||
*** Virtual env
|
|
||||||
To separate uncloud requirements, you can use a python virtual
|
|
||||||
env as follows:
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
python3 -m venv venv
|
|
||||||
. ./venv/bin/activate
|
|
||||||
#+END_SRC
|
|
||||||
Then install the requirements
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
pip install -r requirements.txt
|
|
||||||
#+END_SRC
|
|
||||||
** Setting up the the database
|
|
||||||
*** Install the database service
|
|
||||||
The database can run on the same host as uncloud, but can also run
|
|
||||||
a different server. Consult the usual postgresql documentation for
|
|
||||||
a secure configuration.
|
|
||||||
|
|
||||||
The database needs to be accessible from all worker nodes.
|
|
||||||
**** Alpine
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
apk add postgresql-server
|
|
||||||
rc-update add postgresql
|
|
||||||
rc-service postgresql start`
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
**** Debian/Devuan:
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
apt install postgresql
|
|
||||||
#+END_SRC
|
|
||||||
*** Create the database
|
|
||||||
Due to the use of the JSONField, postgresql is required.
|
|
||||||
To get started,
|
|
||||||
create a database and have it owned by the user that runs uncloud
|
|
||||||
(usually "uncloud"):
|
|
||||||
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
bridge:~# su - postgres
|
|
||||||
bridge:~$ psql
|
|
||||||
postgres=# create role uncloud login;
|
|
||||||
postgres=# create database uncloud owner nico;
|
|
||||||
#+END_SRC
|
|
||||||
*** Creating the schema
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
python manage.py migrate
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
*** Configuring remote access
|
|
||||||
- Get a letsencrypt certificate
|
|
||||||
- Expose SSL ports
|
|
||||||
- Create a user
|
|
||||||
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
certbot certonly --standalone \
|
|
||||||
-d <yourdbhostname> -m your@email.come \
|
|
||||||
--agree-tos --no-eff-email
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
- Configuring postgresql.conf:
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
listen_addresses = '*' # what IP address(es) to listen on;
|
|
||||||
ssl = on
|
|
||||||
ssl_cert_file = '/etc/postgresql/server.crt'
|
|
||||||
ssl_key_file = '/etc/postgresql/server.key'
|
|
||||||
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
- Cannot load directly due to permission error:
|
|
||||||
2020-12-26 13:01:55.235 CET [27805] FATAL: could not load server
|
|
||||||
certificate file
|
|
||||||
"/etc/letsencrypt/live/2a0a-e5c0-0013-0000-9f4b-e619-efe5-a4ac.has-a.name/fullchain.pem":
|
|
||||||
Permission denied
|
|
||||||
|
|
||||||
- hook
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
bridge:/etc/letsencrypt/renewal-hooks/deploy# cat /etc/letsencrypt/renewal-hooks/deploy/postgresql
|
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
umask 0177
|
|
||||||
export DOMAIN=2a0a-e5c0-0013-0000-9f4b-e619-efe5-a4ac.has-a.name
|
|
||||||
export DATA_DIR=/etc/postgresql
|
|
||||||
|
|
||||||
cp /etc/letsencrypt/live/$DOMAIN/fullchain.pem $DATA_DIR/server.crt
|
|
||||||
cp /etc/letsencrypt/live/$DOMAIN/privkey.pem $DATA_DIR/server.key
|
|
||||||
chown postgres:postgres $DATA_DIR/server.crt $DATA_DIR/server.key
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
- Allowing access with md5 encrypted password encrypted via TLS
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
hostssl all all ::/0 md5
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
|
|
||||||
postgres=# create role uncloud password '...';
|
|
||||||
CREATE ROLE
|
|
||||||
postgres=# alter role uncloud login ;
|
|
||||||
ALTER ROLE
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
Testing the connection:
|
|
||||||
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
psql postgresql://uncloud@2a0a-e5c0-0013-0000-9f4b-e619-efe5-a4ac.has-a.name/uncloud?sslmode
|
|
||||||
=require
|
|
||||||
g #+END_SRC
|
|
||||||
|
|
||||||
** Bootstrap
|
|
||||||
- Login via a user so that the user object gets created
|
|
||||||
- Run the following (replace nicocustomer with the username)
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
python manage.py bootstrap-user --username nicocustomer
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
** Initialise the database
|
|
||||||
While it is not strictly required to add default values to the
|
|
||||||
database, it might significantly reduce the starting time with
|
|
||||||
uncloud.
|
|
||||||
|
|
||||||
To add the default database values run:
|
|
||||||
|
|
||||||
#+BEGIN_SRC shell
|
|
||||||
# Add local objects
|
|
||||||
python manage.py db-add-defaults
|
|
||||||
|
|
||||||
# Import VAT rates
|
|
||||||
python manage.py import-vat-rates
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
** Worker nodes
|
|
||||||
Nodes that realise services (VMHosts, VPNHosts, etc.) need to be
|
|
||||||
accessible from the main node and also need access to the database.
|
|
||||||
|
|
||||||
Workers usually should have an "uncloud" user account, even though
|
|
||||||
strictly speaking the username can be any.
|
|
||||||
*** WireGuardVPN Server
|
|
||||||
- Allow write access to /etc/wireguard for uncloud user
|
|
||||||
- Allow sudo access to "ip" and "wg"
|
|
||||||
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
chown uncloud /etc/wireguard/
|
|
||||||
[14:30] vpn-2a0ae5c1200:/etc/sudoers.d# cat uncloud
|
|
||||||
app ALL=(ALL) NOPASSWD:/sbin/ip
|
|
||||||
app ALL=(ALL) NOPASSWD:/usr/bin/wg
|
|
||||||
#+END_SRC
|
|
||||||
** Typical source code based deployment
|
|
||||||
- Deploy using bin/deploy.sh on a remote server
|
|
||||||
- Remote server should have
|
|
||||||
- postgresql running, accessible via TLS from outside
|
|
||||||
- rabbitmq-configured [in progress]
|
|
||||||
|
|
||||||
* Testing / CLI Access
|
|
||||||
Access via the commandline (CLI) can be done using curl or
|
|
||||||
httpie. In our examples we will use httpie.
|
|
||||||
** Checkout out the API
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
http localhost:8000/api/
|
|
||||||
#+END_SRC
|
|
||||||
** Authenticate via ldap user in password store
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
http --auth nicocustomer:$(pass ldap/nicocustomer) localhost:8000/api/
|
|
||||||
#+END_SRC
|
|
||||||
* Database
|
|
||||||
** uncloud clients access the data base from a variety of outside hosts
|
|
||||||
** So the postgresql data base needs to be remotely accessible
|
|
||||||
** Instead of exposing the tcp socket, we make postgresql bind to localhost via IPv6
|
|
||||||
*** ::1, port 5432
|
|
||||||
** Then we remotely connect to the database server with ssh tunneling
|
|
||||||
*** ssh -L5432:localhost:5432 uncloud-database-host
|
|
||||||
** Configuring your database for SSH based remote access
|
|
||||||
*** host all all ::1/128 trust
|
|
||||||
|
|
||||||
* URLs
|
|
||||||
- api/ - the rest API
|
|
||||||
* uncloud Products
|
|
||||||
** Product features
|
|
||||||
- Dependencies on other products
|
|
||||||
- Minimum parameters (min cpu, min ram, etc).
|
|
||||||
- Can also realise the dcl vm
|
|
||||||
- dualstack vm = VM + IPv4 + SSD
|
|
||||||
- Need to have a non-misguiding name for the "bare VM"
|
|
||||||
- Should support network boot (?)
|
|
||||||
|
|
||||||
** VPN
|
|
||||||
*** How to add a new VPN Host
|
|
||||||
**** Install wireguard to the host
|
|
||||||
**** Install uncloud to the host
|
|
||||||
**** Add `python manage.py vpn --hostname fqdn-of-this-host` to the crontab
|
|
||||||
**** Use the CLI to configure one or more VPN Networks for this host
|
|
||||||
*** Example of adding a VPN host at ungleich
|
|
||||||
**** Create a new dual stack alpine VM
|
|
||||||
**** Add it to DNS as vpn-XXX.ungleich.ch
|
|
||||||
**** Route a /40 network to its IPv6 address
|
|
||||||
**** Install wireguard on it
|
|
||||||
**** TODO [#C] Enable wireguard on boot
|
|
||||||
**** TODO [#C] Create a new VPNPool on uncloud with
|
|
||||||
***** the network address (selecting from our existing pool)
|
|
||||||
***** the network size (/...)
|
|
||||||
***** the vpn host that provides the network (selecting the created VM)
|
|
||||||
***** the wireguard private key of the vpn host (using wg genkey)
|
|
||||||
***** http command
|
|
||||||
```
|
|
||||||
http -a nicoschottelius:$(pass
|
|
||||||
ungleich.ch/nico.schottelius@ungleich.ch)
|
|
||||||
http://localhost:8000/admin/vpnpool/ network=2a0a:e5c1:200:: \
|
|
||||||
network_size=40 subnetwork_size=48
|
|
||||||
vpn_hostname=vpn-2a0ae5c1200.ungleich.ch
|
|
||||||
wireguard_private_key=...
|
|
||||||
```
|
|
||||||
*** Example http commands / REST calls
|
|
||||||
**** creating a new vpn pool
|
|
||||||
http -a nicoschottelius:$(pass
|
|
||||||
ungleich.ch/nico.schottelius@ungleich.ch)
|
|
||||||
http://localhost:8000/admin/vpnpool/ network_size=40
|
|
||||||
subnetwork_size=48 network=2a0a:e5c1:200::
|
|
||||||
vpn_hostname=vpn-2a0ae5c1200.ungleich.ch wireguard_private_key=$(wg
|
|
||||||
genkey)
|
|
||||||
**** Creating a new vpn network
|
|
||||||
*** Creating a VPN pool
|
|
||||||
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
http -a uncloudadmin:$(pass uncloudadmin) https://localhost:8000/v1/admin/vpnpool/ \
|
|
||||||
network=2a0a:e5c1:200:: network_size=40 subnetwork_size=48 \
|
|
||||||
vpn_hostname=vpn-2a0ae5c1200.ungleich.ch wireguard_private_key=$(wg genkey)
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
This will create the VPNPool 2a0a:e5c1:200::/40 from which /48
|
|
||||||
networks will be used for clients.
|
|
||||||
|
|
||||||
VPNPools can only be managed by staff.
|
|
||||||
|
|
||||||
*** Managing VPNNetworks
|
|
||||||
|
|
||||||
To request a network as a client, use the following call:
|
|
||||||
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
http -a user:$(pass user) https://localhost:8000/v1/net/vpn/ \
|
|
||||||
network_size=48 \
|
|
||||||
wireguard_public_key=$(wg genkey | tee privatekey | wg pubkey)
|
|
||||||
```
|
|
||||||
|
|
||||||
VPNNetworks can be managed by all authenticated users.
|
|
||||||
|
|
||||||
* Developer Handbook
|
|
||||||
The following section describe decisions / architecture of
|
|
||||||
uncloud. These chapters are intended to be read by developers.
|
|
||||||
** This Documentation
|
|
||||||
This documentation is written in org-mode. To compile it to
|
|
||||||
html/pdf, just open emacs and press *C-c C-e l p*.
|
|
||||||
** Models
|
|
||||||
*** Bill
|
|
||||||
Bills are summarising usage in a specific timeframe. Bills usually
|
|
||||||
spawn one month.
|
|
||||||
*** BillRecord
|
|
||||||
Bill records are used to model the usage of one order during the
|
|
||||||
timeframe.
|
|
||||||
*** Order
|
|
||||||
Orders register the intent of a user to buy something. They might
|
|
||||||
refer to a product. (???)
|
|
||||||
Order register the one time price and the recurring price. These
|
|
||||||
fields should be treated as immutable. If they need to be modified,
|
|
||||||
a new order that replaces the current order should be created.
|
|
||||||
**** Replacing orders
|
|
||||||
If an order is updated, a new order is created and points to the
|
|
||||||
old order. The old order stops one second before the new order
|
|
||||||
starts.
|
|
||||||
|
|
||||||
If a order has been replaced can be seen by its replaced_by count:
|
|
||||||
#+BEGIN_SRC sh
|
|
||||||
>>> Order.objects.get(id=1).replaced_by.count()
|
|
||||||
1
|
|
||||||
#+END_SRC
|
|
||||||
|
|
||||||
*** Product and Product Children
|
|
||||||
- A product describes something a user can buy
|
|
||||||
- A product inherits from the uncloud_pay.models.Product model to
|
|
||||||
get basic attributes
|
|
||||||
** Identifiers
|
|
||||||
*** Problem description
|
|
||||||
Identifiers can be integers, strings or other objects. They should
|
|
||||||
be unique.
|
|
||||||
*** Approach 1: integers
|
|
||||||
Integers are somewhat easy to remember, but also include
|
|
||||||
predictable growth, which might allow access to guessed hacking
|
|
||||||
(obivously proper permissions should prevent this).
|
|
||||||
*** Approach 2: random uuids
|
|
||||||
UUIDs are 128 bit integers. Python supports uuid.uuid4() for random
|
|
||||||
uuids.
|
|
||||||
*** Approach 3: IPv6 addresses
|
|
||||||
uncloud heavily depends on IPv6 in the first place. uncloud could
|
|
||||||
use a /48 to identify all objects. Objects that have IPv6 addresses
|
|
||||||
on their own, don't need to draw from the system /48.
|
|
||||||
**** Possible Subnetworks
|
|
||||||
Assuming uncloud uses a /48 to represent all resources.
|
|
||||||
|
|
||||||
| Network | Name | Description |
|
|
||||||
|-----------------+-----------------+----------------------------------------------|
|
|
||||||
| 2001:db8::/48 | uncloud network | All identifiers drawn from here |
|
|
||||||
| 2001:db8:1::/64 | VM network | Every VM has an IPv6 address in this network |
|
|
||||||
| 2001:db8:2::/64 | Bill network | Every bill has an IPv6 address |
|
|
||||||
| 2001:db8:3::/64 | Order network | Every order has an IPv6 address |
|
|
||||||
| 2001:db8:5::/64 | Product network | Every product (?) has an IPv6 address |
|
|
||||||
| 2001:db8:4::/64 | Disk network | Every disk is identified |
|
|
||||||
|
|
||||||
**** Tests
|
|
||||||
[15:47:37] black3.place6:~# rbd create -s 10G ssd/2a0a:e5c0:1::8
|
|
||||||
|
|
||||||
*** Decision
|
|
||||||
We use integers, because they are easy.
|
|
||||||
|
|
||||||
** Distributing/Dispatching/Orchestrating
|
|
||||||
*** Variant 1: using cdist
|
|
||||||
- The uncloud server can git commit things
|
|
||||||
- The uncloud server loads cdist and configures the server
|
|
||||||
- Advantages
|
|
||||||
- Fully integrated into normal flow
|
|
||||||
- Disadvantage
|
|
||||||
- web frontend has access to more data than it needs
|
|
||||||
- On compromise of the machine, more data leaks
|
|
||||||
- Some cdist usual delay
|
|
||||||
*** Variant 2: via celery
|
|
||||||
- The uncloud server dispatches via celery
|
|
||||||
- Every decentral node also runs celery/connects to the broker
|
|
||||||
- Summary brokers:
|
|
||||||
- If local only celery -> good to use redis - Broker
|
|
||||||
- If remote: probably better to use rabbitmq
|
|
||||||
- redis
|
|
||||||
- simpler
|
|
||||||
- rabbitmq
|
|
||||||
- more versatile
|
|
||||||
- made for remote connections
|
|
||||||
- quorom queues would be nice, but not clear if supported
|
|
||||||
- https://github.com/celery/py-amqp/issues/302
|
|
||||||
- https://github.com/celery/celery/issues/6067
|
|
||||||
- Cannot be installed on alpine Linux at the moment
|
|
||||||
- Advantage
|
|
||||||
- Very python / django integrated
|
|
||||||
- Rather instant
|
|
||||||
- Disadvantages
|
|
||||||
- Every decentral node needs to have the uncloud code available
|
|
||||||
- Decentral nodes *might* need to access the database
|
|
||||||
- Tasks can probably be written to work without that
|
|
||||||
(i.e. only strings/bytes)
|
|
||||||
|
|
||||||
**** log/tests
|
|
||||||
(venv) [19:54] vpn-2a0ae5c1200:~/uncloud$ celery -A uncloud -b redis://bridge.place7.ungleich.ch worker -n worker1@%h --logfile ~/celery.log -
|
|
||||||
Q vpn-2a0ae5c1200.ungleich.ch
|
|
||||||
|
|
||||||
|
|
||||||
*** Variant 3: dedicated cdist instance via message broker
|
|
||||||
- A separate VM/machine
|
|
||||||
- Has Checkout of ~/.cdist
|
|
||||||
- Has cdist checkout
|
|
||||||
- Tiny API for management
|
|
||||||
- Not directly web accessible
|
|
||||||
- "cdist" queue
|
|
||||||
|
|
||||||
** Milestones :uncloud:
|
|
||||||
*** 1.1 (cleanup 1)
|
|
||||||
**** TODO [#C] Unify ValidationError, FieldError - define proper Exception
|
|
||||||
- What do we use for model errors
|
|
||||||
**** TODO [#C] Cleanup the results handling in celery
|
|
||||||
- Remove the results broker?
|
|
||||||
- Setup app to ignore results?
|
|
||||||
- Actually use results?
|
|
||||||
*** 1.0 (initial release)
|
|
||||||
**** TODO [#C] Initial Generic product support
|
|
||||||
- Product
|
|
||||||
***** TODO [#C] Recurring product support
|
|
||||||
****** TODO [#C] Support replacing orders for updates
|
|
||||||
****** DONE [#A] Finish split of bill creation
|
|
||||||
CLOSED: [2020-09-11 Fri 23:19]
|
|
||||||
****** TODO [#C] Test the new functions in the Order class
|
|
||||||
****** Define the correct order replacement logic
|
|
||||||
Assumption:
|
|
||||||
- recurringperiods are 30days
|
|
||||||
******* Case 1: downgrading
|
|
||||||
- User commits to 10 CHF for 30 days
|
|
||||||
- Wants to downgrade after 15 days to 5 CHF product
|
|
||||||
- Expected result:
|
|
||||||
- order 1: 10 CHF until +30days
|
|
||||||
- order 2: 5 CHF starting 30days + 1s
|
|
||||||
- Sum of the two orders is 15 CHF
|
|
||||||
- Question is
|
|
||||||
- when is the VM shutdown?
|
|
||||||
- a) instantly
|
|
||||||
- b) at the end of the cycle
|
|
||||||
- best solution
|
|
||||||
- user can choose between a ... b any time
|
|
||||||
******* Duration
|
|
||||||
- You cannot cancel the duration
|
|
||||||
- You can upgrade and with that cancel the duration
|
|
||||||
- The idea of a duration is that you commit for it
|
|
||||||
- If you want to commit lower (daily basis for instance) you
|
|
||||||
have higher per period prices
|
|
||||||
******* Case X
|
|
||||||
- User has VM with 2 Core / 2 GB RAM
|
|
||||||
- User modifies with to 1 core / 3 GB RAM
|
|
||||||
- We treat it as down/upgrade independent of the modifications
|
|
||||||
|
|
||||||
******* Case 2: upgrading after 1 day
|
|
||||||
- committed for 30 days
|
|
||||||
- upgrade after 1 day
|
|
||||||
- so first order will be charged for 1/30ths
|
|
||||||
|
|
||||||
******* Case 2: upgrading
|
|
||||||
- User commits to 10 CHF for 30 days
|
|
||||||
- Wants to upgrade after 15 days to 20 CHF product
|
|
||||||
- Order 1 : 1 VM with 2 Core / 2 GB / 10 SSD -- 10 CHF
|
|
||||||
- 30days period, stopped after 15, so quantity is 0.5 = 5 CHF
|
|
||||||
- Order 2 : 1 VM with 2 Core / 6 GB / 10 SSD -- 20 CHF
|
|
||||||
- after 15 days
|
|
||||||
- VM is upgraded instantly
|
|
||||||
- Expected result:
|
|
||||||
- order 1: 10 CHF until +15days = 0.5 units = 5 CHF
|
|
||||||
- order 2: 20 CHF starting 15days + 1s ... +30 days after
|
|
||||||
the 15 days -> 45 days = 1 unit = 20 CHF
|
|
||||||
- Total on bill: 25 CHF
|
|
||||||
|
|
||||||
******* Case 2: upgrading
|
|
||||||
- User commits to 10 CHF for 30 days
|
|
||||||
- Wants to upgrade after 15 days to 20 CHF product
|
|
||||||
- Expected result:
|
|
||||||
- order 1: 10 CHF until +30days = 1 units = 10 CHF
|
|
||||||
|
|
||||||
- order 2: 20 CHF starting 15days + 1s = 1 unit = 20 CHF
|
|
||||||
- Total on bill: 30 CHF
|
|
||||||
|
|
||||||
|
|
||||||
****** TODO [#C] Note: ending date not set if replaced by default (implicit!)
|
|
||||||
- Should the new order modify the old order on save()?
|
|
||||||
****** DONE Fix totally wrong bill dates in our test case
|
|
||||||
CLOSED: [2020-09-09 Wed 01:00]
|
|
||||||
- 2020 used instead of 2019
|
|
||||||
- Was due to existing test data ...
|
|
||||||
***** DONE Bill logic is still wrong
|
|
||||||
CLOSED: [2020-11-05 Thu 18:58]
|
|
||||||
- Bill starting_date is the date of the first order
|
|
||||||
- However first encountered order does not have to be the
|
|
||||||
earliest in the bill!
|
|
||||||
- Bills should not have a duration
|
|
||||||
- Bills should only have a (unique) issue date
|
|
||||||
- We charge based on bill_records
|
|
||||||
- Last time charged issue date of the bill OR earliest date
|
|
||||||
after that
|
|
||||||
- Every bill generation checks all (relevant) orders
|
|
||||||
- add a flag "not_for_billing" or "closed"
|
|
||||||
- query on that flag
|
|
||||||
- verify it every time
|
|
||||||
|
|
||||||
***** TODO Generating bill for admins/staff
|
|
||||||
-
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**** Bill fixes needed
|
|
||||||
***** TODO Double bill in bill id
|
|
||||||
***** TODO Name the currency
|
|
||||||
***** TODO Maybe remove the chromium pdf rendering artefacts
|
|
||||||
- date on the top
|
|
||||||
- title on the top
|
|
||||||
- filename bottom left
|
|
||||||
- page number could even stay
|
|
||||||
***** TODO Try to shorten the timestamp (remove time zone?)
|
|
||||||
***** TODO Bill date might be required
|
|
||||||
***** TODO Total and VAT are empty
|
|
||||||
***** TODO Line below detail/ heading
|
|
||||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
21
manage.py
21
manage.py
|
|
@ -1,21 +0,0 @@
|
||||||
#!/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()
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
from django.contrib import admin
|
|
||||||
from .models import VMInstance
|
|
||||||
|
|
||||||
admin.site.register(VMInstance)
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixhostingConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'matrixhosting'
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
from . import signals
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import tldextract
|
|
||||||
|
|
||||||
from django import forms
|
|
||||||
from django.forms import ModelForm
|
|
||||||
from django.utils.translation import get_language, ugettext_lazy as _
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from .validators import domain_name_validator
|
|
||||||
from uncloud_pay.models import BillingAddress
|
|
||||||
|
|
||||||
|
|
||||||
class DomainNameField(forms.CharField):
|
|
||||||
description = 'Domain name form field'
|
|
||||||
default_validators = [domain_name_validator, ]
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super(DomainNameField, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
class RequestHostedVMForm(forms.Form):
|
|
||||||
cores = forms.IntegerField(label='CPU', min_value=1, max_value=48, initial=1)
|
|
||||||
memory = forms.IntegerField(label='RAM', min_value=2, max_value=200, initial=2)
|
|
||||||
storage = forms.IntegerField(label='Storage', min_value=100, max_value=10000, initial=100)
|
|
||||||
matrix_domain = DomainNameField(required=True)
|
|
||||||
homeserver_domain = DomainNameField(required=True)
|
|
||||||
webclient_domain = DomainNameField(required=True)
|
|
||||||
is_open_registration = forms.BooleanField(required=False, initial=False)
|
|
||||||
pricing_name = forms.CharField(required=True)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
homeserver_domain = self.cleaned_data.get('homeserver_domain', False)
|
|
||||||
webclient_domain = self.cleaned_data.get('webclient_domain', False)
|
|
||||||
if homeserver_domain and webclient_domain:
|
|
||||||
# Homserver-Domain and Webclient-Domain cannot be below the same second level domain (i.e. homeserver.abc.ch and webclient.def.cloud are ok,
|
|
||||||
# homeserver.abc.ch and webclient.abc.ch are not ok
|
|
||||||
homeserver_base = tldextract.extract(homeserver_domain).domain
|
|
||||||
webclient_base = tldextract.extract(webclient_domain).domain
|
|
||||||
if homeserver_base == webclient_base:
|
|
||||||
self._errors['webclient_domain'] = self.error_class([
|
|
||||||
'Homserver-Domain and Webclient-Domain cannot be below the same second level domain'])
|
|
||||||
return self.cleaned_data
|
|
||||||
|
|
||||||
|
|
||||||
class BillingAddressForm(ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = BillingAddress
|
|
||||||
fields = ['full_name', 'street',
|
|
||||||
'city', 'postal_code', 'country', 'vat_number', 'active', 'owner']
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# Generated by Django 3.2.4 on 2021-06-30 07:42
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='VMPricing',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=255, unique=True)),
|
|
||||||
('vat_inclusive', models.BooleanField(default=True)),
|
|
||||||
('vat_percentage', models.DecimalField(blank=True, decimal_places=5, default=0, max_digits=7)),
|
|
||||||
('set_up_fees', models.DecimalField(decimal_places=5, default=0, max_digits=7)),
|
|
||||||
('cores_unit_price', models.DecimalField(decimal_places=5, default=0, max_digits=7)),
|
|
||||||
('ram_unit_price', models.DecimalField(decimal_places=5, default=0, max_digits=7)),
|
|
||||||
('storage_unit_price', models.DecimalField(decimal_places=5, default=0, max_digits=7)),
|
|
||||||
('discount_name', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=6)),
|
|
||||||
('stripe_coupon_id', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
# Generated by Django 3.2.4 on 2021-07-01 08:48
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('matrixhosting', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RenameModel(
|
|
||||||
old_name='VMPricing',
|
|
||||||
new_name='MatrixVMPricing',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# Generated by Django 3.2.4 on 2021-07-03 15:23
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('matrixhosting', '0002_rename_vmpricing_matrixvmpricing'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='matrixvmpricing',
|
|
||||||
name='cores_unit_price',
|
|
||||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='matrixvmpricing',
|
|
||||||
name='ram_unit_price',
|
|
||||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='matrixvmpricing',
|
|
||||||
name='set_up_fees',
|
|
||||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='matrixvmpricing',
|
|
||||||
name='storage_unit_price',
|
|
||||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
# Generated by Django 3.2.4 on 2021-07-05 06:52
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('uncloud_pay', '0014_auto_20210703_1747'),
|
|
||||||
('matrixhosting', '0003_auto_20210703_1523'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='VMSpecs',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('cores', models.IntegerField(default=1)),
|
|
||||||
('memory', models.IntegerField(default=2)),
|
|
||||||
('storage', models.IntegerField(default=100)),
|
|
||||||
('matrix_domain', models.CharField(max_length=255)),
|
|
||||||
('homeserver_domain', models.CharField(max_length=255)),
|
|
||||||
('webclient_domain', models.CharField(max_length=255)),
|
|
||||||
('is_open_registration', models.BooleanField(default=False, null=True)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='MatrixHostingOrder',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('vm_id', models.IntegerField(default=0)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('status', models.CharField(choices=[('draft', 'Draft'), ('declined', 'Declined'), ('approved', 'Approved')], default='draft', max_length=100)),
|
|
||||||
('stripe_charge_id', models.CharField(max_length=100, null=True)),
|
|
||||||
('price', models.FloatField()),
|
|
||||||
('billing_address', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='uncloud_pay.billingaddress')),
|
|
||||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.stripecustomer')),
|
|
||||||
('specs', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='matrixhosting.vmspecs')),
|
|
||||||
('vm_pricing', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='matrixhosting.matrixvmpricing')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
# Generated by Django 3.2.4 on 2021-07-05 08:49
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('matrixhosting', '0004_matrixhostingorder_vmspecs'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name='MatrixHostingOrder',
|
|
||||||
),
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name='VMSpecs',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# Generated by Django 3.2.4 on 2021-07-06 13:21
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('matrixhosting', '0005_auto_20210705_0849'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name='MatrixVMPricing',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
# Generated by Django 3.2.4 on 2021-07-09 09:14
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('uncloud_pay', '0021_auto_20210709_0914'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
('matrixhosting', '0006_delete_matrixvmpricing'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='VMInstance',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('ip', models.TextField(default='')),
|
|
||||||
('config', models.JSONField()),
|
|
||||||
('creation_date', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('termination_date', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='instance_id', to='uncloud_pay.order')),
|
|
||||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
# Generated by Django 3.2.4 on 2021-07-10 14:29
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('matrixhosting', '0007_vminstance'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='vminstance',
|
|
||||||
name='ip',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
# Generated by Django 3.2.4 on 2021-07-13 10:20
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('matrixhosting', '0008_remove_vminstance_ip'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='vminstance',
|
|
||||||
name='vm_id',
|
|
||||||
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import gitlab
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
|
|
||||||
from uncloud_pay.models import Order
|
|
||||||
|
|
||||||
|
|
||||||
# Initialize logger.
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class VMInstance(models.Model):
|
|
||||||
owner = models.ForeignKey(get_user_model(),
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
editable=True)
|
|
||||||
|
|
||||||
vm_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
|
||||||
|
|
||||||
config = models.JSONField(null=False, blank=False)
|
|
||||||
|
|
||||||
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name='instance_id')
|
|
||||||
|
|
||||||
creation_date = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
termination_date = models.DateTimeField(blank=True, null=True)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
# Read the deployment yaml file and render the template
|
|
||||||
# Then save it as new yaml file and push it to github repo
|
|
||||||
if 'test' in sys.argv:
|
|
||||||
return super().save(*args, **kwargs)
|
|
||||||
template_dir = os.path.join(os.path.dirname(__file__), 'yaml')
|
|
||||||
env = Environment(loader = FileSystemLoader(template_dir),autoescape = True)
|
|
||||||
tmpl = env.get_template('deployment.yaml.tmpl')
|
|
||||||
result = tmpl.render(
|
|
||||||
name=self.vm_id
|
|
||||||
)
|
|
||||||
gl = gitlab.Gitlab(settings.GITLAB_SERVER, oauth_token=settings.GITLAB_OAUTH_TOKEN)
|
|
||||||
project = gl.projects.get(settings.GITLAB_PROJECT_ID)
|
|
||||||
project.files.create({'file_path': settings.GITLAB_YAML_DIR + f'{self.vm_id}.yaml',
|
|
||||||
'branch': 'master',
|
|
||||||
'content': result,
|
|
||||||
'author_email': settings.GITLAB_AUTHOR_EMAIL,
|
|
||||||
'author_name': settings.GITLAB_AUTHOR_NAME,
|
|
||||||
'commit_message': f'Add New Deployment for {self.vm_id}'})
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
|
||||||
# Delete the deployment yaml file first then
|
|
||||||
# Then delete it
|
|
||||||
if 'test' in sys.argv:
|
|
||||||
return super().delete(*args, **kwargs)
|
|
||||||
gl = gitlab.Gitlab(settings.GITLAB_SERVER, oauth_token=settings.GITLAB_OAUTH_TOKEN)
|
|
||||||
project = gl.projects.get(settings.GITLAB_PROJECT_ID)
|
|
||||||
f_path = settings.GITLAB_YAML_DIR + f'{self.vm_id}.yaml'
|
|
||||||
file = project.files.get(file_path=f_path, ref='master')
|
|
||||||
if file:
|
|
||||||
project.files.delete(file_path=f_path,
|
|
||||||
commit_message=f'Delete {self.vm_id}', branch='master',
|
|
||||||
author_email=settings.GITLAB_AUTHOR_EMAIL,
|
|
||||||
author_name=settings.GITLAB_AUTHOR_NAME)
|
|
||||||
|
|
||||||
super().delete(*args, **kwargs)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.id}-{self.order}"
|
|
||||||
|
|
||||||
def delete_for_bill(self, bill):
|
|
||||||
#TODO delete related instances
|
|
||||||
return True
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from .models import *
|
|
||||||
|
|
||||||
class VMInstanceSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = VMInstance
|
|
||||||
fields = '__all__'
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
from matrixhosting.models import VMInstance
|
|
||||||
from uncloud_pay.models import Order
|
|
||||||
from django.db.models.signals import post_save
|
|
||||||
from django.dispatch import receiver
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Order)
|
|
||||||
def create_instance(sender, instance, created, **kwargs):
|
|
||||||
machine = VMInstance.objects.filter(order=instance).first()
|
|
||||||
if not machine:
|
|
||||||
VMInstance.objects.create(owner=instance.owner, order=instance, config=instance.config)
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,618 +0,0 @@
|
||||||
.navbar-transparent #logoWhite {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-transparent #logoBlack {
|
|
||||||
display: block;
|
|
||||||
width: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topnav .navbar-fixed-top .navbar-collapse {
|
|
||||||
max-height: 740px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-default .navbar-header {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-right .highlights-dropdown .dropdown-menu {
|
|
||||||
left: 0 !important;
|
|
||||||
min-width: 155px;
|
|
||||||
margin-left: 15px;
|
|
||||||
padding: 0 5px 8px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
|
||||||
.navbar-default .navbar-nav>li a,
|
|
||||||
.navbar-right .highlights-dropdown .dropdown-menu>li a {
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
.navbar-right .highlights-dropdown .dropdown-menu {
|
|
||||||
border-width: 0 0 1px 0;
|
|
||||||
border-color: #e7e7e7;
|
|
||||||
box-shadow: -8px 14px 20px -5px rgba(77, 77, 77, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-right .highlights-dropdown .dropdown-menu>li a {
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'Lato', sans-serif;
|
|
||||||
padding: 1px 10px 1px 18px !important;
|
|
||||||
background: transparent;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-right .highlights-dropdown .dropdown-menu>li a:hover,
|
|
||||||
.navbar-right .highlights-dropdown .dropdown-menu>li a:focus,
|
|
||||||
.navbar-right .highlights-dropdown .dropdown-menu>li a:active {
|
|
||||||
background: transparent;
|
|
||||||
text-decoration: underline !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.un-icon {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
opacity: 0.5;
|
|
||||||
margin-top: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/***** DCL payment page **********/
|
|
||||||
|
|
||||||
.dcl-order-container {
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-place-order-text {
|
|
||||||
color: #808080;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-warning-content {
|
|
||||||
font-weight: 300;
|
|
||||||
border: 1px solid #a1a1a1;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 5px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-warning-error {
|
|
||||||
border: 1px solid #EB4D5C;
|
|
||||||
color: #EB4D5C;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-warning-addtional-margin {
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-cvc-element label {
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-element {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-element label {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-input {
|
|
||||||
border-bottom: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-cvc-element .my-input {
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#card-errors {
|
|
||||||
clear: both;
|
|
||||||
padding: 0 0 10px;
|
|
||||||
color: #eb4d5c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credit-card-goup {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.card-expiry-element {
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-cvc-element {
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#billing-form .form-control {
|
|
||||||
box-shadow: none !important;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
.dcl-order-container {
|
|
||||||
width: 990px;
|
|
||||||
padding: 0 15px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-vm p.copyright {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-default .navbar-nav>.open>a,
|
|
||||||
.navbar-default .navbar-nav>.open>a:focus,
|
|
||||||
.navbar-default .navbar-nav>.open>a:hover,
|
|
||||||
.navbar-default .navbar-nav>.active>a,
|
|
||||||
.navbar-default .navbar-nav>.active>a:focus,
|
|
||||||
.navbar-default .navbar-nav>.active>a:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.navbar-default .navbar-nav .open .dropdown-menu>.active a,
|
|
||||||
.navbar-default .navbar-nav .open .dropdown-menu>.active a:focus,
|
|
||||||
.navbar-default .navbar-nav .open .dropdown-menu>.active a:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* bootstrap input box-shadow disable */
|
|
||||||
|
|
||||||
.has-error .form-control:focus,
|
|
||||||
.has-error .form-control:active,
|
|
||||||
.has-success .form-control:focus,
|
|
||||||
.has-success .form-control:active {
|
|
||||||
box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-dashboard {
|
|
||||||
min-height: calc(100vh - 96px);
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
max-width: 1120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.content-dashboard {
|
|
||||||
padding: 0 15px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 575px) {
|
|
||||||
select {
|
|
||||||
width: 280px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:focus,
|
|
||||||
.btn:active:focus {
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/***********Styles for Model********************/
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
border-radius: 0px;
|
|
||||||
font-family: Lato, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
||||||
width: 100%;
|
|
||||||
float: left;
|
|
||||||
border-radius: 0;
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
min-height: 30px;
|
|
||||||
border-bottom: 0px solid #e5e5e5;
|
|
||||||
padding: 0px 15px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header .close {
|
|
||||||
font-size: 75px;
|
|
||||||
font-weight: 300;
|
|
||||||
margin-top: 0;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 11px;
|
|
||||||
z-index: 10;
|
|
||||||
line-height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header .close span {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header .close:focus {
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
float: left;
|
|
||||||
padding: 0px 30px 15px 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body .modal-icon i {
|
|
||||||
font-size: 80px;
|
|
||||||
font-weight: 100;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body .modal-icon {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.42857143;
|
|
||||||
font-size: 25px;
|
|
||||||
padding: 0;
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-text {
|
|
||||||
padding-top: 5px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-text p:not(:last-of-type) {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title+.modal-footer {
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
border-top: 0px solid #e5e5e5;
|
|
||||||
width: 100%;
|
|
||||||
float: left;
|
|
||||||
text-align: center;
|
|
||||||
padding: 15px 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-dialog {
|
|
||||||
display: inline-block;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: middle;
|
|
||||||
width: 40%;
|
|
||||||
margin: 15px auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) and (max-width: 991px) {
|
|
||||||
.modal-dialog {
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.modal-dialog {
|
|
||||||
width: 95%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media(min-width: 576px) {
|
|
||||||
.modal:before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
height: 100%;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-right: -4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ========= */
|
|
||||||
|
|
||||||
.btn-wide {
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choice-btn {
|
|
||||||
min-width: 110px;
|
|
||||||
background-color: #3C5480;
|
|
||||||
color: #fff;
|
|
||||||
border: 2px solid #3C5480;
|
|
||||||
padding: 4px 10px;
|
|
||||||
transition: 0.3s all ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.choice-btn:focus,
|
|
||||||
.choice-btn:hover,
|
|
||||||
.choice-btn:active {
|
|
||||||
color: #3C5480;
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.choice-btn {
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.payment-container {
|
|
||||||
padding-top: 70px;
|
|
||||||
padding-bottom: 11%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.last-p {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-payment-section {
|
|
||||||
max-width: 391px;
|
|
||||||
margin: 0 auto 30px;
|
|
||||||
padding: 0 10px 30px;
|
|
||||||
border-bottom: 1px solid #edebeb;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-payment-section hr {
|
|
||||||
margin-top: 15px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-payment-section .top-hr {
|
|
||||||
margin-left: -10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-payment-section h3 {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-payment-section p {
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-payment-section .card-warning-content {
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-payment-order strong {
|
|
||||||
font-size: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-payment-order p {
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-payment-section .form-group {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-payment-section .form-control {
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 6px 12px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-payment-user {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dcl-payment-user h4 {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.dcl-payment-grid {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.dcl-payment-box {
|
|
||||||
width: 50%;
|
|
||||||
position: relative;
|
|
||||||
padding: 0 30px;
|
|
||||||
}
|
|
||||||
.dcl-payment-box:nth-child(2) {
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
.dcl-payment-box:nth-child(4) {
|
|
||||||
order: 2;
|
|
||||||
}
|
|
||||||
.dcl-payment-section {
|
|
||||||
padding-top: 15px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
border-bottom-width: 5px;
|
|
||||||
}
|
|
||||||
.dcl-payment-box:nth-child(2n) .dcl-payment-section {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.dcl-payment-box:nth-child(1):after,
|
|
||||||
.dcl-payment-box:nth-child(2):after {
|
|
||||||
content: ' ';
|
|
||||||
display: block;
|
|
||||||
background: #eee;
|
|
||||||
width: 1px;
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
z-index: 2;
|
|
||||||
top: 20px;
|
|
||||||
bottom: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#virtual_machine_create_form {
|
|
||||||
padding: 15px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-vm-contact {
|
|
||||||
color: #fff;
|
|
||||||
background: #A3C0E2;
|
|
||||||
border: 2px solid #A3C0E2;
|
|
||||||
padding: 5px 25px;
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 1.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-vm-contact:hover,
|
|
||||||
.btn-vm-contact:focus {
|
|
||||||
background: #fff;
|
|
||||||
color: #a3c0e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* hosting-order */
|
|
||||||
|
|
||||||
.order-detail-container {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 100px auto 40px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
padding: 30px 30px 20px;
|
|
||||||
color: #595959;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-detail-container .dashboard-title-thin {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-left: -3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-detail-container .dashboard-title-thin .un-icon {
|
|
||||||
margin-top: -6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-detail-container .dashboard-container-head {
|
|
||||||
position: relative;
|
|
||||||
padding: 0;
|
|
||||||
margin-bottom: 38px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-detail-container .order-details {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-detail-container h4 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-detail-container p {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-detail-container hr {
|
|
||||||
margin: 15px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-detail-container .thin-hr {
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-detail-container .subtotal-price {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-detail-container .subtotal-price .text-primary {
|
|
||||||
font-size: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-detail-container .total-price {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.order-detail-container {
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
.order-confirm-btn {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
.order-detail-container .dashboard-container-options {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
right: -4px;
|
|
||||||
}
|
|
||||||
.order-detail-container .dashboard-container-options .svg-img {
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.order_detail_footer {
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: #333333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order_detail_footer strong {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order_detail_footer small {
|
|
||||||
font-size: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-title-thin {
|
|
||||||
font-weight: 300;
|
|
||||||
font-size: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-title-thin .un-icon {
|
|
||||||
height: 34px;
|
|
||||||
margin-right: 5px;
|
|
||||||
margin-top: -2px;
|
|
||||||
width: 34px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width:767px) {
|
|
||||||
.dashboard-title-thin {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
.dashboard-title-thin .un-icon {
|
|
||||||
height: 22px;
|
|
||||||
width: 22px;
|
|
||||||
margin-top: -3px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale_date {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale_date.done {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-vm-back {
|
|
||||||
color: #fff;
|
|
||||||
background: #C4CEDA;
|
|
||||||
border: 2px solid #C4CEDA;
|
|
||||||
padding: 5px 25px;
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 1.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-vm-back:hover,
|
|
||||||
.btn-vm-back:focus {
|
|
||||||
color: #fff;
|
|
||||||
background: #8da4c0;
|
|
||||||
border-color: #8da4c0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
(function($) {
|
|
||||||
"use strict"; // Start of use strict
|
|
||||||
|
|
||||||
$(document).ready(function() {
|
|
||||||
function fetch_pricing() {
|
|
||||||
var url = '/matrix/pricing/' + $('#pricing_name').val() + '/calculate/';
|
|
||||||
var cores = $('#cores').val();
|
|
||||||
var memory = $('#memory').val();
|
|
||||||
var storage = $('#storage').val();
|
|
||||||
$.ajax({
|
|
||||||
type: 'GET',
|
|
||||||
url: url,
|
|
||||||
data: { cores: cores, memory: memory, storage: storage},
|
|
||||||
dataType: 'json',
|
|
||||||
success: function (data) {
|
|
||||||
if (data && data['price']) {
|
|
||||||
$('#total').text(data['price']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function incrementValue(e) {
|
|
||||||
var valueElement = $(e.target).parent().parent().find('input');
|
|
||||||
var step = $(valueElement).attr('step');
|
|
||||||
var min = parseInt($(valueElement).attr('min'));
|
|
||||||
var max = parseInt($(valueElement).attr('max'));
|
|
||||||
var new_value = 0;
|
|
||||||
if (e.data.inc == 1) {
|
|
||||||
new_value = Math.min(parseInt($(valueElement).val()) + parseInt(step) * e.data.inc, max);
|
|
||||||
} else {
|
|
||||||
new_value = Math.max(parseInt($(valueElement).val()) + parseInt(step) * e.data.inc, min);
|
|
||||||
}
|
|
||||||
$(valueElement).val(new_value);
|
|
||||||
fetch_pricing();
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if ($('#pricing_name') != undefined) {
|
|
||||||
fetch_pricing();
|
|
||||||
}
|
|
||||||
|
|
||||||
$('.fa-plus-circle.right').bind('click', {inc: 1}, incrementValue);
|
|
||||||
|
|
||||||
$('.fa-minus-circle.left').bind('click', {inc: -1}, incrementValue);
|
|
||||||
});
|
|
||||||
})(jQuery);
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
$( document ).ready(function() {
|
|
||||||
var create_vm_form = $('#virtual_machine_create_form');
|
|
||||||
create_vm_form.submit(placeOrderPayment);
|
|
||||||
function placeOrderPayment(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
$.ajax({
|
|
||||||
url: create_vm_form.attr('action'),
|
|
||||||
type: 'POST',
|
|
||||||
data: create_vm_form.serialize(),
|
|
||||||
init: function () {
|
|
||||||
ok_btn = $('#createvm-modal-done-btn');
|
|
||||||
close_btn = $('#createvm-modal-close-btn');
|
|
||||||
ok_btn.addClass('btn btn-success btn-ok btn-wide hide');
|
|
||||||
close_btn.addClass('btn btn-danger btn-ok btn-wide hide');
|
|
||||||
},
|
|
||||||
success: function (data) {
|
|
||||||
fa_icon = $('.modal-icon').find('.fa-cog');
|
|
||||||
modal_btn = $('#createvm-modal-done-btn');
|
|
||||||
if (data.error) {
|
|
||||||
// Display error.message in your UI.
|
|
||||||
modal_btn.attr('href', error_url).removeClass('visually-hidden');
|
|
||||||
fa_icon.attr('class', 'fa fa-close');
|
|
||||||
modal_btn.attr('class', '').addClass('btn btn-danger btn-ok btn-wide');
|
|
||||||
$('#createvm-modal-title').text("Error Occurred");
|
|
||||||
$('#createvm-modal-body').html(data.error.message);
|
|
||||||
} else {
|
|
||||||
// The payment has succeeded
|
|
||||||
// Display a success message
|
|
||||||
modal_btn.attr('href', data.redirect).removeClass('visually-hidden');
|
|
||||||
$('#createvm-modal-title').text("Order Succeeded");
|
|
||||||
$('#createvm-modal-body').html("Order has been added and the instance will be ready soon");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
var cardBrandToPfClass = {
|
|
||||||
'visa': 'pf-visa',
|
|
||||||
'mastercard': 'pf-mastercard',
|
|
||||||
'amex': 'pf-american-express',
|
|
||||||
'discover': 'pf-discover',
|
|
||||||
'diners': 'pf-diners',
|
|
||||||
'jcb': 'pf-jcb',
|
|
||||||
'unknown': 'pf-credit-card'
|
|
||||||
};
|
|
||||||
function setBrandIcon(brand) {
|
|
||||||
var brandIconElement = document.getElementById('brand-icon');
|
|
||||||
var pfClass = 'pf-credit-card';
|
|
||||||
if (brand in cardBrandToPfClass) {
|
|
||||||
pfClass = cardBrandToPfClass[brand];
|
|
||||||
}
|
|
||||||
for (var i = brandIconElement.classList.length - 1; i >= 0; i--) {
|
|
||||||
brandIconElement.classList.remove(brandIconElement.classList[i]);
|
|
||||||
}
|
|
||||||
brandIconElement.classList.add('pf');
|
|
||||||
brandIconElement.classList.add(pfClass);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
$(document).ready(function () {
|
|
||||||
$.ajaxSetup({
|
|
||||||
beforeSend: function (xhr, settings) {
|
|
||||||
function getCookie(name) {
|
|
||||||
var cookieValue = null;
|
|
||||||
if (document.cookie && document.cookie != '') {
|
|
||||||
var cookies = document.cookie.split(';');
|
|
||||||
for (var i = 0; i < cookies.length; i++) {
|
|
||||||
var cookie = jQuery.trim(cookies[i]);
|
|
||||||
// Does this cookie string begin with the name we want?
|
|
||||||
if (cookie.substring(0, name.length + 1) == (name + '=')) {
|
|
||||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cookieValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(/^http:.*/.test(settings.url) || /^https:.*/.test(settings.url))) {
|
|
||||||
// Only send the token to relative URLs i.e. locally.
|
|
||||||
xhr.setRequestHeader("X-CSRFToken", getCookie('csrftoken'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
var hasCreditcard = window.hasCreditcard || false;
|
|
||||||
if (!hasCreditcard && window.stripeKey) {
|
|
||||||
var stripe = Stripe(window.stripeKey);
|
|
||||||
if (window.pm_id) {
|
|
||||||
|
|
||||||
} else {
|
|
||||||
var element_style = {
|
|
||||||
fonts: [{
|
|
||||||
family: 'lato-light',
|
|
||||||
src: 'url(https://cdn.jsdelivr.net/font-lato/2.0/Lato/Lato-Light.woff) format("woff2")'
|
|
||||||
}, {
|
|
||||||
family: 'lato-regular',
|
|
||||||
src: 'url(https://cdn.jsdelivr.net/font-lato/2.0/Lato/Lato-Regular.woff) format("woff2")'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
locale: window.current_lan
|
|
||||||
};
|
|
||||||
var elements = stripe.elements(element_style);
|
|
||||||
var credit_card_text_style = {
|
|
||||||
base: {
|
|
||||||
iconColor: '#666EE8',
|
|
||||||
color: '#31325F',
|
|
||||||
lineHeight: '25px',
|
|
||||||
fontWeight: 300,
|
|
||||||
fontFamily: "'lato-light', sans-serif",
|
|
||||||
fontSize: '14px',
|
|
||||||
'::placeholder': {
|
|
||||||
color: '#777'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
invalid: {
|
|
||||||
iconColor: '#eb4d5c',
|
|
||||||
color: '#eb4d5c',
|
|
||||||
lineHeight: '25px',
|
|
||||||
fontWeight: 300,
|
|
||||||
fontFamily: "'lato-regular', sans-serif",
|
|
||||||
fontSize: '14px',
|
|
||||||
'::placeholder': {
|
|
||||||
color: '#eb4d5c',
|
|
||||||
fontWeight: 400
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var enter_ccard_text = "Enter your credit card number";
|
|
||||||
if (typeof window.enter_your_card_text !== 'undefined') {
|
|
||||||
enter_ccard_text = window.enter_your_card_text;
|
|
||||||
}
|
|
||||||
var cardNumberElement = elements.create('cardNumber', {
|
|
||||||
style: credit_card_text_style,
|
|
||||||
placeholder: enter_ccard_text
|
|
||||||
});
|
|
||||||
cardNumberElement.mount('#card-number-element');
|
|
||||||
|
|
||||||
var cardExpiryElement = elements.create('cardExpiry', {
|
|
||||||
style: credit_card_text_style
|
|
||||||
});
|
|
||||||
cardExpiryElement.mount('#card-expiry-element');
|
|
||||||
|
|
||||||
var cardCvcElement = elements.create('cardCvc', {
|
|
||||||
style: credit_card_text_style
|
|
||||||
});
|
|
||||||
cardCvcElement.mount('#card-cvc-element');
|
|
||||||
cardNumberElement.on('change', function (event) {
|
|
||||||
if (event.brand) {
|
|
||||||
setBrandIcon(event.brand);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitBillingForm(pmId) {
|
|
||||||
var billing_form = $('#billing-form');
|
|
||||||
billing_form.append('<input type="hidden" name="id_payment_method" value="' + pmId + '" />');
|
|
||||||
billing_form.submit();
|
|
||||||
}
|
|
||||||
|
|
||||||
var $form_new = $('#payment-form-new');
|
|
||||||
$form_new.submit(payWithPaymentIntent);
|
|
||||||
window.result = "";
|
|
||||||
window.card = "";
|
|
||||||
function payWithPaymentIntent(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
function stripePMHandler(paymentMethod) {
|
|
||||||
// Insert the token ID into the form so it gets submitted to the server
|
|
||||||
console.log(paymentMethod);
|
|
||||||
$('#id_payment_method').val(paymentMethod.id);
|
|
||||||
submitBillingForm(paymentMethod.id);
|
|
||||||
}
|
|
||||||
stripe.createPaymentMethod({
|
|
||||||
type: 'card',
|
|
||||||
card: cardNumberElement,
|
|
||||||
})
|
|
||||||
.then(function(result) {
|
|
||||||
// Handle result.error or result.paymentMethod
|
|
||||||
window.result = result;
|
|
||||||
if(result.error) {
|
|
||||||
var errorElement = document.getElementById('card-errors');
|
|
||||||
errorElement.textContent = result.error.message;
|
|
||||||
} else {
|
|
||||||
console.log("created paymentMethod " + result.paymentMethod.id);
|
|
||||||
stripePMHandler(result.paymentMethod);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.card = cardNumberElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form validation */
|
|
||||||
$.validator.addMethod("month", function (value, element) {
|
|
||||||
return this.optional(element) || /^(01|02|03|04|05|06|07|08|09|10|11|12)$/.test(value);
|
|
||||||
}, "Please specify a valid 2-digit month.");
|
|
||||||
|
|
||||||
$.validator.addMethod("year", function (value, element) {
|
|
||||||
return this.optional(element) || /^[0-9]{2}$/.test(value);
|
|
||||||
}, "Please specify a valid 2-digit year.");
|
|
||||||
|
|
||||||
validator = $form_new.validate({
|
|
||||||
rules: {
|
|
||||||
cardNumber: {
|
|
||||||
required: true,
|
|
||||||
creditcard: true,
|
|
||||||
digits: true
|
|
||||||
},
|
|
||||||
expMonth: {
|
|
||||||
required: true,
|
|
||||||
month: true
|
|
||||||
},
|
|
||||||
expYear: {
|
|
||||||
required: true,
|
|
||||||
year: true
|
|
||||||
},
|
|
||||||
cvCode: {
|
|
||||||
required: true,
|
|
||||||
digits: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
highlight: function (element) {
|
|
||||||
$(element).closest('.form-control').removeClass('success').addClass('error');
|
|
||||||
},
|
|
||||||
unhighlight: function (element) {
|
|
||||||
$(element).closest('.form-control').removeClass('error').addClass('success');
|
|
||||||
},
|
|
||||||
errorPlacement: function (error, element) {
|
|
||||||
$(element).closest('.form-group').append(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$('.credit-card-info .btn.choice-btn').click(function () {
|
|
||||||
var id = this.dataset['id_card'];
|
|
||||||
$('#id_card').val(id);
|
|
||||||
submitBillingForm(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import logging
|
|
||||||
from datetime import date, timedelta, timezone
|
|
||||||
from django.conf import settings
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django_q.tasks import async_task, schedule
|
|
||||||
from django_q.models import Schedule
|
|
||||||
from django.db.models import Q
|
|
||||||
from uncloud_pay.models import Bill, Payment
|
|
||||||
from uncloud_pay.selectors import has_enough_balance, get_balance_for_user
|
|
||||||
from .models import VMInstance
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def send_warning_email(bill, html_message):
|
|
||||||
schedule('django.core.mail.send_mail',
|
|
||||||
'Renewal Warning',
|
|
||||||
None,
|
|
||||||
settings.RENEWAL_FROM_EMAIL,
|
|
||||||
[bill.owner.email],
|
|
||||||
html_message,
|
|
||||||
schedule_type=Schedule.ONCE,
|
|
||||||
next_run=timezone.now() + timedelta(hours=1))
|
|
||||||
|
|
||||||
def charge_open_bills():
|
|
||||||
un_paid_bills = Bill.objects.filter(is_closed=False)
|
|
||||||
for bill in un_paid_bills:
|
|
||||||
date_diff = (date.today() - bill.due_date.date()).days
|
|
||||||
# If there is not enough money in the account 7 days before renewal, the system sends a warning
|
|
||||||
# If there is not enough money in the account 3 days before renewal, the system sends a 2nd warning
|
|
||||||
# If on renewal date there is not enough money in the account, delete the instance
|
|
||||||
if date_diff == 7:
|
|
||||||
if not has_enough_balance(bill.owner):
|
|
||||||
context = {'name': bill.owner.name, 'message': "You don't have enough balance for renewal... upload to your account _here"}
|
|
||||||
html_message = render_to_string('matrixhosting/emails/renewal_warning.html', context)
|
|
||||||
send_warning_email(bill, html_message)
|
|
||||||
elif date_diff == 3:
|
|
||||||
if not has_enough_balance(bill.owner):
|
|
||||||
context = {'name': bill.owner.name, 'message': "You don't have enough balance for renewal... Your instance will be deleted in 3 days"}
|
|
||||||
html_message = render_to_string('matrixhosting/emails/renewal_warning.html', context)
|
|
||||||
send_warning_email(bill, html_message)
|
|
||||||
elif date_diff <= 0:
|
|
||||||
if not has_enough_balance(bill.owner):
|
|
||||||
VMInstance.delete_for_bill(bill)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
balance = get_balance_for_user(bill.owner)
|
|
||||||
if balance < 0:
|
|
||||||
payment = Payment.objects.create(owner=bill.owner, amount=balance, source='stripe')
|
|
||||||
if payment:
|
|
||||||
bill.close()
|
|
||||||
bill.close()
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"It seems that there is issue in payment for {bill.owner.name}", e)
|
|
||||||
# do nothing
|
|
||||||
|
|
||||||
|
|
||||||
def process_recurring_orders():
|
|
||||||
"""
|
|
||||||
Check for pending recurring and charge it and generate bills or send the customer warning
|
|
||||||
"""
|
|
||||||
Bill.create_bills_for_all_users()
|
|
||||||
|
|
||||||
def delete_instance(instance_id):
|
|
||||||
VMInstance.objects.delete(instance_id)
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
{% load static i18n %}
|
|
||||||
{% get_current_language as LANGUAGE_CODE %}
|
|
||||||
{% load bootstrap5 %}
|
|
||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{LANGUAGE_CODE}}">
|
|
||||||
<head>
|
|
||||||
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="description" content="Matrix Hosting by ungleich">
|
|
||||||
<meta name="author" content="ungleich glarus ag">
|
|
||||||
<title>Matrix Hosting - {% block title %} made in Switzerland{% endblock %}</title>
|
|
||||||
|
|
||||||
<!-- Vendor CSS -->
|
|
||||||
<!-- Bootstrap Core CSS -->
|
|
||||||
{% bootstrap_css %}
|
|
||||||
<!-- Icon Fonts -->
|
|
||||||
<link href="{% static 'fontawesome_free/css/all.min.css' %}" rel="stylesheet" type="text/css">
|
|
||||||
<!-- Custom CSS -->
|
|
||||||
<link href="{% static 'matrixhosting/css/common.css' %}" rel="stylesheet">
|
|
||||||
{% block css_extra %}
|
|
||||||
{% endblock css_extra %}
|
|
||||||
|
|
||||||
<!-- External Fonts -->
|
|
||||||
<link href="//fonts.googleapis.com/css?family=Lato:300,400,600,700" rel="stylesheet" type="text/css">
|
|
||||||
|
|
||||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
|
||||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
|
||||||
<!--[if lt IE 9]>
|
|
||||||
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
|
||||||
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
|
|
||||||
<![endif]-->
|
|
||||||
|
|
||||||
<!-- Google analytics -->
|
|
||||||
<!-- End Google Analytics -->
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
{% block navbar %}
|
|
||||||
{% include "matrixhosting/includes/_navbar.html" %}
|
|
||||||
{% endblock navbar %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% include "matrixhosting/includes/_footer.html" %}
|
|
||||||
|
|
||||||
<!-- jQuery -->
|
|
||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
|
||||||
<script src="{% static 'fontawesome_free/js/all.min.js' %}"></script>
|
|
||||||
<!-- Bootstrap Core JavaScript -->
|
|
||||||
{% bootstrap_javascript %}
|
|
||||||
<!-- Custom JS -->
|
|
||||||
<script src="{% static 'matrixhosting/js/main.js' %}"></script>
|
|
||||||
{% block js_extra %}
|
|
||||||
{% endblock js_extra %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
{% extends "matrixhosting/base.html" %} {% load static i18n %}
|
|
||||||
{% block content%}
|
|
||||||
<!-- Page Content -->
|
|
||||||
{% csrf_token %}
|
|
||||||
<div>
|
|
||||||
<div class="container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">#</th>
|
|
||||||
<th scope="col">Description</th>
|
|
||||||
<th scope="col">Starting At</th>
|
|
||||||
<th scope="col">Config</th>
|
|
||||||
<th scope="col">Pricing Plan</th>
|
|
||||||
<th scope="col">OneTime Price</th>
|
|
||||||
<th scope="col">Recurring Price</th>
|
|
||||||
<th scope="col">Ending At</th>
|
|
||||||
<th scope="col"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for object in object_list %}
|
|
||||||
<tr data-id="{{object.id}}">
|
|
||||||
<th scope="row">{{ object.id }}</th>
|
|
||||||
<td>{{ object.description }}</td>
|
|
||||||
<td>{{ object.starting_date }}</td>
|
|
||||||
<td>{{ object.config }}</td>
|
|
||||||
<td>{{ object.pricing_plan}}</td>
|
|
||||||
<td>{{ object.one_time_price }}</td>
|
|
||||||
<td>{{ object.recurring_price }}</td>
|
|
||||||
<td>{{ object.ending_date }}</td>
|
|
||||||
{% if object.ending_date %}
|
|
||||||
<td></td>
|
|
||||||
{% else %}
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
class="btn btn-danger btn-sm cancel-subscription"
|
|
||||||
type="submit"
|
|
||||||
name="action"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="modal fade"
|
|
||||||
tabindex="-1"
|
|
||||||
role="dialog"
|
|
||||||
aria-labelledby="mySmallModalLabel"
|
|
||||||
aria-hidden="true"
|
|
||||||
id="mi-modal"
|
|
||||||
>
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h4 class="modal-title" id="myModalLabel">Cancel Subscription</h4>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>
|
|
||||||
Are you sure that you want to cancel this subscription?. </p>
|
|
||||||
<p>
|
|
||||||
The instance will be active till the end date of the last bill and will be deleted
|
|
||||||
after that.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-default" id="modal-btn-yes">
|
|
||||||
Yes
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-primary" id="modal-btn-no">
|
|
||||||
No
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert" role="alert" id="result"></div>
|
|
||||||
<!-- /.banner -->
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block js_extra %}
|
|
||||||
<script type="text/javascript">
|
|
||||||
var modalConfirm = function (callback) {
|
|
||||||
$(".cancel-subscription").on("click", function (event) {
|
|
||||||
$('.selected').removeClass('selected');
|
|
||||||
$(event.target).parent().parent().addClass('selected');
|
|
||||||
$("#mi-modal").modal("show");
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#modal-btn-yes").on("click", function () {
|
|
||||||
callback(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#modal-btn-no").on("click", function () {
|
|
||||||
callback(false);
|
|
||||||
$("#mi-modal").modal("hide");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
modalConfirm(function (confirm) {
|
|
||||||
if (confirm) {
|
|
||||||
var selected_order = $('.selected').data('id');
|
|
||||||
$.ajax({
|
|
||||||
url: '{% url "matrix:dashboard" %}',
|
|
||||||
type: 'POST',
|
|
||||||
data: {'order_id': selected_order, 'csrfmiddlewaretoken': '{{ csrf_token }}',},
|
|
||||||
success: function (data) {
|
|
||||||
$("#mi-modal").modal("hide");
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Renewal Warning</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
hello <strong>{{name}},</strong>
|
|
||||||
{{message}}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
{% load static i18n %}
|
|
||||||
|
|
||||||
<form id="order_form" method="POST" action="{% url 'matrix:index' %}" data-toggle="validator" role="form">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="title">
|
|
||||||
<h3>{% trans "Matrix Chat hosting" %} </h3>
|
|
||||||
</div>
|
|
||||||
<div class="price">
|
|
||||||
<span id="total"> {{ matrix_vm_pricing.name }}</span>
|
|
||||||
<span>CHF/{% trans "month" %}</span>
|
|
||||||
<div class="price-text">
|
|
||||||
<p>
|
|
||||||
{% if matrix_vm_pricing.set_up_fees %}{{ matrix_vm_pricing.set_up_fees }} CHF Setup<br>{% endif %}
|
|
||||||
{% if matrix_vm_pricing.vat_inclusive %}{% trans "VAT included" %} <br>{% endif %}
|
|
||||||
{% if matrix_vm_pricing.discount_amount %}
|
|
||||||
{% trans "You save" %} {{ matrix_vm_pricing.discount_amount }} CHF
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="descriptions">
|
|
||||||
<div class="description form-group">
|
|
||||||
<p>{% trans "Hosted in Switzerland" %}</p>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="description input">
|
|
||||||
<i class="fa fa-minus-circle left" data-minus="cores" aria-hidden="true"></i>
|
|
||||||
<input class="input-price select-number" type="number" min="1" max="48" id="cores" step="1" name="cores"
|
|
||||||
{% if form.cores.value != None %}value="{{ form.cores.value }}"{% endif %} data-error="{% trans 'Please enter a value in range 1 - 48.' %}" required>
|
|
||||||
<span> Core</span>
|
|
||||||
<i class="fa fa-plus-circle right" data-plus="cores" aria-hidden="true"></i>
|
|
||||||
</div>
|
|
||||||
<div class="help-block with-errors">
|
|
||||||
{% for message in messages %}
|
|
||||||
{% if 'cores' in message.tags %}
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li>{{ message|safe }}</li>
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="description input">
|
|
||||||
<i class="fa fa-minus-circle left" data-minus="memory" aria-hidden="true"></i>
|
|
||||||
<input id="memory" class="input-price select-number" type="number" min="2" max="200" name="memory"
|
|
||||||
{% if form.memory.value != None %}value="{{ form.memory.value }}"{% endif %} data-error="{% blocktrans with min_ram=min_ram %}Please enter a value in range {{min_ram}} - 200.{% endblocktrans %}" required step="1">
|
|
||||||
<span> GB RAM</span>
|
|
||||||
<i class="fa fa-plus-circle right" data-plus="memory" aria-hidden="true"></i>
|
|
||||||
</div>
|
|
||||||
<div class="help-block with-errors">
|
|
||||||
{% for message in messages %}
|
|
||||||
{% if 'memory' in message.tags %}
|
|
||||||
<ul class="list-unstyled"><li>
|
|
||||||
{{ message|safe }}
|
|
||||||
</li></ul>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="description input">
|
|
||||||
<i class="fa fa-minus-circle left" data-minus="storage" aria-hidden="true"></i>
|
|
||||||
<input id="storage" class="input-price select-number" type="number" min="100" max="10000" step="100"
|
|
||||||
name="storage" {% if form.storage.value != None %}value="{{ form.storage.value }}"{% endif %} data-error="{% trans 'Please enter a value in range 100 - 10000.' %}" required>
|
|
||||||
<span>{% trans "GB Storage (SSD)" %}</span>
|
|
||||||
<i class="fa fa-plus-circle right" data-plus="storage" aria-hidden="true"></i>
|
|
||||||
</div>
|
|
||||||
<div class="help-block with-errors">
|
|
||||||
{% for message in messages %}
|
|
||||||
{% if 'storage' in message.tags %}
|
|
||||||
<ul class="list-unstyled"><li>
|
|
||||||
{{ message|safe }}
|
|
||||||
</li></ul>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="description domain select-configuration input form-group justify-center">
|
|
||||||
<input type="text" id="matrix_domain" name="matrix_domain" placeholder="Matrix Domain" {% if form.matrix_domain.value != None %}value="{{ form.matrix_domain.value }}"{% endif %}></input>
|
|
||||||
<p class="text-danger">{{ form.matrix_domain.errors }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="description domain select-configuration input form-group justify-center">
|
|
||||||
<input type="text" id="homeserver_domain" name="homeserver_domain" placeholder="Homeserver Domain" {% if form.homeserver_domain.value != None %}value="{{ form.homeserver_domain.value }}"{% endif %} ></input>
|
|
||||||
<p class="text-danger">{{ form.homeserver_domain.errors }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="description domain select-configuration input form-group justify-center">
|
|
||||||
<input type="text" id="webclient_domain" name="webclient_domain" placeholder="Webclient Domain" {% if form.webclient_domain.value != None %}value="{{ form.webclient_domain.value }}"{% endif %}></input>
|
|
||||||
<p class="text-danger">{{ form.webclient_domain.errors }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="description input form-group">
|
|
||||||
<div class="fieldWrapper">
|
|
||||||
<span>Is open registration possible:</span>
|
|
||||||
{{ form.is_open_registration }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="pricing_name" id="pricing_name" value="{% if matrix_vm_pricing.name %}{{matrix_vm_pricing.name}}{% else %}unknown{% endif%}"></input>
|
|
||||||
<input type="submit" class="btn btn-primary" value="{% trans 'Continue' %}"></input>
|
|
||||||
</form>
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
{% load i18n %}
|
|
||||||
<form action="" id="payment-form-new" method="POST">
|
|
||||||
<input type="hidden" name="token"/>
|
|
||||||
<input type="hidden" name="id_card" id="id_card" value=""/>
|
|
||||||
<div class="group">
|
|
||||||
<div class="credit-card-goup">
|
|
||||||
<div class="card-element card-number-element">
|
|
||||||
<label>{%trans "Card Number" %}</label>
|
|
||||||
<div id="card-number-element" class="field my-input"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-5 card-element card-expiry-element">
|
|
||||||
<label>{%trans "Expiry Date" %}</label>
|
|
||||||
<div id="card-expiry-element" class="field my-input"></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-3 col-xs-offset-4 card-element card-cvc-element">
|
|
||||||
<label>{%trans "CVC" %}</label>
|
|
||||||
<div id="card-cvc-element" class="field my-input"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-element brand">
|
|
||||||
<label>{%trans "Card Type" %}</label>
|
|
||||||
<i class="pf pf-credit-card" id="brand-icon"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="card-errors"></div>
|
|
||||||
<div id='payment_error'>
|
|
||||||
{% for message in messages %}
|
|
||||||
{% if 'failed_payment' in message.tags or 'make_charge_error' in message.tags or 'error' in message.tags %}
|
|
||||||
<ul class="list-unstyled">
|
|
||||||
<li><p class="card-warning-content card-warning-error">{{ message|safe }}</p></li>
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
<button class="btn btn-vm-contact btn-wide" type="submit" name="payment-form">{%trans "SUBMIT" %}</button>
|
|
||||||
</div>
|
|
||||||
<div style="display:none;">
|
|
||||||
<p class="payment-errors"></p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{% load i18n %}
|
|
||||||
<footer>
|
|
||||||
<div class="container">
|
|
||||||
<ul class="list-inline">
|
|
||||||
<li>
|
|
||||||
<a class="url-init" href="">{% trans "Home" %}</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="url-init" href="">{% trans "Contact" %}</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="url-init" href="">{% trans "Terms of Service" %}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p class="copyright text-muted small">Copyright © ungleich glarus ag {% now "Y" %}. {% trans "All Rights Reserved" %}</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
{% load static i18n %}
|
|
||||||
{% get_current_language as LANGUAGE_CODE %}
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="{% url 'uncloudindex' %}">Matrix Hosting</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNavDropdown">
|
|
||||||
<ul class="navbar-nav ms-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" aria-current="page" href="{% url 'matrix:index' %}">Home</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="#">Features</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="#">Pricing</a>
|
|
||||||
</li>
|
|
||||||
{% if not request.user.is_authenticated %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{% url 'account_login' %}">{% trans "Login" %} <i class="fa fa-sign-in-alt"></i></a>
|
|
||||||
</li>
|
|
||||||
{% else %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{% url 'matrix:dashboard' %}">{% trans "Dashboard" %}</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{% extends "matrixhosting/base.html" %}
|
|
||||||
{% load static i18n %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Page Content -->
|
|
||||||
|
|
||||||
<div class="split-section pricing-section section-gradient" id="price">
|
|
||||||
<div class="container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="price-calc-section">
|
|
||||||
<div class="card">
|
|
||||||
{% include "matrixhosting/includes/_calculator_form.html" %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- /.banner -->
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
{% load static i18n %}
|
|
||||||
{% load bootstrap5 %}
|
|
||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="description" content="Matrix Hosting by ungleich">
|
|
||||||
<meta name="author" content="ungleich glarus ag">
|
|
||||||
<title>Matrix Hosting - {% block title %} made in Switzerland{% endblock %}</title>
|
|
||||||
|
|
||||||
<!-- Vendor CSS -->
|
|
||||||
<!-- Bootstrap Core CSS -->
|
|
||||||
{% bootstrap_css %}
|
|
||||||
|
|
||||||
<!-- External Fonts -->
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/paymentfont/1.2.5/css/paymentfont.min.css"/>
|
|
||||||
<link href="//fonts.googleapis.com/css?family=Lato:300,400,600,700" rel="stylesheet" type="text/css">
|
|
||||||
|
|
||||||
<link href="{% static 'matrixhosting/css/hosting.css' %}" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
|
||||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
|
||||||
<!--[if lt IE 9]>
|
|
||||||
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
|
||||||
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
|
|
||||||
<![endif]-->
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
window.paymentIntentSecret = "{{payment_intent_secret}}";
|
|
||||||
</script>
|
|
||||||
<div id="order-detail{{order.pk}}" class="order-detail-container">
|
|
||||||
{% if messages %}
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
{% for message in messages %}
|
|
||||||
<span>{{ message }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if not error %}
|
|
||||||
<div class="dashboard-container-head">
|
|
||||||
<h1 class="dashboard-title-thin">
|
|
||||||
{% blocktrans with page_header_text=page_header_text|default:"Order" %}{{page_header_text}}{% endblocktrans %}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div class="order-details">
|
|
||||||
<hr>
|
|
||||||
<div>
|
|
||||||
<address>
|
|
||||||
<h4>{% trans "Billed to" %}:</h4>
|
|
||||||
<p>
|
|
||||||
{% with request.session.billing_address_data as billing_address %}
|
|
||||||
{{billing_address.full_name}}<br>
|
|
||||||
{{billing_address.street}}, {{billing_address.postal_code}}<br>
|
|
||||||
{{billing_address.city}}, {{billing_address.country}}
|
|
||||||
{% if billing_address.vat_number %}
|
|
||||||
<br/>{% trans "VAT Number" %} {{billing_address.vat_number}}
|
|
||||||
{% if pricing.vat_country != "ch" and pricing.vat_validation_status != "not_needed" %}
|
|
||||||
{% if pricing.vat_validation_status == "verified" %}
|
|
||||||
<span class="fa fa-fw fa-check-circle" aria-hidden="true" title='{% trans "Your VAT number has been verified" %}'></span>
|
|
||||||
{% else %}
|
|
||||||
<span class="fa fa-fw fa-info-circle" aria-hidden="true" title='{% trans "Your VAT number is under validation. VAT will be adjusted, once the validation is complete." %}'></span>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</p>
|
|
||||||
</address>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<div>
|
|
||||||
<h4>{% trans "Payment method" %}:</h4>
|
|
||||||
<p>
|
|
||||||
{{card.brand|default:_('Credit Card')}} {% trans "ending in" %} ****{{card.last4}}<br>
|
|
||||||
{% trans "Expiry" %} {{card.exp_year}}/{{card.exp_month}}<br/>
|
|
||||||
{{request.user.email}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<div>
|
|
||||||
<h4>{% trans "Order summary" %}</h4>
|
|
||||||
<style>
|
|
||||||
@media screen and (max-width:400px){
|
|
||||||
.header-no-left-padding {
|
|
||||||
padding-left: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media screen and (max-width:767px){
|
|
||||||
.cmf-ord-heading {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.order-detail-container .order-details {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width:367px){
|
|
||||||
.cmf-ord-heading {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.order-detail-container .order-details {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
<p>
|
|
||||||
<strong>{% trans "Product" %}:</strong>
|
|
||||||
Matrix Chat Hosting
|
|
||||||
</p>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-9">
|
|
||||||
<p>
|
|
||||||
<span>{% trans "Cores" %}: </span>
|
|
||||||
<strong class="pull-right">{{order.cores}}</strong>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span>{% trans "Memory" %}: </span>
|
|
||||||
<strong class="pull-right">{{order.memory}} GB</strong>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span>{% trans "Disk space" %}: </span>
|
|
||||||
<strong class="pull-right">{{order.storage}} GB</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<hr class="thin-hr">
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-9">
|
|
||||||
<p>
|
|
||||||
<strong class="text-uppercase">{% trans "Price Before VAT" %}</strong>
|
|
||||||
<strong class="pull-right">{{pricing.subtotal|floatformat:2}} CHF</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<hr class="thin-hr">
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-9">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4 col-sm-4 col-xs-4">
|
|
||||||
<p><span></span></p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-sm-3 col-xs-4">
|
|
||||||
<p class="text-right"><strong class="cmf-ord-heading">{% trans "Pre VAT" %}</strong></p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-5 col-sm-5 col-xs-4 header-no-left-padding">
|
|
||||||
<p class="text-right"><strong class="cmf-ord-heading">{% trans "With VAT for" %} {{pricing.vat_country}} ({{pricing.vat_percent}}%)</strong></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4 col-sm-4 col-xs-4">
|
|
||||||
<p><span>Subtotal</span></p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-sm-3 col-xs-4">
|
|
||||||
<p><span class="pull-right" >{{pricing.subtotal|floatformat:2}} CHF</span></p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-5 col-sm-5 col-xs-4">
|
|
||||||
<p><span class="pull-right">{{pricing.price_with_vat|floatformat:2}} CHF</span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if pricing.discount.amount > 0 %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4 col-sm-4 col-xs-4">
|
|
||||||
<p><span>{{pricing.discount.name}}</span></p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-sm-3 col-xs-4">
|
|
||||||
<p><span class="pull-right">-{{pricing.discount.amount|floatformat:2}} CHF</span></p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-5 col-sm-5 col-xs-4">
|
|
||||||
<p><span class="pull-right">-{{pricing.discount.amount_with_vat|floatformat:2}} CHF</span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<hr class="thin-hr">
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-9">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4 col-sm-4 col-xs-4">
|
|
||||||
<p><strong>Total</strong></p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-sm-3 col-xs-4">
|
|
||||||
<p><strong class="pull-right">{{pricing.subtotal_after_discount|floatformat:2}} CHF</strong></p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-5 col-sm-5 col-xs-4">
|
|
||||||
<p><strong class="pull-right">{{pricing.price_after_discount_with_vat|floatformat:2}} CHF</strong></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<hr class="thin-hr">
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-9">
|
|
||||||
<strong class="text-uppercase align-center">{% trans "Your Price in Total" %}</strong>
|
|
||||||
<strong class="total-price pull-right">{{pricing.total_price|floatformat:2}} CHF</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr class="thin-hr">
|
|
||||||
</div>
|
|
||||||
<form id="virtual_machine_create_form" action="{% url 'matrix:order_details' %}" method="POST">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<div class="dcl-place-order-text">{% blocktrans with vm_total_price=vm.total_price|floatformat:2 %}By clicking "Place order" you agree to our <a href="">Terms of Service</a> and this plan will charge your credit card account with {{ vm_total_price }} CHF/month{% endblocktrans %}.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-4 order-confirm-btn text-right">
|
|
||||||
<button class="btn choice-btn" id="btn-create-vm" data-bs-toggle="modal" data-bs-target="#createvm-modal">
|
|
||||||
{% trans "Place order" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Create VM Modal -->
|
|
||||||
<div class="modal fade" id="createvm-modal" tabindex="-1" role="dialog"
|
|
||||||
aria-hidden="true" data-backdrop="static" data-keyboard="false">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="modal-icon">
|
|
||||||
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
|
||||||
<span class="sr-only">{% trans "Processing..." %}</span>
|
|
||||||
</div>
|
|
||||||
<h4 class="modal-title" id="createvm-modal-title"></h4>
|
|
||||||
<div class="modal-text" id="createvm-modal-body">
|
|
||||||
{% trans "Hold tight, we are processing your request" %}
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<a id="createvm-modal-done-btn" class="btn btn-success btn-ok btn-wide visually-hidden" href="">{% trans "OK" %}</a>
|
|
||||||
<button id="createvm-modal-close-btn" type="button" class="btn btn-danger btn-ok btn-wide visually-hidden" data-dismiss="modal" aria-label="create-vm-close">{% trans "Close" %}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- / Create VM Modal -->
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
var create_vm_error_message = 'Some problem encountered. Please try again later';
|
|
||||||
var pm_id = '{{id_payment_method}}';
|
|
||||||
var error_url = '{{ error_msg.redirect }}';
|
|
||||||
var success_url = '{{ success_msg.redirect }}';
|
|
||||||
window.stripeKey = "{{stripe_key}}";
|
|
||||||
window.isSubscription = ("{{is_subscription}}" === 'true');
|
|
||||||
</script>
|
|
||||||
<!-- jQuery -->
|
|
||||||
<script src="https://js.stripe.com/v3/"></script>
|
|
||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.3/jquery.validate.min.js"></script>
|
|
||||||
|
|
||||||
<script src="{% static 'fontawesome_free/js/all.min.js' %}"></script>
|
|
||||||
<!-- Bootstrap Core JavaScript -->
|
|
||||||
{% bootstrap_javascript %}
|
|
||||||
<!-- Custom JS -->
|
|
||||||
<script type="text/javascript" src="{% static 'matrixhosting/js/order.js' %}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
{% load static i18n %}
|
|
||||||
{% load bootstrap5 %}
|
|
||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="description" content="Matrix Hosting by ungleich">
|
|
||||||
<meta name="author" content="ungleich glarus ag">
|
|
||||||
<title>Matrix Hosting - {% block title %} made in Switzerland{% endblock %}</title>
|
|
||||||
|
|
||||||
<!-- Vendor CSS -->
|
|
||||||
<!-- Bootstrap Core CSS -->
|
|
||||||
{% bootstrap_css %}
|
|
||||||
|
|
||||||
<!-- External Fonts -->
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/paymentfont/1.2.5/css/paymentfont.min.css"/>
|
|
||||||
<link href="//fonts.googleapis.com/css?family=Lato:300,400,600,700" rel="stylesheet" type="text/css">
|
|
||||||
|
|
||||||
<link href="{% static 'matrixhosting/css/hosting.css' %}" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
|
||||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
|
||||||
<!--[if lt IE 9]>
|
|
||||||
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
|
||||||
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
|
|
||||||
<![endif]-->
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<div class="row">
|
|
||||||
<div class="dcl-payment-section">
|
|
||||||
<h3>{%trans "Your Order" %}</h3>
|
|
||||||
<hr class="top-hr">
|
|
||||||
<div class="dcl-payment-order">
|
|
||||||
<p>{% trans "Cores"%} <strong class="float-end">{{request.session.order.cores|floatformat}}</strong></p>
|
|
||||||
<hr>
|
|
||||||
<p>{% trans "Memory"%} <strong class="float-end">{{request.session.order.memory|floatformat}} GB</strong></p>
|
|
||||||
<hr>
|
|
||||||
<p>{% trans "Disk space"%} <strong class="float-end">{{request.session.order.storage|floatformat}} GB</strong></p>
|
|
||||||
<hr>
|
|
||||||
<p>
|
|
||||||
<strong>{%trans "Total" %}</strong>
|
|
||||||
<small>
|
|
||||||
({% if matrix_vm_pricing.vat_inclusive %}{%trans "including VAT" %}{% else %}{%trans "excluding VAT" %}{% endif %})
|
|
||||||
</small>
|
|
||||||
<strong class="float-end">{{request.session.order.subtotal|floatformat}} CHF / {% trans "Month" %}</strong>
|
|
||||||
</p>
|
|
||||||
<hr>
|
|
||||||
{% if matrix_vm_pricing.discount_amount %}
|
|
||||||
<p class="mb-0">
|
|
||||||
<strong>{{ request.session.order.discount.name }}</strong>
|
|
||||||
<strong class="float-end text-success">- {{ request.session.order.discount.amount }} CHF / {% trans "Month" %}</strong>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="dcl-payment-section">
|
|
||||||
<h2><b>{%trans "Billing Address"%}</b></h2>
|
|
||||||
<hr class="top-hr">
|
|
||||||
{% for message in messages %}
|
|
||||||
{% if 'vat_error' in message.tags %}
|
|
||||||
<ul class="list-unstyled"><li>
|
|
||||||
{{ message|safe }}
|
|
||||||
</li></ul>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<form role="form" id="billing-form" method="post" action="" novalidate>
|
|
||||||
{% csrf_token %}
|
|
||||||
{% for field in billing_address_form %}
|
|
||||||
{% if field.html_name in 'active,owner' %}
|
|
||||||
{{ field.as_hidden }}
|
|
||||||
{%else %}
|
|
||||||
{% bootstrap_field field show_label=False type='fields'%}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="dcl-payment-section">
|
|
||||||
{% with cards_len=cards|length %}
|
|
||||||
<h3><b>{%trans "Credit Card"%}</b></h3>
|
|
||||||
<hr class="top-hr">
|
|
||||||
<p>
|
|
||||||
{% if cards_len > 0 %}
|
|
||||||
{% blocktrans %}Please select one of the cards that you used before or fill in your credit card information below. We are using <a href="https://stripe.com" target="_blank">Stripe</a> for payment and do not store your information in our database.{% endblocktrans %}
|
|
||||||
{% else %}
|
|
||||||
{% blocktrans %}Please fill in your credit card information below. We are using <a href="https://stripe.com" target="_blank">Stripe</a> for payment and do not store your information in our database.{% endblocktrans %}
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
{% for card in cards %}
|
|
||||||
<div class="credit-card-info">
|
|
||||||
<div class="col-xs-6 no-padding">
|
|
||||||
<h5 class="billing-head">{% trans "Credit Card" %}</h5>
|
|
||||||
<h5 class="membership-lead">{% trans "Last" %} 4: ***** {{card.last4}}</h5>
|
|
||||||
<h5 class="membership-lead">{% trans "Type" %}: {{card.brand}}</h5>
|
|
||||||
<h5 class="membership-lead">{% trans "Expiry" %}: {{card.month}}/{{card.year}}</h5>
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-6 text-right align-bottom">
|
|
||||||
<a class="btn choice-btn choice-btn-faded" href="#" data-id_card="{{card.id}}">{% trans "SELECT" %}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% if cards_len > 0 %}
|
|
||||||
<div class="new-card-head">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-6">
|
|
||||||
<h4>{% trans "Add a new credit card" %}</h4>
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-6 text-right new-card-button-margin">
|
|
||||||
<button data-bs-toggle="collapse" data-bs-target="#newcard" class="btn choice-btn">
|
|
||||||
<span class="fa fa-plus"></span> {% trans "NEW CARD" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="newcard" class="collapse">
|
|
||||||
<hr class="thick-hr">
|
|
||||||
<div class="card-details-box">
|
|
||||||
<h3>{%trans "New Credit Card" %}</h3>
|
|
||||||
<hr>
|
|
||||||
{% include "matrixhosting/includes/_card.html" %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else%}
|
|
||||||
{% include "matrixhosting/includes/_card.html" %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if stripe_key %}
|
|
||||||
{% get_current_language as LANGUAGE_CODE %}
|
|
||||||
<script type="text/javascript">
|
|
||||||
window.processing_text = '{%trans "Processing" %}';
|
|
||||||
window.enter_your_card_text = '{%trans "Enter your credit card number" %}';
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
window.stripeKey = "{{stripe_key}}";
|
|
||||||
window.current_lan = "{{LANGUAGE_CODE}}";
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{%endif%}
|
|
||||||
|
|
||||||
<!-- jQuery -->
|
|
||||||
<script src="https://js.stripe.com/v3/"></script>
|
|
||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.3/jquery.validate.min.js"></script>
|
|
||||||
|
|
||||||
<script src="{% static 'fontawesome_free/js/all.min.js' %}"></script>
|
|
||||||
<!-- Bootstrap Core JavaScript -->
|
|
||||||
{% bootstrap_javascript %}
|
|
||||||
<!-- Custom JS -->
|
|
||||||
<script type="text/javascript" src="{% static 'matrixhosting/js/payment.js' %}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from .models import VMInstance
|
|
||||||
from uncloud_pay.models import Order, PricingPlan, BillingAddress, Product, RecurringPeriod
|
|
||||||
|
|
||||||
|
|
||||||
vm_product_config = {
|
|
||||||
'features': {
|
|
||||||
'cores':
|
|
||||||
{ 'min': 1,
|
|
||||||
'max': 48
|
|
||||||
},
|
|
||||||
'ram_gb':
|
|
||||||
{ 'min': 2,
|
|
||||||
'max': 200
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
class VMInstanceTestCase(TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
RecurringPeriod.populate_db_defaults()
|
|
||||||
self.user = get_user_model().objects.create(
|
|
||||||
username='random_user',
|
|
||||||
email='jane.random@domain.tld')
|
|
||||||
self.config = json.dumps({
|
|
||||||
'cores': 1,
|
|
||||||
'memory': 2,
|
|
||||||
'storage': 100,
|
|
||||||
'homeserver_domain': '',
|
|
||||||
'webclient_domain': '',
|
|
||||||
'matrix_domain': '',
|
|
||||||
})
|
|
||||||
self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
|
|
||||||
ram_unit_price=4, storage_unit_price=0.02)
|
|
||||||
self.ba = BillingAddress.objects.create(
|
|
||||||
owner=self.user,
|
|
||||||
organization = 'Test org',
|
|
||||||
street="unknown",
|
|
||||||
city="unknown",
|
|
||||||
postal_code="somewhere else",
|
|
||||||
active=True)
|
|
||||||
|
|
||||||
self.product = Product.objects.create(name="Testproduct",
|
|
||||||
description="Only for testing",
|
|
||||||
config=vm_product_config)
|
|
||||||
self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
|
|
||||||
self.product.recurring_periods.add(self.default_recurring_period,
|
|
||||||
through_defaults= { 'is_default': True })
|
|
||||||
|
|
||||||
def test_create_matrix_vm(self):
|
|
||||||
order = Order.objects.create(owner=self.user,
|
|
||||||
recurring_period=self.default_recurring_period,
|
|
||||||
billing_address=self.ba,
|
|
||||||
pricing_plan = self.pricing_plan,
|
|
||||||
product=self.product,
|
|
||||||
config=self.config)
|
|
||||||
instances = VMInstance.objects.filter(order=order)
|
|
||||||
self.assertEqual(len(instances), 1)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
from django.urls import path, include
|
|
||||||
from django.conf import settings
|
|
||||||
from django.conf.urls.static import static
|
|
||||||
|
|
||||||
from .views import IndexView, PricingView, OrderPaymentView, OrderDetailsView, Dashboard
|
|
||||||
|
|
||||||
app_name = 'matrixhosting'
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('pricing/<slug:name>/calculate/', PricingView.as_view(), name='pricing_calculator'),
|
|
||||||
path('payment/', OrderPaymentView.as_view(), name='payment'),
|
|
||||||
path('order/details/', OrderDetailsView.as_view(), name='order_details'),
|
|
||||||
path('dashboard/', Dashboard.as_view(), name='dashboard'),
|
|
||||||
path('', IndexView.as_view(), name='index'),
|
|
||||||
]
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
from django.core.validators import RegexValidator
|
|
||||||
|
|
||||||
|
|
||||||
def _validator():
|
|
||||||
|
|
||||||
ul = '\u00a1-\uffff' # unicode letters range (must not be a raw string)
|
|
||||||
|
|
||||||
# IP patterns
|
|
||||||
ipv4_re = r'(?:25[0-5]|2[0-4]\d|[0-1]?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}'
|
|
||||||
ipv6_re = r'\[[0-9a-f:\.]+\]' # (simple regex, validated later)
|
|
||||||
|
|
||||||
# Host patterns
|
|
||||||
hostname_re = r'[a-z' + ul + \
|
|
||||||
r'0-9](?:[a-z' + ul + r'0-9-]{0,61}[a-z' + ul + r'0-9])?'
|
|
||||||
# Max length for domain name labels is 63 characters per RFC 1034 sec. 3.1
|
|
||||||
domain_re = r'(?:\.(?!-)[a-z' + ul + r'0-9-]{1,63}(?<!-))*'
|
|
||||||
tld_re = (
|
|
||||||
r'\.' # dot
|
|
||||||
r'(?!-)' # can't start with a dash
|
|
||||||
r'(?:[a-z' + ul + '-]{2,63}' # domain label
|
|
||||||
r'|xn--[a-z0-9]{1,59})' # or punycode label
|
|
||||||
r'(?<!-)' # can't end with a dash
|
|
||||||
r'\.?' # may have a trailing dot
|
|
||||||
r'/?'
|
|
||||||
)
|
|
||||||
host_re = '(' + hostname_re + domain_re + tld_re + ')'
|
|
||||||
regex = (
|
|
||||||
r'(?:' + ipv4_re + '|' + ipv6_re + '|' + host_re + ')'
|
|
||||||
r'(?::\d{2,5})?' # port
|
|
||||||
r'\Z')
|
|
||||||
return RegexValidator(regex, message='Enter a valid Domain (Not a URL)', code='invalid_domain')
|
|
||||||
|
|
||||||
|
|
||||||
domain_name_validator = _validator()
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
|
|
||||||
from django.shortcuts import redirect, render
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.utils.translation import get_language, ugettext_lazy as _
|
|
||||||
from django.contrib.auth.decorators import login_required
|
|
||||||
from django.views.decorators.cache import cache_control
|
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.views import View
|
|
||||||
from django.views.generic import FormView, DetailView
|
|
||||||
from django.views.generic.list import ListView
|
|
||||||
from matrixhosting.forms import RequestHostedVMForm, BillingAddressForm
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.conf import settings
|
|
||||||
from django.http import (
|
|
||||||
HttpResponseRedirect, JsonResponse
|
|
||||||
)
|
|
||||||
from rest_framework import viewsets, permissions
|
|
||||||
|
|
||||||
from uncloud_pay.models import PricingPlan
|
|
||||||
from uncloud_pay.utils import get_order_total_with_vat
|
|
||||||
from uncloud_pay.models import *
|
|
||||||
from uncloud_pay.utils import validate_vat_number
|
|
||||||
from uncloud_pay.selectors import get_billing_address_for_user
|
|
||||||
import uncloud_pay.stripe as uncloud_stripe
|
|
||||||
from .models import VMInstance
|
|
||||||
from .serializers import *
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class PricingView(View):
|
|
||||||
def get(self, request, **args):
|
|
||||||
subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, discount = get_order_total_with_vat(
|
|
||||||
request.GET.get('cores'),
|
|
||||||
request.GET.get('memory'),
|
|
||||||
request.GET.get('storage'),
|
|
||||||
pricing_name = args['name']
|
|
||||||
)
|
|
||||||
return JsonResponse({'subtotal': subtotal})
|
|
||||||
|
|
||||||
class IndexView(FormView):
|
|
||||||
template_name = "matrixhosting/index.html"
|
|
||||||
form_class = RequestHostedVMForm
|
|
||||||
success_url = "/matrixhosting#requestform"
|
|
||||||
success_message = "Thank you, we will contact you as soon as possible"
|
|
||||||
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
context = super().get_context_data(**kwargs)
|
|
||||||
context['matrix_vm_pricing'] = PricingPlan.get_default_pricing()
|
|
||||||
return context
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
|
||||||
self.request.session['order'] = form.cleaned_data
|
|
||||||
subtotal, subtotal_with_discount, total, vat, vat_percent, discount = get_order_total_with_vat(
|
|
||||||
form.cleaned_data['cores'],
|
|
||||||
form.cleaned_data['memory'],
|
|
||||||
form.cleaned_data['storage'],
|
|
||||||
form.cleaned_data['pricing_name'],
|
|
||||||
False
|
|
||||||
)
|
|
||||||
self.request.session['pricing'] = {'name': form.cleaned_data['pricing_name'],
|
|
||||||
'subtotal': subtotal, 'vat': vat,
|
|
||||||
'vat_percent': vat_percent, 'discount': discount}
|
|
||||||
return HttpResponseRedirect(reverse('matrix:payment'))
|
|
||||||
|
|
||||||
|
|
||||||
class OrderPaymentView(FormView):
|
|
||||||
template_name = 'matrixhosting/payment.html'
|
|
||||||
success_url = 'matrix:order_confirmation'
|
|
||||||
form_class = BillingAddressForm
|
|
||||||
|
|
||||||
@method_decorator(login_required)
|
|
||||||
def dispatch(self, *args, **kwargs):
|
|
||||||
return super().dispatch(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
context = super(OrderPaymentView, self).get_context_data(**kwargs)
|
|
||||||
if 'billing_address_data' in self.request.session:
|
|
||||||
billing_address_form = BillingAddressForm(
|
|
||||||
initial=self.request.session['billing_address_data']
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
old_active = get_billing_address_for_user(self.request.user)
|
|
||||||
billing_address_form = BillingAddressForm(
|
|
||||||
instance=old_active
|
|
||||||
) if old_active else BillingAddressForm(
|
|
||||||
initial={'active': True, 'owner': self.request.user.id}
|
|
||||||
)
|
|
||||||
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
|
|
||||||
cards = uncloud_stripe.get_customer_cards(customer_id)
|
|
||||||
context.update({
|
|
||||||
'matrix_vm_pricing': PricingPlan.get_by_name(self.request.session.get('pricing', {'name': 'unknown'})['name']),
|
|
||||||
'billing_address_form': billing_address_form,
|
|
||||||
'cards': cards,
|
|
||||||
'stripe_key': settings.STRIPE_PUBLIC_KEY
|
|
||||||
})
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
@cache_control(no_cache=True, must_revalidate=True, no_store=True)
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
for k in ['vat_validation_status', 'token', 'id_payment_method']:
|
|
||||||
if request.session.get(k):
|
|
||||||
request.session.pop(k)
|
|
||||||
if 'order' not in request.session:
|
|
||||||
return HttpResponseRedirect(reverse('matrix:index'))
|
|
||||||
return self.render_to_response(self.get_context_data())
|
|
||||||
|
|
||||||
def form_valid(self, address_form):
|
|
||||||
id_payment_method = self.request.POST.get('id_payment_method', None)
|
|
||||||
self.request.session["id_payment_method"] = id_payment_method
|
|
||||||
this_user = {
|
|
||||||
'email': self.request.user.email,
|
|
||||||
'username': self.request.user.username
|
|
||||||
}
|
|
||||||
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
|
|
||||||
uncloud_stripe.attach_payment_method(id_payment_method, customer_id)
|
|
||||||
address = get_billing_address_for_user(self.request.user)
|
|
||||||
if address:
|
|
||||||
form = BillingAddressForm(self.request.POST, instance=address)
|
|
||||||
else:
|
|
||||||
form = BillingAddressForm(self.request.POST)
|
|
||||||
if form.is_valid:
|
|
||||||
billing_address_ins = form.save()
|
|
||||||
self.request.session["billing_address_id"] = billing_address_ins.id
|
|
||||||
self.request.session['billing_address_data'] = address_form.cleaned_data
|
|
||||||
self.request.session['billing_address_data']['owner'] = self.request.user.id
|
|
||||||
self.request.session['user'] = this_user
|
|
||||||
self.request.session['customer'] = customer_id
|
|
||||||
|
|
||||||
vat_number = address_form.cleaned_data.get('vat_number').strip()
|
|
||||||
if vat_number:
|
|
||||||
validate_result = validate_vat_number(
|
|
||||||
stripe_customer_id=customer_id,
|
|
||||||
billing_address_id=billing_address_ins.id
|
|
||||||
)
|
|
||||||
|
|
||||||
if 'error' in validate_result and validate_result['error']:
|
|
||||||
messages.add_message(
|
|
||||||
self.request, messages.ERROR, validate_result["error"],
|
|
||||||
extra_tags='vat_error'
|
|
||||||
)
|
|
||||||
return HttpResponseRedirect(
|
|
||||||
reverse('matrix:payment') + '#vat_error'
|
|
||||||
)
|
|
||||||
self.request.session["vat_validation_status"] = validate_result["status"]
|
|
||||||
|
|
||||||
return HttpResponseRedirect(reverse('matrix:order_details'))
|
|
||||||
|
|
||||||
|
|
||||||
class OrderDetailsView(DetailView):
|
|
||||||
template_name = "matrixhosting/order_detail.html"
|
|
||||||
context_object_name = "order"
|
|
||||||
model = Order
|
|
||||||
|
|
||||||
@method_decorator(login_required)
|
|
||||||
def dispatch(self, *args, **kwargs):
|
|
||||||
return super().dispatch(*args, **kwargs)
|
|
||||||
|
|
||||||
@cache_control(no_cache=True, must_revalidate=True, no_store=True)
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
context = {}
|
|
||||||
if ('order' not in request.session or 'user' not in request.session):
|
|
||||||
return HttpResponseRedirect(reverse('matrix:index'))
|
|
||||||
if 'id_payment_method' in self.request.session:
|
|
||||||
card = uncloud_stripe.get_card_from_payment(self.request.user, self.request.session['id_payment_method'])
|
|
||||||
if not card:
|
|
||||||
return HttpResponseRedirect(reverse('matrix:payment'))
|
|
||||||
context['card'] = card
|
|
||||||
elif 'id_payment_method' not in self.request.session or 'vat_validation_status' not in self.request.session:
|
|
||||||
return HttpResponseRedirect(reverse('matrix:payment'))
|
|
||||||
specs = request.session.get('order')
|
|
||||||
pricing = request.session.get('pricing')
|
|
||||||
billing_address = BillingAddress.objects.get(id=request.session.get('billing_address_id'))
|
|
||||||
vat_rate = VATRate.get_vat_rate(billing_address)
|
|
||||||
vat_validation_status = "verified" if billing_address.vat_number_validated_on and billing_address.vat_number_verified else False
|
|
||||||
subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, discount = get_order_total_with_vat(
|
|
||||||
specs['cores'], specs['memory'], specs['storage'], request.session['pricing']['name'],
|
|
||||||
vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status
|
|
||||||
)
|
|
||||||
pricing = {
|
|
||||||
"subtotal": subtotal, "discount": discount, "vat": vat, "vat_percent": vat_percent,
|
|
||||||
"vat_country": billing_address.country.lower(),
|
|
||||||
"subtotal_after_discount": subtotal_after_discount,
|
|
||||||
"price_after_discount_with_vat": price_after_discount_with_vat
|
|
||||||
|
|
||||||
}
|
|
||||||
pricing["price_with_vat"] = round(subtotal * (1 + pricing["vat_percent"] * 0.01), 2)
|
|
||||||
discount["amount_with_vat"] = round(pricing["price_with_vat"] - pricing["price_after_discount_with_vat"], 2)
|
|
||||||
pricing["total_price"] = pricing["price_after_discount_with_vat"]
|
|
||||||
self.request.session['total_price'] = pricing["price_after_discount_with_vat"]
|
|
||||||
payment_intent_response = uncloud_stripe.get_payment_intent(request.user, pricing["price_after_discount_with_vat"])
|
|
||||||
context.update({
|
|
||||||
'payment_intent_secret': payment_intent_response.client_secret,
|
|
||||||
'order': specs,
|
|
||||||
'pricing': pricing,
|
|
||||||
'stripe_key': settings.STRIPE_PUBLIC_KEY,
|
|
||||||
})
|
|
||||||
return render(request, self.template_name, context)
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
customer = StripeCustomer.objects.get(owner=self.request.user)
|
|
||||||
billing_address = BillingAddress.objects.get(id=request.session.get('billing_address_id'))
|
|
||||||
if 'id_payment_method' in request.session:
|
|
||||||
card = uncloud_stripe.get_card_from_payment(self.request.user, self.request.session['id_payment_method'])
|
|
||||||
if not card:
|
|
||||||
return show_error("There was a payment related error.", self.request)
|
|
||||||
else:
|
|
||||||
return show_error("There was a payment related error.", self.request)
|
|
||||||
|
|
||||||
order = finalize_order(request, customer,
|
|
||||||
billing_address,
|
|
||||||
self.request.session['total_price'],
|
|
||||||
PricingPlan.get_by_name(self.request.session['pricing']['name']),
|
|
||||||
request.session.get('order'))
|
|
||||||
if order:
|
|
||||||
bill = Bill.create_next_bill_for_user_address(billing_address)
|
|
||||||
payment= Payment.objects.create(owner=request.user, amount=self.request.session['total_price'], source='stripe')
|
|
||||||
if payment:
|
|
||||||
#Close the bill as the payment has been added
|
|
||||||
bill.close()
|
|
||||||
response = {
|
|
||||||
'status': True,
|
|
||||||
'redirect': (reverse('matrix:dashboard')),
|
|
||||||
'msg_title': str(_('Thank you for the order.')),
|
|
||||||
'msg_body': str(
|
|
||||||
_('Your VM will be up and running in a few moments.'
|
|
||||||
' We will send you a confirmation email as soon as'
|
|
||||||
' it is ready.'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return JsonResponse(response)
|
|
||||||
|
|
||||||
|
|
||||||
def finalize_order(request, customer, billing_address,
|
|
||||||
one_time_price, pricing_plan,
|
|
||||||
specs):
|
|
||||||
product = Product.objects.first()
|
|
||||||
recurring_period_product = ProductToRecurringPeriod.objects.filter(product=product, is_default=True).first()
|
|
||||||
order = Order.objects.create(
|
|
||||||
owner=request.user,
|
|
||||||
customer=customer,
|
|
||||||
billing_address=billing_address,
|
|
||||||
one_time_price=one_time_price,
|
|
||||||
pricing_plan=pricing_plan,
|
|
||||||
recurring_period= recurring_period_product.recurring_period,
|
|
||||||
product = product,
|
|
||||||
config=json.dumps(specs)
|
|
||||||
)
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
class Dashboard(ListView):
|
|
||||||
template_name = "matrixhosting/dashboard.html"
|
|
||||||
model = Order
|
|
||||||
|
|
||||||
@method_decorator(login_required)
|
|
||||||
def dispatch(self, *args, **kwargs):
|
|
||||||
return super().dispatch(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return Order.objects.filter(owner=self.request.user)
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
order = Order.objects.get(id=request.POST.get('order_id', 0))
|
|
||||||
order.cancel()
|
|
||||||
return JsonResponse({'message': 'Successfully Cancelled'})
|
|
||||||
|
|
||||||
|
|
||||||
def get_error_response_dict(request):
|
|
||||||
response = {
|
|
||||||
'status': False,
|
|
||||||
'redirect': "{url}#{section}".format(
|
|
||||||
url=(reverse('matrix:payment')),
|
|
||||||
section='payment_error'
|
|
||||||
),
|
|
||||||
'msg_title': str(_('Error.')),
|
|
||||||
'msg_body': str(
|
|
||||||
_('There was a payment related error.'
|
|
||||||
' On close of this popup, you will be redirected back to'
|
|
||||||
' the payment page.'))
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def show_error(msg, request):
|
|
||||||
messages.add_message(request, messages.ERROR, msg,
|
|
||||||
extra_tags='failed_payment')
|
|
||||||
return JsonResponse(get_error_response_dict(request))
|
|
||||||
|
|
||||||
|
|
||||||
class MachineViewSet(viewsets.ReadOnlyModelViewSet):
|
|
||||||
serializer_class = VMInstanceSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return VMInstance.objects.filter(owner=self.request.user)
|
|
||||||
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue