Compare commits

...

70 commits

Author SHA1 Message Date
PCoder
29bd6b5b3c Use correct app name 2021-04-15 09:45:20 +05:30
PCoder
f02f15f09b First pass at the add-opennebula-vm-orders management command
Command executes but still does not create bills
2021-04-14 17:02:45 +05:30
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
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
Nico Schottelius
8d8c4d660c Can order a generic product now 2020-09-28 21:59:35 +02:00
Nico Schottelius
c32499199a Add JSON support for product description 2020-09-28 21:34:24 +02:00
Nico Schottelius
c6bacab35a Phasing out Product model
Signed-off-by: Nico Schottelius <nico@nico-notebook.schottelius.org>
2020-09-28 20:59:08 +02:00
Nico Schottelius
1aead50170 remove big mistake: orders from product
Signed-off-by: Nico Schottelius <nico@nico-notebook.schottelius.org>
2020-09-28 20:44:50 +02:00
Nico Schottelius
d8a7964fed Continue to refactor for shifting logic into the order 2020-09-09 00:35:55 +02:00
Nico Schottelius
077c665c53 ++update 2020-09-03 17:16:18 +02:00
Nico Schottelius
f7274fe967 Adding logic to order to find out whether its closed 2020-09-03 16:38:51 +02:00
Nico Schottelius
1c7d81762d begin splitting bill record creation function 2020-09-02 16:02:28 +02:00
Nico Schottelius
18f9a3848a Implement ending/replacing date logic 2020-08-27 22:00:54 +02:00
Nico Schottelius
9211894b23 implement basic logic for updating a recurring order
Signed-off-by: Nico Schottelius <nico@nico-notebook.schottelius.org>
2020-08-27 14:45:37 +02:00
Nico Schottelius
b8b15704a3 begin testing bill sums
Signed-off-by: Nico Schottelius <nico@nico-notebook.schottelius.org>
2020-08-25 21:53:25 +02:00
Nico Schottelius
ab412cb877 Test that creating products w/o correct billing address fails 2020-08-25 21:31:12 +02:00
Nico Schottelius
7b83efe995 [pay] make sample products more modular 2020-08-25 21:11:28 +02:00
Nico Schottelius
4d5ca58b2a [tests] cleanup old tests
Finally manage.py tests runs through
2020-08-25 20:40:33 +02:00
Nico Schottelius
f693dd3d18 ++notes 2020-08-09 21:10:43 +02:00
Nico Schottelius
5ceaaf7c90 bill cleanup, note next step 2020-08-09 14:52:42 +02:00
Nico Schottelius
2b29e300dd [product] migrate orders to ManyToManyField 2020-08-09 14:44:29 +02:00
Nico Schottelius
8df1d8dc7c begin refactor product to user orders instead of single order
Signed-off-by: Nico Schottelius <nico@nico-notebook.schottelius.org>
2020-08-09 14:38:10 +02:00
Nico Schottelius
ef02cb61fd Refine tests for bills, multiple bills 2020-08-09 12:34:25 +02:00
Nico Schottelius
70c450afc8 fix tests for Product()
Signed-off-by: Nico Schottelius <nico@nico-notebook.schottelius.org>
2020-08-09 11:44:22 +02:00
Nico Schottelius
0dd1093812 add sample products and improve testing for Product 2020-08-09 11:02:45 +02:00
86 changed files with 3551 additions and 3073 deletions

View file

