Compare commits

...

69 Commits
0.8 ... master

Author SHA1 Message Date
nico14571 8287e73f6b Merge branch 'master' into 'master'
- Implement a complete cycle for buying a Matrix Chat Host

See merge request uncloud/uncloud!11
2021-07-19 16:36:11 +02:00
amalelshihaby b7aa1c6971 - Added PricingPlan Model
- Implement a complete cycle for buying a Matrix Chat Host
- Refactor the Payement cycle and stripe related methods
2021-07-19 16:36:10 +02:00
Nico Schottelius e205d8d07c Merge branch 'master' of code.ungleich.ch:uncloud/uncloud 2021-06-20 11:58:23 +02:00
Nico Schottelius a463bcf7bd Late commits 2021-06-20 11:51:27 +02:00
Nico Schottelius d872357dd1 Fix login -> move to bootstrap5 2021-05-25 20:22:18 +02:00
Nico Schottelius 485f08e25c Cleanup views 2021-05-25 19:55:33 +02:00
Nico Schottelius 745abc48ef Add balance if user is logged in 2021-05-25 19:55:13 +02:00
Nico Schottelius 49f52fd41d [bootstrap] update to bootstrap5 2021-02-13 18:50:28 +01:00
Nico Schottelius c8ce7dbb40 do not touch local_settings.py on deploy 2021-01-17 15:54:16 +01:00
Nico Schottelius a920887100 ++bridge update 2021-01-17 15:53:30 +01:00
Nico Schottelius 6b9b15e663 Add deploy.sh 2021-01-17 15:47:37 +01:00
Nico Schottelius 48ce21f833 integrate bootstrap 2021-01-01 13:25:52 +01:00
Nico Schottelius 6c15d2086e implement balance getting 2021-01-01 12:41:54 +01:00
Nico Schottelius 1b06d8ee03 [credit card] implement payment 2020-12-29 01:43:33 +01:00
Nico Schottelius e225bf1cc0 implement credit card listing 2020-12-28 23:35:34 +01:00
Nico Schottelius e2c4a19049 Less verbose 2020-12-26 14:48:10 +01:00
Nico Schottelius 74749bf07c fix templating 2020-12-26 14:45:28 +01:00
Nico Schottelius 93e5d39c7b moving vpn to direct configuration 2020-12-26 14:42:53 +01:00
Nico Schottelius 18d4c99571 [doc] workers need access to the database 2020-12-26 13:42:20 +01:00
Nico Schottelius e51edab2f5 cleanup/in between commit 2020-12-26 11:22:51 +01:00
Nico Schottelius f7c68b5ca5 Rename template 2020-12-25 17:33:01 +01:00
Nico Schottelius 6efedcb381 hackish way of registering works 2020-12-25 17:29:17 +01:00
Nico Schottelius df4c0c3060 in between commit to update for cc tests 2020-12-25 10:31:42 +01:00
Nico Schottelius 8dd4b712fb [views] add index view for uncloud 2020-12-25 10:11:13 +01:00
Nico Schottelius 50a395c8ec sort requirements.txt 2020-12-25 10:10:57 +01:00
Nico Schottelius 663d72269a [wireguard] verify key length 2020-12-25 10:08:34 +01:00
Nico Schottelius a0fbe2d6ed [wireguard] add unique constrain for keys in pool 2020-12-24 17:26:53 +01:00
Nico Schottelius 858aabb5ba Return value from validation 2020-12-20 22:03:43 +01:00
Nico Schottelius ece2bca831 add new /sizes endpoint 2020-12-20 21:45:47 +01:00
Nico Schottelius cdab685269 [vpn/doc] update docs 2020-12-20 19:37:12 +01:00
Nico Schottelius 689375a2fe Fix the config task 2020-12-20 19:17:03 +01:00
Nico Schottelius 8f83679c48 test cleaning tasks in a task fails:
[2020-12-20 18:01:50,264: WARNING/ForkPoolWorker-7] Pruning UncloudTask object (571ffc76-8b40-4cb6-9658-87030834bc6c)...
[2020-12-20 18:01:50,265: ERROR/ForkPoolWorker-7] Task uncloud.tasks.cleanup_tasks[f9fb1480-f122-41c9-bec1-3d6d0f92a22e] raised unexpected: RuntimeError('Never call result.get() within a task!\nSee http://docs.celeryq.org/en/latest/userguide/tasks.html#task-synchronous-subtasks\n')
Traceback (most recent call last):
  File "/home/nico/vcs/uncloud/venv/lib/python3.8/site-packages/celery/app/trace.py", line 405, in trace_task
    R = retval = fun(*args, **kwargs)
  File "/home/nico/vcs/uncloud/venv/lib/python3.8/site-packages/celery/app/trace.py", line 697, in __protected_call__
    return self.run(*args, **kwargs)
  File "/home/nico/vcs/uncloud/uncloud/tasks.py", line 13, in cleanup_tasks
    print(res.get())
  File "/home/nico/vcs/uncloud/venv/lib/python3.8/site-packages/celery/result.py", line 209, in get
    assert_will_not_block()
  File "/home/nico/vcs/uncloud/venv/lib/python3.8/site-packages/celery/result.py", line 37, in assert_will_not_block
    raise RuntimeError(E_WOULDBLOCK)
RuntimeError: Never call result.get() within a task!
See http://docs.celeryq.org/en/latest/userguide/tasks.html#task-synchronous-subtasks
2020-12-20 19:01:37 +01:00
Nico Schottelius 5e870f04b1 ++celery/tasks 2020-12-20 18:36:46 +01:00
Nico Schottelius 63191c0a88 Remove $ that is not needed in python... 2020-12-20 13:24:55 +01:00
Nico Schottelius 03c0b34446 ++config vpn server 2020-12-20 13:00:36 +01:00
Nico Schottelius 1922a0d92d ++routing tests 2020-12-20 12:54:02 +01:00
Nico Schottelius 2e6c72c093 wireguard/celery fixes 2020-12-20 12:45:36 +01:00
Nico Schottelius b3626369a2 --syntax error 2020-12-20 12:24:35 +01:00
Nico Schottelius 179baee96d fix celery task routes syntax error 2020-12-20 12:22:50 +01:00
Nico Schottelius 054886fd9c begin phasing in config of vpn via cdist 2020-12-20 12:20:54 +01:00
Nico Schottelius e2b36c8bca celery test 2020-12-13 19:50:36 +01:00
Nico Schottelius 372fe800cd fill in template values for settings 2020-12-13 19:06:22 +01:00
Nico Schottelius 16f3adef93 [doc] ++requirements alpine 2020-12-13 18:56:47 +01:00
Nico Schottelius 2d62388eb1 phasing in celery
for configuring the vpn server
2020-12-13 18:34:43 +01:00
Nico Schottelius aec79cba74 [vpn] include vpn server public key 2020-12-13 18:05:48 +01:00
Nico Schottelius cd19c47fdb [vpn] implement creating vpns 2020-12-13 17:59:35 +01:00
Nico Schottelius cf948b03a8 ++vpn network 2020-12-13 13:28:43 +01:00
Nico Schottelius 5716cae900 [vpn] add selector for size 2020-12-13 11:43:49 +01:00
Nico Schottelius 10d5a72c5a [refactor] cleaning up uncloud_net for Wireguardvpn 2020-12-13 11:38:41 +01:00
Nico Schottelius 074cffcbd7 Add selection for vpnnetworkreservations 2020-12-09 21:20:33 +01:00
Nico Schottelius 7f32d05cd4 begin phasing in vpn support [poc] 2020-12-09 20:22:33 +01:00
Nico Schottelius 0fd5ac18cd do not import pay->auth
Try to keep common things in the "uncloud" module
2020-12-06 11:53:37 +01:00
Nico Schottelius ad0c2f1e9d Merge branch 'master' of code.ungleich.ch:uncloud/uncloud 2020-11-17 11:47:53 +01:00
Nico Schottelius 0b1c2cc168 Cleanup code so that *most* test work again
Still need to solve the downgrade test
2020-11-15 15:43:11 +01:00
ahmadbilalkhalid 4845ab1e39 Create account using api
Registration and change_email is backed by ldap
2020-11-14 14:50:43 +05:00
Nico Schottelius ecc9e6f734 [reverseDNS] add basic logic 2020-10-25 22:43:34 +01:00
Nico Schottelius 20c7c86703 restructure to move uncloudnetwork into core 2020-10-25 21:00:30 +01:00
Nico Schottelius 8959bc6ad5 various updates 2020-10-25 13:52:36 +01:00
Nico Schottelius 0cd8a3a787 ++update ungleich_provider 2020-10-11 22:36:01 +02:00
Nico Schottelius bbc7625550 phase in configuration - move address to base 2020-10-11 22:32:08 +02:00
Nico Schottelius fe4e200dc0 Begin phasing in the uncloudprovider 2020-10-11 17:45:25 +02:00
Nico Schottelius e03cdf214a update VAT importer 2020-10-08 19:54:04 +02:00
Nico Schottelius 50fd9e1f37 ++work 2020-10-07 00:54:56 +02:00
Nico Schottelius 2e74661702 Fix first test case / billing 2020-10-06 23:14:32 +02:00
Nico Schottelius c26ff253de One step furter to allow saving of orders w/o explicit recurringperiod 2020-10-06 19:21:37 +02:00
Nico Schottelius 9623a77907 Updating for products/recurring periods 2020-10-06 18:53:13 +02:00
Nico Schottelius c435639241 gitignore some tests 2020-10-06 16:13:03 +02:00
Nico Schottelius 992c7c551e Make recurring period a database model
- For easier handling (foreignkeys, many2many)
- For higher flexibility (users can define their own periods)
2020-10-06 15:46:22 +02:00
Nico Schottelius 58883765d7 [tests] back to 5 working tests! 2020-09-28 23:16:17 +02:00
174 changed files with 8384 additions and 5238 deletions

2
.gitignore vendored
View File

@ -22,6 +22,6 @@ uncloud/version.py
build/
venv/
dist/
.history/
*.iso
*.sqlite3

View File

@ -9,13 +9,15 @@ Cloud management platform, the ungleich way.
## Useful commands
* `./manage.py import-vat-rates path/to/csv`
* `./manage.py make-admin username`
* `./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`.
@ -53,6 +55,12 @@ 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

39
bin/deploy.sh Executable file
View File

@ -0,0 +1,39 @@
#!/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"

0
bin/make-migrations-from-scratch.sh Normal file → Executable file
View File

View File

@ -1,8 +1,15 @@
* Bootstrap / Installation
* 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
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
@ -25,6 +32,8 @@ pip install -r requirements.txt
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
@ -53,6 +62,66 @@ postgres=# create database uncloud owner nico;
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)
@ -60,6 +129,43 @@ python manage.py migrate
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.
@ -155,7 +261,7 @@ 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.
** Documentation
** 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
@ -219,17 +325,69 @@ VPNNetworks can be managed by all authenticated users.
*** 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 Initial Generic product support
**** TODO [#C] Initial Generic product support
- Product
***** TODO Recurring product support
****** TODO Support replacing orders for updates
***** 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 Test the new functions in the Order class
****** TODO [#C] Test the new functions in the Order class
****** Define the correct order replacement logic
Assumption:
- recurringperiods are 30days
@ -286,13 +444,14 @@ VPNNetworks can be managed by all authenticated users.
- Total on bill: 30 CHF
****** TODO Note: ending date not set if replaced by default (implicit!)
****** 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 ...
***** TODO Bill logic is still wrong
***** 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!
@ -306,6 +465,21 @@ VPNNetworks can be managed by all authenticated users.
- 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

4
matrixhosting/admin.py Normal file
View File

@ -0,0 +1,4 @@
from django.contrib import admin
from .models import VMInstance
admin.site.register(VMInstance)

9
matrixhosting/apps.py Normal file
View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class MatrixhostingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'matrixhosting'
def ready(self):
from . import signals

48
matrixhosting/forms.py Normal file
View File

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

View File

@ -0,0 +1,30 @@
# 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)),
],
),
]

View File

@ -0,0 +1,17 @@
# 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',
),
]

View File

@ -0,0 +1,33 @@
# 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),
),
]

View File

@ -0,0 +1,43 @@
# 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')),
],
),
]

View File

@ -0,0 +1,19 @@
# 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',
),
]

View File

@ -0,0 +1,16 @@
# 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',
),
]

View File

@ -0,0 +1,31 @@
# 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)),
],
),
]

View File

@ -1,4 +1,4 @@
# Generated by Django 3.1 on 2020-09-28 19:14
# Generated by Django 3.2.4 on 2021-07-10 14:29
from django.db import migrations
@ -6,12 +6,12 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0018_order_product'),
('matrixhosting', '0007_vminstance'),
]
operations = [
migrations.RemoveField(
model_name='product',
name='owner',
model_name='vminstance',
name='ip',
),
]

