diff --git a/doc/uncloud-manual-2020-08-01.org b/doc/uncloud-manual-2020-08-01.org
index cead06e..6dd8fb2 100644
--- a/doc/uncloud-manual-2020-08-01.org
+++ b/doc/uncloud-manual-2020-08-01.org
@@ -60,6 +60,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 +99,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 +118,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)
@@ -210,3 +233,95 @@ VPNNetworks can be managed by all authenticated users.
*** Decision
We use integers, because they are easy.
+
+** Milestones :uncloud:
+*** 1.1 (cleanup 1)
+**** TODO Unify ValidationError, FieldError - define proper Exception
+ - What do we use for model errors
+*** 1.0 (initial release)
+**** TODO Initial Generic product support
+ - Product
+***** TODO Recurring product support
+****** TODO Support replacing orders for updates
+****** DONE [#A] Finish split of bill creation
+ CLOSED: [2020-09-11 Fri 23:19]
+****** TODO Test the new functions in the Order class
+****** 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 Note: ending date not set if replaced by default (implicit!)
+ - Should the new order modify the old order on save()?
+****** DONE Fix totally wrong bill dates in our test case
+ CLOSED: [2020-09-09 Wed 01:00]
+ - 2020 used instead of 2019
+ - Was due to existing test data ...
+***** TODO Bill logic is still wrong
+ - 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
+ -
diff --git a/opennebula/migrations/0005_remove_vm_orders.py b/opennebula/migrations/0005_remove_vm_orders.py
new file mode 100644
index 0000000..8426aec
--- /dev/null
+++ b/opennebula/migrations/0005_remove_vm_orders.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-09-28 18:44
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('opennebula', '0004_auto_20200809_1237'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='vm',
+ name='orders',
+ ),
+ ]
diff --git a/opennebula/migrations/0006_auto_20200928_1858.py b/opennebula/migrations/0006_auto_20200928_1858.py
new file mode 100644
index 0000000..49da56f
--- /dev/null
+++ b/opennebula/migrations/0006_auto_20200928_1858.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.1 on 2020-09-28 18:58
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('opennebula', '0005_remove_vm_orders'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='vm',
+ name='extra_data',
+ ),
+ migrations.RemoveField(
+ model_name='vm',
+ name='owner',
+ ),
+ migrations.RemoveField(
+ model_name='vm',
+ name='status',
+ ),
+ ]
diff --git a/opennebula/models.py b/opennebula/models.py
index 6c7dc52..f15b845 100644
--- a/opennebula/models.py
+++ b/opennebula/models.py
@@ -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()
diff --git a/opennebula/views.py b/opennebula/views.py
index 89b1a52..688f0b4 100644
--- a/opennebula/views.py
+++ b/opennebula/views.py
@@ -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
diff --git a/uncloud/__init__.py b/uncloud/__init__.py
index e69de29..2676f97 100644
--- a/uncloud/__init__.py
+++ b/uncloud/__init__.py
@@ -0,0 +1,243 @@
+from django.utils.translation import gettext_lazy as _
+
+# 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')),
+)
diff --git a/uncloud/admin.py b/uncloud/admin.py
new file mode 100644
index 0000000..4ecc53a
--- /dev/null
+++ b/uncloud/admin.py
@@ -0,0 +1,6 @@
+from django.contrib import admin
+
+from .models import UncloudProvider, UncloudNetwork
+
+for m in [ UncloudProvider, UncloudNetwork ]:
+ admin.site.register(m)
diff --git a/uncloud/management/commands/db-add-defaults.py b/uncloud/management/commands/db-add-defaults.py
new file mode 100644
index 0000000..605c8f5
--- /dev/null
+++ b/uncloud/management/commands/db-add-defaults.py
@@ -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()
diff --git a/uncloud/migrations/0001_initial.py b/uncloud/migrations/0001_initial.py
new file mode 100644
index 0000000..8753d29
--- /dev/null
+++ b/uncloud/migrations/0001_initial.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.1 on 2020-10-11 19:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UncloudProvider',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('starting_date', models.DateField()),
+ ('ending_date', models.DateField(blank=True)),
+ ('name', models.CharField(max_length=256)),
+ ('address', models.TextField()),
+ ],
+ ),
+ ]
diff --git a/uncloud/migrations/0002_auto_20201011_2001.py b/uncloud/migrations/0002_auto_20201011_2001.py
new file mode 100644
index 0000000..16b3f60
--- /dev/null
+++ b/uncloud/migrations/0002_auto_20201011_2001.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1 on 2020-10-11 20:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='uncloudprovider',
+ name='ending_date',
+ field=models.DateField(blank=True, null=True),
+ ),
+ ]
diff --git a/uncloud/migrations/0003_auto_20201011_2009.py b/uncloud/migrations/0003_auto_20201011_2009.py
new file mode 100644
index 0000000..9aee763
--- /dev/null
+++ b/uncloud/migrations/0003_auto_20201011_2009.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.1 on 2020-10-11 20:09
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0010_auto_20201011_2009'),
+ ('uncloud', '0002_auto_20201011_2001'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='uncloudprovider',
+ name='billing_network',
+ field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderbill', to='uncloud_net.uncloudnetwork'),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='uncloudprovider',
+ name='referral_network',
+ field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderreferral', to='uncloud_net.uncloudnetwork'),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud/migrations/0004_auto_20201011_2031.py b/uncloud/migrations/0004_auto_20201011_2031.py
new file mode 100644
index 0000000..3b53b7f
--- /dev/null
+++ b/uncloud/migrations/0004_auto_20201011_2031.py
@@ -0,0 +1,51 @@
+# Generated by Django 3.1 on 2020-10-11 20:31
+
+from django.db import migrations, models
+import uncloud.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud', '0003_auto_20201011_2009'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='uncloudprovider',
+ old_name='name',
+ new_name='full_name',
+ ),
+ migrations.RemoveField(
+ model_name='uncloudprovider',
+ name='address',
+ ),
+ migrations.AddField(
+ model_name='uncloudprovider',
+ name='city',
+ field=models.CharField(default='', max_length=256),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='uncloudprovider',
+ name='country',
+ field=uncloud.models.CountryField(blank=True, choices=[('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')], default='CH', max_length=2),
+ ),
+ migrations.AddField(
+ model_name='uncloudprovider',
+ name='organization',
+ field=models.CharField(blank=True, max_length=256, null=True),
+ ),
+ migrations.AddField(
+ model_name='uncloudprovider',
+ name='postal_code',
+ field=models.CharField(default='', max_length=64),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='uncloudprovider',
+ name='street',
+ field=models.CharField(default='', max_length=256),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud/migrations/0005_uncloudprovider_coupon_network.py b/uncloud/migrations/0005_uncloudprovider_coupon_network.py
new file mode 100644
index 0000000..b74b878
--- /dev/null
+++ b/uncloud/migrations/0005_uncloudprovider_coupon_network.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1 on 2020-10-12 17:32
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0010_auto_20201011_2009'),
+ ('uncloud', '0004_auto_20201011_2031'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='uncloudprovider',
+ name='coupon_network',
+ field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='uncloudprovidercoupon', to='uncloud_net.uncloudnetwork'),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud/migrations/0006_auto_20201025_1931.py b/uncloud/migrations/0006_auto_20201025_1931.py
new file mode 100644
index 0000000..d1162ef
--- /dev/null
+++ b/uncloud/migrations/0006_auto_20201025_1931.py
@@ -0,0 +1,40 @@
+# Generated by Django 3.1 on 2020-10-25 19:31
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud', '0005_uncloudprovider_coupon_network'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UncloudNetwork',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('network_address', models.GenericIPAddressField(unique=True)),
+ ('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
+ ('description', models.CharField(max_length=256)),
+ ],
+ ),
+ migrations.AlterField(
+ model_name='uncloudprovider',
+ name='billing_network',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderbill', to='uncloud.uncloudnetwork'),
+ ),
+ migrations.AlterField(
+ model_name='uncloudprovider',
+ name='coupon_network',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudprovidercoupon', to='uncloud.uncloudnetwork'),
+ ),
+ migrations.AlterField(
+ model_name='uncloudprovider',
+ name='referral_network',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uncloudproviderreferral', to='uncloud.uncloudnetwork'),
+ ),
+ ]
+2
diff --git a/uncloud/migrations/__init__.py b/uncloud/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uncloud/models.py b/uncloud/models.py
index 212d555..5a65f1c 100644
--- a/uncloud/models.py
+++ b/uncloud/models.py
@@ -1,7 +1,10 @@
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 uncloud import COUNTRIES
class UncloudModel(models.Model):
"""
@@ -34,3 +37,127 @@ 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}"
diff --git a/uncloud/settings.py b/uncloud/settings.py
index df3ba17..17f5200 100644
--- a/uncloud/settings.py
+++ b/uncloud/settings.py
@@ -19,8 +19,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__)))
@@ -185,6 +183,8 @@ 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"
# Overwrite settings with local settings, if existing
try:
diff --git a/uncloud/urls.py b/uncloud/urls.py
index 8b4862e..ef950a0 100644
--- a/uncloud/urls.py
+++ b/uncloud/urls.py
@@ -12,7 +12,7 @@ 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_auth import views as authviews
from uncloud_net import views as netviews
from uncloud_pay import views as payviews
@@ -60,7 +60,7 @@ router.register(r'v1/admin/order', payviews.AdminOrderViewSet, basename='admin/o
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/opennebula', oneviews.VMViewSet, basename='opennebula')
# User/Account
router.register(r'v1/my/user', authviews.UserViewSet, basename='user')
diff --git a/uncloud_net/admin.py b/uncloud_net/admin.py
index 8c38f3f..5dad27b 100644
--- a/uncloud_net/admin.py
+++ b/uncloud_net/admin.py
@@ -1,3 +1,6 @@
from django.contrib import admin
+from .models import ReverseDNSEntry
-# Register your models here.
+
+for m in [ ReverseDNSEntry ]:
+ admin.site.register(m)
diff --git a/uncloud_net/migrations/0005_remove_vpnnetwork_orders.py b/uncloud_net/migrations/0005_remove_vpnnetwork_orders.py
new file mode 100644
index 0000000..f7b607a
--- /dev/null
+++ b/uncloud_net/migrations/0005_remove_vpnnetwork_orders.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-09-28 18:44
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0004_auto_20200809_1237'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='vpnnetwork',
+ name='orders',
+ ),
+ ]
diff --git a/uncloud_net/migrations/0006_auto_20200928_1858.py b/uncloud_net/migrations/0006_auto_20200928_1858.py
new file mode 100644
index 0000000..b1a04a6
--- /dev/null
+++ b/uncloud_net/migrations/0006_auto_20200928_1858.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.1 on 2020-09-28 18:58
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0005_remove_vpnnetwork_orders'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='vpnnetwork',
+ name='extra_data',
+ ),
+ migrations.RemoveField(
+ model_name='vpnnetwork',
+ name='owner',
+ ),
+ migrations.RemoveField(
+ model_name='vpnnetwork',
+ name='status',
+ ),
+ ]
diff --git a/uncloud_net/migrations/0007_uncloudnetwork.py b/uncloud_net/migrations/0007_uncloudnetwork.py
new file mode 100644
index 0000000..aea05bb
--- /dev/null
+++ b/uncloud_net/migrations/0007_uncloudnetwork.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.1 on 2020-10-11 19:20
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0006_auto_20200928_1858'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UncloudNetwork',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('network_address', models.GenericIPAddressField()),
+ ('network_mask', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])),
+ ],
+ ),
+ ]
diff --git a/uncloud_net/migrations/0008_auto_20201011_1924.py b/uncloud_net/migrations/0008_auto_20201011_1924.py
new file mode 100644
index 0000000..29ad3e6
--- /dev/null
+++ b/uncloud_net/migrations/0008_auto_20201011_1924.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1 on 2020-10-11 19:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0007_uncloudnetwork'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='uncloudnetwork',
+ name='network_address',
+ field=models.GenericIPAddressField(unique=True),
+ ),
+ ]
diff --git a/uncloud_net/migrations/0009_uncloudnetwork_description.py b/uncloud_net/migrations/0009_uncloudnetwork_description.py
new file mode 100644
index 0000000..46292fa
--- /dev/null
+++ b/uncloud_net/migrations/0009_uncloudnetwork_description.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1 on 2020-10-11 19:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0008_auto_20201011_1924'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='uncloudnetwork',
+ name='description',
+ field=models.CharField(default='', max_length=256),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud_net/migrations/0010_auto_20201011_2009.py b/uncloud_net/migrations/0010_auto_20201011_2009.py
new file mode 100644
index 0000000..b713a4b
--- /dev/null
+++ b/uncloud_net/migrations/0010_auto_20201011_2009.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1 on 2020-10-11 20:09
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_net', '0009_uncloudnetwork_description'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='vpnnetworkreservation',
+ name='extra_data',
+ ),
+ migrations.RemoveField(
+ model_name='vpnpool',
+ name='extra_data',
+ ),
+ ]
diff --git a/uncloud_net/migrations/0011_auto_20201025_1931.py b/uncloud_net/migrations/0011_auto_20201025_1931.py
new file mode 100644
index 0000000..c4135d9
--- /dev/null
+++ b/uncloud_net/migrations/0011_auto_20201025_1931.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.1 on 2020-10-25 19:31
+
+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', '0006_auto_20201025_1931'),
+ ('uncloud_net', '0010_auto_20201011_2009'),
+ ]
+
+ operations = [
+ 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)),
+ ],
+ ),
+ migrations.DeleteModel(
+ name='UncloudNetwork',
+ ),
+ ]
diff --git a/uncloud_net/models.py b/uncloud_net/models.py
index 4f80246..c9c8bc3 100644
--- a/uncloud_net/models.py
+++ b/uncloud_net/models.py
@@ -4,16 +4,14 @@ 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 Product, RecurringPeriod
-from uncloud.models import UncloudModel, UncloudStatus
-
+from uncloud_pay.models import Order
class MACAdress(models.Model):
default_prefix = 0x420000000000
-class VPNPool(UncloudModel):
+class VPNPool(models.Model):
"""
Network address pools from which VPNs can be created
"""
@@ -145,7 +143,7 @@ AllowedIPs = {peer_network}
pass
-class VPNNetworkReservation(UncloudModel):
+class VPNNetworkReservation(models.Model):
"""
This class tracks the used VPN networks. It will be deleted, when the product is cancelled.
"""
@@ -163,7 +161,7 @@ class VPNNetworkReservation(UncloudModel):
)
-class VPNNetwork(Product):
+class VPNNetwork(models.Model):
"""
A selected network. Used for tracking reservations / used networks
"""
@@ -173,7 +171,7 @@ class VPNNetwork(Product):
wireguard_public_key = models.CharField(max_length=48)
- default_recurring_period = RecurringPeriod.PER_365D
+# default_recurring_period = RecurringPeriod.PER_365D
@property
def recurring_price(self):
@@ -185,3 +183,42 @@ class VPNNetwork(Product):
self.network.save()
super().save(*args, **kwargs)
print("deleted {}".format(self))
+
+
+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)
+
+ def save(self, *args, **kwargs):
+ # Product.objects.filter(config__parameters__contains='reverse_dns_network')
+ # FIXME: check if order is still active / not replaced
+
+ allowed = False
+
+ 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
+ break
+
+
+ if not allowed:
+ raise ValidationError(f"User {self.owner} does not have the right to create reverse DNS entry for {self.ip_address}")
+
+ super().save(*args, **kwargs)
+
+
+ def __str__(self):
+ return f"{self.ip_address} - {self.name}"
diff --git a/uncloud_net/tests.py b/uncloud_net/tests.py
index 540bc6a..974f8dd 100644
--- a/uncloud_net/tests.py
+++ b/uncloud_net/tests.py
@@ -3,13 +3,20 @@ 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
+
+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):
self.user = get_user_model().objects.create_user('django-test-user', 'noreply@ungleich.ch')
diff --git a/uncloud_pay/__init__.py b/uncloud_pay/__init__.py
index 4bda45f..810fd3e 100644
--- a/uncloud_pay/__init__.py
+++ b/uncloud_pay/__init__.py
@@ -1,4 +1,4 @@
-from django.utils.translation import gettext_lazy as _
+
import decimal
# Define DecimalField properties, used to represent amounts of money.
@@ -6,245 +6,3 @@ 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')),
-)
diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py
index df2cdde..2123397 100644
--- a/uncloud_pay/admin.py
+++ b/uncloud_pay/admin.py
@@ -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 ]:
+ admin.site.register(m)
diff --git a/uncloud_pay/management/commands/.gitignore b/uncloud_pay/management/commands/.gitignore
new file mode 100644
index 0000000..cf5c7fa
--- /dev/null
+++ b/uncloud_pay/management/commands/.gitignore
@@ -0,0 +1,2 @@
+# Customer tests
+customer-*.py
diff --git a/uncloud_pay/management/commands/import-vat-rates.py b/uncloud_pay/management/commands/import-vat-rates.py
index 32938e4..46848cd 100644
--- a/uncloud_pay/management/commands/import-vat-rates.py
+++ b/uncloud_pay/management/commands/import-vat-rates.py
@@ -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"]
+ )
diff --git a/uncloud_pay/migrations/0001_initial.py b/uncloud_pay/migrations/0001_initial.py
index 439b3d0..9169f19 100644
--- a/uncloud_pay/migrations/0001_initial.py
+++ b/uncloud_pay/migrations/0001_initial.py
@@ -6,6 +6,7 @@ from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uncloud_pay.models
+import uncloud.models
class Migration(migrations.Migration):
@@ -38,7 +39,7 @@ class Migration(migrations.Migration):
('street', models.CharField(max_length=100)),
('city', models.CharField(max_length=50)),
('postal_code', models.CharField(max_length=50)),
- ('country', uncloud_pay.models.CountryField(blank=True, choices=[('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')], default='CH', max_length=2)),
+ ('country', uncloud.models.CountryField(blank=True, choices=[('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')], default='CH', max_length=2)),
('vat_number', models.CharField(blank=True, default='', max_length=100)),
('active', models.BooleanField(default=False)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
diff --git a/uncloud_pay/migrations/0014_auto_20200825_1915.py b/uncloud_pay/migrations/0014_auto_20200825_1915.py
new file mode 100644
index 0000000..97c4b7a
--- /dev/null
+++ b/uncloud_pay/migrations/0014_auto_20200825_1915.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.1 on 2020-08-25 19:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0013_auto_20200809_1237'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='sampleonetimeproduct',
+ name='ot_price',
+ field=models.IntegerField(default=5),
+ ),
+ migrations.AddField(
+ model_name='samplerecurringproduct',
+ name='rc_price',
+ field=models.IntegerField(default=10),
+ ),
+ migrations.AddField(
+ model_name='samplerecurringproductonetimefee',
+ name='ot_price',
+ field=models.IntegerField(default=5),
+ ),
+ migrations.AddField(
+ model_name='samplerecurringproductonetimefee',
+ name='rc_price',
+ field=models.IntegerField(default=10),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0015_auto_20200928_1844.py b/uncloud_pay/migrations/0015_auto_20200928_1844.py
new file mode 100644
index 0000000..4aecb6e
--- /dev/null
+++ b/uncloud_pay/migrations/0015_auto_20200928_1844.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.1 on 2020-09-28 18:44
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0014_auto_20200825_1915'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='sampleonetimeproduct',
+ name='orders',
+ ),
+ migrations.RemoveField(
+ model_name='samplerecurringproduct',
+ name='orders',
+ ),
+ migrations.RemoveField(
+ model_name='samplerecurringproductonetimefee',
+ name='orders',
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0016_auto_20200928_1858.py b/uncloud_pay/migrations/0016_auto_20200928_1858.py
new file mode 100644
index 0000000..0c5ebfa
--- /dev/null
+++ b/uncloud_pay/migrations/0016_auto_20200928_1858.py
@@ -0,0 +1,65 @@
+# Generated by Django 3.1 on 2020-09-28 18:58
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('uncloud_pay', '0015_auto_20200928_1844'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='sampleonetimeproduct',
+ name='extra_data',
+ ),
+ migrations.RemoveField(
+ model_name='sampleonetimeproduct',
+ name='owner',
+ ),
+ migrations.RemoveField(
+ model_name='sampleonetimeproduct',
+ name='status',
+ ),
+ migrations.RemoveField(
+ model_name='samplerecurringproduct',
+ name='extra_data',
+ ),
+ migrations.RemoveField(
+ model_name='samplerecurringproduct',
+ name='owner',
+ ),
+ migrations.RemoveField(
+ model_name='samplerecurringproduct',
+ name='status',
+ ),
+ migrations.RemoveField(
+ model_name='samplerecurringproductonetimefee',
+ name='extra_data',
+ ),
+ migrations.RemoveField(
+ model_name='samplerecurringproductonetimefee',
+ name='owner',
+ ),
+ migrations.RemoveField(
+ model_name='samplerecurringproductonetimefee',
+ name='status',
+ ),
+ migrations.CreateModel(
+ name='Product',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('extra_data', models.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)),
+ ('config', models.JSONField()),
+ ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0017_order_config.py b/uncloud_pay/migrations/0017_order_config.py
new file mode 100644
index 0000000..3afecee
--- /dev/null
+++ b/uncloud_pay/migrations/0017_order_config.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1 on 2020-09-28 19:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0016_auto_20200928_1858'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='order',
+ name='config',
+ field=models.JSONField(default={}),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0018_order_product.py b/uncloud_pay/migrations/0018_order_product.py
new file mode 100644
index 0000000..e4e6eb1
--- /dev/null
+++ b/uncloud_pay/migrations/0018_order_product.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.1 on 2020-09-28 19:08
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0017_order_config'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='order',
+ name='product',
+ field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product'),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0019_remove_product_owner.py b/uncloud_pay/migrations/0019_remove_product_owner.py
new file mode 100644
index 0000000..05ea2a8
--- /dev/null
+++ b/uncloud_pay/migrations/0019_remove_product_owner.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-09-28 19:14
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0018_order_product'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='product',
+ name='owner',
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0020_auto_20200928_1915.py b/uncloud_pay/migrations/0020_auto_20200928_1915.py
new file mode 100644
index 0000000..2190397
--- /dev/null
+++ b/uncloud_pay/migrations/0020_auto_20200928_1915.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.1 on 2020-09-28 19:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0019_remove_product_owner'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='product',
+ name='status',
+ ),
+ migrations.AddField(
+ model_name='product',
+ name='description',
+ field=models.CharField(default='', max_length=1024),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='product',
+ name='name',
+ field=models.CharField(default='', max_length=256),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0021_auto_20200928_1932.py b/uncloud_pay/migrations/0021_auto_20200928_1932.py
new file mode 100644
index 0000000..85b8afe
--- /dev/null
+++ b/uncloud_pay/migrations/0021_auto_20200928_1932.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.1 on 2020-09-28 19:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0020_auto_20200928_1915'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='product',
+ name='default_currency',
+ field=models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32),
+ ),
+ migrations.AddField(
+ model_name='product',
+ name='default_recurring_period',
+ field=models.IntegerField(choices=[(31536000, 'Per 365 days'), (2592000, 'Per 30 days'), (604800, 'Per Week'), (86400, 'Per Day'), (3600, 'Per Hour'), (60, 'Per Minute'), (1, 'Per Second'), (0, 'Onetime')], default=2592000),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0022_auto_20200928_1932.py b/uncloud_pay/migrations/0022_auto_20200928_1932.py
new file mode 100644
index 0000000..0969b79
--- /dev/null
+++ b/uncloud_pay/migrations/0022_auto_20200928_1932.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1 on 2020-09-28 19:32
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0021_auto_20200928_1932'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='product',
+ old_name='default_currency',
+ new_name='currency',
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0023_auto_20200928_1944.py b/uncloud_pay/migrations/0023_auto_20200928_1944.py
new file mode 100644
index 0000000..3eb0010
--- /dev/null
+++ b/uncloud_pay/migrations/0023_auto_20200928_1944.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1 on 2020-09-28 19:44
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0022_auto_20200928_1932'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='order',
+ old_name='price',
+ new_name='one_time_price',
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0024_auto_20200928_1945.py b/uncloud_pay/migrations/0024_auto_20200928_1945.py
new file mode 100644
index 0000000..6792049
--- /dev/null
+++ b/uncloud_pay/migrations/0024_auto_20200928_1945.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.1 on 2020-09-28 19:45
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0023_auto_20200928_1944'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='order',
+ name='currency',
+ field=models.CharField(choices=[('CHF', 'Swiss Franc'), ('EUR', 'Euro'), ('USD', 'US Dollar')], default='CHF', max_length=32),
+ ),
+ migrations.AddField(
+ model_name='order',
+ name='recurring_price',
+ field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0025_billrecord_is_recurring_record.py b/uncloud_pay/migrations/0025_billrecord_is_recurring_record.py
new file mode 100644
index 0000000..5e3e141
--- /dev/null
+++ b/uncloud_pay/migrations/0025_billrecord_is_recurring_record.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1 on 2020-09-28 20:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0024_auto_20200928_1945'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='billrecord',
+ name='is_recurring_record',
+ field=models.BooleanField(default=False),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0026_order_should_be_billed.py b/uncloud_pay/migrations/0026_order_should_be_billed.py
new file mode 100644
index 0000000..c32c688
--- /dev/null
+++ b/uncloud_pay/migrations/0026_order_should_be_billed.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1 on 2020-09-28 20:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0025_billrecord_is_recurring_record'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='order',
+ name='should_be_billed',
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0027_auto_20201006_1319.py b/uncloud_pay/migrations/0027_auto_20201006_1319.py
new file mode 100644
index 0000000..a82955a
--- /dev/null
+++ b/uncloud_pay/migrations/0027_auto_20201006_1319.py
@@ -0,0 +1,41 @@
+# Generated by Django 3.1 on 2020-10-06 13:19
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0026_order_should_be_billed'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='RecurringPeriod',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('duration_seconds', models.IntegerField(unique=True)),
+ ],
+ ),
+ migrations.DeleteModel(
+ name='SampleOneTimeProduct',
+ ),
+ migrations.DeleteModel(
+ name='SampleRecurringProduct',
+ ),
+ migrations.DeleteModel(
+ name='SampleRecurringProductOneTimeFee',
+ ),
+ migrations.AlterField(
+ model_name='order',
+ name='recurring_period',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod'),
+ ),
+ migrations.AlterField(
+ model_name='product',
+ name='default_recurring_period',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod'),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0028_auto_20201006_1529.py b/uncloud_pay/migrations/0028_auto_20201006_1529.py
new file mode 100644
index 0000000..1ca4ee1
--- /dev/null
+++ b/uncloud_pay/migrations/0028_auto_20201006_1529.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.1 on 2020-10-06 15:29
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0027_auto_20201006_1319'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='product',
+ name='default_recurring_period',
+ ),
+ migrations.CreateModel(
+ name='ProductToRecurringPeriod',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('is_default', models.BooleanField(default=False)),
+ ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.product')),
+ ('recurring_period', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.recurringperiod')),
+ ],
+ ),
+ migrations.AddField(
+ model_name='product',
+ name='recurring_periods',
+ field=models.ManyToManyField(through='uncloud_pay.ProductToRecurringPeriod', to='uncloud_pay.RecurringPeriod'),
+ ),
+ migrations.AddConstraint(
+ model_name='producttorecurringperiod',
+ constraint=models.UniqueConstraint(condition=models.Q(is_default=True), fields=('recurring_period', 'product'), name='one_default_recurring_period_per_product'),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0029_auto_20201006_1540.py b/uncloud_pay/migrations/0029_auto_20201006_1540.py
new file mode 100644
index 0000000..e439d54
--- /dev/null
+++ b/uncloud_pay/migrations/0029_auto_20201006_1540.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1 on 2020-10-06 15:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0028_auto_20201006_1529'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='product',
+ name='name',
+ field=models.CharField(max_length=256, unique=True),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0030_auto_20201006_1640.py b/uncloud_pay/migrations/0030_auto_20201006_1640.py
new file mode 100644
index 0000000..51bc1f2
--- /dev/null
+++ b/uncloud_pay/migrations/0030_auto_20201006_1640.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1 on 2020-10-06 16:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0029_auto_20201006_1540'),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name='producttorecurringperiod',
+ name='one_default_recurring_period_per_product',
+ ),
+ migrations.AddConstraint(
+ model_name='producttorecurringperiod',
+ constraint=models.UniqueConstraint(condition=models.Q(is_default=True), fields=('product',), name='one_default_recurring_period_per_product'),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0031_auto_20201006_1655.py b/uncloud_pay/migrations/0031_auto_20201006_1655.py
new file mode 100644
index 0000000..e56a4cc
--- /dev/null
+++ b/uncloud_pay/migrations/0031_auto_20201006_1655.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-10-06 16:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0030_auto_20201006_1640'),
+ ]
+
+ operations = [
+ migrations.AddConstraint(
+ model_name='producttorecurringperiod',
+ constraint=models.UniqueConstraint(fields=('product', 'recurring_period'), name='recurring_period_once_per_product'),
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0032_uncloudprovider.py b/uncloud_pay/migrations/0032_uncloudprovider.py
new file mode 100644
index 0000000..0eef76c
--- /dev/null
+++ b/uncloud_pay/migrations/0032_uncloudprovider.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.1 on 2020-10-11 15:42
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0031_auto_20201006_1655'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='UncloudProvider',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('valid_from', models.DateField()),
+ ('valid_to', models.DateField(blank=True)),
+ ('billing_address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.billingaddress')),
+ ],
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0033_auto_20201011_2003.py b/uncloud_pay/migrations/0033_auto_20201011_2003.py
new file mode 100644
index 0000000..186dd16
--- /dev/null
+++ b/uncloud_pay/migrations/0033_auto_20201011_2003.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.1 on 2020-10-11 20:03
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0032_uncloudprovider'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='product',
+ name='extra_data',
+ ),
+ migrations.DeleteModel(
+ name='UncloudProvider',
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0034_auto_20201011_2031.py b/uncloud_pay/migrations/0034_auto_20201011_2031.py
new file mode 100644
index 0000000..b976450
--- /dev/null
+++ b/uncloud_pay/migrations/0034_auto_20201011_2031.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.1 on 2020-10-11 20:31
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0033_auto_20201011_2003'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='billingaddress',
+ name='city',
+ ),
+ migrations.RemoveField(
+ model_name='billingaddress',
+ name='country',
+ ),
+ migrations.RemoveField(
+ model_name='billingaddress',
+ name='name',
+ ),
+ migrations.RemoveField(
+ model_name='billingaddress',
+ name='organization',
+ ),
+ migrations.RemoveField(
+ model_name='billingaddress',
+ name='postal_code',
+ ),
+ migrations.RemoveField(
+ model_name='billingaddress',
+ name='street',
+ ),
+ ]
diff --git a/uncloud_pay/migrations/0035_auto_20201012_1728.py b/uncloud_pay/migrations/0035_auto_20201012_1728.py
new file mode 100644
index 0000000..af30d98
--- /dev/null
+++ b/uncloud_pay/migrations/0035_auto_20201012_1728.py
@@ -0,0 +1,48 @@
+# Generated by Django 3.1 on 2020-10-12 17:28
+
+from django.db import migrations, models
+import uncloud.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('uncloud_pay', '0034_auto_20201011_2031'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='billingaddress',
+ name='city',
+ field=models.CharField(default='', max_length=256),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='billingaddress',
+ name='country',
+ field=uncloud.models.CountryField(blank=True, choices=[('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')], default='CH', max_length=2),
+ ),
+ migrations.AddField(
+ model_name='billingaddress',
+ name='full_name',
+ field=models.CharField(default='', max_length=256),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='billingaddress',
+ name='organization',
+ field=models.CharField(blank=True, max_length=256, null=True),
+ ),
+ migrations.AddField(
+ model_name='billingaddress',
+ name='postal_code',
+ field=models.CharField(default='', max_length=64),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='billingaddress',
+ name='street',
+ field=models.CharField(default='', max_length=256),
+ preserve_default=False,
+ ),
+ ]
diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py
index fd9eab8..353ab94 100644
--- a/uncloud_pay/models.py
+++ b/uncloud_pay/models.py
@@ -1,29 +1,25 @@
+import logging
+import itertools
+import datetime
+from math import ceil
+from calendar import monthrange
+from decimal import Decimal
+from functools import reduce
+
from django.db import models
from django.db.models import Q
from django.contrib.auth import get_user_model
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
from django.core.validators import MinValueValidator
from django.utils import timezone
from django.core.exceptions import ObjectDoesNotExist, ValidationError
-
-from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
-
-import logging
-from functools import reduce
-import itertools
-from math import ceil
-import datetime
-from calendar import monthrange
-from decimal import Decimal
+from django.conf import settings
import uncloud_pay.stripe
-from uncloud_pay.helpers import beginning_of_month, end_of_month
-from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS, COUNTRIES
-from uncloud.models import UncloudModel, UncloudStatus
-
-from decimal import Decimal
-import decimal
+from uncloud_pay import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
+from uncloud.models import UncloudAddress
# Used to generate bill due dates.
BILL_PAYMENT_DELAY=datetime.timedelta(days=10)
@@ -57,35 +53,21 @@ def end_before(a_date):
""" Return suitable datetimefield for ending just before a_date """
return a_date - datetime.timedelta(seconds=1)
+def start_after(a_date):
+ """ Return suitable datetimefield for starting just after a_date """
+ return a_date + datetime.timedelta(seconds=1)
+
def default_payment_delay():
return timezone.now() + BILL_PAYMENT_DELAY
-# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
-class RecurringPeriod(models.IntegerChoices):
+class Currency(models.TextChoices):
"""
- We don't support months are years, because they vary in length.
- This is not only complicated, but also unfair to the user, as the user pays the same
- amount for different durations.
+ Possible currencies to be billed
"""
- PER_365D = 365*24*3600, _('Per 365 days')
- PER_30D = 30*24*3600, _('Per 30 days')
- PER_WEEK = 7*24*3600, _('Per Week')
- PER_DAY = 24*3600, _('Per Day')
- PER_HOUR = 3600, _('Per Hour')
- PER_MINUTE = 60, _('Per Minute')
- PER_SECOND = 1, _('Per Second')
- ONE_TIME = 0, _('Onetime')
+ CHF = 'CHF', _('Swiss Franc')
+ EUR = 'EUR', _('Euro')
+ USD = 'USD', _('US Dollar')
-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"
def get_balance_for_user(user):
bills = reduce(
@@ -98,12 +80,16 @@ def get_balance_for_user(user):
0)
return payments - bills
+###
+# Stripe
+
class StripeCustomer(models.Model):
owner = models.OneToOneField( get_user_model(),
primary_key=True,
on_delete=models.CASCADE)
stripe_id = models.CharField(max_length=32)
+
###
# Payments and Payment Methods.
@@ -222,18 +208,63 @@ class PaymentMethod(models.Model):
# non-primary method.
pass
+# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
+class RecurringPeriodChoices(models.IntegerChoices):
+ """
+ This is an old class and being superseeded by the database model below
+ """
+ PER_365D = 365*24*3600, _('Per 365 days')
+ PER_30D = 30*24*3600, _('Per 30 days')
+ PER_WEEK = 7*24*3600, _('Per Week')
+ PER_DAY = 24*3600, _('Per Day')
+ PER_HOUR = 3600, _('Per Hour')
+ PER_MINUTE = 60, _('Per Minute')
+ PER_SECOND = 1, _('Per Second')
+ ONE_TIME = 0, _('Onetime')
+
+# RecurringPeriods
+class RecurringPeriod(models.Model):
+ """
+ Available recurring periods.
+ By default seeded from RecurringPeriodChoices
+ """
+
+ name = models.CharField(max_length=100, unique=True)
+ duration_seconds = models.IntegerField(unique=True)
+
+ @classmethod
+ def populate_db_defaults(cls):
+ for (seconds, name) in RecurringPeriodChoices.choices:
+ obj, created = cls.objects.get_or_create(name=name,
+ defaults={ 'duration_seconds': seconds })
+
+ @staticmethod
+ def secs_to_name(secs):
+ name = ""
+ days = 0
+ hours = 0
+
+ if secs > 24*3600:
+ days = secs // (24*3600)
+ secs -= (days*24*3600)
+
+ if secs > 3600:
+ hours = secs // 3600
+ secs -= hours*3600
+
+ return f"{days} days {hours} hours {secs} seconds"
+
+ def __str__(self):
+ duration = self.secs_to_name(self.duration_seconds)
+
+ return f"{self.name} ({duration})"
+
+
###
# Bills.
-class BillingAddress(models.Model):
+class BillingAddress(UncloudAddress):
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
-
- organization = models.CharField(max_length=100, blank=True, null=True)
- name = models.CharField(max_length=100)
- street = models.CharField(max_length=100)
- city = models.CharField(max_length=50)
- postal_code = models.CharField(max_length=50)
- country = CountryField(blank=True)
vat_number = models.CharField(max_length=100, default="", blank=True)
active = models.BooleanField(default=False)
@@ -244,6 +275,31 @@ class BillingAddress(models.Model):
name='one_active_billing_address_per_user')
]
+ @classmethod
+ def populate_db_defaults(cls):
+ """
+ Ensure we have at least one billing address that is associated with the uncloud-admin.
+
+ This way we are sure that an UncloudProvider can be created.
+
+ Cannot use get_or_create as that looks for exactly one.
+
+ """
+
+ owner = get_user_model().objects.get(username=settings.UNCLOUD_ADMIN_NAME)
+ billing_address = cls.objects.filter(owner=owner).first()
+
+ if not billing_address:
+ billing_address = cls.objects.create(owner=owner,
+ organization="uncloud admins",
+ name="Uncloud Admin",
+ street="Uncloudstreet. 42",
+ city="Luchsingen",
+ postal_code="8775",
+ country="CH",
+ active=True)
+
+
@staticmethod
def get_address_for(user):
return BillingAddress.objects.get(owner=user, active=True)
@@ -251,7 +307,7 @@ class BillingAddress(models.Model):
def __str__(self):
return "{} - {}, {}, {} {}, {}".format(
self.owner,
- self.name, self.street, self.postal_code, self.city,
+ self.full_name, self.street, self.postal_code, self.city,
self.country)
###
@@ -279,351 +335,156 @@ class VATRate(models.Model):
logger.debug("Did not find VAT rate for %s, returning 0" % country_code)
return 0
-###
-# Orders.
-
-class Order(models.Model):
- """
- Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
- bills. Do **NOT** mutate then!
- """
-
- owner = models.ForeignKey(get_user_model(),
- on_delete=models.CASCADE,
- editable=True)
-
- billing_address = models.ForeignKey(BillingAddress,
- on_delete=models.CASCADE)
-
- description = models.TextField()
-
- # TODO: enforce ending_date - starting_date to be larger than recurring_period.
- creation_date = models.DateTimeField(auto_now_add=True)
- starting_date = models.DateTimeField(default=timezone.now)
- ending_date = models.DateTimeField(blank=True, null=True)
-
- recurring_period = models.IntegerField(choices = RecurringPeriod.choices,
- default = RecurringPeriod.PER_30D)
-
- price = models.DecimalField(default=0.0,
- max_digits=AMOUNT_MAX_DIGITS,
- decimal_places=AMOUNT_DECIMALS,
- validators=[MinValueValidator(0)])
-
- replaces = models.ForeignKey('self',
- related_name='replaced_by',
- on_delete=models.CASCADE,
- blank=True,
- null=True)
-
- depends_on = models.ForeignKey('self',
- related_name='parent_of',
- on_delete=models.CASCADE,
- blank=True,
- null=True)
-
-
- @property
- def earliest_ending_date(self):
- """
- Recurring orders cannot end before finishing at least one recurring period.
-
- One time orders have a recurring period of 0, so this work universally
- """
-
- return self.starting_date + datetime.timedelta(seconds=self.recurring_period)
-
-
- @property
- def count_billed(self):
- """
- How many times this order was billed so far.
- This logic is mainly thought to be for recurring bills, but also works for one time bills
- """
-
- return sum([ br.quantity for br in self.bill_records.all() ])
-
- @property
- def is_recurring(self):
- return not self.recurring_period == RecurringPeriod.ONE_TIME
-
- @property
- def is_one_time(self):
- return not self.is_recurring
-
- def replace_with(self, new_order):
- new_order.replaces = self
- self.ending_date = new_order.starting_date - datetime.timedelta(seconds=1)
- self.save()
-
-
- def save(self, *args, **kwargs):
- if self.ending_date and self.ending_date < self.starting_date:
- raise ValidationError("End date cannot be before starting date")
-
- if self.ending_date and self.ending_date < self.earliest_ending_date:
- raise ValidationError("Ending date is before minimum duration (starting_date + recurring period)")
-
- super().save(*args, **kwargs)
def __str__(self):
- return f"{self.description} (order={self.id})"
-
-class Bill(models.Model):
- """
- A bill is a representation of usage at a specific time
- """
- owner = models.ForeignKey(get_user_model(),
- on_delete=models.CASCADE)
-
- creation_date = models.DateTimeField(auto_now_add=True)
- starting_date = models.DateTimeField(default=start_of_this_month)
- ending_date = models.DateTimeField()
- due_date = models.DateField(default=default_payment_delay)
-
-
- billing_address = models.ForeignKey(BillingAddress,
- on_delete=models.CASCADE,
- editable=True,
- null=False)
- # FIXME: editable=True -> is in the admin, but also editable in DRF
-
- is_final = models.BooleanField(default=False)
-
- class Meta:
- constraints = [
- models.UniqueConstraint(fields=['owner',
- 'starting_date',
- 'ending_date' ],
- name='one_bill_per_month_per_user')
- ]
-
- def __str__(self):
- return f"Bill {self.owner}-{self.id}"
-
- def close(self):
- """
- Close/finish a bill
- """
-
- self.is_final = True
- self.save()
-
- @property
- def sum(self):
- bill_records = BillRecord.objects.filter(bill=self)
- return sum([ br.sum for br in bill_records ])
-
- @classmethod
- def create_bills_for_all_users(cls):
- """
- Create bills for all users
- """
-
- for owner in get_user_model().objects.all():
- cls.create_next_bills_for_user(owner)
-
- @classmethod
- def create_next_bills_for_user(cls, owner, ending_date=None):
- """
- Create one bill per billing address, as the VAT rates might be different
- for each address
- """
-
- bills = []
-
- for billing_address in BillingAddress.objects.filter(owner=owner):
- bills.append(cls.create_next_bill_for_user_address(owner, billing_address, ending_date))
-
- return bills
-
- @classmethod
- def create_next_bill_for_user_address(cls, owner, billing_address, ending_date=None):
- last_bill = cls.objects.filter(owner=owner, billing_address=billing_address).order_by('id').last()
-
- # it is important to sort orders here, as bill records will be
- # created (and listed) in this order
- all_orders = Order.objects.filter(owner=owner, billing_address=billing_address).order_by('id')
- first_order = all_orders.first()
-
- bill = None
- ending_date = None
-
- # Get date & bill from previous bill
- if last_bill:
- if not last_bill.is_final:
- bill = last_bill
- starting_date = last_bill.starting_date
- ending_date = bill.ending_date
- else:
- starting_date = last_bill.ending_date + datetime.timedelta(seconds=1)
- else:
- if first_order:
- starting_date = first_order.starting_date
- else:
- starting_date = timezone.now()
-
- if not ending_date:
- ending_date = end_of_month(starting_date)
-
- if not bill:
- bill = cls.objects.create(
- owner=owner,
- starting_date=starting_date,
- ending_date=ending_date,
- billing_address=billing_address)
-
- for order in all_orders:
- if order.is_one_time:
- # this code should be ok, but needs to be double checked
- if order.billrecord_set.count() == 0:
- br = BillRecord.objects.create(bill=bill,
- order=order,
- starting_date=order.starting_date,
- ending_date=order.ending_date)
-
- else:
- # Bill all recurring orders
- bill_record_for_this_bill = BillRecord.objects.filter(bill=bill,
- order=order).first()
-
-
- # This bill already has a bill record for the order
- # We potentially need to update the ending_date if the ending_date
- # of the bill changed.
- if bill_record_for_this_bill:
- # we may need to adjust it, but let's do this logic another time
-
- # If the order has an ending date set, we might need to adjust the bill_record
- if order.ending_date:
- if bill_record_for_this_bill.ending_date != order.ending_date:
- bill_record_for_this_bill.ending_date = order.ending_date
-
- else:
- # recurring, not terminated, should go until at least end of bill
- if bill_record_for_this_bill.ending_date < bill.ending_date:
- bill_record_for_this_bill.ending_date = bill.ending_date
-
-
- bill_record_for_this_bill.save()
-
- else:
- # No bill record in this bill for the order yet
-
- # Find out whether it was already billed for the billing period of
- # this bill
- last_bill_record = BillRecord.objects.filter(order=order).order_by('id').last()
-
- # Default starting date
- this_starting_date=order.starting_date
-
- # Skip billing again, if we have been processed for this bill duration
- if last_bill_record:
- if last_bill_record.ending_date >= bill.ending_date:
- continue
-
- # If the order ended and has been fully billed - do not process it
- # anymore
- if order.ending_date:
- if last_bill_record.ending_date == order.ending_date:
- # FIXME: maybe mark order for not processing anymore?
- # I imagina a boolean column, once this code is stable and
- # verified
- continue
-
- # Catch programming bugs if the last bill_record was
- # created incorrectly - should never be entered!
- if order.ending_date < last_bill_record.ending_date:
- raise ValidationError(f"Order {order.id} ends before last bill record {last_bill_record.id}")
-
- # Start right after last billing run
- this_starting_date = last_bill_record.ending_date + datetime.timedelta(seconds=1)
-
-
- # If the order is already terminated, use that date instead of bill date
- if order.ending_date:
- this_ending_date = order.ending_date
- else:
- if order.earliest_ending_date > bill.ending_date:
- this_ending_date = order.earliest_ending_date
- else:
- # bill at maximum for this billing period
- this_ending_date = bill.ending_date
-
- # And finally create a new billrecord!
- br = BillRecord.objects.create(bill=bill,
- order=order,
- starting_date=this_starting_date,
- ending_date=this_ending_date)
-
-
-
- # Filtering ideas:
- # If order is replaced, it should not be added anymore if it has been billed "last time"
- # If order has ended and finally charged, do not charge anymore
-
- # Find out the last billrecord for the order, if there is none, bill from the starting date
-
-
- return bill
-
-
-class BillRecord(models.Model):
- """
- Entry of a bill, dynamically generated from an order.
- """
-
- bill = models.ForeignKey(Bill, on_delete=models.CASCADE)
- order = models.ForeignKey(Order, on_delete=models.CASCADE)
-
- creation_date = models.DateTimeField(auto_now_add=True)
- starting_date = models.DateTimeField()
- ending_date = models.DateTimeField()
-
- @property
- def quantity(self):
- """ Determine the quantity by the duration"""
- if self.order.is_one_time:
- return 1
-
- record_delta = self.ending_date - self.starting_date
-
- return record_delta.total_seconds()/self.order.recurring_period
-
- @property
- def sum(self):
- return self.order.price * Decimal(self.quantity)
-
- def __str__(self):
- return f"{self.bill}: {self.quantity} x {self.order}"
-
- def save(self, *args, **kwargs):
- if self.ending_date < self.starting_date:
- raise ValidationError("End date cannot be before starting date")
-
- super().save(*args, **kwargs)
-
+ return f"{self.territory_codes}: {self.starting_date} - {self.ending_date}: {self.rate_type}"
###
# Products
-class Product(UncloudModel):
- owner = models.ForeignKey(get_user_model(),
- on_delete=models.CASCADE,
- editable=False)
+class Product(models.Model):
+ """
+ A product is something a user can order. To record the pricing, we
+ create order that define a state in time.
- description = "Generic Product"
+ A product can have *one* one_time_order and/or *one*
+ recurring_order.
- orders = models.ManyToManyField(Order)
+ If either of them needs to be updated, a new order of the same
+ type will be created and links to the previous order.
- status = models.CharField(max_length=32,
- choices=UncloudStatus.choices,
- default=UncloudStatus.AWAITING_PAYMENT)
+ """
- # Default period for all products
- default_recurring_period = RecurringPeriod.PER_30D
+ name = models.CharField(max_length=256, unique=True)
+ description = models.CharField(max_length=1024)
+ config = models.JSONField()
+ recurring_periods = models.ManyToManyField(RecurringPeriod, through='ProductToRecurringPeriod')
+ currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF)
- def create_order_at(self, when_to_start=None, recurring_period=None):
+ @property
+ def default_recurring_period(self):
+ """
+ Return the default recurring Period
+ """
+ return self.recurring_periods.get(producttorecurringperiod__is_default=True)
+
+ @classmethod
+ def populate_db_defaults(cls):
+ recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
+
+ obj, created = cls.objects.get_or_create(name="Dual Stack Virtual Machine v1",
+ description="A standard virtual machine",
+ currency=Currency.CHF,
+ config={
+ 'features': {
+ 'cores':
+ { 'min': 1,
+ 'max': 48,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 3
+ },
+ 'ram_gb':
+ { 'min': 1,
+ 'max': 256,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 4
+ },
+ 'ssd_gb':
+ { 'min': 10,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 0.35
+ },
+ 'hdd_gb':
+ { 'min': 0,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 15/1000
+ },
+ 'additional_ipv4_address':
+ { 'min': 0,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 8
+ },
+ }
+ }
+ )
+
+ obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True })
+
+ obj, created = cls.objects.get_or_create(name="Dual Stack Virtual Machine v2",
+ description="A standard virtual machine",
+ currency=Currency.CHF,
+ config={
+ 'features': {
+ 'base':
+ { 'min': 1,
+ 'max': 1,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 1
+ },
+ 'cores':
+ { 'min': 1,
+ 'max': 48,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 3
+ },
+ 'ram_gb':
+ { 'min': 1,
+ 'max': 256,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 4
+ },
+ 'ssd_gb':
+ { 'min': 10,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 0.35
+ },
+ 'hdd_gb':
+ { 'min': 0,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 15/1000
+ },
+ 'additional_ipv4_address':
+ { 'min': 0,
+ 'one_time_price_per_unit': 0,
+ 'recurring_price_per_unit': 9
+ },
+ }
+ }
+ )
+
+ obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True })
+
+ obj, created = cls.objects.get_or_create(name="reverse DNS",
+ description="Reverse DNS network",
+ currency=Currency.CHF,
+ config={
+ 'parameters': [
+ 'network'
+ ]
+ })
+ obj.recurring_periods.add(recurring_period, through_defaults= { 'is_default': True })
+
+
+ def __str__(self):
+ return f"{self.name} - {self.description}"
+
+ @property
+ def recurring_orders(self):
+ return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.ONE_TIME)
+
+ @property
+ def last_recurring_order(self):
+ return self.recurring_orders.last()
+
+ @property
+ def one_time_orders(self):
+ return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.ONE_TIME)
+
+ @property
+ def last_one_time_order(self):
+ return self.one_time_orders.last()
+
+ def create_order(self, when_to_start=None, recurring_period=None):
billing_address = BillingAddress.get_address_for(self.owner)
if not billing_address:
@@ -636,7 +497,8 @@ class Product(UncloudModel):
recurring_period = self.default_recurring_period
- if self.one_time_price > 0:
+ # Create one time order if we did not create one already
+ if self.one_time_price > 0 and not self.last_one_time_order:
one_time_order = Order.objects.create(owner=self.owner,
billing_address=billing_address,
starting_date=when_to_start,
@@ -666,44 +528,37 @@ class Product(UncloudModel):
self.orders.add(recurring_order)
+ # FIXME: this could/should be part of Order (?)
def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None):
+ if not self.recurring_price:
+ return
+
+ if not recurring_period:
+ recurring_period = self.default_recurring_period
+
if not when_to_start:
when_to_start = timezone.now()
-# current_recurring_order = Order.objects.filter(
- # NEXT: find the latest order, use that one...
- # Update order = create new order
- if self.order:
- previous_order = self.order
+ if self.last_recurring_order:
+ if self.recurring_price < self.last_recurring_order.price:
+
+ if when_to_start < self.last_recurring_order.next_cancel_or_downgrade_date:
+ when_to_start = start_after(self.last_recurring_order.next_cancel_or_downgrade_date)
+
when_to_end = end_before(when_to_start)
new_order = Order.objects.create(owner=self.owner,
- billing_address=self.order.billing_address,
+ billing_address=self.last_recurring_order.billing_address,
starting_date=when_to_start,
price=self.recurring_price,
recurring_period=recurring_period,
description=str(self),
- replaces=self.order)
-
- print(new_order)
- self.order.end_date = when_to_end
- self.order.save()
-
- self.order = new_order
+ replaces=self.last_recurring_order)
+ self.last_recurring_order.replace_with(new_order)
+ self.orders.add(new_order)
else:
- return self.create_order_at(when_to_start, recurring_period)
-
- @property
- def recurring_price(self):
- """ implement correct values in the child class """
- return 0
-
- @property
- def one_time_price(self):
- """ implement correct values in the child class """
- return 0
-
+ self.create_order(when_to_start, recurring_period)
@property
def is_recurring(self):
@@ -713,13 +568,6 @@ class Product(UncloudModel):
def billing_address(self):
return self.order.billing_address
- @staticmethod
- def allowed_recurring_periods():
- return RecurringPeriod.choices
-
- class Meta:
- abstract = True
-
def discounted_price_by_period(self, requested_period):
"""
Each product has a standard recurring period for which
@@ -761,6 +609,8 @@ class Product(UncloudModel):
"""
+ # FIXME: This logic needs to be phased out / replaced by product specific (?)
+ # proportions. Maybe using the RecurringPeriod table to link the possible discounts/add ups
if self.default_recurring_period == RecurringPeriod.PER_365D:
if requested_period == RecurringPeriod.PER_365D:
@@ -789,29 +639,621 @@ class Product(UncloudModel):
# FIXME: use the right type of exception here!
raise Exception("Did not implement the discounter for this case")
-# Sample products included into uncloud
-class SampleOneTimeProduct(Product):
- default_recurring_period = RecurringPeriod.ONE_TIME
+ def save(self, *args, **kwargs):
+ # try:
+ # ba = BillingAddress.get_address_for(self.owner)
+ # except BillingAddress.DoesNotExist:
+ # raise ValidationError("User does not have a billing address")
+
+ # if not ba.active:
+ # raise ValidationError("User does not have an active billing address")
+
+
+ # Verify the required JSON fields
+
+ super().save(*args, **kwargs)
+
+
+
+###
+# Orders.
+
+class Order(models.Model):
+ """
+ Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
+ bills. Do **NOT** mutate then!
+
+ An one time order is "closed" (does not need to be billed anymore)
+ if it has one bill record. Having more than one is a programming
+ error.
+
+ A recurring order is closed if it has been replaced
+ (replaces__isnull=False) AND the ending_date is set AND it was
+ billed the last time it needed to be billed (how to check the last
+ item?)
+
+ BOTH are closed, if they are ended/closed AND have been fully
+ charged.
+
+ Fully charged == fully billed: sum_of_order_usage == sum_of_bill_records
+
+ """
+
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE,
+ editable=True)
+
+ billing_address = models.ForeignKey(BillingAddress,
+ on_delete=models.CASCADE)
+
+ description = models.TextField()
+
+ product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE)
+ config = models.JSONField()
+
+ creation_date = models.DateTimeField(auto_now_add=True)
+ starting_date = models.DateTimeField(default=timezone.now)
+ ending_date = models.DateTimeField(blank=True, null=True)
+
+ recurring_period = models.ForeignKey(RecurringPeriod,
+ on_delete=models.CASCADE,
+ editable=True)
+
+ one_time_price = models.DecimalField(default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS,
+ validators=[MinValueValidator(0)])
+
+ recurring_price = models.DecimalField(default=0.0,
+ max_digits=AMOUNT_MAX_DIGITS,
+ decimal_places=AMOUNT_DECIMALS,
+ validators=[MinValueValidator(0)])
+
+ currency = models.CharField(max_length=32, choices=Currency.choices, default=Currency.CHF)
+
+ replaces = models.ForeignKey('self',
+ related_name='replaced_by',
+ on_delete=models.CASCADE,
+ blank=True,
+ null=True)
+
+ depends_on = models.ForeignKey('self',
+ related_name='parent_of',
+ on_delete=models.CASCADE,
+ blank=True,
+ null=True)
+
+ should_be_billed = models.BooleanField(default=True)
@property
- def one_time_price(self):
- return 5
+ def earliest_ending_date(self):
+ """
+ Recurring orders cannot end before finishing at least one recurring period.
+
+ One time orders have a recurring period of 0, so this work universally
+ """
+
+ return self.starting_date + datetime.timedelta(seconds=self.recurring_period.duration_seconds)
+
+
+ def next_cancel_or_downgrade_date(self, until_when=None):
+ """
+ Return the next proper ending date after n times the
+ recurring_period, where n is an integer that applies for downgrading
+ or cancelling.
+ """
+
+ if not until_when:
+ until_when = timezone.now()
+
+ if until_when < self.starting_date:
+ raise ValidationError("Cannot end before start of start of order")
+
+ if self.recurring_period.duration_seconds > 0:
+ delta = until_when - self.starting_date
+
+ num_times = ceil(delta.total_seconds() / self.recurring_period.duration_seconds)
+
+ next_date = self.starting_date + datetime.timedelta(seconds=num_times * self.recurring_period.duration_seconds)
+ else:
+ next_date = self.starting_date
+
+ return next_date
+
+ def get_ending_date_for_bill(self, bill):
+ """
+ Determine the ending date given a specific bill
+ """
+
+ # If the order is quit, charge the final amount / finish (????)
+ # Probably not a good idea -- FIXME :continue until usual
+ if self.ending_date:
+ this_ending_date = self.ending_date
+ else:
+ if self.next_cancel_or_downgrade_date(bill.ending_date) > bill.ending_date:
+ this_ending_date = self.next_cancel_or_downgrade_date(bill.ending_date)
+ else:
+ this_ending_date = bill.ending_date
+
+ return this_ending_date
-class SampleRecurringProduct(Product):
- default_recurring_period = RecurringPeriod.PER_30D
@property
- def recurring_price(self):
- return 10
+ def count_billed(self):
+ """
+ How many times this order was billed so far.
+ This logic is mainly thought to be for recurring bills, but also works for one time bills
+ """
-class SampleRecurringProductOneTimeFee(Product):
- default_recurring_period = RecurringPeriod.PER_30D
+ return sum([ br.quantity for br in self.bill_records.all() ])
+
+ def count_used(self, when=None):
+ """
+ How many times this order was billed so far.
+ This logic is mainly thought to be for recurring bills, but also works for one time bills
+ """
+
+ if self.is_one_time:
+ return 1
+
+ if not when:
+ when = timezone.now()
+
+ # Cannot be used after it ended
+ if self.ending_date and when > self.ending_date:
+ when = self.ending_date
+
+ return (when - self.starting_date) / self.default_recurring_period
@property
- def one_time_price(self):
- return 5
+ def all_usage_billed(self, when=None):
+ """
+ Returns true if this order does not need any further billing
+ ever. In other words: is this order "closed"?
+ """
+
+ if self.count_billed == self.count_used(when):
+ return True
+ else:
+ return False
@property
- def recurring_price(self):
- return 1
+ def is_closed(self):
+ if self.all_usage_billed and self.ending_date:
+ return True
+ else:
+ return False
+
+ @property
+ def is_recurring(self):
+ return not self.recurring_period == RecurringPeriod.ONE_TIME
+
+ @property
+ def is_one_time(self):
+ return not self.is_recurring
+
+ def replace_with(self, new_order):
+ new_order.replaces = self
+ self.ending_date = end_before(new_order.starting_date)
+ self.save()
+
+ def update_order(self, config, starting_date=None):
+ """
+ Updating an order means creating a new order and reference the previous order
+ """
+
+ if not starting_date:
+ starting_date = timezone.now()
+
+ new_order = self.__class__(owner=self.owner,
+ billing_address=self.billing_address,
+ description=self.description,
+ product=self.product,
+ config=config,
+ starting_date=starting_date,
+ currency=self.currency
+ )
+
+ (new_order.one_time_price, new_order.recurring_price, new_order.config) = new_order.calculate_prices_and_config()
+
+ new_order.replaces = self
+ new_order.save()
+
+ self.ending_date = end_before(new_order.starting_date)
+ self.save()
+
+ return new_order
+
+
+ def create_bill_record(self, bill):
+ br = None
+
+ # Note: check for != 0 not > 0, as we allow discounts to be expressed with < 0
+ if self.one_time_price != 0 and self.billrecord_set.count() == 0:
+ br = BillRecord.objects.create(bill=bill,
+ order=self,
+ starting_date=self.starting_date,
+ ending_date=self.starting_date,
+ is_recurring_record=False)
+
+ if self.recurring_price != 0:
+ br = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first()
+
+ if br:
+ self.update_bill_record_for_recurring_order(br, bill)
+ else:
+ br = self.create_new_bill_record_for_recurring_order(bill)
+
+ return br
+
+ def update_bill_record_for_recurring_order(self,
+ bill_record,
+ bill):
+ """
+ Possibly update a bill record according to the information in the bill
+ """
+
+ # If the order has an ending date set, we might need to adjust the bill_record
+ if self.ending_date:
+ if bill_record_for_this_bill.ending_date != self.ending_date:
+ bill_record_for_this_bill.ending_date = self.ending_date
+
+ else:
+ # recurring, not terminated, should go until at least end of bill
+ if bill_record_for_this_bill.ending_date < bill.ending_date:
+ bill_record_for_this_bill.ending_date = bill.ending_date
+
+ bill_record_for_this_bill.save()
+
+ def create_new_bill_record_for_recurring_order(self, bill):
+ """
+ Create a new bill record
+ """
+
+ last_bill_record = BillRecord.objects.filter(order=self, is_recurring_record=True).order_by('id').last()
+
+ starting_date=self.starting_date
+
+ if last_bill_record:
+ # We already charged beyond the end of this bill's period
+ if last_bill_record.ending_date >= bill.ending_date:
+ return
+
+ # This order is terminated or replaced
+ if self.ending_date:
+ # And the last bill record already covered us -> nothing to be done anymore
+ if last_bill_record.ending_date == self.ending_date:
+ return
+
+ starting_date = start_after(last_bill_record.ending_date)
+
+ ending_date = self.get_ending_date_for_bill(bill)
+
+ return BillRecord.objects.create(bill=bill,
+ order=self,
+ starting_date=starting_date,
+ ending_date=ending_date,
+ is_recurring_record=True)
+
+ def calculate_prices_and_config(self):
+ one_time_price = 0
+ recurring_price = 0
+
+ if self.config:
+ config = self.config
+
+ if 'features' not in self.config:
+ self.config['features'] = {}
+
+ else:
+ config = {
+ 'features': {}
+ }
+
+ # FIXME: adjust prices to the selected recurring_period to the
+
+ if 'features' in self.product.config:
+ for feature in self.product.config['features']:
+
+ # Set min to 0 if not specified
+ min_val = self.product.config['features'][feature].get('min', 0)
+
+ # We might not even have 'features' cannot use .get() on it
+ try:
+ value = self.config['features'][feature]
+ except (KeyError, TypeError):
+ value = self.product.config['features'][feature]['min']
+
+ # Set max to current value if not specified
+ max_val = self.product.config['features'][feature].get('max', value)
+
+
+ if value < min_val or value > max_val:
+ raise ValidationError(f"Feature '{feature}' must be at least {min_val} and at maximum {max_val}. Value is: {value}")
+
+ one_time_price += self.product.config['features'][feature]['one_time_price_per_unit'] * value
+ recurring_price += self.product.config['features'][feature]['recurring_price_per_unit'] * value
+ config['features'][feature] = value
+
+ return (one_time_price, recurring_price, config)
+
+ def check_parameters(self):
+ if 'parameters' in self.product.config:
+ for parameter in self.product.config['parameters']:
+ if not parameter in self.config['parameters']:
+ raise ValidationError(f"Required parameter '{parameter}' is missing.")
+
+
+ def save(self, *args, **kwargs):
+ # Calculate the price of the order when we create it
+ # IMMUTABLE fields -- need to create new order to modify them
+ # However this is not enforced here...
+ if self._state.adding:
+ (self.one_time_price, self.recurring_price, self.config) = self.calculate_prices_and_config()
+
+ if self.recurring_period_id is None:
+ self.recurring_period = self.product.default_recurring_period
+
+ try:
+ prod_period = self.product.recurring_periods.get(producttorecurringperiod__recurring_period=self.recurring_period)
+ except ObjectDoesNotExist:
+ raise ValidationError(f"Recurring Period {self.recurring_period} not allowed for product {self.product}")
+
+ self.check_parameters()
+
+ if self.ending_date and self.ending_date < self.starting_date:
+ raise ValidationError("End date cannot be before starting date")
+
+
+ super().save(*args, **kwargs)
+
+
+ def __str__(self):
+ try:
+ conf = " ".join([ f"{key}:{val}" for key,val in self.config['features'].items() if val != 0 ])
+ except KeyError:
+ conf = ""
+
+ return f"Order {self.id}: {self.description} {conf}"
+
+class Bill(models.Model):
+ """
+ A bill is a representation of usage at a specific time
+ """
+ owner = models.ForeignKey(get_user_model(),
+ on_delete=models.CASCADE)
+
+ creation_date = models.DateTimeField(auto_now_add=True)
+ starting_date = models.DateTimeField(default=start_of_this_month)
+ ending_date = models.DateTimeField()
+ due_date = models.DateField(default=default_payment_delay)
+
+
+ billing_address = models.ForeignKey(BillingAddress,
+ on_delete=models.CASCADE,
+ editable=True,
+ null=False)
+
+ # FIXME: editable=True -> is in the admin, but also editable in DRF
+ # Maybe filter fields in the serializer?
+
+ is_final = models.BooleanField(default=False)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['owner',
+ 'starting_date',
+ 'ending_date' ],
+ name='one_bill_per_month_per_user')
+ ]
+
+ def close(self):
+ """
+ Close/finish a bill
+ """
+
+ self.is_final = True
+ self.save()
+
+ @property
+ def sum(self):
+ bill_records = BillRecord.objects.filter(bill=self)
+ return sum([ br.sum for br in bill_records ])
+
+ @property
+ def vat_rate(self):
+ """
+ Handling VAT is a tricky business - thus we only implement the cases
+ that we clearly now and leave it open to fellow developers to implement
+ correct handling for other cases.
+
+ Case CH:
+
+ - If the customer is in .ch -> apply standard rate
+ - If the customer is in EU AND private -> apply country specific rate
+ - If the customer is in EU AND business -> do not apply VAT
+ - If the customer is outside EU and outside CH -> do not apply VAT
+ """
+
+ provider = UncloudProvider.objects.get()
+
+ # Assume always VAT inside the country
+ if provider.country == self.billing_address.country:
+ vat_rate = VATRate.objects.get(country=provider.country,
+ when=self.ending_date)
+ elif self.billing_address.country in EU:
+ # FIXME: need to check for validated vat number
+ if self.billing_address.vat_number:
+ return 0
+ else:
+ return VATRate.objects.get(country=self.biling_address.country,
+ when=self.ending_date)
+ else: # non-EU, non-national
+ return 0
+
+
+ @classmethod
+ def create_bills_for_all_users(cls):
+ """
+ Create next bill for each user
+ """
+
+ for owner in get_user_model().objects.all():
+ cls.create_next_bills_for_user(owner)
+
+ @classmethod
+ def create_next_bills_for_user(cls, owner, ending_date=None):
+ """
+ Create one bill per billing address, as the VAT rates might be different
+ for each address
+ """
+
+ bills = []
+
+ for billing_address in BillingAddress.objects.filter(owner=owner):
+ bills.append(cls.create_next_bill_for_user_address(billing_address, ending_date))
+
+ return bills
+
+ @classmethod
+ def create_next_bill_for_user_address(cls, billing_address, ending_date=None):
+ """
+ Create the next bill for a specific billing address of a user
+ """
+
+ owner = billing_address.owner
+
+ all_orders = Order.objects.filter(owner=owner,
+ billing_address=billing_address).order_by('id')
+
+ bill = cls.get_or_create_bill(billing_address, ending_date=ending_date)
+
+ for order in all_orders:
+ order.create_bill_record(bill)
+
+ return bill
+
+
+ @classmethod
+ def get_or_create_bill(cls, billing_address, ending_date=None):
+ """
+ Get / reuse last bill if it is not yet closed
+
+ Create bill, if there is no bill or if bill is closed.
+ """
+
+ last_bill = cls.objects.filter(billing_address=billing_address).order_by('id').last()
+
+ all_orders = Order.objects.filter(billing_address=billing_address).order_by('id')
+ first_order = all_orders.first()
+
+ bill = None
+
+ # Get date & bill from previous bill, if it exists
+ if last_bill:
+ if not last_bill.is_final:
+ bill = last_bill
+ starting_date = last_bill.starting_date
+ ending_date = bill.ending_date
+ else:
+ starting_date = last_bill.ending_date + datetime.timedelta(seconds=1)
+ else:
+ # Might be an idea to make this the start of the month, too
+ if first_order:
+ starting_date = first_order.starting_date
+ else:
+ starting_date = timezone.now()
+
+ if not ending_date:
+ ending_date = end_of_month(starting_date)
+
+ if not bill:
+ bill = cls.objects.create(
+ owner=billing_address.owner,
+ starting_date=starting_date,
+ ending_date=ending_date,
+ billing_address=billing_address)
+
+
+ return bill
+
+ def __str__(self):
+ return f"Bill {self.owner}-{self.id}"
+
+
+class BillRecord(models.Model):
+ """
+ Entry of a bill, dynamically generated from an order.
+ """
+
+ bill = models.ForeignKey(Bill, on_delete=models.CASCADE)
+ order = models.ForeignKey(Order, on_delete=models.CASCADE)
+
+ creation_date = models.DateTimeField(auto_now_add=True)
+ starting_date = models.DateTimeField()
+ ending_date = models.DateTimeField()
+
+ is_recurring_record = models.BooleanField(blank=False, null=False)
+
+ @property
+ def quantity(self):
+ """ Determine the quantity by the duration"""
+ if not self.is_recurring_record:
+ return 1
+
+ record_delta = self.ending_date - self.starting_date
+
+ return record_delta.total_seconds()/self.order.recurring_period.duration_seconds
+
+ @property
+ def sum(self):
+ if self.is_recurring_record:
+ return self.order.recurring_price * Decimal(self.quantity)
+ else:
+ return self.order.one_time_price
+
+ @property
+ def price(self):
+ if self.is_recurring_record:
+ return self.order.recurring_price
+ else:
+ return self.order.one_time_price
+
+ def __str__(self):
+ if self.is_recurring_record:
+ bill_line = f"{self.starting_date} - {self.ending_date}: {self.quantity} x {self.order}"
+ else:
+ bill_line = f"{self.starting_date}: {self.order}"
+
+ return bill_line
+
+ def save(self, *args, **kwargs):
+ if self.ending_date < self.starting_date:
+ raise ValidationError("End date cannot be before starting date")
+
+ super().save(*args, **kwargs)
+
+
+class ProductToRecurringPeriod(models.Model):
+ """
+ Intermediate manytomany mapping class that allows storing the default recurring period
+ for a product
+ """
+
+ recurring_period = models.ForeignKey(RecurringPeriod, on_delete=models.CASCADE)
+ product = models.ForeignKey(Product, on_delete=models.CASCADE)
+
+ is_default = models.BooleanField(default=False)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(fields=['product'],
+ condition=Q(is_default=True),
+ name='one_default_recurring_period_per_product'),
+ models.UniqueConstraint(fields=['product', 'recurring_period'],
+ name='recurring_period_once_per_product')
+ ]
+
+ def __str__(self):
+ return f"{self.product} - {self.recurring_period} (default: {self.is_default})"
diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py
index e00541c..9214105 100644
--- a/uncloud_pay/serializers.py
+++ b/uncloud_pay/serializers.py
@@ -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)
diff --git a/uncloud_pay/templates/bill.html.j2 b/uncloud_pay/templates/bill.html.j2
index 6fdfca8..c227f43 100644
--- a/uncloud_pay/templates/bill.html.j2
+++ b/uncloud_pay/templates/bill.html.j2
@@ -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;
@@ -696,9 +695,9 @@ oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7
Detail
- Units
Price/Unit
- Total price
+ Units
+ Total price