@ -1,8 +1,15 @@
* Bootstrap / Installation
** 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
@ -60,6 +67,21 @@ 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
* Testing / CLI Access
Access via the commandline (CLI) can be done using curl or
httpie. In our examples we will use httpie.
@ -84,6 +106,14 @@ python manage.py migrate
* URLs
- api/ - the rest API
* uncloud Products
** Product features
- Dependencies on other products
- Minimum parameters (min cpu, min ram, etc).
- Can also realise the dcl vm
- dualstack vm = VM + IPv4 + SSD
- Need to have a non-misguiding name for the "bare VM"
- Should support network boot (?)
** VPN
*** How to add a new VPN Host
**** Install wireguard to the host
@ -95,8 +125,8 @@ python manage.py migrate
**** Add it to DNS as vpn-XXX.ungleich.ch
**** Route a /40 network to its IPv6 address
**** Install wireguard on it
**** TODO Enable wireguard on boot
**** TODO Create a new VPNPool on uncloud with
**** TODO [#C] Enable wireguard on boot
**** TODO [#C] Create a new VPNPool on uncloud with
***** the network address (selecting from our existing pool)
***** the network size (/...)
***** the vpn host that provides the network (selecting the created VM)
@ -119,7 +149,6 @@ python manage.py migrate
vpn_hostname=vpn-2a0ae5c1200.ungleich.ch wireguard_private_key=$(wg
genkey)
**** Creating a new vpn network
** VPN
*** Creating a VPN pool
#+BEGIN_SRC sh
@ -148,7 +177,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
@ -211,3 +240,143 @@ 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
*** 1.0 (initial release)
**** TODO [#C] Initial Generic product support
- Product
***** TODO [#C] Recurring product support
****** TODO [#C] Support replacing orders for updates
****** DONE [#A] Finish split of bill creation
CLOSED: [2020-09-11 Fri 23:19]
****** TODO [#C] Test the new functions in the Order class
****** Define the correct order replacement logic
Assumption:
- recurringperiods are 30days
******* Case 1: downgrading
- User commits to 10 CHF for 30 days
- Wants to downgrade after 15 days to 5 CHF product
- Expected result:
- order 1: 10 CHF until +30days
- order 2: 5 CHF starting 30days + 1s
- Sum of the two orders is 15 CHF
- Question is
- when is the VM shutdown?
- a) instantly
- b) at the end of the cycle
- best solution
- user can choose between a ... b any time
******* Duration
- You cannot cancel the duration
- You can upgrade and with that cancel the duration
- The idea of a duration is that you commit for it
- If you want to commit lower (daily basis for instance) you
have higher per period prices
******* Case X
- User has VM with 2 Core / 2 GB RAM
- User modifies with to 1 core / 3 GB RAM
- We treat it as down/upgrade independent of the modifications
******* Case 2: upgrading after 1 day
- committed for 30 days
- upgrade after 1 day
- so first order will be charged for 1/30ths
******* Case 2: upgrading
- User commits to 10 CHF for 30 days
- Wants to upgrade after 15 days to 20 CHF product
- Order 1 : 1 VM with 2 Core / 2 GB / 10 SSD -- 10 CHF
- 30days period, stopped after 15, so quantity is 0.5 = 5 CHF
- Order 2 : 1 VM with 2 Core / 6 GB / 10 SSD -- 20 CHF
- after 15 days
- VM is upgraded instantly
- Expected result:
- order 1: 10 CHF until +15days = 0.5 units = 5 CHF
- order 2: 20 CHF starting 15days + 1s ... +30 days after
the 15 days -> 45 days = 1 unit = 20 CHF
- Total on bill: 25 CHF
******* Case 2: upgrading
- User commits to 10 CHF for 30 days
- Wants to upgrade after 15 days to 20 CHF product
- Expected result:
- order 1: 10 CHF until +30days = 1 units = 10 CHF
- order 2: 20 CHF starting 15days + 1s = 1 unit = 20 CHF
- Total on bill: 30 CHF
****** TODO [#C] Note: ending date not set if replaced by default (implicit!)
- Should the new order modify the old order on save()?
****** DONE Fix totally wrong bill dates in our test case
CLOSED: [2020-09-09 Wed 01:00]
- 2020 used instead of 2019
- Was due to existing test data ...
***** DONE Bill logic is still wrong
CLOSED: [2020-11-05 Thu 18:58]
- Bill starting_date is the date of the first order
- However first encountered order does not have to be the
earliest in the bill!
- Bills should not have a duration
- Bills should only have a (unique) issue date
- We charge based on bill_records
- Last time charged issue date of the bill OR earliest date
after that
- Every bill generation checks all (relevant) orders
- add a flag "not_for_billing" or "closed"
- query on that flag
- verify it every time
***** TODO Generating bill for admins/staff
-

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

@ -10,7 +10,7 @@ storage_class_mapping = {
'hdd': 'hdd'
}
class VM(Product):
class VM(models.Model):
vmid = models.IntegerField(primary_key=True)
data = models.JSONField()

View file

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

View file

@ -1,9 +1,12 @@
# Django basics
django
djangorestframework
django-auth-ldap
stripe
xmltodict
psycopg2
ldap3
xmltodict
parsedatetime
@ -19,6 +22,11 @@ django-hardcopy
pyyaml
uritemplate
# Comprehensive interface to validate VAT numbers, making use of the VIES
# service for European countries.
# Payment & VAT
vat-validator
stripe
# Tasks
celery
redis

1
uncloud/.gitignore vendored
View file

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

View file

@ -0,0 +1,254 @@
from django.utils.translation import gettext_lazy as _
import decimal
from .celery import app as celery_app
# 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__ = ('celery_app',)

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, UncloudTask ]:
admin.site.register(m)

17
uncloud/celery.py Normal file
View file

@ -0,0 +1,17 @@
import os
from celery import Celery
# set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings')
app = Celery('uncloud')
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object('django.conf:settings', namespace='CELERY')
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()

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',
),
]

View file

@ -1,7 +1,11 @@
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
class UncloudModel(models.Model):
"""
@ -34,3 +38,135 @@ 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)
organization = models.CharField(max_length=256, blank=True, null=True)
street = models.CharField(max_length=256)
city = models.CharField(max_length=256)
postal_code = models.CharField(max_length=64)
country = CountryField(blank=True)
class Meta:
abstract = True
###
# 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
"""
if not when:
when = timezone.now()
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}"
class UncloudTask(models.Model):
"""
Class to store dispatched tasks to be handled
"""
task_id = models.UUIDField(primary_key=True)

View file

@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/3.0/ref/settings/
"""
import os
import re
import ldap
from django.core.management.utils import get_random_secret_key
@ -19,8 +20,6 @@ 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__)))
@ -172,7 +171,6 @@ 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=""
@ -185,6 +183,55 @@ 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"
# def route_task(name, args, kwargs, options, task=None, **kw):
# print(f"{name} - {args} - {kwargs}")
# # if name == 'myapp.tasks.compress_video':
# return {'queue': 'vpn1' }
# # 'exchange_type': 'topic',
# # 'routing_key': 'video.compress'}
# CELERY_TASK_ROUTES = (route_task,)
# CELERY_TASK_ROUTES = {
# '*': {
# 'queue': 'vpn1'
# }
# }
CELERY_BROKER_URL = 'redis://:uncloud.example.com:6379/0'
CELERY_RESULT_BACKEND = 'redis://:uncloud.example.com:6379/0'
CELERY_TASK_ROUTES = {
re.compile(r'.*.tasks.cdist.*'): { 'queue': 'cdist' } # cdist tasks go into cdist queue
}
CELERY_BEAT_SCHEDULE = {
'cleanup_tasks': {
'task': 'uncloud.tasks.cleanup_tasks',
'schedule': 10
}
}
# CELERY_TASK_CREATE_MISSING_QUEUES = False
# Overwrite settings with local settings, if existing
try:

19
uncloud/tasks.py Normal file
View file

@ -0,0 +1,19 @@
from celery import shared_task
from celery.result import AsyncResult
from .models import UncloudTask
@shared_task(bind=True)
def cleanup_tasks(self):
print(f"Cleanup time from {self}: {self.request.id}")
for task in UncloudTask.objects.all():
print(f"Pruning {task}...")
if str(task.task_id) == str(self.request.id):
print("Skipping myself")
continue
res = AsyncResult(id=str(task.task_id))
if res.ready():
print(res.get())
task.delete()

View file

@ -0,0 +1,15 @@
{% extends 'uncloud/base.html' %}
{% block title %}{% endblock %}
{% block body %}
<div>
<h1>Welcome to uncloud</h1>
Welcome to uncloud, checkout the following locations:
<ul>
<li><a href="/api/">The API</a>
<li><a href="/cc/reg/">The CC registration</a>
</ul>
</div>
{% endblock %}

View file

@ -12,7 +12,8 @@ from django.conf.urls.static import static
from rest_framework import routers
from rest_framework.schemas import get_schema_view
from opennebula import views as oneviews
#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
@ -41,10 +42,6 @@ router.register(r'v1/service/matrix', serviceviews.MatrixServiceProductViewSet,
router.register(r'v1/service/generic', serviceviews.GenericServiceProductViewSet, basename='genericserviceproduct')
# Net
router.register(r'v1/net/vpn', netviews.VPNNetworkViewSet, basename='vpnnetwork')
router.register(r'v1/admin/vpnreservation', netviews.VPNNetworkReservationViewSet, basename='vpnnetreservation')
# Pay
router.register(r'v1/my/address', payviews.BillingAddressViewSet, basename='billingaddress')
@ -59,16 +56,26 @@ router.register(r'v1/admin/payment', payviews.AdminPaymentViewSet, basename='adm
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')
#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')
router.register(r'v1/user/register', authviews.AccountManagementViewSet, basename='user/register')
################################################################################
# v2
# Net
router.register(r'v2/net/wireguardvpn', netviews.WireGuardVPNViewSet, basename='wireguardvpnnetwork')
router.register(r'v2/net/wireguardvpnsizes', netviews.WireGuardVPNSizes, basename='wireguardvpnnetworksizes')
urlpatterns = [
path(r'api/', include(router.urls)),
# web/ = stuff to view in the browser
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), # for login to REST API
path('openapi', get_schema_view(
@ -76,5 +83,12 @@ urlpatterns = [
description="uncloud API",
version="1.0.0"
), name='openapi-schema'),
# web/ = stuff to view in the browser
# path('web/vpn/create/', netviews.WireGuardVPNCreateView.as_view(), name="vpncreate"),
path('login/', authviews.LoginView.as_view(), name="login"),
path('logout/', authviews.LogoutView.as_view(), name="logout"),
path('admin/', admin.site.urls),
path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"),
path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"),
]

4
uncloud/views.py Normal file
View file

@ -0,0 +1,4 @@
from django.views.generic.base import TemplateView
class UncloudIndex(TemplateView):
template_name = "uncloud/index.html"

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,13 @@
{% extends 'uncloud/base.html' %}
{% block body %}
<div class="container">
<form method="post">
{% csrf_token %}
{{ form }}
<input type="submit" value="Login">
</form>
</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

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

@ -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,189 @@ 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
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 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})"
class WireGuardVPNFreeLeases(models.Model):
"""
Previously used VPNNetworks
"""
vpnpool = models.ForeignKey(WireGuardVPNPool,
on_delete=models.CASCADE)
pool_index = models.IntegerField(unique=True)
################################################################################
class MACAdress(models.Model):
default_prefix = 0x420000000000
class ReverseDNSEntry(models.Model):
"""
A reverse DNS entry
"""
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
ip_address = models.GenericIPAddressField(null=False, unique=True)
name = models.CharField(max_length=253, null=False)
@property
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
class VPNNetworkReservation(UncloudModel):
"""
This class tracks the used VPN networks. It will be deleted, when the product is cancelled.
"""
vpnpool = models.ForeignKey(VPNPool,
on_delete=models.CASCADE)
def save(self, *args, **kwargs):
# Product.objects.filter(config__parameters__contains='reverse_dns_network')
# FIXME: check if order is still active / not replaced
address = models.GenericIPAddressField(primary_key=True)
allowed = False
product = None
status = models.CharField(max_length=256,
default='used',
choices = (
('used', 'used'),
('free', 'free')
)
)
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
class VPNNetwork(Product):
"""
A selected network. Used for tracking reservations / used networks
"""
network = models.ForeignKey(VPNNetworkReservation,
on_delete=models.CASCADE,
editable=False)
if not allowed:
raise ValidationError(f"User {self.owner} does not have the right to create reverse DNS entry for {self.ip_address}")
wireguard_public_key = models.CharField(max_length=48)
default_recurring_period = RecurringPeriod.PER_365D
@property
def recurring_price(self):
return 120
def delete(self, *args, **kwargs):
self.network.status = 'free'
self.network.save()
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)

47
uncloud_net/services.py Normal file
View file

@ -0,0 +1,47 @@
from django.db import transaction
from .models import *
from .selectors import *
from .tasks import *
@transaction.atomic
def create_wireguard_vpn(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)
configure_wireguard_server(pool)
return vpn

60
uncloud_net/tasks.py Normal file
View file

@ -0,0 +1,60 @@
from celery import shared_task
from .models import *
from uncloud.models import UncloudTask
import os
import subprocess
import logging
import uuid
log = logging.getLogger(__name__)
@shared_task
def whereami():
print(os.uname())
return os.uname()
def configure_wireguard_server(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)")
task_id = uuid.UUID(cdist_configure_wireguard_server.apply_async((config, server)).id)
UncloudTask.objects.create(task_id=task_id)
@shared_task
def cdist_configure_wireguard_server(config, server):
"""
Create config and configure server.
To be executed on the cdist workers.
"""
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):
@ -57,36 +64,37 @@ class VPNTests(TestCase):
# 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
# 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)
# })
# 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' }
)
# # we don't have a billing address -> should raise an error
# # with self.assertRaises(ValidationError):
# # response = view(request)
# This should work now
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' }
# )
# 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)
# # 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):

View file

@ -1,33 +1,70 @@
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']
)
configure_wireguard_server(vpn.vpnpool)
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)
# class VPNPoolViewSet(viewsets.ModelViewSet):
# serializer_class = VPNPoolSerializer
# permission_classes = [permissions.IsAdminUser]
# queryset = VPNPool.objects.all()

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

@ -11,19 +11,17 @@ from django.http import FileResponse
from django.template.loader import render_to_string
from uncloud_pay.models import Bill, Order, BillRecord, BillingAddress
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 ]
@ -87,9 +85,8 @@ class BillAdmin(admin.ModelAdmin):
admin.site.register(Bill, BillAdmin)
admin.site.register(Order)
admin.site.register(BillRecord)
admin.site.register(BillingAddress)
admin.site.register(ProductToRecurringPeriod)
admin.site.register(Product, ProductAdmin)
#admin.site.register(Order, OrderAdmin)
for m in [ Order, BillRecord, BillingAddress, RecurringPeriod, VATRate, StripeCustomer ]:
admin.site.register(m)

View file

@ -0,0 +1,2 @@
# Customer tests
customer-*.py

View file

@ -1,32 +1,38 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import datetime, timedelta
from uncloud_pay.models import *
from uncloud_vm.models import *
import datetime
import sys
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from django.utils import timezone
from uncloud_pay.models import (
BillingAddress
)
from uncloud_vm.models import (
VMDiskType, VMProduct
)
def vm_price_2020(cpu=1, ram=2, v6only=False):
if v6only:
discount = 9
else:
discount = 0
return cpu*3 + ram*4 - discount
return cpu * 3 + ram * 4 - discount
def disk_price_2020(size_in_gb, disk_type):
if disk_type == VMDiskType.CEPH_SSD:
price = 3.5/10
price = 3.5 / 10
elif disk_type == VMDiskType.CEPH_HDD:
price = 1.5/100
price = 1.5 / 100
else:
raise Exception("not yet defined price")
return size_in_gb * price
class Command(BaseCommand):
help = 'Adding VMs / creating orders for user'
@ -40,106 +46,107 @@ class Command(BaseCommand):
owner=user,
active=True,
defaults={'organization': 'Undefined organisation',
'name': 'Undefined name',
'full_name': 'Undefined name',
'street': 'Undefined Street',
'city': 'Undefined city',
'postal_code': '8750',
'country': 'CH',
'active': True
}
}
)
# 25206 + SSD
vm25206 = VMProduct(name="25206", cores=1, ram_in_gb=4, owner=user)
vm25206.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3)))
vm25206.save()
vm25206 = VMProduct.objects.create(name="one-25206", cores=1,
ram_in_gb=4, owner=user)
vm25206.create_order_at(
timezone.make_aware(datetime.datetime(2020, 3, 3)))
vm25206_ssd = VMDiskProduct(vm=vm25206, owner=user, size_in_gb=30)
vm25206_ssd.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3)))
vm25206_ssd.save()
# vm25206_ssd = VMDiskProduct.objects.create(vm=vm25206, owner=user, size_in_gb=30)
# vm25206_ssd.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3)))
# change 1
vm25206.cores = 2
vm25206.ram_in_gb = 8
vm25206.save()
vm25206.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,4,17)))
vm25206.create_or_update_order(
when_to_start=timezone.make_aware(datetime.datetime(2020, 4, 17)))
sys.exit(0)
# change 2
vm25206_ssd.size_in_gb = 50
vm25206_ssd.save()
vm25206_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5)))
# vm25206_ssd.size_in_gb = 50
# vm25206_ssd.save()
# vm25206_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5)))
# 25206 done.
# 25615
vm25615 = VMProduct(name="25615", cores=1, ram_in_gb=4, owner=user)
vm25615.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3)))
vm25615.save()
vm25615_ssd = VMDiskProduct(vm=vm25615, owner=user, size_in_gb=30)
vm25615_ssd.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3)))
vm25615_ssd.save()
Bill.create_next_bill_for_user(user)
sys.exit(0)
vm25615 = VMProduct.objects.create(name="one-25615", cores=1,
ram_in_gb=4, owner=user)
vm25615.create_order_at(
timezone.make_aware(datetime.datetime(2020, 3, 3)))
# Change 2020-04-17
vm25615.cores = 2
vm25615.ram_in_gb = 8
vm25615.save()
vm25615.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,4,17)))
vm25615.create_or_update_order(
when_to_start=timezone.make_aware(datetime.datetime(2020, 4, 17)))
vm25615_ssd.size_in_gb = 50
vm25615_ssd.save()
vm25615_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5)))
vm25208 = VMProduct.objects.create(name="OpenNebula 25208",
cores=1,
ram_in_gb=4,
owner=user)
vm25208_ssd = VMDiskProduct.objects.create(vm=vm25208,
owner=user,
size_in_gb=30)
# vm25615_ssd = VMDiskProduct(vm=vm25615, owner=user, size_in_gb=30)
# vm25615_ssd.create_order_at(timezone.make_aware(datetime.datetime(2020,3,3)))
# vm25615_ssd.save()
vm25208 = VMProduct.objects.create(name="one-25208", cores=1,
ram_in_gb=4, owner=user)
vm25208.create_order_at(
timezone.make_aware(datetime.datetime(2020, 3, 5)))
vm25208.cores = 2
vm25208.ram_in_gb = 8
vm25208.save()
vm25208.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,4,17)))
vm25208.create_or_update_order(
when_to_start=timezone.make_aware(datetime.datetime(2020, 4, 17)))
Bill.create_next_bills_for_user(user, ending_date=end_of_month(
timezone.make_aware(datetime.datetime(2020, 7, 31))))
sys.exit(0)
vm25615_ssd.size_in_gb = 50
vm25615_ssd.save()
vm25615_ssd.create_or_update_order(
when_to_start=timezone.make_aware(datetime.datetime(2020, 8, 5)))
vm25208_ssd = VMDiskProduct.objects.create(vm=vm25208,
owner=user,
size_in_gb=30)
vm25208_ssd.size_in_gb = 50
vm25208_ssd.save()
vm25208_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5)))
vm25208_ssd.create_or_update_order(
when_to_start=timezone.make_aware(datetime.datetime(2020, 8, 5)))
# 25207
vm25207 = VMProduct.objects.create(name="OpenNebula 25207",
cores=1,
ram_in_gb=4,
owner=user)
cores=1,
ram_in_gb=4,
owner=user)
vm25207_ssd = VMDiskProduct.objects.create(vm=vm25207,
owner=user,
size_in_gb=30)
owner=user,
size_in_gb=30)
vm25207_ssd.size_in_gb = 50
vm25207_ssd.save()
vm25207_ssd.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,8,5)))
vm25207_ssd.create_or_update_order(
when_to_start=timezone.make_aware(datetime.datetime(2020, 8, 5)))
vm25207.cores = 2
vm25207.ram_in_gb = 8
vm25207.save()
vm25207.create_or_update_order(when_to_start=timezone.make_aware(datetime.datetime(2020,6,19)))
vm25207.create_or_update_order(
when_to_start=timezone.make_aware(datetime.datetime(2020, 6, 19)))
# FIXES: check starting times (they are slightly different)
# add vm 25236

View file

@ -1,44 +1,35 @@
from django.core.management.base import BaseCommand
from uncloud_pay.models import VATRate
import csv
import urllib
import csv
import sys
import io
class Command(BaseCommand):
help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv'''
vat_url = "https://raw.githubusercontent.com/ungleich/vat-rates/main/vat_rates.csv"
def add_arguments(self, parser):
parser.add_argument('csv_file', nargs='+', type=str)
parser.add_argument('--vat-url', default=self.vat_url)
def handle(self, *args, **options):
try:
for c_file in options['csv_file']:
print("c_file = %s" % c_file)
with open(c_file, mode='r') as csv_file:
csv_reader = csv.DictReader(csv_file)
line_count = 0
for row in csv_reader:
if line_count == 0:
line_count += 1
obj, created = VATRate.objects.get_or_create(
start_date=row["start_date"],
stop_date=row["stop_date"] if row["stop_date"] is not "" else None,
territory_codes=row["territory_codes"],
currency_code=row["currency_code"],
rate=row["rate"],
rate_type=row["rate_type"],
description=row["description"]
)
if created:
self.stdout.write(self.style.SUCCESS(
'%s. %s - %s - %s - %s' % (
line_count,
obj.start_date,
obj.stop_date,
obj.territory_codes,
obj.rate
)
))
line_count+=1
vat_url = options['vat_url']
url_open = urllib.request.urlopen(vat_url)
except Exception as e:
print(" *** Error occurred. Details {}".format(str(e)))
# map to fileio using stringIO
csv_file = io.StringIO(url_open.read().decode('utf-8'))
reader = csv.DictReader(csv_file)
for row in reader:
# print(row)
obj, created = VATRate.objects.get_or_create(
starting_date=row["start_date"],
ending_date=row["stop_date"] if row["stop_date"] != "" else None,
territory_codes=row["territory_codes"],
currency_code=row["currency_code"],
rate=row["rate"],
rate_type=row["rate_type"],
description=row["description"]
)

File diff suppressed because one or more lines are too long

View file

@ -1,18 +0,0 @@
# Generated by Django 3.0.8 on 2020-08-01 22:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='billingaddress',
name='organization',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View file

@ -1,33 +0,0 @@
# Generated by Django 3.0.8 on 2020-08-01 23:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0002_auto_20200801_2208'),
]
operations = [
migrations.RenameField(
model_name='vatrate',
old_name='stop_date',
new_name='ending_date',
),
migrations.RenameField(
model_name='vatrate',
old_name='start_date',
new_name='starting_date',
),
migrations.AlterField(
model_name='bill',
name='ending_date',
field=models.DateTimeField(),
),
migrations.AlterField(
model_name='billrecord',
name='quantity',
field=models.DecimalField(decimal_places=10, max_digits=19),
),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 3.1 on 2020-08-08 19:53
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0003_auto_20200801_2332'),
]
operations = [
migrations.RemoveField(
model_name='order',
name='one_time_price',
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 3.1 on 2020-08-08 19:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0004_remove_order_one_time_price'),
]
operations = [
migrations.RenameField(
model_name='order',
old_name='recurring_price',
new_name='price',
),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 3.1 on 2020-08-08 19:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0005_auto_20200808_1954'),
]
operations = [
migrations.RemoveField(
model_name='billrecord',
name='quantity',
),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 3.1 on 2020-08-08 20:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0006_remove_billrecord_quantity'),
]
operations = [
migrations.RemoveField(
model_name='bill',
name='bill_records',
),
]

View file

@ -1,16 +0,0 @@
# Generated by Django 3.1 on 2020-08-08 20:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0007_remove_bill_bill_records'),
]
operations = [
migrations.DeleteModel(
name='OrderRecord',
),
]

View file

@ -1,22 +0,0 @@
# Generated by Django 3.1 on 2020-08-08 21:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('uncloud_pay', '0008_delete_orderrecord'),
]
operations = [
migrations.RemoveField(
model_name='bill',
name='valid',
),
migrations.AddField(
model_name='bill',
name='is_final',
field=models.BooleanField(default=False),
),
]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -82,7 +82,7 @@ class BillRecordSerializer(serializers.Serializer):
description = serializers.CharField()
one_time_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
recurring_price = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
recurring_period = serializers.ChoiceField(choices=RecurringPeriod.choices)
# recurring_period = serializers.ChoiceField()
recurring_count = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
vat_rate = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)
vat_amount = serializers.DecimalField(AMOUNT_MAX_DIGITS, AMOUNT_DECIMALS)

View file

@ -6,7 +6,7 @@
Icons, fonts, etc. are INLINED. This is rather ugly, but as the PDF
generation is based on a local snapshot of the HTML file, URLs are
screwed if they are not absolute.
screwed if they are not absolute to the *local* filesystem.
As this document is used ONLY for bills and ONLY for downloading, I
decided that this is an acceptable uglyness.
@ -36,7 +36,6 @@
font-weight: 500;
line-height: 1.1;
font-size: 14px;
width: 600px;
margin: auto;
padding-top: 40px;
padding-bottom: 15px;
@ -672,16 +671,12 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7
<br>
</div>
<div class="d1">
{% if bill.billing_address.organization != "" %}
<b>ORG{{ bill.billing_address.organization }}</b>
<br>{{ bill.billing_address.name }} <bill.owner.email>
{% else %}
<b>{{ bill.billing_address.name }} <bill.owner.email></b>
{% endif %}
<br>{{ bill.billing_address.street }}
<br>{{ bill.billing_address.postal_code }} {{ bill.billing_address.city }}
<br>{{ bill.billing_address.country }}
<br>
<b>{{ bill.billing_address.organization }}</b><br/>
<b>{{ bill.billing_address.name }}</b><br/>
{{ bill.owner.email }}<br/>
{{ bill.billing_address.street }}<br/>
{{ bill.billing_address.country }} {{ bill.billing_address.postal_code }} {{ bill.billing_address.city }}<br/>
</div>
<div class="d4">
<div class="b1">
@ -700,22 +695,20 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7
<thead>
<tr>
<th>Detail</th>
<th>Units</th>
<th>Price/Unit</th>
<th class="tr">Total price</tH>
<th>Units</th>
<th>Total price</th>
</tr>
</thead>
<tbody>
{% for record in bill_records %}
<tr class="table-list">
<td>{{ record.starting_date|date:"c" }}
{% if record.ending_date %}
- {{ record.ending_date|date:"c" }}
{% endif %}
{{ record.order.description }}
{{ record.order }}
</td>
<td>{{ record.price|floatformat:2 }}</td>
<td>{{ record.quantity|floatformat:2 }}</td>
<td>{{ record.order.price|floatformat:2 }}</td>
<td>{{ record.sum|floatformat:2 }}</td>
</tr>
{% endfor %}

View file

@ -0,0 +1,72 @@
{% extends 'uncloud/base.html' %}
{% block header %}
<script src="https://js.stripe.com/v3/"></script>
<style>
#content {
width: 400px;
margin: auto;
}
#callback-form {
display: none;
}
</style>
{% endblock %}
{% block body %}
<div id="content">
<h1>Registering Stripe Credit Card</h1>
<!-- Stripe form and messages -->
<span id="message"></span>
<form id="setup-form">
<div id="card-element"></div>
<button type='button' id="card-button">
Save
</button>
</form>
<!-- Dirty hack used for callback to API -->
<form id="callback-form" action="{{ callback }}" method="post"></form>
</div>
<!-- Enable Stripe from UI elements -->
<script>
var stripe = Stripe('{{ stripe_pk }}');
var elements = stripe.elements();
var cardElement = elements.create('card');
cardElement.mount('#card-element');
</script>
<!-- Handle card submission -->
<script>
var cardButton = document.getElementById('card-button');
var messageContainer = document.getElementById('message');
var clientSecret = '{{ client_secret }}';
cardButton.addEventListener('click', function(ev) {
stripe.confirmCardSetup(
clientSecret,
{
payment_method: {
card: cardElement,
billing_details: {
},
},
}
).then(function(result) {
if (result.error) {
var message = document.createTextNode('Error:' + result.error.message);
messageContainer.appendChild(message);
} else {
// Return to API on success.
document.getElementById("callback-form").submit();
}
});
});
</script>
{% endblock %}

View file

@ -1,11 +1,72 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from datetime import datetime, date, timedelta
from django.utils import timezone
from .models import *
from uncloud_service.models import GenericServiceProduct
class ProductOrderTestCase(TestCase):
import json
chocolate_product_config = {
'features': {
'gramm':
{ 'min': 100,
'max': 5000,
'one_time_price_per_unit': 0.2,
'recurring_price_per_unit': 0
},
},
}
chocolate_order_config = {
'features': {
'gramm': 500,
}
}
chocolate_one_time_price = chocolate_order_config['features']['gramm'] * chocolate_product_config['features']['gramm']['one_time_price_per_unit']
vm_product_config = {
'features': {
'cores':
{ 'min': 1,
'max': 48,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
},
'ram_gb':
{ 'min': 1,
'max': 256,
'one_time_price_per_unit': 0,
'recurring_price_per_unit': 4
},
},
}
vm_order_config = {
'features': {
'cores': 2,
'ram_gb': 2
}
}
vm_order_downgrade_config = {
'features': {
'cores': 1,
'ram_gb': 1
}
}
vm_order_upgrade_config = {
'features': {
'cores': 4,
'ram_gb': 4
}
}
class ProductTestCase(TestCase):
"""
Test products and products <-> order interaction
"""
@ -15,81 +76,227 @@ class ProductOrderTestCase(TestCase):
username='random_user',
email='jane.random@domain.tld')
def test_update_one_time_product(self):
self.ba = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="somewhere else",
active=True)
RecurringPeriod.populate_db_defaults()
self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
def test_create_product(self):
"""
One time payment products cannot be updated - can they?
Create a sample product
"""
pass
p = Product.objects.create(name="Testproduct",
description="Only for testing",
config=vm_product_config)
p.recurring_periods.add(self.default_recurring_period,
through_defaults= { 'is_default': True })
class BillingAddressTestCase(TestCase):
class OrderTestCase(TestCase):
"""
The heart of ordering products
"""
def setUp(self):
self.user = get_user_model().objects.create(
username='random_user',
email='jane.random@domain.tld')
def test_user_only_inactive_address(self):
"""
Raise an error, when there is no active address
"""
ba = BillingAddress.objects.create(
self.ba = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="somewhere else",
active=False)
self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist,
BillingAddress.get_address_for,
self.user)
def test_user_only_active_address(self):
"""
Find the active address
"""
ba = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="unknown",
active=True)
self.product = Product.objects.create(name="Testproduct",
description="Only for testing",
config=vm_product_config)
self.assertEqual(BillingAddress.get_address_for(self.user), ba)
RecurringPeriod.populate_db_defaults()
self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
def test_multiple_addresses(self):
self.product.recurring_periods.add(self.default_recurring_period,
through_defaults= { 'is_default': True })
def test_order_invalid_recurring_period(self):
"""
Find the active address only, skip inactive
Order a products with a recurringperiod that is not added to the product
"""
ba = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="unknown",
active=True)
ba2 = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
city="unknown",
postal_code="somewhere else",
active=False)
o = Order.objects.create(owner=self.user,
billing_address=self.ba,
product=self.product,
config=vm_order_config)
self.assertEqual(BillingAddress.get_address_for(self.user), ba)
def test_order_product(self):
"""
Order a product, ensure the order has correct price setup
"""
o = Order.objects.create(owner=self.user,
billing_address=self.ba,
product=self.product)
self.assertEqual(o.one_time_price, 0)
self.assertEqual(o.recurring_price, 16)
def test_change_order(self):
"""
Change an order and ensure that
- a new order is created
- the price is correct in the new order
"""
order1 = Order.objects.create(owner=self.user,
billing_address=self.ba,
product=self.product,
config=vm_order_config)
class BillAndOrderTestCase(TestCase):
self.assertEqual(order1.one_time_price, 0)
self.assertEqual(order1.recurring_price, 16)
class ModifyOrderTestCase(TestCase):
"""
Test typical order flows like
- cancelling
- downgrading
- upgrading
"""
def setUp(self):
self.user = get_user_model().objects.create(
username='random_user',
email='jane.random@domain.tld')
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)
RecurringPeriod.populate_db_defaults()
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_change_order(self):
"""
Test changing an order
Expected result:
- Old order should be closed before new order starts
- New order should start at starting data
"""
user = self.user
starting_price = 16
downgrade_price = 8
starting_date = timezone.make_aware(datetime.datetime(2019,3,3))
ending1_date = starting_date + datetime.timedelta(days=15)
change1_date = start_after(ending1_date)
bill_ending_date = change1_date + datetime.timedelta(days=1)
order1 = Order.objects.create(owner=self.user,
billing_address=BillingAddress.get_address_for(self.user),
product=self.product,
config=vm_order_config,
starting_date=starting_date)
order1.update_order(vm_order_downgrade_config, starting_date=change1_date)
bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
bill = bills[0]
bill_records = BillRecord.objects.filter(bill=bill)
self.assertEqual(len(bill_records), 2)
self.assertEqual(bill_records[0].starting_date, starting_date)
self.assertEqual(bill_records[0].ending_date, ending1_date)
self.assertEqual(bill_records[1].starting_date, change1_date)
def test_downgrade_product(self):
"""
Test downgrading behaviour:
We create a recurring product (recurring time: 30 days) and downgrade after 15 days.
We create the bill right AFTER the end of the first order.
Expected result:
- First bill record for 30 days
- Second bill record starting after 30 days
- Bill contains two bill records
"""
user = self.user
starting_price = 16
downgrade_price = 8
starting_date = timezone.make_aware(datetime.datetime(2019,3,3))
first_order_should_end_at = starting_date + datetime.timedelta(days=30)
change1_date = start_after(starting_date + datetime.timedelta(days=15))
bill_ending_date = change1_date + datetime.timedelta(days=1)
order1 = Order.objects.create(owner=self.user,
billing_address=BillingAddress.get_address_for(self.user),
product=self.product,
config=vm_order_config,
starting_date=starting_date)
order1.update_order(vm_order_downgrade_config, starting_date=change1_date)
bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
bill = bills[0]
bill_records = BillRecord.objects.filter(bill=bill)
self.assertEqual(len(bill_records), 2)
self.assertEqual(bill_records[0].starting_date, starting_date)
self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at)
class BillTestCase(TestCase):
"""
Test aspects of billing / creating a bill
"""
def setUp(self):
RecurringPeriod.populate_db_defaults()
self.user_without_address = get_user_model().objects.create(
username='no_home_person',
email='far.away@domain.tld')
@ -102,7 +309,7 @@ class BillAndOrderTestCase(TestCase):
username='recurrent_product_user',
email='jane.doe@domain.tld')
BillingAddress.objects.create(
self.user_addr = BillingAddress.objects.create(
owner=self.user,
organization = 'Test org',
street="unknown",
@ -110,7 +317,7 @@ class BillAndOrderTestCase(TestCase):
postal_code="unknown",
active=True)
BillingAddress.objects.create(
self.recurring_user_addr = BillingAddress.objects.create(
owner=self.recurring_user,
organization = 'Test org',
street="Somewhere",
@ -126,23 +333,27 @@ class BillAndOrderTestCase(TestCase):
'description': 'One chocolate bar'
}
self.one_time_order = Order.objects.create(
owner=self.user,
starting_date=self.order_meta[1]['starting_date'],
ending_date=self.order_meta[1]['ending_date'],
recurring_period=RecurringPeriod.ONE_TIME,
price=self.order_meta[1]['price'],
description=self.order_meta[1]['description'],
billing_address=BillingAddress.get_address_for(self.user))
self.chocolate = Product.objects.create(name="Swiss Chocolate",
description="Not only for testing, but for joy",
config=chocolate_product_config)
self.vm = Product.objects.create(name="Super Fast VM",
description="Zooooom",
config=vm_product_config)
RecurringPeriod.populate_db_defaults()
self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
self.onetime_recurring_period = RecurringPeriod.objects.get(name="Onetime")
self.chocolate.recurring_periods.add(self.onetime_recurring_period,
through_defaults= { 'is_default': True })
self.vm.recurring_periods.add(self.default_recurring_period,
through_defaults= { 'is_default': True })
self.recurring_order = Order.objects.create(
owner=self.recurring_user,
starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
recurring_period=RecurringPeriod.PER_30D,
price=15,
description="A pretty VM",
billing_address=BillingAddress.get_address_for(self.recurring_user)
)
# used for generating multiple bills
self.bill_dates = [
@ -152,22 +363,59 @@ class BillAndOrderTestCase(TestCase):
]
def order_chocolate(self):
return Order.objects.create(
owner=self.user,
recurring_period=RecurringPeriod.objects.get(name="Onetime"),
product=self.chocolate,
billing_address=BillingAddress.get_address_for(self.user),
starting_date=self.order_meta[1]['starting_date'],
ending_date=self.order_meta[1]['ending_date'],
config=chocolate_order_config)
def order_vm(self, owner=None):
if not owner:
owner = self.recurring_user
return Order.objects.create(
owner=owner,
product=self.vm,
config=vm_order_config,
billing_address=BillingAddress.get_address_for(self.recurring_user),
starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
)
return Order.objects.create(
owner=self.user,
recurring_period=RecurringPeriod.objects.get(name="Onetime"),
product=self.chocolate,
billing_address=BillingAddress.get_address_for(self.user),
starting_date=self.order_meta[1]['starting_date'],
ending_date=self.order_meta[1]['ending_date'],
config=chocolate_order_config)
def test_bill_one_time_one_bill_record(self):
"""
Ensure there is only 1 bill record per order
"""
bill = Bill.create_next_bill_for_user(self.user)
order = self.order_chocolate()
self.assertEqual(self.one_time_order.billrecord_set.count(), 1)
bill = Bill.create_next_bill_for_user_address(self.user_addr)
self.assertEqual(order.billrecord_set.count(), 1)
def test_bill_sum_onetime(self):
"""
Check the bill sum for a single one time order
"""
bill = Bill.create_next_bill_for_user(self.user)
self.assertEqual(bill.sum, self.order_meta[1]['price'])
order = self.order_chocolate()
bill = Bill.create_next_bill_for_user_address(self.user_addr)
self.assertEqual(bill.sum, chocolate_one_time_price)
def test_bill_creates_record_for_recurring_order(self):
@ -175,9 +423,10 @@ class BillAndOrderTestCase(TestCase):
Ensure there is only 1 bill record per order
"""
bill = Bill.create_next_bill_for_user(self.recurring_user)
order = self.order_vm()
bill = Bill.create_next_bill_for_user_address(self.recurring_user_addr)
self.assertEqual(self.recurring_order.billrecord_set.count(), 1)
self.assertEqual(order.billrecord_set.count(), 1)
self.assertEqual(bill.billrecord_set.count(), 1)
@ -187,8 +436,10 @@ class BillAndOrderTestCase(TestCase):
the next bill run should create e new bill
"""
order = self.order_vm()
for ending_date in self.bill_dates:
b = Bill.create_next_bill_for_user(self.recurring_user, ending_date)
b = Bill.create_next_bill_for_user_address(self.recurring_user_addr, ending_date)
b.close()
bill_count = Bill.objects.filter(owner=self.recurring_user).count()
@ -196,236 +447,19 @@ class BillAndOrderTestCase(TestCase):
self.assertEqual(len(self.bill_dates), bill_count)
# class NotABillingTC(TestCase):
# #class BillingTestCase(TestCase):
# def setUp(self):
# self.user = get_user_model().objects.create(
# username='jdoe',
# email='john.doe@domain.tld')
# self.billing_address = BillingAddress.objects.create(
# owner=self.user,
# street="unknown",
# city="unknown",
# postal_code="unknown")
# def test_basic_monthly_billing(self):
# one_time_price = 10
# recurring_price = 20
# description = "Test Product 1"
class BillingAddressTestCase(TestCase):
def setUp(self):
self.user = get_user_model().objects.create(
username='random_user',
email='jane.random@domain.tld')
# # Three months: full, full, partial.
# # starting_date = datetime.fromisoformat('2020-03-01')
# starting_date = datetime(2020,3,1)
# ending_date = datetime(2020,5,8)
# # Create order to be billed.
# order = Order.objects.create(
# owner=self.user,
# starting_date=starting_date,
# ending_date=ending_date,
# recurring_period=RecurringPeriod.PER_30D,
# recurring_price=recurring_price,
# one_time_price=one_time_price,
# description=description,
# billing_address=self.billing_address)
def test_user_no_address(self):
"""
Raise an error, when there is no address
"""
# # Generate & check bill for first month: full recurring_price + setup.
# first_month_bills = order.generate_initial_bill()
# self.assertEqual(len(first_month_bills), 1)
# self.assertEqual(first_month_bills[0].amount, one_time_price + recurring_price)
# # Generate & check bill for second month: full recurring_price.
# second_month_bills = Bill.generate_for(2020, 4, self.user)
# self.assertEqual(len(second_month_bills), 1)
# self.assertEqual(second_month_bills[0].amount, recurring_price)
# # Generate & check bill for third and last month: partial recurring_price.
# third_month_bills = Bill.generate_for(2020, 5, self.user)
# self.assertEqual(len(third_month_bills), 1)
# # 31 days in May.
# self.assertEqual(float(third_month_bills[0].amount),
# round(round((7/31), AMOUNT_DECIMALS) * recurring_price, AMOUNT_DECIMALS))
# # Check that running Bill.generate_for() twice does not create duplicates.
# self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0)
# def test_basic_yearly_billing(self):
# one_time_price = 10
# recurring_price = 150
# description = "Test Product 1"
# starting_date = datetime.fromisoformat('2020-03-31T08:05:23')
# # Create order to be billed.
# order = Order.objects.create(
# owner=self.user,
# starting_date=starting_date,
# recurring_period=RecurringPeriod.PER_365D,
# recurring_price=recurring_price,
# one_time_price=one_time_price,
# description=description,
# billing_address=self.billing_address)
# # Generate & check bill for first year: recurring_price + setup.
# first_year_bills = order.generate_initial_bill()
# self.assertEqual(len(first_year_bills), 1)
# self.assertEqual(first_year_bills[0].starting_date.date(),
# date.fromisoformat('2020-03-31'))
# self.assertEqual(first_year_bills[0].ending_date.date(),
# date.fromisoformat('2021-03-30'))
# self.assertEqual(first_year_bills[0].amount,
# recurring_price + one_time_price)
# # Generate & check bill for second year: recurring_price.
# second_year_bills = Bill.generate_for(2021, 3, self.user)
# self.assertEqual(len(second_year_bills), 1)
# self.assertEqual(second_year_bills[0].starting_date.date(),
# date.fromisoformat('2021-03-31'))
# self.assertEqual(second_year_bills[0].ending_date.date(),
# date.fromisoformat('2022-03-30'))
# self.assertEqual(second_year_bills[0].amount, recurring_price)
# # Check that running Bill.generate_for() twice does not create duplicates.
# self.assertEqual(len(Bill.generate_for(2020, 3, self.user)), 0)
# self.assertEqual(len(Bill.generate_for(2020, 4, self.user)), 0)
# self.assertEqual(len(Bill.generate_for(2020, 2, self.user)), 0)
# self.assertEqual(len(Bill.generate_for(2021, 3, self.user)), 0)
# def test_basic_hourly_billing(self):
# one_time_price = 10
# recurring_price = 1.4
# description = "Test Product 1"
# starting_date = datetime.fromisoformat('2020-03-31T08:05:23')
# ending_date = datetime.fromisoformat('2020-04-01T11:13:32')
# # Create order to be billed.
# order = Order.objects.create(
# owner=self.user,
# starting_date=starting_date,
# ending_date=ending_date,
# recurring_period=RecurringPeriod.PER_HOUR,
# recurring_price=recurring_price,
# one_time_price=one_time_price,
# description=description,
# billing_address=self.billing_address)
# # Generate & check bill for first month: recurring_price + setup.
# first_month_bills = order.generate_initial_bill()
# self.assertEqual(len(first_month_bills), 1)
# self.assertEqual(float(first_month_bills[0].amount),
# round(16 * recurring_price, AMOUNT_DECIMALS) + one_time_price)
# # Generate & check bill for first month: recurring_price.
# second_month_bills = Bill.generate_for(2020, 4, self.user)
# self.assertEqual(len(second_month_bills), 1)
# self.assertEqual(float(second_month_bills[0].amount),
# round(12 * recurring_price, AMOUNT_DECIMALS))
# class ProductActivationTestCase(TestCase):
# def setUp(self):
# self.user = get_user_model().objects.create(
# username='jdoe',
# email='john.doe@domain.tld')
# self.billing_address = BillingAddress.objects.create(
# owner=self.user,
# street="unknown",
# city="unknown",
# postal_code="unknown")
# def test_product_activation(self):
# starting_date = datetime.fromisoformat('2020-03-01')
# one_time_price = 0
# recurring_price = 1
# description = "Test Product"
# order = Order.objects.create(
# owner=self.user,
# starting_date=starting_date,
# recurring_period=RecurringPeriod.PER_30D,
# recurring_price=recurring_price,
# one_time_price=one_time_price,
# description=description,
# billing_address=self.billing_address)
# product = GenericServiceProduct(
# custom_description=description,
# custom_one_time_price=one_time_price,
# custom_recurring_price=recurring_price,
# owner=self.user,
# order=order)
# product.save()
# # Validate initial state: must be awaiting payment.
# self.assertEqual(product.status, UncloudStatus.AWAITING_PAYMENT)
# # Pay initial bill, check that product is activated.
# order.generate_initial_bill()
# amount = product.order.bills[0].amount
# payment = Payment(owner=self.user, amount=amount)
# payment.save()
# self.assertEqual(
# GenericServiceProduct.objects.get(uuid=product.uuid).status,
# UncloudStatus.PENDING
# )
# class BillingAddressTestCase(TestCase):
# def setUp(self):
# self.user = get_user_model().objects.create(
# username='jdoe',
# email='john.doe@domain.tld')
# self.billing_address_01 = BillingAddress.objects.create(
# owner=self.user,
# street="unknown1",
# city="unknown1",
# postal_code="unknown1",
# country="CH")
# self.billing_address_02 = BillingAddress.objects.create(
# owner=self.user,
# street="unknown2",
# city="unknown2",
# postal_code="unknown2",
# country="CH")
# def test_billing_with_single_address(self):
# # Create new orders somewhere in the past so that we do not encounter
# # auto-created initial bills.
# starting_date = datetime.fromisoformat('2020-03-01')
# order_01 = Order.objects.create(
# owner=self.user,
# starting_date=starting_date,
# recurring_period=RecurringPeriod.PER_30D,
# billing_address=self.billing_address_01)
# order_02 = Order.objects.create(
# owner=self.user,
# starting_date=starting_date,
# recurring_period=RecurringPeriod.PER_30D,
# billing_address=self.billing_address_01)
# # We need a single bill since we work with a single address.
# bills = Bill.generate_for(2020, 4, self.user)
# self.assertEqual(len(bills), 1)
# def test_billing_with_multiple_addresses(self):
# # Create new orders somewhere in the past so that we do not encounter
# # auto-created initial bills.
# starting_date = datetime.fromisoformat('2020-03-01')
# order_01 = Order.objects.create(
# owner=self.user,
# starting_date=starting_date,
# recurring_period=RecurringPeriod.PER_30D,
# billing_address=self.billing_address_01)
# order_02 = Order.objects.create(
# owner=self.user,
# starting_date=starting_date,
# recurring_period=RecurringPeriod.PER_30D,
# billing_address=self.billing_address_02)
# # We need different bills since we work with different addresses.
# bills = Bill.generate_for(2020, 4, self.user)
# self.assertEqual(len(bills), 2)
self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist,
BillingAddress.get_address_for,
self.user)

View file

@ -1,3 +1,7 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.base import TemplateView
from django.shortcuts import render
from django.db import transaction
from django.contrib.auth import get_user_model
@ -43,6 +47,25 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
return Order.objects.filter(owner=self.request.user)
class RegisterCard(LoginRequiredMixin, TemplateView):
login_url = '/login/'
# This is not supposed to be "static" --
# the idea is to be able to switch the provider when needed
template_name = "uncloud_pay/stripe.html"
def get_context_data(self, **kwargs):
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
setup_intent = uncloud_stripe.create_setup_intent(customer_id)
context = super().get_context_data(**kwargs)
context['client_secret'] = setup_intent.client_secret
context['username'] = self.request.user
context['stripe_pk'] = uncloud_stripe.public_api_key
return context
class PaymentMethodViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated]
@ -201,6 +224,7 @@ class BillViewSet(viewsets.ReadOnlyModelViewSet):
Allow to download
"""
bill = self.get_object()
provider = UncloudProvider.get_provider()
output_file = NamedTemporaryFile()
bill_html = render_to_string("bill.html.j2", {'bill': bill})

View file

@ -2,4 +2,4 @@ from django.apps import AppConfig
class UngleichServiceConfig(AppConfig):
name = 'ungleich_service'
name = 'uncloud_service'

View file

@ -1,52 +0,0 @@
# Generated by Django 3.0.6 on 2020-08-01 16:38
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
class Migration(migrations.Migration):
initial = True
dependencies = [
('uncloud_vm', '__first__'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_pay', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='MatrixServiceProduct',
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)),
('domain', models.CharField(default='domain.tld', max_length=255)),
('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='GenericServiceProduct',
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)),
('custom_description', models.TextField()),
('custom_recurring_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('custom_one_time_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('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)),
],
options={
'abstract': False,
},
),
]