View File

@ -0,0 +1,19 @@
# 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),
),
]

View File

77
matrixhosting/models.py Normal file
View File

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

View File

@ -0,0 +1,8 @@
from rest_framework import serializers
from .models import *
class VMInstanceSerializer(serializers.ModelSerializer):
class Meta:
model = VMInstance
fields = '__all__'

10
matrixhosting/signals.py Normal file
View File

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

View File

@ -0,0 +1,618 @@
.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;
}

View File

@ -0,0 +1,46 @@
(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);

View File

@ -0,0 +1,36 @@
$( 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");
}
}
});
}
});

View File

@ -0,0 +1,204 @@
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);
});
});

64
matrixhosting/tasks.py Normal file
View File

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

View File

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

View File

@ -0,0 +1,127 @@
{% 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 %}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,18 @@
{% 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 &copy; ungleich glarus ag {% now "Y" %}. {% trans "All Rights Reserved" %}</p>
</div>
</footer>

View File

@ -0,0 +1,33 @@
{% 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" %}&nbsp;&nbsp;<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>

View File

@ -0,0 +1,21 @@
{% 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 %}

View File

@ -0,0 +1,268 @@
{% 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>&nbsp;
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>

View File

@ -0,0 +1,169 @@
{% 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>&nbsp;&nbsp;
<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>&nbsp;&nbsp;
<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>&nbsp;&nbsp;{% 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>

67
matrixhosting/tests.py Normal file
View File

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

15
matrixhosting/urls.py Normal file
View File

@ -0,0 +1,15 @@
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'),
]

View File

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

301
matrixhosting/views.py Normal file
View File

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

View File

@ -0,0 +1,15 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ name }}-matrix
spec:
selector:
matchLabels:
app: {{ name }}-matrix
replicas: 1
template:
metadata:
labels:
app: {{ name }}-matrix
use-as-service: {{ name }}

View File

@ -1,9 +1,6 @@
# Generated by Django 3.0.6 on 2020-08-01 16:38
# Generated by Django 3.1 on 2020-12-13 10:38
from django.conf import settings
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
@ -11,23 +8,14 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_pay', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='VM',
fields=[
('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)),
('vmid', models.IntegerField(primary_key=True, serialize=False)),
('data', django.contrib.postgres.fields.jsonb.JSONField()),
('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('data', models.JSONField()),
],
options={
'abstract': False,
},
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 3.0.8 on 2020-08-01 23:32
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0003_auto_20200801_2332'),
('opennebula', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='vm',
name='order',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 3.1 on 2020-08-08 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('opennebula', '0002_auto_20200801_2332'),
]
operations = [
migrations.AlterField(
model_name='vm',
name='data',
field=models.JSONField(),
),
migrations.AlterField(
model_name='vm',
name='extra_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 3.1 on 2020-08-09 12:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0013_auto_20200809_1237'),
('opennebula', '0003_auto_20200808_1953'),
]
operations = [
migrations.RemoveField(
model_name='vm',
name='order',
),
migrations.AddField(
model_name='vm',
name='orders',
field=models.ManyToManyField(to='uncloud_pay.Order'),
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 3.1 on 2020-09-28 18:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('opennebula', '0004_auto_20200809_1237'),
]
operations = [
migrations.RemoveField(
model_name='vm',
name='orders',
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 3.1 on 2020-09-28 18:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('opennebula', '0005_remove_vm_orders'),
]
operations = [
migrations.RemoveField(
model_name='vm',
name='extra_data',
),
migrations.RemoveField(
model_name='vm',
name='owner',
),
migrations.RemoveField(
model_name='vm',
name='status',
),
]

View File

@ -1,12 +1,15 @@
django
# Django basics
Django==3.2.4
djangorestframework
django-auth-ldap
stripe
xmltodict
django-bootstrap-v5
fontawesome-free
psycopg2
ldap3
django-allauth
xmltodict
parsedatetime
# Follow are for creating graph models
pyparsing
pydot
@ -18,7 +21,14 @@ django-hardcopy
# schema support
pyyaml
uritemplate
# Comprehensive interface to validate VAT numbers, making use of the VIES
# service for European countries.
tldextract
# Payment & VAT
vat-validator
stripe
#Jobs
django-q
redis
jinja2
python-gitlab

1
uncloud/.gitignore vendored
View File

@ -1 +1,2 @@
local_settings.py
ldap_max_uid_file

View File

@ -0,0 +1,253 @@
from django.utils.translation import gettext_lazy as _
import decimal
# Define DecimalField properties, used to represent amounts of money.
AMOUNT_MAX_DIGITS=10
AMOUNT_DECIMALS=2
decimal.getcontext().prec = AMOUNT_DECIMALS
# http://xml.coverpages.org/country3166.html
COUNTRIES = (
('AD', _('Andorra')),
('AE', _('United Arab Emirates')),
('AF', _('Afghanistan')),
('AG', _('Antigua & Barbuda')),
('AI', _('Anguilla')),
('AL', _('Albania')),
('AM', _('Armenia')),
('AN', _('Netherlands Antilles')),
('AO', _('Angola')),
('AQ', _('Antarctica')),
('AR', _('Argentina')),
('AS', _('American Samoa')),
('AT', _('Austria')),
('AU', _('Australia')),
('AW', _('Aruba')),
('AZ', _('Azerbaijan')),
('BA', _('Bosnia and Herzegovina')),
('BB', _('Barbados')),
('BD', _('Bangladesh')),
('BE', _('Belgium')),
('BF', _('Burkina Faso')),
('BG', _('Bulgaria')),
('BH', _('Bahrain')),
('BI', _('Burundi')),
('BJ', _('Benin')),
('BM', _('Bermuda')),
('BN', _('Brunei Darussalam')),
('BO', _('Bolivia')),
('BR', _('Brazil')),
('BS', _('Bahama')),
('BT', _('Bhutan')),
('BV', _('Bouvet Island')),
('BW', _('Botswana')),
('BY', _('Belarus')),
('BZ', _('Belize')),
('CA', _('Canada')),
('CC', _('Cocos (Keeling) Islands')),
('CF', _('Central African Republic')),
('CG', _('Congo')),
('CH', _('Switzerland')),
('CI', _('Ivory Coast')),
('CK', _('Cook Iislands')),
('CL', _('Chile')),
('CM', _('Cameroon')),
('CN', _('China')),
('CO', _('Colombia')),
('CR', _('Costa Rica')),
('CU', _('Cuba')),
('CV', _('Cape Verde')),
('CX', _('Christmas Island')),
('CY', _('Cyprus')),
('CZ', _('Czech Republic')),
('DE', _('Germany')),
('DJ', _('Djibouti')),
('DK', _('Denmark')),
('DM', _('Dominica')),
('DO', _('Dominican Republic')),
('DZ', _('Algeria')),
('EC', _('Ecuador')),
('EE', _('Estonia')),
('EG', _('Egypt')),
('EH', _('Western Sahara')),
('ER', _('Eritrea')),
('ES', _('Spain')),
('ET', _('Ethiopia')),
('FI', _('Finland')),
('FJ', _('Fiji')),
('FK', _('Falkland Islands (Malvinas)')),
('FM', _('Micronesia')),
('FO', _('Faroe Islands')),
('FR', _('France')),
('FX', _('France, Metropolitan')),
('GA', _('Gabon')),
('GB', _('United Kingdom (Great Britain)')),
('GD', _('Grenada')),
('GE', _('Georgia')),
('GF', _('French Guiana')),
('GH', _('Ghana')),
('GI', _('Gibraltar')),
('GL', _('Greenland')),
('GM', _('Gambia')),
('GN', _('Guinea')),
('GP', _('Guadeloupe')),
('GQ', _('Equatorial Guinea')),
('GR', _('Greece')),
('GS', _('South Georgia and the South Sandwich Islands')),
('GT', _('Guatemala')),
('GU', _('Guam')),
('GW', _('Guinea-Bissau')),
('GY', _('Guyana')),
('HK', _('Hong Kong')),
('HM', _('Heard & McDonald Islands')),
('HN', _('Honduras')),
('HR', _('Croatia')),
('HT', _('Haiti')),
('HU', _('Hungary')),
('ID', _('Indonesia')),
('IE', _('Ireland')),
('IL', _('Israel')),
('IN', _('India')),
('IO', _('British Indian Ocean Territory')),
('IQ', _('Iraq')),
('IR', _('Islamic Republic of Iran')),
('IS', _('Iceland')),
('IT', _('Italy')),
('JM', _('Jamaica')),
('JO', _('Jordan')),
('JP', _('Japan')),
('KE', _('Kenya')),
('KG', _('Kyrgyzstan')),
('KH', _('Cambodia')),
('KI', _('Kiribati')),
('KM', _('Comoros')),
('KN', _('St. Kitts and Nevis')),
('KP', _('Korea, Democratic People\'s Republic of')),
('KR', _('Korea, Republic of')),
('KW', _('Kuwait')),
('KY', _('Cayman Islands')),
('KZ', _('Kazakhstan')),
('LA', _('Lao People\'s Democratic Republic')),
('LB', _('Lebanon')),
('LC', _('Saint Lucia')),
('LI', _('Liechtenstein')),
('LK', _('Sri Lanka')),
('LR', _('Liberia')),
('LS', _('Lesotho')),
('LT', _('Lithuania')),
('LU', _('Luxembourg')),
('LV', _('Latvia')),
('LY', _('Libyan Arab Jamahiriya')),
('MA', _('Morocco')),
('MC', _('Monaco')),
('MD', _('Moldova, Republic of')),
('MG', _('Madagascar')),
('MH', _('Marshall Islands')),
('ML', _('Mali')),
('MN', _('Mongolia')),
('MM', _('Myanmar')),
('MO', _('Macau')),
('MP', _('Northern Mariana Islands')),
('MQ', _('Martinique')),
('MR', _('Mauritania')),
('MS', _('Monserrat')),
('MT', _('Malta')),
('MU', _('Mauritius')),
('MV', _('Maldives')),
('MW', _('Malawi')),
('MX', _('Mexico')),
('MY', _('Malaysia')),
('MZ', _('Mozambique')),
('NA', _('Namibia')),
('NC', _('New Caledonia')),
('NE', _('Niger')),
('NF', _('Norfolk Island')),
('NG', _('Nigeria')),
('NI', _('Nicaragua')),
('NL', _('Netherlands')),
('NO', _('Norway')),
('NP', _('Nepal')),
('NR', _('Nauru')),
('NU', _('Niue')),
('NZ', _('New Zealand')),
('OM', _('Oman')),
('PA', _('Panama')),
('PE', _('Peru')),
('PF', _('French Polynesia')),
('PG', _('Papua New Guinea')),
('PH', _('Philippines')),
('PK', _('Pakistan')),
('PL', _('Poland')),
('PM', _('St. Pierre & Miquelon')),
('PN', _('Pitcairn')),
('PR', _('Puerto Rico')),
('PT', _('Portugal')),
('PW', _('Palau')),
('PY', _('Paraguay')),
('QA', _('Qatar')),
('RE', _('Reunion')),
('RO', _('Romania')),
('RU', _('Russian Federation')),
('RW', _('Rwanda')),
('SA', _('Saudi Arabia')),
('SB', _('Solomon Islands')),
('SC', _('Seychelles')),
('SD', _('Sudan')),
('SE', _('Sweden')),
('SG', _('Singapore')),
('SH', _('St. Helena')),
('SI', _('Slovenia')),
('SJ', _('Svalbard & Jan Mayen Islands')),
('SK', _('Slovakia')),
('SL', _('Sierra Leone')),
('SM', _('San Marino')),
('SN', _('Senegal')),
('SO', _('Somalia')),
('SR', _('Suriname')),
('ST', _('Sao Tome & Principe')),
('SV', _('El Salvador')),
('SY', _('Syrian Arab Republic')),
('SZ', _('Swaziland')),
('TC', _('Turks & Caicos Islands')),
('TD', _('Chad')),
('TF', _('French Southern Territories')),
('TG', _('Togo')),
('TH', _('Thailand')),
('TJ', _('Tajikistan')),
('TK', _('Tokelau')),
('TM', _('Turkmenistan')),
('TN', _('Tunisia')),
('TO', _('Tonga')),
('TP', _('East Timor')),
('TR', _('Turkey')),
('TT', _('Trinidad & Tobago')),
('TV', _('Tuvalu')),
('TW', _('Taiwan, Province of China')),
('TZ', _('Tanzania, United Republic of')),
('UA', _('Ukraine')),
('UG', _('Uganda')),
('UM', _('United States Minor Outlying Islands')),
('US', _('United States of America')),
('UY', _('Uruguay')),
('UZ', _('Uzbekistan')),
('VA', _('Vatican City State (Holy See)')),
('VC', _('St. Vincent & the Grenadines')),
('VE', _('Venezuela')),
('VG', _('British Virgin Islands')),
('VI', _('United States Virgin Islands')),
('VN', _('Viet Nam')),
('VU', _('Vanuatu')),
('WF', _('Wallis & Futuna Islands')),
('WS', _('Samoa')),
('YE', _('Yemen')),
('YT', _('Mayotte')),
('YU', _('Yugoslavia')),
('ZA', _('South Africa')),
('ZM', _('Zambia')),
('ZR', _('Zaire')),
('ZW', _('Zimbabwe')),
)
__all__ = ()

6
uncloud/admin.py Normal file
View File

@ -0,0 +1,6 @@
from django.contrib import admin
from .models import *
for m in [ UncloudProvider, UncloudNetwork ]:
admin.site.register(m)

8
uncloud/forms.py Normal file
View File

@ -0,0 +1,8 @@
from django import forms
from django.contrib.auth.models import User
class UserDeleteForm(forms.ModelForm):
class Meta:
model = User
fields = []

View File

@ -0,0 +1,43 @@
import random
import string
from django.core.management.base import BaseCommand
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.auth import get_user_model
from django.conf import settings
from uncloud_pay.models import BillingAddress, RecurringPeriod, Product
from uncloud.models import UncloudProvider, UncloudNetwork
class Command(BaseCommand):
help = 'Add standard uncloud values'
def add_arguments(self, parser):
pass
def handle(self, *args, **options):
# Order matters, objects can be dependent on each other
admin_username="uncloud-admin"
pw_length = 32
# Only set password if the user did not exist before
try:
admin_user = get_user_model().objects.get(username=settings.UNCLOUD_ADMIN_NAME)
except ObjectDoesNotExist:
random_password = ''.join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(pw_length))
admin_user = get_user_model().objects.create_user(username=settings.UNCLOUD_ADMIN_NAME, password=random_password)
admin_user.is_superuser=True
admin_user.is_staff=True
admin_user.save()
print(f"Created admin user '{admin_username}' with password '{random_password}'")
BillingAddress.populate_db_defaults()
RecurringPeriod.populate_db_defaults()
Product.populate_db_defaults()
UncloudNetwork.populate_db_defaults()
UncloudProvider.populate_db_defaults()

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,19 @@
# Generated by Django 3.1 on 2020-12-20 17:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='UncloudTasks',
fields=[
('task_id', models.UUIDField(primary_key=True, serialize=False)),
],
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.1 on 2020-12-20 17:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud', '0002_uncloudtasks'),
]
operations = [
migrations.RenameModel(
old_name='UncloudTasks',
new_name='UncloudTask',
),
]

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
# Generated by Django 3.1 on 2020-08-08 20:36
# Generated by Django 3.2.4 on 2021-07-07 15:11
from django.db import migrations
@ -6,11 +6,11 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0007_remove_bill_bill_records'),
('uncloud', '0004_auto_20210101_1308'),
]
operations = [
migrations.DeleteModel(
name='OrderRecord',
name='UncloudTask',
),
]

View File

View File

@ -1,7 +1,12 @@
from django.db import models
from django.db.models import JSONField
from django.db.models import JSONField, Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.exceptions import FieldError
from uncloud import COUNTRIES
from .selectors import filter_for_when
class UncloudModel(models.Model):
"""
@ -34,3 +39,171 @@ class UncloudStatus(models.TextChoices):
DELETED = 'DELETED', _('Deleted') # Resource has been deleted
DISABLED = 'DISABLED', _('Disabled') # Is usable, but cannot be used for new things
UNUSABLE = 'UNUSABLE', _('Unusable'), # Has some kind of error
###
# General address handling
class CountryField(models.CharField):
def __init__(self, *args, **kwargs):
kwargs.setdefault('choices', COUNTRIES)
kwargs.setdefault('default', 'CH')
kwargs.setdefault('max_length', 2)
super().__init__(*args, **kwargs)
def get_internal_type(self):
return "CharField"
class UncloudAddress(models.Model):
full_name = models.CharField(max_length=256, null=False)
organization = models.CharField(max_length=256, blank=True, null=True)
street = models.CharField(max_length=256, null=False)
city = models.CharField(max_length=256, null=False)
postal_code = models.CharField(max_length=64)
country = CountryField(blank=False, null=False)
class Meta:
abstract = True
class UncloudValidTimeFrame(models.Model):
"""
A model that allows to limit validity of something to a certain
time frame. Used for versioning basically.
Logic:
"""
class Meta:
abstract = True
constraints = [
models.UniqueConstraint(fields=['owner'],
condition=models.Q(active=True),
name='one_active_card_per_user')
]
valid_from = models.DateTimeField(default=timezone.now, null=True, blank=True)
valid_to = models.DateTimeField(null=True, blank=True)
@classmethod
def get_current(cls, *args, **kwargs):
now = timezone.now()
# With both given
cls.objects.filter(valid_from__lte=now,
valid_to__gte=now)
# With to missing
cls.objects.filter(valid_from__lte=now,
valid_to__isnull=true)
# With from missing
cls.objects.filter(valid_from__isnull=true,
valid_to__gte=now)
# Both missing
cls.objects.filter(valid_from__isnull=true,
valid_to__gte=now)
###
# UncloudNetworks are used as identifiers - such they are a base of uncloud
class UncloudNetwork(models.Model):
"""
Storing IP networks
"""
network_address = models.GenericIPAddressField(null=False, unique=True)
network_mask = models.IntegerField(null=False,
validators=[MinValueValidator(0),
MaxValueValidator(128)]
)
description = models.CharField(max_length=256)
@classmethod
def populate_db_defaults(cls):
for net, desc in [
( "2a0a:e5c0:11::", "uncloud Billing" ),
( "2a0a:e5c0:11:1::", "uncloud Referral" ),
( "2a0a:e5c0:11:2::", "uncloud Coupon" )
]:
obj, created = cls.objects.get_or_create(network_address=net,
defaults= {
'network_mask': 64,
'description': desc
}
)
def save(self, *args, **kwargs):
if not ':' in self.network_address and self.network_mask > 32:
raise FieldError("Mask cannot exceed 32 for IPv4")
super().save(*args, **kwargs)
def __str__(self):
return f"{self.network_address}/{self.network_mask} {self.description}"
###
# Who is running / providing this instance of uncloud?
class UncloudProvider(UncloudAddress):
"""
A class resembling who is running this uncloud instance.
This might change over time so we allow starting/ending dates
This also defines the taxation rules.
starting/ending date define from when to when this is valid. This way
we can model address changes and have it correct in the bills.
"""
# Meta:
# FIXMe: only allow non overlapping time frames -- how to define this as a constraint?
starting_date = models.DateField()
ending_date = models.DateField(blank=True, null=True)
billing_network = models.ForeignKey(UncloudNetwork, related_name="uncloudproviderbill", on_delete=models.CASCADE)
referral_network = models.ForeignKey(UncloudNetwork, related_name="uncloudproviderreferral", on_delete=models.CASCADE)
coupon_network = models.ForeignKey(UncloudNetwork, related_name="uncloudprovidercoupon", on_delete=models.CASCADE)
@classmethod
def get_provider(cls, when=None):
"""
Find active provide at a certain time - if there was any
"""
return cls.objects.get(Q(starting_date__gte=when, ending_date__lte=when) |
Q(starting_date__gte=when, ending_date__isnull=True))
@classmethod
def populate_db_defaults(cls):
obj, created = cls.objects.get_or_create(full_name="ungleich glarus ag",
street="Bahnhofstrasse 1",
postal_code="8783",
city="Linthal",
country="CH",
starting_date=timezone.now(),
billing_network=UncloudNetwork.objects.get(description="uncloud Billing"),
referral_network=UncloudNetwork.objects.get(description="uncloud Referral"),
coupon_network=UncloudNetwork.objects.get(description="uncloud Coupon")
)
def __str__(self):
return f"{self.full_name} {self.country}"

23
uncloud/selectors.py Normal file
View File

@ -0,0 +1,23 @@
from django.db.models import Q
from django.utils import timezone
def filter_for_when(queryset, when=None):
"""
Return a filtered queryset which is valid for the given date
Logic:
Look for entries that have a starting date before when
and either
- No ending date
- Ending date after "when"
Returns a queryset, you'll neet to apply .first() or similar on it
"""
if not when:
when = timezone.now()
return queryset.filter(starting_date__lte=when).filter(Q(ending_date__gte=when) |
Q(ending_date__isnull=True))

View File

@ -11,7 +11,9 @@ https://docs.djangoproject.com/en/3.0/ref/settings/
"""
import os
import re
import ldap
import sys
from django.core.management.utils import get_random_secret_key
from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
@ -19,11 +21,11 @@ from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
LOGGING = {}
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.modules['fontawesome_free'] = __import__('fontawesome-free')
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
DATABASES = {
'default': {
@ -39,7 +41,7 @@ DATABASES = {
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
SITE_ID = 1
# Application definition
@ -49,9 +51,16 @@ INSTALLED_APPS = [
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.sites',
'allauth',
'allauth.account',
'allauth.socialaccount',
'django.contrib.staticfiles',
'django_extensions',
'rest_framework',
'bootstrap5',
'django_q',
'fontawesome_free',
'uncloud',
'uncloud_pay',
'uncloud_auth',
@ -59,7 +68,8 @@ INSTALLED_APPS = [
'uncloud_storage',
'uncloud_vm',
'uncloud_service',
'opennebula'
'opennebula',
'matrixhosting',
]
MIDDLEWARE = [
@ -110,7 +120,13 @@ AUTH_PASSWORD_VALIDATORS = [
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
###############################################################################
# Authall Settings
ACCOUNT_AUTHENTICATION_METHOD = "username"
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 1
ACCOUNT_EMAIL_REQUIRED = False
ACCOUNT_EMAIL_VERIFICATION = "optional"
ACCOUNT_UNIQUE_EMAIL = False
################################################################################
# AUTH/LDAP
@ -131,7 +147,8 @@ AUTH_LDAP_USER_ATTR_MAP = {
# AUTH/Django
AUTHENTICATION_BACKENDS = [
"django_auth_ldap.backend.LDAPBackend",
"django.contrib.auth.backends.ModelBackend"
"django.contrib.auth.backends.ModelBackend",
'allauth.account.auth_backends.AuthenticationBackend',
]
AUTH_USER_MODEL = 'uncloud_auth.User'
@ -165,6 +182,18 @@ USE_TZ = True
# https://docs.djangoproject.com/en/3.0/howto/static-files/
STATIC_URL = '/static/'
STATICFILES_DIRS = [ os.path.join(BASE_DIR, "static") ]
STATICFILES_FINDERS = [
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
]
#VM Deployment TEMPLATE
GITLAB_SERVER = 'https://code.ungleich.ch'
GITLAB_OAUTH_TOKEN = ''
GITLAB_PROJECT_ID = 388
GITLAB_AUTHOR_EMAIL = ''
GITLAB_AUTHOR_NAME = ''
GITLAB_YAML_DIR = ''
# XML-RPC interface of opennebula
OPENNEBULA_URL = 'https://opennebula.example.com:2634/RPC2'
@ -172,11 +201,10 @@ OPENNEBULA_URL = 'https://opennebula.example.com:2634/RPC2'
# user:pass for accessing opennebula
OPENNEBULA_USER_PASS = 'user:password'
# Stripe (Credit Card payments)
STRIPE_KEY=""
STRIPE_PUBLIC_KEY=""
BILL_PAYMENT_DELAY = 0
# The django secret key
SECRET_KEY=get_random_secret_key()
@ -185,6 +213,50 @@ ALLOWED_HOSTS = []
# required for hardcopy / pdf rendering: https://github.com/loftylabs/django-hardcopy
CHROME_PATH = '/usr/bin/chromium-browser'
# Username that is created by default and owns the configuration objects
UNCLOUD_ADMIN_NAME = "uncloud-admin"
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
# replace these in local_settings.py
AUTH_LDAP_SERVER_URI = "ldaps://ldap1.example.com,ldaps://ldap2.example.com"
AUTH_LDAP_BIND_DN="uid=django,ou=system,dc=example,dc=com"
AUTH_LDAP_BIND_PASSWORD="a very secure ldap password"
AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=example,dc=com",
ldap.SCOPE_SUBTREE,
"(uid=%(user)s)")
# where to create customers
LDAP_CUSTOMER_DN="ou=customer,dc=example,dc=com"
EMAIL_USE_TLS = True
EMAIL_HOST = ''
EMAIL_PORT = 465
EMAIL_HOST_USER = DEFAULT_FROM_EMAIL = ''
EMAIL_HOST_PASSWORD = ''
DEFAULT_FROM_EMAIL = ''
RENEWAL_FROM_EMAIL = 'test@example.com'
# Should be removed in production
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
##############
# Jobs
Q_CLUSTER = {
'name': 'matrixhosting',
'workers': 1,
'recycle': 500,
'timeout': 60,
'compress': True,
'cpu_affinity': 1,
'save_limit': 250,
'queue_limit': 500,
'label': 'Django Q',
'redis': {
'host': '127.0.0.1',
'port': 6379,
'db': 0, }
}
# Overwrite settings with local settings, if existing
try:

View File

@ -0,0 +1,4 @@
#content {
width: 400px;
margin: auto;
}

View File

@ -1,14 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{% extends 'bootstrap5/bootstrap5.html' %}
{% block bootstrap5_before_content %}
<title>{% block title %}Welcome to uncloud{% endblock %}</title>
{% block header %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
<nav class="navbar sticky-top navbar-expand-lg navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="{% url 'uncloudindex' %}">uncloud</a>
<a class="navbar-brand" href="{% url 'matrix:index' %}">Matrix Hosting</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
{% if user.is_authenticated %}
<span class="navbar-text">Logged in as {{ user }}. Your balance:
{{ balance }} CHF. </span>
<li class="nav-item">
<a class="nav-link" href="{% url 'account_logout' %}">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'account_login' %}">Login</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
{% endblock %}

View File

@ -0,0 +1,170 @@
{% extends 'uncloud/base.html' %}
{% block title %}Welcome to uncloud [beta]{% endblock %}
{% block bootstrap5_content %}
<div class="container">
<div id="content">
<div id="intro" class="row">
<div class=col>
<h1>Welcome to uncloud [beta]</h1>
</div>
</div>
<div id="about" class="row">
<div class="col"><h3>About uncloud</h3></div>
<div class="col-8">
<p>
Welcome to uncloud, the Open Source cloud management
system by <a href="https://ungleich.ch">ungleich</a>.
It is an <a href="{% url 'api-root' %}">API</a> driven system with
some convience views provided by
the <a href="https://www.django-rest-framework.org/">Django Rest
Framework</a>. You can
freely <a href="https://code.ungleich.ch/uncloud/uncloud/">access
the source code of uncloud</a>.
<strong>This is a BETA service.</strong> As such, some
functionality might not be very sophisticated.
</p>
</div>
</div>
<div id="howto" class="row">
<div class="col"><h3>Getting started</h3></div>
<div class="col-8">
<p>uncloud is designed to be as easy as possible to use. However,
there are some "real world" requirements that need to be met to
start using uncloud:
<ul>
<li>First you need
to <a href="https://account.ungleich.ch">register an
account</a>. If you already have one, you can
<a href="{% url 'account_login' %}">login</a>.
<li>If you have forgotten your password or other issues with
logging in, you can contact the ungleich support
via <strong>support at ungleich.ch</strong>.
<li>Secondy you will need to
<a href="{% url 'billingaddress-list' %}">create a billing
address</a>. This is required for determining the correct
tax.
<li>Next you will need to
<a href="{% url 'cc_register' %}">register a credit card</a>
from which payments can be made. Your credit card will not
be charged without your consent.
</ul>
</div>
</div>
<div id="howto" class="row">
<div class="col"><h3>Introduction to uncloud concepts</h3></div>
<div class="col-8">
<p>We plan to offer many services on uncloud ranging from
for free, for a small amount or regular charges. As transfer
fees are a major challenge for our business, we based uncloud
on the <strong>pre-paid account model</strong>. Which means
that you can charge your account and then use your balance to
pay for product usage.</p>
</div>
</div>
<div id="creditcards" class="row">
<div class="col"><h3>Credit cards</h3></div>
<div class="col-8">
<p>
Credit cards are registered with stripe. We only save a the
last 4 digits and the expiry date of the card to make
identification for you easier.
</p>
<ul>
<li><a href="{% url 'cc_register' %}">Register a credit card</a>
(this is required to be done via Javascript so that we never see
your credit card, but it is sent directly to stripe)
<li><a href="{% url 'stripecreditcard-list' %}">You can list your
credit cards</a>
By default the first credit card is used for charging
("active: true") and later added cards will not be
used. To change this, first disable the active flag and
then set it on another credit card.
</div>
</div>
<div id="pay" class="row">
<div class="col"><h3>Billing Address, Payments and Balance</h3></div>
<div class="col-8">
<p>Billing addresses behave similar to credit cards: you can
have many of them, but only one can be active. The active
billing address is taken for creating new orders.</p>
<p>In uncloud we use the pre-paid model: you can add money to
your account via payments. You can always check your
balance. The products you use will automatically be charged from
your existing balance.
</p>
<p>In the future you will be able opt-in to automatically
recharging your account at a certain time frame or whenever it
is below a certain amount</p>
<ul>
<li><a href="{% url 'billingaddress-list' %}">Create or list
your billing addresses</a>
<li><a href="{% url 'orders-list' %}">List your Orders</a>
<li><a href="{% url 'bills-list' %}">List your Bills</a>
<li><a href="{% url 'payment-list' %}">Make a payment or list your payments</a>
<li><a href="{% url 'payment-balance-list' %}">Show your balance</a>
<li><a href="{% url 'machines-list' %}">Show your VM Instances</a>
</ul>
</div>
</div>
<div id="net" class="row">
<div class="col"><h3>Networking</h3></div>
<div class="col-8">
<p>
With uncloud you can use a variety of network related
services.
</p>
<ul>
<li>You can <a href="{% url 'wireguardvpnnetwork-list' %}">list or
create VPNs</a> based on wireguard
<ul>
<li>Checkout
<a href="{% url 'wireguardvpnnetworksizes-list' %}">which
network sizes are available</a> at the moment.
</ul>
</ul>
</div>
</div>
<div id="net" class="row">
<div class="col"><h3>Current limitations</h3></div>
<div class="col-8">
<ul>
<li>Payments are only possible in CHF.
</ul>
</div>
</div>
{% if user.is_authenticated %}
<div id="account-settings" class="row">
<div class="col"><h3>Account Settings</h3></div>
<div class="col-8">
<ul>
<form method="POST">
{% csrf_token %}
<fieldset class="form-group">
<legend class="border-bottom mb-4">Delete User Account</legend>
<p>Are you sure you want to delete your account? This will permanently delete your
profile and any orders you have generated.</p>
{{ delete_form }}
</fieldset>
<div class="form-group">
<button class="btn btn-danger btn-lg" type="submit" name="action">Delete Account</button>
</div>
</form>
</ul>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -13,68 +13,53 @@ from rest_framework import routers
from rest_framework.schemas import get_schema_view
#from opennebula import views as oneviews
from uncloud import views as uncloudviews
from uncloud_auth import views as authviews
from uncloud_net import views as netviews
from uncloud_pay import views as payviews
from uncloud_vm import views as vmviews
from uncloud_service import views as serviceviews
from matrixhosting import views as matrixviews
router = routers.DefaultRouter()
# Beta endpoints
router.register(r'beta/vm', vmviews.NicoVMProductViewSet, basename='nicovmproduct')
# VM
router.register(r'v1/vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct')
router.register(r'v1/vm/diskimage', vmviews.VMDiskImageProductViewSet, basename='vmdiskimageproduct')
router.register(r'v1/vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct')
router.register(r'v1/vm/vm', vmviews.VMProductViewSet, basename='vmproduct')
# creates VM from os image
#router.register(r'vm/ipv6onlyvm', vmviews.VMProductViewSet, basename='vmproduct')
# ... AND adds IPv4 mapping
#router.register(r'vm/dualstackvm', vmviews.VMProductViewSet, basename='vmproduct')
# Services
router.register(r'v1/service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct')
router.register(r'v1/service/generic', serviceviews.GenericServiceProductViewSet, basename='genericserviceproduct')
################################################################################
# v2
# Net
router.register(r'v1/net/vpn', netviews.VPNNetworkViewSet, basename='vpnnetwork')
router.register(r'v1/admin/vpnreservation', netviews.VPNNetworkReservationViewSet, basename='vpnnetreservation')
router.register(r'v2/net/wireguardvpn', netviews.WireGuardVPNViewSet, basename='wireguardvpnnetwork')
router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basename='wireguardvpnnetworksizes')
# Payment related for a user
router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename='stripecreditcard')
router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment')
router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance')
router.register(r'v2/payment/address', payviews.BillingAddressViewSet, basename='billingaddress')
router.register(r'v2/orders', payviews.OrderViewSet, basename='orders')
router.register(r'v2/bill', payviews.BillViewSet, basename='bills')
router.register(r'v2/machines', matrixviews.MachineViewSet, basename='machines')
# Pay
router.register(r'v1/my/address', payviews.BillingAddressViewSet, basename='billingaddress')
router.register(r'v1/my/bill', payviews.BillViewSet, basename='bill')
router.register(r'v1/my/order', payviews.OrderViewSet, basename='order')
router.register(r'v1/my/payment', payviews.PaymentViewSet, basename='payment')
router.register(r'v1/my/payment-method', payviews.PaymentMethodViewSet, basename='payment-method')
# Generic helper views that are usually not needed
router.register(r'v2/generic/vat-rate', payviews.VATRateViewSet, basename='vatrate')
# admin/staff urls
router.register(r'v1/admin/bill', payviews.AdminBillViewSet, basename='admin/bill')
router.register(r'v1/admin/payment', payviews.AdminPaymentViewSet, basename='admin/payment')
router.register(r'v1/admin/order', payviews.AdminOrderViewSet, basename='admin/order')
router.register(r'v1/admin/vmhost', vmviews.VMHostViewSet)
router.register(r'v1/admin/vmcluster', vmviews.VMClusterViewSet)
router.register(r'v1/admin/vpnpool', netviews.VPNPoolViewSet)
#router.register(r'v1/admin/opennebula', oneviews.VMViewSet, basename='opennebula')
# User/Account
router.register(r'v1/my/user', authviews.UserViewSet, basename='user')
router.register(r'v1/admin/user', authviews.AdminUserViewSet, basename='useradmin')
urlpatterns = [
path(r'api/', include(router.urls)),
# web/ = stuff to view in the browser
path(r'api/', include(router.urls), name='api'),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), # for login to REST API
path('openapi', get_schema_view(
title="uncloud",
description="uncloud API",
version="1.0.0"
version="2.0.0"
), name='openapi-schema'),
path('admin/', admin.site.urls),
path('accounts/', include('allauth.urls')),
path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"),
path('matrix/', include('matrixhosting.urls', namespace='matrix')),
path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"),
]

23
uncloud/views.py Normal file
View File

@ -0,0 +1,23 @@
from django.views.generic.base import TemplateView
from django.contrib import messages
from django.shortcuts import redirect
from uncloud_pay.selectors import get_balance_for_user
from .forms import UserDeleteForm
class UncloudIndex(TemplateView):
template_name = "uncloud/index.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.user.is_authenticated:
context['balance'] = get_balance_for_user(self.request.user)
context['delete_form'] = UserDeleteForm(instance=self.request.user)
return context
def post(self, request, *args, **kwargs):
UserDeleteForm(request.POST, instance=request.user)
user = request.user
user.delete()
messages.info(request, 'Your account has been deleted.')
return redirect('uncloudindex')

View File

@ -1,4 +1,4 @@
# Generated by Django 3.0.6 on 2020-08-01 16:38
# Generated by Django 3.1 on 2020-12-13 10:38
import django.contrib.auth.models
import django.contrib.auth.validators
@ -12,7 +12,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0011_update_proxy_permissions'),
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
@ -24,7 +24,7 @@ class Migration(migrations.Migration):
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),

View File

@ -1,18 +0,0 @@
# Generated by Django 3.1 on 2020-08-08 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_auth', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='user',
name='first_name',
field=models.CharField(blank=True, max_length=150, verbose_name='first name'),
),
]

View File

@ -2,8 +2,7 @@ from django.contrib.auth.models import AbstractUser
from django.db import models
from django.core.validators import MinValueValidator
from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
from uncloud_pay.models import get_balance_for_user
from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
class User(AbstractUser):
"""
@ -16,10 +15,3 @@ class User(AbstractUser):
max_digits=AMOUNT_MAX_DIGITS,
decimal_places=AMOUNT_DECIMALS,
validators=[MinValueValidator(0)])
# @property
# def primary_billing_address(self):
@property
def balance(self):
return get_balance_for_user(self)

View File

@ -1,25 +1,72 @@
from django.contrib.auth import get_user_model
from django.db import transaction
from ldap3.core.exceptions import LDAPEntryAlreadyExistsResult
from rest_framework import serializers
from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
from uncloud_pay.models import BillingAddress
from .ungleich_ldap import LdapManager
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = get_user_model()
read_only_fields = [ 'username', 'balance', 'maximum_credit' ]
fields = read_only_fields + [ 'email', 'primary_billing_address' ]
fields = read_only_fields + [ 'email' ] # , 'primary_billing_address' ]
def validate(self, data):
"""
Ensure that the primary billing address belongs to the user
"""
if 'primary_billing_address' in data:
if not data['primary_billing_address'].owner == self.instance:
raise serializers.ValidationError("Invalid data")
# The following is raising exceptions probably, it is WIP somewhere
# if 'primary_billing_address' in data:
# if not data['primary_billing_address'].owner == self.instance:
# raise serializers.ValidationError('Invalid data')
return data
def update(self, instance, validated_data):
ldap_manager = LdapManager()
return_val, _ = ldap_manager.change_user_details(
instance.username, {'mail': validated_data.get('email')}
)
if not return_val:
raise serializers.ValidationError('Couldn\'t update email')
instance.email = validated_data.get('email')
instance.save()
return instance
class UserRegistrationSerializer(serializers.ModelSerializer):
class Meta:
model = get_user_model()
fields = ['username', 'first_name', 'last_name', 'email', 'password']
extra_kwargs = {
'password': {'style': {'input_type': 'password'}},
'first_name': {'allow_blank': False, 'required': True},
'last_name': {'allow_blank': False, 'required': True},
'email': {'allow_blank': False, 'required': True},
}
def create(self, validated_data):
ldap_manager = LdapManager()
try:
data = {
'user': validated_data['username'],
'password': validated_data['password'],
'email': validated_data['email'],
'firstname': validated_data['first_name'],
'lastname': validated_data['last_name'],
}
ldap_manager.create_user(**data)
except LDAPEntryAlreadyExistsResult:
raise serializers.ValidationError(
{'username': ['A user with that username already exists.']}
)
else:
return get_user_model().objects.create_user(**validated_data)
class ImportUserSerializer(serializers.Serializer):
username = serializers.CharField()

View File

@ -0,0 +1,21 @@
{% extends 'uncloud/base.html' %}
{% load bootstrap5 %}
{% block bootstrap5_content %}
<div class="container">
<div id="content">
<div id="intro" class="row">
<h1>Login to uncloud</h1>
<form method="post" class="form">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<button type="submit" class="btn btn-primary">Submit</button>
{% endbuttons %}
</form>
</div>
</div>
</div>
{% endblock %}

42
uncloud_auth/uldap.py Normal file
View File

@ -0,0 +1,42 @@
import ldap
# from django.conf import settings
AUTH_LDAP_SERVER_URI = "ldaps://ldap1.ungleich.ch,ldaps://ldap2.ungleich.ch"
AUTH_LDAP_BIND_DN="uid=django-create,ou=system,dc=ungleich,dc=ch"
AUTH_LDAP_BIND_PASSWORD="kS#e+v\zjKn]L!,RIu2}V+DUS"
# AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=ungleich,dc=ch",
# ldap.SCOPE_SUBTREE,
# "(uid=%(user)s)")
ldap_object = ldap.initialize(AUTH_LDAP_SERVER_URI)
cancelid = ldap_object.bind(AUTH_LDAP_BIND_DN, AUTH_LDAP_BIND_PASSWORD)
res = ldap_object.search_s("dc=ungleich,dc=ch", ldap.SCOPE_SUBTREE, "(uid=nico)")
print(res)
# class LDAP(object):
# """
# Managing users in LDAP
# Requires the following settings?
# LDAP_USER_DN: where to create users in the tree
# LDAP_ADMIN_DN: which DN to use for managing users
# LDAP_ADMIN_PASSWORD: which password to used
# This module will reuse information from djagno_auth_ldap, including:
# AUTH_LDAP_SERVER_URI
# """
# def __init__(self):
# pass
# def create_user(self):
# pass
# def change_password(self):
# pass

View File

@ -0,0 +1,284 @@
import base64
import hashlib
import logging
import random
import ldap3
from django.conf import settings
logger = logging.getLogger(__name__)
class LdapManager:
__instance = None
def __new__(cls):
if LdapManager.__instance is None:
LdapManager.__instance = object.__new__(cls)
return LdapManager.__instance
def __init__(self):
"""
Initialize the LDAP subsystem.
"""
self.rng = random.SystemRandom()
self.server = ldap3.Server(settings.AUTH_LDAP_SERVER)
def get_admin_conn(self):
"""
Return a bound :class:`ldap3.Connection` instance which has write
permissions on the dn in which the user accounts reside.
"""
conn = self.get_conn(user=settings.LDAP_ADMIN_DN,
password=settings.LDAP_ADMIN_PASSWORD,
raise_exceptions=True)
conn.bind()
return conn
def get_conn(self, **kwargs):
"""
Return an unbound :class:`ldap3.Connection` which talks to the configured
LDAP server.
The *kwargs* are passed to the constructor of :class:`ldap3.Connection` and
can be used to set *user*, *password* and other useful arguments.
"""
return ldap3.Connection(self.server, **kwargs)
def _ssha_password(self, password):
"""
Apply the SSHA password hashing scheme to the given *password*.
*password* must be a :class:`bytes` object, containing the utf-8
encoded password.
Return a :class:`bytes` object containing ``ascii``-compatible data
which can be used as LDAP value, e.g. after armoring it once more using
base64 or decoding it to unicode from ``ascii``.
"""
SALT_BYTES = 15
sha1 = hashlib.sha1()
salt = self.rng.getrandbits(SALT_BYTES * 8).to_bytes(SALT_BYTES,
"little")
sha1.update(password)
sha1.update(salt)
digest = sha1.digest()
passwd = b"{SSHA}" + base64.b64encode(digest + salt)
return passwd
def create_user(self, user, password, firstname, lastname, email):
conn = self.get_admin_conn()
uidNumber = self._get_max_uid() + 1
logger.debug("uidNumber={uidNumber}".format(uidNumber=uidNumber))
user_exists = True
while user_exists:
user_exists, _ = self.check_user_exists(
"",
'(&(objectClass=inetOrgPerson)(objectClass=posixAccount)'
'(objectClass=top)(uidNumber={uidNumber}))'.format(
uidNumber=uidNumber
)
)
if user_exists:
logger.debug(
"{uid} exists. Trying next.".format(uid=uidNumber)
)
uidNumber += 1
logger.debug("{uid} does not exist. Using it".format(uid=uidNumber))
self._set_max_uid(uidNumber)
try:
uid = user # user.encode("utf-8")
conn.add("uid={uid},{customer_dn}".format(
uid=uid, customer_dn=settings.LDAP_CUSTOMER_DN
),
["inetOrgPerson", "posixAccount", "ldapPublickey"],
{
"uid": [uid],
"sn": [lastname.encode("utf-8")],
"givenName": [firstname.encode("utf-8")],
"cn": [uid],
"displayName": ["{} {}".format(firstname, lastname).encode("utf-8")],
"uidNumber": [str(uidNumber)],
"gidNumber": [str(settings.LDAP_CUSTOMER_GROUP_ID)],
"loginShell": ["/bin/bash"],
"homeDirectory": ["/home/{}".format(user).encode("utf-8")],
"mail": email.encode("utf-8"),
"userPassword": [self._ssha_password(
password.encode("utf-8")
)]
}
)
logger.debug('Created user %s %s' % (user.encode('utf-8'),
uidNumber))
except Exception as ex:
logger.debug('Could not create user %s' % user.encode('utf-8'))
logger.error("Exception: " + str(ex))
raise
finally:
conn.unbind()
def change_password(self, uid, new_password):
"""
Changes the password of the user identified by user_dn
:param uid: str The uid that identifies the user
:param new_password: str The new password string
:return: True if password was changed successfully False otherwise
"""
conn = self.get_admin_conn()
# Make sure the user exists first to change his/her details
user_exists, entries = self.check_user_exists(
uid=uid,
search_base=settings.ENTIRE_SEARCH_BASE
)
return_val = False
if user_exists:
try:
return_val = conn.modify(
entries[0].entry_dn,
{
"userpassword": (
ldap3.MODIFY_REPLACE,
[self._ssha_password(new_password.encode("utf-8"))]
)
}
)
except Exception as ex:
logger.error("Exception: " + str(ex))
else:
logger.error("User {} not found".format(uid))
conn.unbind()
return return_val
def change_user_details(self, uid, details):
"""
Updates the user details as per given values in kwargs of the user
identified by user_dn.
Assumes that all attributes passed in kwargs are valid.
:param uid: str The uid that identifies the user
:param details: dict A dictionary containing the new values
:return: True if user details were updated successfully False otherwise
"""
conn = self.get_admin_conn()
# Make sure the user exists first to change his/her details
user_exists, entries = self.check_user_exists(
uid=uid,
search_base=settings.ENTIRE_SEARCH_BASE
)
return_val = False
if user_exists:
details_dict = {k: (ldap3.MODIFY_REPLACE, [v.encode("utf-8")]) for
k, v in details.items()}
try:
return_val = conn.modify(entries[0].entry_dn, details_dict)
msg = "success"
except Exception as ex:
msg = str(ex)
logger.error("Exception: " + msg)
finally:
conn.unbind()
else:
msg = "User {} not found".format(uid)
logger.error(msg)
conn.unbind()
return return_val, msg
def check_user_exists(self, uid, search_filter="", attributes=None,
search_base=settings.LDAP_CUSTOMER_DN):
"""
Check if the user with the given uid exists in the customer group.
:param uid: str representing the user
:param search_filter: str representing the filter condition to find
users. If its empty, the search finds the user with
the given uid.
:param attributes: list A list of str representing all the attributes
to be obtained in the result entries
:param search_base: str
:return: tuple (bool, [ldap3.abstract.entry.Entry ..])
A bool indicating if the user exists
A list of all entries obtained in the search
"""
conn = self.get_admin_conn()
entries = []
try:
result = conn.search(
search_base=search_base,
search_filter=search_filter if len(search_filter)> 0 else
'(uid={uid})'.format(uid=uid),
attributes=attributes
)
entries = conn.entries
finally:
conn.unbind()
return result, entries
def delete_user(self, uid):
"""
Deletes the user with the given uid from ldap
:param uid: str representing the user
:return: True if the delete was successful False otherwise
"""
conn = self.get_admin_conn()
try:
return_val = conn.delete(
("uid={uid}," + settings.LDAP_CUSTOMER_DN).format(uid=uid),
)
msg = "success"
except Exception as ex:
msg = str(ex)
logger.error("Exception: " + msg)
return_val = False
finally:
conn.unbind()
return return_val, msg
def _set_max_uid(self, max_uid):
"""
a utility function to save max_uid value to a file
:param max_uid: an integer representing the max uid
:return:
"""
with open(settings.LDAP_MAX_UID_FILE_PATH, 'w+') as handler:
handler.write(str(max_uid))
def _get_max_uid(self):
"""
A utility function to read the max uid value that was previously set
:return: An integer representing the max uid value that was previously
set
"""
try:
with open(settings.LDAP_MAX_UID_FILE_PATH, 'r+') as handler:
try:
return_value = int(handler.read())
except ValueError as ve:
logger.error(
"Error reading int value from {}. {}"
"Returning default value {} instead".format(
settings.LDAP_MAX_UID_PATH,
str(ve),
settings.LDAP_DEFAULT_START_UID
)
)
return_value = settings.LDAP_DEFAULT_START_UID
return return_value
except FileNotFoundError as fnfe:
logger.error("File not found : " + str(fnfe))
return_value = settings.LDAP_DEFAULT_START_UID
logger.error("So, returning UID={}".format(return_value))
return return_value

View File

@ -1,9 +1,22 @@
from rest_framework import viewsets, permissions, status
from .serializers import *
from django.contrib.auth import views as auth_views
from django.contrib.auth import logout
from django_auth_ldap.backend import LDAPBackend
from rest_framework import mixins, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from .serializers import *
class LoginView(auth_views.LoginView):
template_name = 'uncloud_auth/login.html'
class LogoutView(auth_views.LogoutView):
pass
# template_name = 'uncloud_auth/logo.html'
class UserViewSet(viewsets.GenericViewSet):
permission_classes = [permissions.IsAuthenticated]
serializer_class = UserSerializer
@ -19,19 +32,29 @@ class UserViewSet(viewsets.GenericViewSet):
serializer = self.get_serializer(user, context = {'request': request})
return Response(serializer.data)
def create(self, request):
"""
Modify existing user data
"""
user = request.user
serializer = self.get_serializer(user,
context = {'request': request},
data=request.data)
@action(detail=False, methods=['post'])
def change_email(self, request):
serializer = self.get_serializer(
request.user, data=request.data, context={'request': request}
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
class AccountManagementViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
serializer_class = UserRegistrationSerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
class AdminUserViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [permissions.IsAdminUser]

View File

@ -1,3 +1,7 @@
from django.contrib import admin
# Register your models here.
from .models import *
for m in [ ReverseDNSEntry, WireGuardVPNPool, WireGuardVPN ]:
admin.site.register(m)

11
uncloud_net/forms.py Normal file
View File

@ -0,0 +1,11 @@
from django import forms
from .models import *
from .selectors import *
class WireGuardVPNForm(forms.ModelForm):
network_size = forms.ChoiceField(choices=allowed_vpn_network_reservation_size)
class Meta:
model = WireGuardVPN
fields = [ "wireguard_public_key" ]

View File

@ -1,11 +1,9 @@
# Generated by Django 3.0.6 on 2020-08-01 16:38
# Generated by Django 3.1 on 2020-12-13 13:42
from django.conf import settings
import django.contrib.postgres.fields.jsonb
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
@ -14,7 +12,6 @@ class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_pay', '__first__'),
]
operations = [
@ -25,45 +22,41 @@ class Migration(migrations.Migration):
],
),
migrations.CreateModel(
name='VPNPool',
fields=[
('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('network', models.GenericIPAddressField(unique=True)),
('network_size', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
('subnetwork_size', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
('vpn_hostname', models.CharField(max_length=256)),
('wireguard_private_key', models.CharField(max_length=48)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='VPNNetworkReservation',
fields=[
('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)),
('address', models.GenericIPAddressField(primary_key=True, serialize=False)),
('status', models.CharField(choices=[('used', 'used'), ('free', 'free')], default='used', max_length=256)),
('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNPool')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='VPNNetwork',
name='WireGuardVPNPool',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='AWAITING_PAYMENT', max_length=32)),
('wireguard_public_key', models.CharField(max_length=48)),
('network', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNNetworkReservation')),
('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('network', models.GenericIPAddressField(unique=True)),
('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
('subnetwork_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
('vpn_server_hostname', models.CharField(max_length=256)),
('wireguard_private_key', models.CharField(max_length=48)),
],
),
migrations.CreateModel(
name='WireGuardVPNFreeLeases',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('pool_index', models.IntegerField(unique=True)),
('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.wireguardvpnpool')),
],
),
migrations.CreateModel(
name='WireGuardVPN',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('pool_index', models.IntegerField(unique=True)),
('wireguard_public_key', models.CharField(max_length=48)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.wireguardvpnpool')),
],
),
migrations.CreateModel(
name='ReverseDNSEntry',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip_address', models.GenericIPAddressField(unique=True)),
('name', models.CharField(max_length=253)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 3.0.8 on 2020-08-01 23:32
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0003_auto_20200801_2332'),
('uncloud_net', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='vpnnetwork',
name='order',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.1 on 2020-12-13 17:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_net', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='wireguardvpnpool',
name='wireguard_public_key',
field=models.CharField(default='', max_length=48),
preserve_default=False,
),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 3.1 on 2020-08-08 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_net', '0002_auto_20200801_2332'),
]
operations = [
migrations.AlterField(
model_name='vpnnetwork',
name='extra_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
migrations.AlterField(
model_name='vpnnetworkreservation',
name='extra_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
migrations.AlterField(
model_name='vpnpool',
name='extra_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.1 on 2020-12-13 17:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_net', '0002_wireguardvpnpool_wireguard_public_key'),
]
operations = [
migrations.AddField(
model_name='wireguardvpnpool',
name='wg_name',
field=models.CharField(default='wg0', max_length=15),
preserve_default=False,
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 3.1 on 2020-08-09 12:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0013_auto_20200809_1237'),
('uncloud_net', '0003_auto_20200808_1953'),
]
operations = [
migrations.RemoveField(
model_name='vpnnetwork',
name='order',
),
migrations.AddField(
model_name='vpnnetwork',
name='orders',
field=models.ManyToManyField(to='uncloud_pay.Order'),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.1 on 2020-12-13 17:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_net', '0003_wireguardvpnpool_wg_name'),
]
operations = [
migrations.AddConstraint(
model_name='wireguardvpnpool',
constraint=models.UniqueConstraint(fields=('wg_name', 'vpn_server_hostname'), name='unique_interface_name_per_host'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1 on 2020-12-20 18:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_net', '0004_auto_20201213_1734'),
]
operations = [
migrations.AlterField(
model_name='wireguardvpn',
name='wireguard_public_key',
field=models.CharField(max_length=48, unique=True),
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 3.1 on 2020-09-28 18:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_net', '0004_auto_20200809_1237'),
]
operations = [
migrations.RemoveField(
model_name='vpnnetwork',
name='orders',
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 3.1 on 2020-09-28 18:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_net', '0005_remove_vpnnetwork_orders'),
]
operations = [
migrations.RemoveField(
model_name='vpnnetwork',
name='extra_data',
),
migrations.RemoveField(
model_name='vpnnetwork',
name='owner',
),
migrations.RemoveField(
model_name='vpnnetwork',
name='status',
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.1 on 2020-12-24 16:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_net', '0005_auto_20201220_1837'),
]
operations = [
migrations.AddConstraint(
model_name='wireguardvpn',
constraint=models.UniqueConstraint(fields=('vpnpool', 'wireguard_public_key'), name='wg_key_unique_per_pool'),
),
]

View File

@ -4,184 +4,205 @@ import ipaddress
from django.db import models
from django.contrib.auth import get_user_model
from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.exceptions import FieldError, ValidationError
from uncloud_pay.models import Order, Product
from uncloud_pay.models import Product, RecurringPeriod
from uncloud.models import UncloudModel, UncloudStatus
class MACAdress(models.Model):
default_prefix = 0x420000000000
class VPNPool(UncloudModel):
class WireGuardVPNPool(models.Model):
"""
Network address pools from which VPNs can be created
"""
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class Meta:
constraints = [
models.UniqueConstraint(fields=['wg_name', 'vpn_server_hostname' ],
name='unique_interface_name_per_host')
]
# Linux interface naming is restricing to max 15 characters
wg_name = models.CharField(max_length=15)
network = models.GenericIPAddressField(unique=True)
network_size = models.IntegerField(validators=[MinValueValidator(0),
network_mask = models.IntegerField(validators=[MinValueValidator(0),
MaxValueValidator(128)])
subnetwork_size = models.IntegerField(validators=[
MinValueValidator(0),
MaxValueValidator(128)
])
vpn_hostname = models.CharField(max_length=256)
subnetwork_mask = models.IntegerField(validators=[
MinValueValidator(0),
MaxValueValidator(128)
])
vpn_server_hostname = models.CharField(max_length=256)
wireguard_private_key = models.CharField(max_length=48)
wireguard_public_key = models.CharField(max_length=48)
@property
def num_maximum_networks(self):
def max_pool_index(self):
"""
sample:
network_size = 40
subnetwork_size = 48
maximum_networks = 2^(48-40)
2nd sample:
network_size = 8
subnetwork_size = 24
maximum_networks = 2^(24-8)
Return the highest possible network / last network id
"""
return 2**(self.subnetwork_size - self.network_size)
bits = self.subnetwork_mask - self.network_mask
return (2**bits)-1
@property
def used_networks(self):
return self.vpnnetworkreservation_set.filter(vpnpool=self, status='used')
def ip_network(self):
"""
Return the IP network based on our address and mask
"""
return ipaddress.ip_network(f"{self.network}/{self.network_mask}")
@property
def free_networks(self):
return self.vpnnetworkreservation_set.filter(vpnpool=self, status='free')
@property
def num_used_networks(self):
return len(self.used_networks)
@property
def num_free_networks(self):
return self.num_maximum_networks - self.num_used_networks + len(self.free_networks)
@property
def next_free_network(self):
if self.num_free_networks == 0:
# FIXME: use right exception
raise Exception("No free networks")
if len(self.free_networks) > 0:
return self.free_networks[0].address
if len(self.used_networks) > 0:
"""
sample:
pool = 2a0a:e5c1:200::/40
last_used = 2a0a:e5c1:204::/48
next:
"""
last_net = ipaddress.ip_network(self.used_networks.last().address)
last_net_ip = last_net[0]
if last_net_ip.version == 6:
offset_to_next = 2**(128 - self.subnetwork_size)
elif last_net_ip.version == 4:
offset_to_next = 2**(32 - self.subnetwork_size)
next_net_ip = last_net_ip + offset_to_next
return str(next_net_ip)
else:
# first network to be created
return self.network
@property
def wireguard_config_filename(self):
return '/etc/wireguard/{}.conf'.format(self.network)
def __str__(self):
return f"{self.ip_network} (subnets: /{self.subnetwork_mask})"
@property
def wireguard_config(self):
wireguard_config = [
"""
[Interface]
ListenPort = 51820
PrivateKey = {privatekey}
""".format(privatekey=self.wireguard_private_key) ]
wireguard_config = [ f"[Interface]\nListenPort = 51820\nPrivateKey = {self.wireguard_private_key}\n" ]
peers = []
for reservation in self.vpnnetworkreservation_set.filter(status='used'):
public_key = reservation.vpnnetwork_set.first().wireguard_public_key
peer_network = "{}/{}".format(reservation.address, self.subnetwork_size)
owner = reservation.vpnnetwork_set.first().owner
for vpn in self.wireguardvpn_set.all():
public_key = vpn.wireguard_public_key
peer_network = f"{vpn.address}/{self.subnetwork_mask}"
owner = vpn.owner
peers.append("""
# Owner: {owner}
[Peer]
PublicKey = {public_key}
AllowedIPs = {peer_network}
""".format(
owner=owner,
public_key=public_key,
peer_network=peer_network))
peers.append(f"# Owner: {owner}\n[Peer]\nPublicKey = {public_key}\nAllowedIPs = {peer_network}\n\n")
wireguard_config.extend(peers)
return "\n".join(wireguard_config)
def configure_wireguard_vpnserver(self):
class WireGuardVPN(models.Model):
"""
Created VPNNetworks
"""
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
vpnpool = models.ForeignKey(WireGuardVPNPool,
on_delete=models.CASCADE)
pool_index = models.IntegerField(unique=True)
wireguard_public_key = models.CharField(max_length=48, unique=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['vpnpool', 'wireguard_public_key'],
name='wg_key_unique_per_pool')
]
@property
def network_mask(self):
return self.vpnpool.subnetwork_mask
@property
def vpn_server(self):
return self.vpnpool.vpn_server_hostname
@property
def vpn_server_public_key(self):
return self.vpnpool.wireguard_public_key
@property
def address(self):
"""
This method is designed to run as a celery task and should
not be called directly from the web
Locate the correct subnet in the supernet
First get the network itself
"""
# subprocess, ssh
net = self.vpnpool.ip_network
subnet = net[(2**(128-self.vpnpool.subnetwork_mask)) * self.pool_index]
return str(subnet)
def __str__(self):
return f"{self.address} ({self.pool_index})"
def create_product(self):
"""
Ensure we have a product for the WireguardVPN
"""
pass
# Product.objects.get_or_create(
# name="WireGuardVPN",
# description="Wireguard VPN",
# currency=Currency.CHF,
# config=
class VPNNetworkReservation(UncloudModel):
class WireGuardVPNFreeLeases(models.Model):
"""
This class tracks the used VPN networks. It will be deleted, when the product is cancelled.
"""
vpnpool = models.ForeignKey(VPNPool,
Previously used VPNNetworks
"""
vpnpool = models.ForeignKey(WireGuardVPNPool,
on_delete=models.CASCADE)
address = models.GenericIPAddressField(primary_key=True)
pool_index = models.IntegerField(unique=True)
status = models.CharField(max_length=256,
default='used',
choices = (
('used', 'used'),
('free', 'free')
)
)
################################################################################
class MACAdress(models.Model):
default_prefix = 0x420000000000
class VPNNetwork(models.Model):
class ReverseDNSEntry(models.Model):
"""
A selected network. Used for tracking reservations / used networks
A reverse DNS entry
"""
network = models.ForeignKey(VPNNetworkReservation,
on_delete=models.CASCADE,
editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
wireguard_public_key = models.CharField(max_length=48)
ip_address = models.GenericIPAddressField(null=False, unique=True)
default_recurring_period = RecurringPeriod.PER_365D
name = models.CharField(max_length=253, null=False)
@property
def recurring_price(self):
return 120
def reverse_pointer(self):
return ipaddress.ip_address(self.ip_address).reverse_pointer
def implement(self):
"""
The implement function implements the change
"""
# Get all DNS entries (?) / update this DNS entry
# convert to DNS name
#
pass
def delete(self, *args, **kwargs):
self.network.status = 'free'
self.network.save()
def save(self, *args, **kwargs):
# Product.objects.filter(config__parameters__contains='reverse_dns_network')
# FIXME: check if order is still active / not replaced
allowed = False
product = None
for order in Order.objects.filter(config__parameters__reverse_dns_network__isnull=False,
owner=self.owner):
network = order.config['parameters']['reverse_dns_network']
net = ipaddress.ip_network(network)
addr = ipaddress.ip_address(self.ip_address)
if addr in net:
allowed = True
product = order.product
break
if not allowed:
raise ValidationError(f"User {self.owner} does not have the right to create reverse DNS entry for {self.ip_address}")
super().save(*args, **kwargs)
print("deleted {}".format(self))
def __str__(self):
return f"{self.ip_address} - {self.name}"

43
uncloud_net/selectors.py Normal file
View File

@ -0,0 +1,43 @@
from django.db import transaction
from django.db.models import Count, F
from .models import *
def get_suitable_pools(subnetwork_mask):
"""
Find suitable pools for a certain network size.
First, filter for all pools that offer the requested subnetwork_size.
Then find those pools that are not fully exhausted:
The number of available networks in a pool is 2^(subnetwork_size-network_size.
The number of available networks in a pool is given by the number of VPNNetworkreservations.
"""
return WireGuardVPNPool.objects.annotate(
num_reservations=Count('wireguardvpn'),
max_reservations=2**(F('subnetwork_mask')-F('network_mask'))).filter(
num_reservations__lt=F('max_reservations'),
subnetwork_mask=subnetwork_mask)
def allowed_vpn_network_reservation_size():
"""
Find all possible sizes of subnetworks that are available.
Select all pools with free networks.
Get their subnetwork sizes, reduce to a set
"""
pools = WireGuardVPNPool.objects.annotate(num_reservations=Count('wireguardvpn'),
max_reservations=2**(F('subnetwork_mask')-F('network_mask'))).filter(
num_reservations__lt=F('max_reservations'))
# Need to return set of tuples, see
# https://docs.djangoproject.com/en/3.1/ref/models/fields/#field-choices
# return set([ (pool.subnetwork_mask, pool.subnetwork_mask) for pool in pools ])
return set([pool.subnetwork_mask for pool in pools ])

View File

@ -5,96 +5,53 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from .models import *
from .services import *
from .selectors import *
class WireGuardVPNSerializer(serializers.ModelSerializer):
address = serializers.CharField(read_only=True)
vpn_server = serializers.CharField(read_only=True)
vpn_server_public_key = serializers.CharField(read_only=True)
network_mask = serializers.IntegerField()
class VPNPoolSerializer(serializers.ModelSerializer):
class Meta:
model = VPNPool
fields = '__all__'
model = WireGuardVPN
fields = [ 'wireguard_public_key', 'address', 'network_mask', 'vpn_server',
'vpn_server_public_key' ]
class VPNNetworkReservationSerializer(serializers.ModelSerializer):
class Meta:
model = VPNNetworkReservation
fields = '__all__'
extra_kwargs = {
'network_mask': {'write_only': True }
}
class VPNNetworkSerializer(serializers.ModelSerializer):
class Meta:
model = VPNNetwork
fields = '__all__'
def validate_network_mask(self, value):
msg = _(f"No pool for network size {value}")
sizes = allowed_vpn_network_reservation_size()
# This is required for finding the VPN pool, but does not
# exist in the model
network_size = serializers.IntegerField(min_value=0,
max_value=128,
write_only=True)
def validate_wireguard_public_key(self, value):
msg = _("Supplied key is not a valid wireguard public key")
""" FIXME: verify that this does not create broken wireguard config files,
i.e. contains \n or similar!
We might even need to be more strict to not break wireguard...
"""
try:
base64.standard_b64decode(value)
except Exception as e:
raise serializers.ValidationError(msg)
if '\n' in value:
if not value in sizes:
raise serializers.ValidationError(msg)
return value
def validate(self, data):
def validate_wireguard_public_key(self, value):
msg = _("Supplied key is not a valid wireguard public key")
# FIXME: filter for status = active or similar
all_pools = VPNPool.objects.all()
sizes = [ p.subnetwork_size for p in all_pools ]
"""
Verify wireguard key.
See https://lists.zx2c4.com/pipermail/wireguard/2020-December/006221.html
"""
pools = VPNPool.objects.filter(subnetwork_size=data['network_size'])
if len(pools) == 0:
msg = _("No pool available for networks with size = {}. Available are: {}".format(data['network_size'], sizes))
try:
decoded_key = base64.standard_b64decode(value)
except Exception as e:
raise serializers.ValidationError(msg)
return data
if not len(decoded_key) == 32:
raise serializers.ValidationError(msg)
def create(self, validated_data):
"""
Creating a new vpnnetwork - there are a couple of race conditions,
especially when run in parallel.
What we should be doing:
- create a reservation race free
- map the reservation to a network (?)
"""
pools = VPNPool.objects.filter(subnetwork_size=validated_data['network_size'])
vpn_network = None
for pool in pools:
if pool.num_free_networks > 0:
next_address = pool.next_free_network
reservation, created = VPNNetworkReservation.objects.update_or_create(
vpnpool=pool, address=next_address,
defaults = {
'status': 'used'
})
vpn_network = VPNNetwork.objects.create(
owner=self.context['request'].user,
network=reservation,
wireguard_public_key=validated_data['wireguard_public_key']
)
break
if not vpn_network:
# FIXME: use correct exception
raise Exception("Did not find any free pool")
return value
return vpn_network
class WireGuardVPNSizesSerializer(serializers.Serializer):
size = serializers.IntegerField(min_value=0, max_value=128)

65
uncloud_net/services.py Normal file
View File

@ -0,0 +1,65 @@
from django.db import transaction
from .models import *
from .selectors import *
from .tasks import *
from django_q.tasks import async_task, result
@transaction.atomic
def create_wireguard_vpn(owner, public_key, network_mask):
# Check if the user has a membership.
#------------------------------------
# If yes, user is eligible for API access and 2 VPNs
# If user already has 2 VPNs, we deduct from the credit
# If deduction is higher than the allowed credit, we fail
#
# Check if the user has suitable balance
# Create order
#
return create_wireguard_vpn_tech(owner, public_key, network_mask)
@transaction.atomic
def create_wireguard_vpn_tech(owner, public_key, network_mask):
pool = get_suitable_pools(network_mask)[0]
count = pool.wireguardvpn_set.count()
# Try re-using previously used networks first
try:
free_lease = WireGuardVPNFreeLeases.objects.get(vpnpool=pool)
vpn = WireGuardVPN.objects.create(owner=owner,
vpnpool=pool,
pool_index=free_lease.pool_index,
wireguard_public_key=public_key)
free_lease.delete()
except WireGuardVPNFreeLeases.DoesNotExist:
# First object
if count == 0:
vpn = WireGuardVPN.objects.create(owner=owner,
vpnpool=pool,
pool_index=0,
wireguard_public_key=public_key)
else: # Select last network and try +1 it
last_net = WireGuardVPN.objects.filter(vpnpool=pool).order_by('pool_index').last()
next_index = last_net.pool_index + 1
if next_index <= pool.max_pool_index:
vpn = WireGuardVPN.objects.create(owner=owner,
vpnpool=pool,
pool_index=next_index,
wireguard_public_key=public_key)
config = pool.wireguard_config
server = pool.vpn_server_hostname
wg_name = pool.wg_name
async_task(configure_wireguard_server_on_host, (wg_name, config), queue=server)
return vpn

74
uncloud_net/tasks.py Normal file
View File

@ -0,0 +1,74 @@
from .models import *
import os
import subprocess
import logging
import uuid
from django_q.tasks import async_task, result
log = logging.getLogger(__name__)
def configure_wireguard_server_on_host(wg_name, config):
"""
- Create wireguard config (DB query -> string)
- Submit config to cdist worker
- Change config locally on worker / commit / shared
"""
# Write config
fname = f"/etc/wireguard/{wg_name}.conf"
with open(fname, "w") as fd:
fd.write(config)
# Ensure the device exists
subprocess.run(f"ip link show {wg_name} >/dev/null || sudo ip link add {{wg_name}} type wireguard",
shell=True, check=True)
# Ensure the config is correct
subprocess.run(f"sudo wg setconf {wg_name} {fname}",
shell=True, check=True)
def configure_wireguard_server_via_cdist(wireguardvpnpool):
"""
- Create wireguard config (DB query -> string)
- Submit config to cdist worker
- Change config locally on worker / commit / shared
"""
config = wireguardvpnpool.wireguard_config
server = wireguardvpnpool.vpn_server_hostname
log.info(f"Configuring VPN server {server} (async)")
async_task(cdist_configure_wireguard_server,config, server).id
def cdist_configure_wireguard_server(config, server):
"""
Create config and configure server.
To be executed on the cdist worker.
"""
dirname= "/home/app/.cdist/type/__ungleich_wireguard/files/"
fname = os.path.join(dirname,server)
log.info(f"Configuring VPN server {server} (on cdist host)")
with open(fname, "w") as fd:
fd.write(config)
log.debug("git committing wireguard changes")
subprocess.run(f"cd {dirname} && git pull && git add {server} && git commit -m 'Updating config for {server}' && git push",
shell=True, check=True)
log.debug(f"Configuring VPN server {server} with cdist")
subprocess.run(f"cdist config {server}", shell=True, check=True)
# FIXME:
# ensure logs are on the server
# ensure exit codes are known
return True

View File

@ -0,0 +1,25 @@
{% extends 'uncloud/base.html' %}
{% block body %}
<div class="container">
<div class="row">
<div class="col">
<h1>
<h1>Create a VPN Network</h1>
<p>
Create a new wireguard based VPN network.
</p>
</div>
<div class="col">
<form method="post">
{% csrf_token %}
{{ form }}
<input type="submit" value="Submit">
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -3,12 +3,19 @@ from rest_framework.test import APIRequestFactory, force_authenticate
from rest_framework.reverse import reverse
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.exceptions import ValidationError, FieldError
from .views import *
from .models import *
from uncloud_pay.models import BillingAddress, Order
from uncloud.models import UncloudNetwork
class UncloudNetworkTests(TestCase):
def test_invalid_IPv4_network(self):
with self.assertRaises(FieldError):
UncloudNetwork.objects.create(network_address="192.168.1.0",
network_mask=33)
class VPNTests(TestCase):
def setUp(self):
@ -30,7 +37,7 @@ class VPNTests(TestCase):
self.vpn_wireguard_public_key = 'B2b78eWBIXPMM1x4DDjkCDZepS0qDgcLN3T3PjcgXkY='
self.vpnpool = VPNPool.objects.get_or_create(network=self.pool_network,
self.vpnpool = WireGuardVPNPool.objects.get_or_create(network=self.pool_network,
network_size=self.pool_network_size,
subnetwork_size=self.pool_subnetwork_size,
vpn_hostname=self.pool_vpn_hostname,
@ -40,55 +47,6 @@ class VPNTests(TestCase):
self.factory = APIRequestFactory()
def test_create_vpnpool(self):
url = reverse("vpnpool-list")
view = VPNPoolViewSet.as_view({'post': 'create'})
request = self.factory.post(url, { 'network': self.pool_network2,
'network_size': self.pool_network_size,
'subnetwork_size': self.pool_subnetwork_size,
'vpn_hostname': self.pool_vpn_hostname,
'wireguard_private_key': self.pool_wireguard_private_key
})
force_authenticate(request, user=self.admin_user)
response = view(request)
# This raises an exception if the request was not successful
# No assert needed
pool = VPNPool.objects.get(network=self.pool_network2)
# def test_create_vpn(self):
# url = reverse("vpnnetwork-list")
# view = VPNNetworkViewSet.as_view({'post': 'create'})
# request = self.factory.post(url, { 'network_size': self.pool_subnetwork_size,
# 'wireguard_public_key': self.vpn_wireguard_public_key
# })
# force_authenticate(request, user=self.user)
# # we don't have a billing address -> should raise an error
# # with self.assertRaises(ValidationError):
# # response = view(request)
# addr = BillingAddress.objects.get_or_create(
# owner=self.user,
# active=True,
# defaults={'organization': 'ungleich',
# 'name': 'Nico Schottelius',
# 'street': 'Hauptstrasse 14',
# 'city': 'Luchsingen',
# 'postal_code': '8775',
# 'country': 'CH' }
# )
# # This should work now
# response = view(request)
# # Verify that an order was created successfully - there should only be one order at
# # this point in time
# order = Order.objects.get(owner=self.user)
def tearDown(self):
self.user.delete()

View File

@ -1,33 +1,63 @@
from django.views.generic.edit import CreateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from rest_framework.response import Response
from django.shortcuts import render
from rest_framework import viewsets, permissions
from .models import *
from .serializers import *
from .selectors import *
from .services import *
from .forms import *
from .tasks import *
class VPNPoolViewSet(viewsets.ModelViewSet):
serializer_class = VPNPoolSerializer
permission_classes = [permissions.IsAdminUser]
queryset = VPNPool.objects.all()
class VPNNetworkReservationViewSet(viewsets.ModelViewSet):
serializer_class = VPNNetworkReservationSerializer
permission_classes = [permissions.IsAdminUser]
queryset = VPNNetworkReservation.objects.all()
class VPNNetworkViewSet(viewsets.ModelViewSet):
serializer_class = VPNNetworkSerializer
# permission_classes = [permissions.IsAdminUser]
class WireGuardVPNViewSet(viewsets.ModelViewSet):
serializer_class = WireGuardVPNSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
if self.request.user.is_superuser:
obj = VPNNetwork.objects.all()
obj = WireGuardVPN.objects.all()
else:
obj = VPNNetwork.objects.filter(owner=self.request.user)
obj = WireGuardVPN.objects.filter(owner=self.request.user)
return obj
def create(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
vpn = create_wireguard_vpn(
owner=self.request.user,
public_key=serializer.validated_data['wireguard_public_key'],
network_mask=serializer.validated_data['network_mask']
)
return Response(WireGuardVPNSerializer(vpn).data)
class WireGuardVPNCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = WireGuardVPN
login_url = '/login/'
success_url = '/'
success_message = "%(network) was created successfully"
form_class = WireGuardVPNForm
def get_success_message(self, cleaned_data):
return self.success_message % dict(cleaned_data,
the_prefix = self.object.prefix)
class WireGuardVPNSizes(viewsets.ViewSet):
def list(self, request):
sizes = allowed_vpn_network_reservation_size()
print(sizes)
sizes = [ { 'size': size } for size in sizes ]
print(sizes)
return Response(WireGuardVPNSizesSerializer(sizes, many=True).data)

View File

@ -1,250 +1 @@
from django.utils.translation import gettext_lazy as _
import decimal
# Define DecimalField properties, used to represent amounts of money.
AMOUNT_MAX_DIGITS=10
AMOUNT_DECIMALS=2
decimal.getcontext().prec = AMOUNT_DECIMALS
# http://xml.coverpages.org/country3166.html
COUNTRIES = (
('AD', _('Andorra')),
('AE', _('United Arab Emirates')),
('AF', _('Afghanistan')),
('AG', _('Antigua & Barbuda')),
('AI', _('Anguilla')),
('AL', _('Albania')),
('AM', _('Armenia')),
('AN', _('Netherlands Antilles')),
('AO', _('Angola')),
('AQ', _('Antarctica')),
('AR', _('Argentina')),
('AS', _('American Samoa')),
('AT', _('Austria')),
('AU', _('Australia')),
('AW', _('Aruba')),
('AZ', _('Azerbaijan')),
('BA', _('Bosnia and Herzegovina')),
('BB', _('Barbados')),
('BD', _('Bangladesh')),
('BE', _('Belgium')),
('BF', _('Burkina Faso')),
('BG', _('Bulgaria')),
('BH', _('Bahrain')),
('BI', _('Burundi')),
('BJ', _('Benin')),
('BM', _('Bermuda')),
('BN', _('Brunei Darussalam')),
('BO', _('Bolivia')),
('BR', _('Brazil')),
('BS', _('Bahama')),
('BT', _('Bhutan')),
('BV', _('Bouvet Island')),
('BW', _('Botswana')),
('BY', _('Belarus')),
('BZ', _('Belize')),
('CA', _('Canada')),
('CC', _('Cocos (Keeling) Islands')),
('CF', _('Central African Republic')),
('CG', _('Congo')),
('CH', _('Switzerland')),
('CI', _('Ivory Coast')),
('CK', _('Cook Iislands')),
('CL', _('Chile')),
('CM', _('Cameroon')),
('CN', _('China')),
('CO', _('Colombia')),
('CR', _('Costa Rica')),
('CU', _('Cuba')),
('CV', _('Cape Verde')),
('CX', _('Christmas Island')),
('CY', _('Cyprus')),
('CZ', _('Czech Republic')),
('DE', _('Germany')),
('DJ', _('Djibouti')),
('DK', _('Denmark')),
('DM', _('Dominica')),
('DO', _('Dominican Republic')),
('DZ', _('Algeria')),
('EC', _('Ecuador')),
('EE', _('Estonia')),
('EG', _('Egypt')),
('EH', _('Western Sahara')),
('ER', _('Eritrea')),
('ES', _('Spain')),
('ET', _('Ethiopia')),
('FI', _('Finland')),
('FJ', _('Fiji')),
('FK', _('Falkland Islands (Malvinas)')),
('FM', _('Micronesia')),
('FO', _('Faroe Islands')),
('FR', _('France')),
('FX', _('France, Metropolitan')),
('GA', _('Gabon')),
('GB', _('United Kingdom (Great Britain)')),
('GD', _('Grenada')),
('GE', _('Georgia')),
('GF', _('French Guiana')),
('GH', _('Ghana')),
('GI', _('Gibraltar')),
('GL', _('Greenland')),
('GM', _('Gambia')),
('GN', _('Guinea')),
('GP', _('Guadeloupe')),
('GQ', _('Equatorial Guinea')),
('GR', _('Greece')),
('GS', _('South Georgia and the South Sandwich Islands')),
('GT', _('Guatemala')),
('GU', _('Guam')),
('GW', _('Guinea-Bissau')),
('GY', _('Guyana')),
('HK', _('Hong Kong')),
('HM', _('Heard & McDonald Islands')),
('HN', _('Honduras')),
('HR', _('Croatia')),
('HT', _('Haiti')),
('HU', _('Hungary')),
('ID', _('Indonesia')),
('IE', _('Ireland')),
('IL', _('Israel')),
('IN', _('India')),
('IO', _('British Indian Ocean Territory')),
('IQ', _('Iraq')),
('IR', _('Islamic Republic of Iran')),
('IS', _('Iceland')),
('IT', _('Italy')),
('JM', _('Jamaica')),
('JO', _('Jordan')),
('JP', _('Japan')),
('KE', _('Kenya')),
('KG', _('Kyrgyzstan')),
('KH', _('Cambodia')),
('KI', _('Kiribati')),
('KM', _('Comoros')),
('KN', _('St. Kitts and Nevis')),
('KP', _('Korea, Democratic People\'s Republic of')),
('KR', _('Korea, Republic of')),
('KW', _('Kuwait')),
('KY', _('Cayman Islands')),
('KZ', _('Kazakhstan')),
('LA', _('Lao People\'s Democratic Republic')),
('LB', _('Lebanon')),
('LC', _('Saint Lucia')),
('LI', _('Liechtenstein')),
('LK', _('Sri Lanka')),
('LR', _('Liberia')),
('LS', _('Lesotho')),
('LT', _('Lithuania')),
('LU', _('Luxembourg')),
('LV', _('Latvia')),
('LY', _('Libyan Arab Jamahiriya')),
('MA', _('Morocco')),
('MC', _('Monaco')),
('MD', _('Moldova, Republic of')),
('MG', _('Madagascar')),
('MH', _('Marshall Islands')),
('ML', _('Mali')),
('MN', _('Mongolia')),
('MM', _('Myanmar')),
('MO', _('Macau')),
('MP', _('Northern Mariana Islands')),
('MQ', _('Martinique')),
('MR', _('Mauritania')),
('MS', _('Monserrat')),
('MT', _('Malta')),
('MU', _('Mauritius')),
('MV', _('Maldives')),
('MW', _('Malawi')),
('MX', _('Mexico')),
('MY', _('Malaysia')),
('MZ', _('Mozambique')),
('NA', _('Namibia')),
('NC', _('New Caledonia')),
('NE', _('Niger')),
('NF', _('Norfolk Island')),
('NG', _('Nigeria')),
('NI', _('Nicaragua')),
('NL', _('Netherlands')),
('NO', _('Norway')),
('NP', _('Nepal')),
('NR', _('Nauru')),
('NU', _('Niue')),
('NZ', _('New Zealand')),
('OM', _('Oman')),
('PA', _('Panama')),
('PE', _('Peru')),
('PF', _('French Polynesia')),
('PG', _('Papua New Guinea')),
('PH', _('Philippines')),
('PK', _('Pakistan')),
('PL', _('Poland')),
('PM', _('St. Pierre & Miquelon')),
('PN', _('Pitcairn')),
('PR', _('Puerto Rico')),
('PT', _('Portugal')),
('PW', _('Palau')),
('PY', _('Paraguay')),
('QA', _('Qatar')),
('RE', _('Reunion')),
('RO', _('Romania')),
('RU', _('Russian Federation')),
('RW', _('Rwanda')),
('SA', _('Saudi Arabia')),
('SB', _('Solomon Islands')),
('SC', _('Seychelles')),
('SD', _('Sudan')),
('SE', _('Sweden')),
('SG', _('Singapore')),
('SH', _('St. Helena')),
('SI', _('Slovenia')),
('SJ', _('Svalbard & Jan Mayen Islands')),
('SK', _('Slovakia')),
('SL', _('Sierra Leone')),
('SM', _('San Marino')),
('SN', _('Senegal')),
('SO', _('Somalia')),
('SR', _('Suriname')),
('ST', _('Sao Tome & Principe')),
('SV', _('El Salvador')),
('SY', _('Syrian Arab Republic')),
('SZ', _('Swaziland')),
('TC', _('Turks & Caicos Islands')),
('TD', _('Chad')),
('TF', _('French Southern Territories')),
('TG', _('Togo')),
('TH', _('Thailand')),
('TJ', _('Tajikistan')),
('TK', _('Tokelau')),
('TM', _('Turkmenistan')),
('TN', _('Tunisia')),
('TO', _('Tonga')),
('TP', _('East Timor')),
('TR', _('Turkey')),
('TT', _('Trinidad & Tobago')),
('TV', _('Tuvalu')),
('TW', _('Taiwan, Province of China')),
('TZ', _('Tanzania, United Republic of')),
('UA', _('Ukraine')),
('UG', _('Uganda')),
('UM', _('United States Minor Outlying Islands')),
('US', _('United States of America')),
('UY', _('Uruguay')),
('UZ', _('Uzbekistan')),
('VA', _('Vatican City State (Holy See)')),
('VC', _('St. Vincent & the Grenadines')),
('VE', _('Venezuela')),
('VG', _('British Virgin Islands')),
('VI', _('United States Virgin Islands')),
('VN', _('Viet Nam')),
('VU', _('Vanuatu')),
('WF', _('Wallis & Futuna Islands')),
('WS', _('Samoa')),
('YE', _('Yemen')),
('YT', _('Mayotte')),
('YU', _('Yugoslavia')),
('ZA', _('South Africa')),
('ZM', _('Zambia')),
('ZR', _('Zaire')),
('ZW', _('Zimbabwe')),
)

View File

@ -4,26 +4,21 @@ from django.urls import path
from django.shortcuts import render
from django.conf.urls import url
from uncloud_pay.views import BillViewSet
from hardcopy import bytestring_to_pdf
from django.core.files.temp import NamedTemporaryFile
from django.http import FileResponse
from django.template.loader import render_to_string
from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress, Product
from uncloud_pay.models import *
class BillRecordInline(admin.TabularInline):
# model = Bill.bill_records.through
model = BillRecord
# AT some point in the future: expose REPLACED and orders that depend on us
# class OrderInline(admin.TabularInline):
# model = Order
# fk_name = "replaces"
# class OrderAdmin(admin.ModelAdmin):
# inlines = [ OrderInline ]
class RecurringPeriodInline(admin.TabularInline):
model = ProductToRecurringPeriod
class ProductAdmin(admin.ModelAdmin):
inlines = [ RecurringPeriodInline ]
class BillAdmin(admin.ModelAdmin):
inlines = [ BillRecordInline ]
@ -51,9 +46,13 @@ class BillAdmin(admin.ModelAdmin):
raise self._get_404_exception(object_id)
output_file = NamedTemporaryFile()
bill_html = render_to_string("bill.html.j2", {'bill': bill,
'bill_records': bill.billrecord_set.all()
})
bill_html = render_to_string(
"uncloud_pay/bill.html.j2",
{
'bill': bill,
'bill_records': bill.billrecord_set.all()
}
)
bytestring_to_pdf(bill_html.encode('utf-8'), output_file)
response = FileResponse(output_file, content_type="application/pdf")
@ -67,7 +66,7 @@ class BillAdmin(admin.ModelAdmin):
if bill is None:
raise self._get_404_exception(object_id)
return render(request, 'bill.html.j2',
return render(request, 'uncloud_pay/bill.html.j2',
{'bill': bill,
'bill_records': bill.billrecord_set.all()
})
@ -87,12 +86,18 @@ class BillAdmin(admin.ModelAdmin):
admin.site.register(Bill, BillAdmin)
admin.site.register(Order)
admin.site.register(BillRecord)
admin.site.register(BillingAddress)
admin.site.register(Product, ProductAdmin)
#for m in [ SampleOneTimeProduct, SampleRecurringProduct, SampleRecurringProductOneTimeFee ]:
admin.site.register(Product)
#admin.site.register(Order, OrderAdmin)
for m in [
BillingAddress,
Order,
BillRecord,
Payment,
ProductToRecurringPeriod,
RecurringPeriod,
StripeCreditCard,
StripeCustomer,
PricingPlan,
VATRate
]:
admin.site.register(m)

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