View file

@ -1,25 +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_service', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='genericserviceproduct',
name='order',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'),
),
migrations.AlterField(
model_name='matrixserviceproduct',
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 = [
('uncloud_service', '0002_auto_20200801_2332'),
]
operations = [
migrations.AlterField(
model_name='genericserviceproduct',
name='extra_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
migrations.AlterField(
model_name='matrixserviceproduct',
name='extra_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
]

View file

@ -3,7 +3,7 @@ from uncloud_pay.models import Product, RecurringPeriod, AMOUNT_MAX_DIGITS, AMOU
from uncloud_vm.models import VMProduct, VMDiskImageProduct
from django.core.validators import MinValueValidator
class MatrixServiceProduct(Product):
class MatrixServiceProduct(models.Model):
monthly_managment_fee = 20
description = "Managed Matrix HomeServer"
@ -15,8 +15,8 @@ class MatrixServiceProduct(Product):
domain = models.CharField(max_length=255, default='domain.tld')
# Default recurring price is PER_MONT, see Product class.
def recurring_price(self, recurring_period=RecurringPeriod.PER_30D):
return self.monthly_managment_fee
# def recurring_price(self, recurring_period=RecurringPeriod.PER_30D):
# return self.monthly_managment_fee
@staticmethod
def base_image():
@ -24,17 +24,17 @@ class MatrixServiceProduct(Product):
#e return VMDiskImageProduct.objects.get(uuid="93e564c5-adb3-4741-941f-718f76075f02")
return False
@staticmethod
def allowed_recurring_periods():
return list(filter(
lambda pair: pair[0] in [RecurringPeriod.PER_30D],
RecurringPeriod.choices))
# @staticmethod
# def allowed_recurring_periods():
# return list(filter(
# lambda pair: pair[0] in [RecurringPeriod.PER_30D],
# RecurringPeriod.choices))
@property
def one_time_price(self):
return 30
class GenericServiceProduct(Product):
class GenericServiceProduct(models.Model):
custom_description = models.TextField()
custom_recurring_price = models.DecimalField(default=0.0,
max_digits=AMOUNT_MAX_DIGITS,

View file

@ -17,8 +17,8 @@ class MatrixServiceProductSerializer(serializers.ModelSerializer):
read_only_fields = ['order', 'owner', 'status']
class OrderMatrixServiceProductSerializer(MatrixServiceProductSerializer):
recurring_period = serializers.ChoiceField(
choices=MatrixServiceProduct.allowed_recurring_periods())
# recurring_period = serializers.ChoiceField(
# choices=MatrixServiceProduct.allowed_recurring_periods())
def __init__(self, *args, **kwargs):
super(OrderMatrixServiceProductSerializer, self).__init__(*args, **kwargs)
@ -42,8 +42,8 @@ class GenericServiceProductSerializer(serializers.ModelSerializer):
read_only_fields = [ 'owner', 'status']
class OrderGenericServiceProductSerializer(GenericServiceProductSerializer):
recurring_period = serializers.ChoiceField(
choices=GenericServiceProduct.allowed_recurring_periods())
# recurring_period = serializers.ChoiceField(
# choices=GenericServiceProduct.allowed_recurring_periods())
def __init__(self, *args, **kwargs):
super(OrderGenericServiceProductSerializer, self).__init__(*args, **kwargs)

View file

@ -1,7 +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
@ -12,7 +11,6 @@ class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_pay', '0001_initial'),
]
operations = [
@ -20,7 +18,7 @@ class Migration(migrations.Migration):
name='VMCluster',
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)),
('extra_data', models.JSONField(blank=True, editable=False, null=True)),
('name', models.CharField(max_length=128, unique=True)),
],
options={
@ -31,7 +29,7 @@ class Migration(migrations.Migration):
name='VMDiskImageProduct',
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)),
('extra_data', models.JSONField(blank=True, editable=False, null=True)),
('name', models.CharField(max_length=256)),
('is_os_image', models.BooleanField(default=False)),
('is_public', models.BooleanField(default=False, editable=False)),
@ -51,13 +49,13 @@ class Migration(migrations.Migration):
name='VMHost',
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)),
('extra_data', models.JSONField(blank=True, editable=False, null=True)),
('hostname', models.CharField(max_length=253, unique=True)),
('physical_cores', models.IntegerField(default=0)),
('usable_cores', models.IntegerField(default=0)),
('usable_ram_in_gb', models.FloatField(default=0)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32)),
('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMCluster')),
('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmcluster')),
],
options={
'abstract': False,
@ -67,35 +65,21 @@ class Migration(migrations.Migration):
name='VMProduct',
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)),
('name', models.CharField(blank=True, max_length=32, null=True)),
('cores', models.IntegerField()),
('ram_in_gb', models.FloatField()),
('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMCluster')),
('vmhost', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMHost')),
('vmcluster', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmcluster')),
('vmhost', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmhost')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='VMSnapshotProduct',
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)),
('gb_ssd', models.FloatField(editable=False)),
('gb_hdd', models.FloatField(editable=False)),
('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='uncloud_vm.VMProduct')),
('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='uncloud_vm.vmproduct')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='VMNetworkCard',
@ -103,35 +87,25 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mac_address', models.BigIntegerField()),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')),
('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmproduct')),
],
),
migrations.CreateModel(
name='VMDiskProduct',
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)),
('size_in_gb', models.FloatField(blank=True)),
('disk_type', models.CharField(choices=[('ceph/ssd', 'Ceph Ssd'), ('ceph/hdd', 'Ceph Hdd'), ('local/ssd', 'Local Ssd'), ('local/hdd', 'Local Hdd')], default='ceph/ssd', max_length=20)),
('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMDiskImageProduct')),
('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')),
('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')),
('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmdiskimageproduct')),
('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmproduct')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='VMWithOSProduct',
fields=[
('vmproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.VMProduct')),
('primary_disk', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMDiskProduct')),
('vmproduct_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='uncloud_vm.vmproduct')),
('primary_disk', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.vmdiskproduct')),
],
options={
'abstract': False,
},
bases=('uncloud_vm.vmproduct',),
),
]

View file

@ -1,30 +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_vm', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='vmdiskproduct',
name='order',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'),
),
migrations.AlterField(
model_name='vmproduct',
name='order',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'),
),
migrations.AlterField(
model_name='vmsnapshotproduct',
name='order',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'),
),
]

View file

@ -0,0 +1,21 @@
# Generated by Django 3.1.4 on 2021-04-14 10:40
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('uncloud_vm', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='vmproduct',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View file

@ -1,43 +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_vm', '0002_auto_20200801_2332'),
]
operations = [
migrations.AlterField(
model_name='vmcluster',
name='extra_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
migrations.AlterField(
model_name='vmdiskimageproduct',
name='extra_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
migrations.AlterField(
model_name='vmdiskproduct',
name='extra_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
migrations.AlterField(
model_name='vmhost',
name='extra_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
migrations.AlterField(
model_name='vmproduct',
name='extra_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
migrations.AlterField(
model_name='vmsnapshotproduct',
name='extra_data',
field=models.JSONField(blank=True, editable=False, null=True),
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 3.1.4 on 2021-04-14 10:46
import datetime
from django.db import migrations, models
from django.utils.timezone import utc
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0002_vmproduct_owner'),
]
operations = [
migrations.AddField(
model_name='vmproduct',
name='created_order_at',
field=models.DateTimeField(default=datetime.datetime(2021, 4, 14, 10, 46, 14, 96330, tzinfo=utc)),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 3.1.4 on 2021-04-14 10:48
import datetime
from django.db import migrations, models
from django.utils.timezone import utc
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0003_vmproduct_created_order_at'),
]
operations = [
migrations.RemoveField(
model_name='vmproduct',
name='created_order_at',
),
migrations.AddField(
model_name='vmproduct',
name='create_order_at',
field=models.DateTimeField(default=datetime.datetime(2021, 4, 14, 10, 48, 6, 641056, tzinfo=utc)),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 3.1.4 on 2021-04-14 11:19
import datetime
from django.db import migrations, models
from django.utils.timezone import utc
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0004_auto_20210414_1048'),
]
operations = [
migrations.RemoveField(
model_name='vmproduct',
name='create_order_at',
),
migrations.AddField(
model_name='vmproduct',
name='created_order_at',
field=models.DateTimeField(default=datetime.datetime(2021, 4, 14, 11, 19, 39, 447274, tzinfo=utc)),
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 3.1.4 on 2021-04-14 11:22
import datetime
from django.db import migrations, models
from django.utils.timezone import utc
class Migration(migrations.Migration):
dependencies = [
('uncloud_vm', '0005_auto_20210414_1119'),
]
operations = [
migrations.AlterField(
model_name='vmproduct',
name='created_order_at',
field=models.DateTimeField(default=datetime.datetime(2021, 4, 14, 11, 22, 11, 352536, tzinfo=utc)),
),
]

View file

@ -1,3 +1,6 @@
import datetime
from django.utils import timezone
from django.db import models
from django.contrib.auth import get_user_model
@ -49,7 +52,9 @@ class VMHost(UncloudModel):
class VMProduct(Product):
class VMProduct(models.Model):
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE,
blank=True, null=True)
vmhost = models.ForeignKey(
VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True
)
@ -58,38 +63,35 @@ class VMProduct(Product):
VMCluster, on_delete=models.CASCADE, editable=False, blank=True, null=True
)
# VM-specific. The name is only intended for customers: it's a pain to
# remember IDs (speaking from experience as ungleich customer)!
name = models.CharField(max_length=32, blank=True, null=True)
cores = models.IntegerField()
ram_in_gb = models.FloatField()
created_order_at = models.DateTimeField(default=timezone.make_aware(datetime.datetime.now()))
# Default recurring price is PER_MONTH, see uncloud_pay.models.Product.
@property
def recurring_price(self):
return self.cores * 3 + self.ram_in_gb * 4
def __str__(self):
if self.name:
name = f"{self.name} ({self.id})"
else:
name = self.id
return "VM {}: {} cores {} gb ram".format(name,
self.cores,
self.ram_in_gb)
@property
def description(self):
return "Virtual machine '{}': {} core(s), {}GB memory".format(
self.name, self.cores, self.ram_in_gb)
@staticmethod
def allowed_recurring_periods():
return list(filter(
lambda pair: pair[0] in [RecurringPeriod.PER_365D,
RecurringPeriod.PER_30D, RecurringPeriod.PER_HOUR],
RecurringPeriod.choices))
# @staticmethod
# def allowed_recurring_periods():
# return list(filter(
# lambda pair: pair[0] in [RecurringPeriod.PER_365D,
# RecurringPeriod.PER_30D, RecurringPeriod.PER_HOUR],
# RecurringPeriod.choices))
def create_order_at(self, dt):
self.created_order_at = dt
def create_or_update_order(self, when_to_start):
self.created_order_at = when_to_start
def __str__(self):
return f"VM id={self.id},name={self.name},cores={self.cores},ram_in_gb={self.ram_in_gb}"
class VMWithOSProduct(VMProduct):
@ -142,7 +144,7 @@ class VMDiskType(models.TextChoices):
LOCAL_HDD = 'local/hdd'
class VMDiskProduct(Product):
class VMDiskProduct(models.Model):
"""
The VMDiskProduct is attached to a VM.
@ -164,7 +166,7 @@ class VMDiskProduct(Product):
default=VMDiskType.CEPH_SSD)
def __str__(self):
return f"Disk {self.size_in_gb}GB ({self.disk_type}) for VM '{self.vm.name}'"
return f"Disk {self.size_in_gb}GB ({self.disk_type}) for {self.vm}"
@property
def recurring_price(self):
@ -189,7 +191,7 @@ class VMNetworkCard(models.Model):
null=True)
class VMSnapshotProduct(Product):
class VMSnapshotProduct(models.Model):
gb_ssd = models.FloatField(editable=False)
gb_hdd = models.FloatField(editable=False)

View file

@ -101,8 +101,8 @@ class VMProductSerializer(serializers.ModelSerializer):
read_only_fields = ['order', 'owner', 'status']
class OrderVMProductSerializer(VMProductSerializer):
recurring_period = serializers.ChoiceField(
choices=VMWithOSProduct.allowed_recurring_periods())
# recurring_period = serializers.ChoiceField(
# choices=VMWithOSProduct.allowed_recurring_periods())
def __init__(self, *args, **kwargs):
super(VMProductSerializer, self).__init__(*args, **kwargs)
@ -133,8 +133,8 @@ class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer):
"""
# Custom field used at creation (= ordering) only.
recurring_period = serializers.ChoiceField(
choices=VMProduct.allowed_recurring_periods())
# recurring_period = serializers.ChoiceField(
# choices=VMProduct.allowed_recurring_periods())
os_disk_uuid = serializers.UUIDField()
# os_disk_size =

View file

@ -79,22 +79,6 @@ class VMTestCase(TestCase):
# msg='VMDiskProduct created with disk image whose status is not active.'
# )
def test_vm_disk_product_creation(self):
"""Ensure that a user can only create a VMDiskProduct for an existing VM"""
disk_image = VMDiskImageProduct.objects.create(
owner=self.user, name='disk_image', is_os_image=True, is_public=True, size_in_gb=10,
status='active'
)
with self.assertRaises(ValidationError, msg='User created a VMDiskProduct for non-existing VM'):
# Create VMProduct object but don't save it in database
vm = VMProduct()
vm_disk_product = VMDiskProduct.objects.create(
owner=self.user, vm=vm, image=disk_image, size_in_gb=10
)
# TODO: the logic tested by this test is not implemented yet.
# def test_vm_disk_product_creation_for_someone_else(self):
# """Ensure that a user can only create a VMDiskProduct for his/her own VM"""