forked from uncloud/uncloud
- Added PricingPlan Model
- Implement a complete cycle for buying a Matrix Chat Host - Refactor the Payement cycle and stripe related methods
This commit is contained in:
parent
e205d8d07c
commit
b7aa1c6971
81 changed files with 5079 additions and 810 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -22,6 +22,6 @@ uncloud/version.py
|
||||||
build/
|
build/
|
||||||
venv/
|
venv/
|
||||||
dist/
|
dist/
|
||||||
|
.history/
|
||||||
*.iso
|
*.iso
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
10
README.md
10
README.md
|
@ -9,13 +9,15 @@ Cloud management platform, the ungleich way.
|
||||||
## Useful commands
|
## Useful commands
|
||||||
|
|
||||||
* `./manage.py import-vat-rates path/to/csv`
|
* `./manage.py import-vat-rates path/to/csv`
|
||||||
* `./manage.py make-admin username`
|
* `./manage.py createsuperuser`
|
||||||
|
|
||||||
## Development setup
|
## Development setup
|
||||||
|
|
||||||
Install system dependencies:
|
Install system dependencies:
|
||||||
|
|
||||||
* On Fedora, you will need the following packages: `python3-virtualenv python3-devel openldap-devel gcc chromium`
|
* On Fedora, you will need the following packages: `python3-virtualenv python3-devel openldap-devel gcc chromium`
|
||||||
|
* sudo apt-get install libpq-dev python-dev libxml2-dev libxslt1-dev libldap2-dev libsasl2-dev libffi-dev
|
||||||
|
|
||||||
|
|
||||||
NOTE: you will need to configure a LDAP server and credentials for authentication. See `uncloud/settings.py`.
|
NOTE: you will need to configure a LDAP server and credentials for authentication. See `uncloud/settings.py`.
|
||||||
|
|
||||||
|
@ -53,6 +55,12 @@ Django version 3.0.6, using settings 'uncloud.settings'
|
||||||
Starting development server at http://127.0.0.1:8000/
|
Starting development server at http://127.0.0.1:8000/
|
||||||
Quit the server with CONTROL-C.
|
Quit the server with CONTROL-C.
|
||||||
```
|
```
|
||||||
|
### Run Background Job Queue
|
||||||
|
We use Django Q to handle the asynchronous code and Background Cron jobs
|
||||||
|
To start the workers make sure first that Redis or the Django Q broker is working and you can edit it's settings in the settings file.
|
||||||
|
```
|
||||||
|
./manage.py qcluster
|
||||||
|
```
|
||||||
|
|
||||||
### Note on PGSQL
|
### Note on PGSQL
|
||||||
|
|
||||||
|
|
0
matrixhosting/__init__.py
Normal file
0
matrixhosting/__init__.py
Normal file
4
matrixhosting/admin.py
Normal file
4
matrixhosting/admin.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import VMInstance
|
||||||
|
|
||||||
|
admin.site.register(VMInstance)
|
9
matrixhosting/apps.py
Normal file
9
matrixhosting/apps.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MatrixhostingConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'matrixhosting'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from . import signals
|
48
matrixhosting/forms.py
Normal file
48
matrixhosting/forms.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import tldextract
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.forms import ModelForm
|
||||||
|
from django.utils.translation import get_language, ugettext_lazy as _
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from .validators import domain_name_validator
|
||||||
|
from uncloud_pay.models import BillingAddress
|
||||||
|
|
||||||
|
|
||||||
|
class DomainNameField(forms.CharField):
|
||||||
|
description = 'Domain name form field'
|
||||||
|
default_validators = [domain_name_validator, ]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(DomainNameField, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
class RequestHostedVMForm(forms.Form):
|
||||||
|
cores = forms.IntegerField(label='CPU', min_value=1, max_value=48, initial=1)
|
||||||
|
memory = forms.IntegerField(label='RAM', min_value=2, max_value=200, initial=2)
|
||||||
|
storage = forms.IntegerField(label='Storage', min_value=100, max_value=10000, initial=100)
|
||||||
|
matrix_domain = DomainNameField(required=True)
|
||||||
|
homeserver_domain = DomainNameField(required=True)
|
||||||
|
webclient_domain = DomainNameField(required=True)
|
||||||
|
is_open_registration = forms.BooleanField(required=False, initial=False)
|
||||||
|
pricing_name = forms.CharField(required=True)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
homeserver_domain = self.cleaned_data.get('homeserver_domain', False)
|
||||||
|
webclient_domain = self.cleaned_data.get('webclient_domain', False)
|
||||||
|
if homeserver_domain and webclient_domain:
|
||||||
|
# Homserver-Domain and Webclient-Domain cannot be below the same second level domain (i.e. homeserver.abc.ch and webclient.def.cloud are ok,
|
||||||
|
# homeserver.abc.ch and webclient.abc.ch are not ok
|
||||||
|
homeserver_base = tldextract.extract(homeserver_domain).domain
|
||||||
|
webclient_base = tldextract.extract(webclient_domain).domain
|
||||||
|
if homeserver_base == webclient_base:
|
||||||
|
self._errors['webclient_domain'] = self.error_class([
|
||||||
|
'Homserver-Domain and Webclient-Domain cannot be below the same second level domain'])
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class BillingAddressForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = BillingAddress
|
||||||
|
fields = ['full_name', 'street',
|
||||||
|
'city', 'postal_code', 'country', 'vat_number', 'active', 'owner']
|
||||||
|
|
||||||
|
|
30
matrixhosting/migrations/0001_initial.py
Normal file
30
matrixhosting/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-06-30 07:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='VMPricing',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, unique=True)),
|
||||||
|
('vat_inclusive', models.BooleanField(default=True)),
|
||||||
|
('vat_percentage', models.DecimalField(blank=True, decimal_places=5, default=0, max_digits=7)),
|
||||||
|
('set_up_fees', models.DecimalField(decimal_places=5, default=0, max_digits=7)),
|
||||||
|
('cores_unit_price', models.DecimalField(decimal_places=5, default=0, max_digits=7)),
|
||||||
|
('ram_unit_price', models.DecimalField(decimal_places=5, default=0, max_digits=7)),
|
||||||
|
('storage_unit_price', models.DecimalField(decimal_places=5, default=0, max_digits=7)),
|
||||||
|
('discount_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=6)),
|
||||||
|
('stripe_coupon_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-01 08:48
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('matrixhosting', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name='VMPricing',
|
||||||
|
new_name='MatrixVMPricing',
|
||||||
|
),
|
||||||
|
]
|
33
matrixhosting/migrations/0003_auto_20210703_1523.py
Normal file
33
matrixhosting/migrations/0003_auto_20210703_1523.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-03 15:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('matrixhosting', '0002_rename_vmpricing_matrixvmpricing'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='matrixvmpricing',
|
||||||
|
name='cores_unit_price',
|
||||||
|
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='matrixvmpricing',
|
||||||
|
name='ram_unit_price',
|
||||||
|
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='matrixvmpricing',
|
||||||
|
name='set_up_fees',
|
||||||
|
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='matrixvmpricing',
|
||||||
|
name='storage_unit_price',
|
||||||
|
field=models.DecimalField(decimal_places=2, default=0, max_digits=7),
|
||||||
|
),
|
||||||
|
]
|
43
matrixhosting/migrations/0004_matrixhostingorder_vmspecs.py
Normal file
43
matrixhosting/migrations/0004_matrixhostingorder_vmspecs.py
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-05 06:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0014_auto_20210703_1747'),
|
||||||
|
('matrixhosting', '0003_auto_20210703_1523'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='VMSpecs',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('cores', models.IntegerField(default=1)),
|
||||||
|
('memory', models.IntegerField(default=2)),
|
||||||
|
('storage', models.IntegerField(default=100)),
|
||||||
|
('matrix_domain', models.CharField(max_length=255)),
|
||||||
|
('homeserver_domain', models.CharField(max_length=255)),
|
||||||
|
('webclient_domain', models.CharField(max_length=255)),
|
||||||
|
('is_open_registration', models.BooleanField(default=False, null=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MatrixHostingOrder',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('vm_id', models.IntegerField(default=0)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('status', models.CharField(choices=[('draft', 'Draft'), ('declined', 'Declined'), ('approved', 'Approved')], default='draft', max_length=100)),
|
||||||
|
('stripe_charge_id', models.CharField(max_length=100, null=True)),
|
||||||
|
('price', models.FloatField()),
|
||||||
|
('billing_address', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='uncloud_pay.billingaddress')),
|
||||||
|
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.stripecustomer')),
|
||||||
|
('specs', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='matrixhosting.vmspecs')),
|
||||||
|
('vm_pricing', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='matrixhosting.matrixvmpricing')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
19
matrixhosting/migrations/0005_auto_20210705_0849.py
Normal file
19
matrixhosting/migrations/0005_auto_20210705_0849.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-05 08:49
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('matrixhosting', '0004_matrixhostingorder_vmspecs'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='MatrixHostingOrder',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='VMSpecs',
|
||||||
|
),
|
||||||
|
]
|
16
matrixhosting/migrations/0006_delete_matrixvmpricing.py
Normal file
16
matrixhosting/migrations/0006_delete_matrixvmpricing.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-06 13:21
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('matrixhosting', '0005_auto_20210705_0849'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='MatrixVMPricing',
|
||||||
|
),
|
||||||
|
]
|
31
matrixhosting/migrations/0007_vminstance.py
Normal file
31
matrixhosting/migrations/0007_vminstance.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-09 09:14
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0021_auto_20210709_0914'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('matrixhosting', '0006_delete_matrixvmpricing'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='VMInstance',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('ip', models.TextField(default='')),
|
||||||
|
('config', models.JSONField()),
|
||||||
|
('creation_date', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('termination_date', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='instance_id', to='uncloud_pay.order')),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
17
matrixhosting/migrations/0008_remove_vminstance_ip.py
Normal file
17
matrixhosting/migrations/0008_remove_vminstance_ip.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-10 14:29
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('matrixhosting', '0007_vminstance'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='vminstance',
|
||||||
|
name='ip',
|
||||||
|
),
|
||||||
|
]
|
19
matrixhosting/migrations/0009_vminstance_vm_id.py
Normal file
19
matrixhosting/migrations/0009_vminstance_vm_id.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-13 10:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('matrixhosting', '0008_remove_vminstance_ip'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='vminstance',
|
||||||
|
name='vm_id',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
]
|
0
matrixhosting/migrations/__init__.py
Normal file
0
matrixhosting/migrations/__init__.py
Normal file
77
matrixhosting/models.py
Normal file
77
matrixhosting/models.py
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import gitlab
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
from uncloud_pay.models import Order
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize logger.
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class VMInstance(models.Model):
|
||||||
|
owner = models.ForeignKey(get_user_model(),
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
editable=True)
|
||||||
|
|
||||||
|
vm_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||||
|
|
||||||
|
config = models.JSONField(null=False, blank=False)
|
||||||
|
|
||||||
|
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name='instance_id')
|
||||||
|
|
||||||
|
creation_date = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
termination_date = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Read the deployment yaml file and render the template
|
||||||
|
# Then save it as new yaml file and push it to github repo
|
||||||
|
if 'test' in sys.argv:
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
template_dir = os.path.join(os.path.dirname(__file__), 'yaml')
|
||||||
|
env = Environment(loader = FileSystemLoader(template_dir),autoescape = True)
|
||||||
|
tmpl = env.get_template('deployment.yaml.tmpl')
|
||||||
|
result = tmpl.render(
|
||||||
|
name=self.vm_id
|
||||||
|
)
|
||||||
|
gl = gitlab.Gitlab(settings.GITLAB_SERVER, oauth_token=settings.GITLAB_OAUTH_TOKEN)
|
||||||
|
project = gl.projects.get(settings.GITLAB_PROJECT_ID)
|
||||||
|
project.files.create({'file_path': settings.GITLAB_YAML_DIR + f'{self.vm_id}.yaml',
|
||||||
|
'branch': 'master',
|
||||||
|
'content': result,
|
||||||
|
'author_email': settings.GITLAB_AUTHOR_EMAIL,
|
||||||
|
'author_name': settings.GITLAB_AUTHOR_NAME,
|
||||||
|
'commit_message': f'Add New Deployment for {self.vm_id}'})
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
# Delete the deployment yaml file first then
|
||||||
|
# Then delete it
|
||||||
|
if 'test' in sys.argv:
|
||||||
|
return super().delete(*args, **kwargs)
|
||||||
|
gl = gitlab.Gitlab(settings.GITLAB_SERVER, oauth_token=settings.GITLAB_OAUTH_TOKEN)
|
||||||
|
project = gl.projects.get(settings.GITLAB_PROJECT_ID)
|
||||||
|
f_path = settings.GITLAB_YAML_DIR + f'{self.vm_id}.yaml'
|
||||||
|
file = project.files.get(file_path=f_path, ref='master')
|
||||||
|
if file:
|
||||||
|
project.files.delete(file_path=f_path,
|
||||||
|
commit_message=f'Delete {self.vm_id}', branch='master',
|
||||||
|
author_email=settings.GITLAB_AUTHOR_EMAIL,
|
||||||
|
author_name=settings.GITLAB_AUTHOR_NAME)
|
||||||
|
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.id}-{self.order}"
|
||||||
|
|
||||||
|
def delete_for_bill(self, bill):
|
||||||
|
#TODO delete related instances
|
||||||
|
return True
|
8
matrixhosting/serializers.py
Normal file
8
matrixhosting/serializers.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .models import *
|
||||||
|
|
||||||
|
class VMInstanceSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = VMInstance
|
||||||
|
fields = '__all__'
|
10
matrixhosting/signals.py
Normal file
10
matrixhosting/signals.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from matrixhosting.models import VMInstance
|
||||||
|
from uncloud_pay.models import Order
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Order)
|
||||||
|
def create_instance(sender, instance, created, **kwargs):
|
||||||
|
machine = VMInstance.objects.filter(order=instance).first()
|
||||||
|
if not machine:
|
||||||
|
VMInstance.objects.create(owner=instance.owner, order=instance, config=instance.config)
|
1346
matrixhosting/static/matrixhosting/css/common.css
Normal file
1346
matrixhosting/static/matrixhosting/css/common.css
Normal file
File diff suppressed because it is too large
Load diff
618
matrixhosting/static/matrixhosting/css/hosting.css
Normal file
618
matrixhosting/static/matrixhosting/css/hosting.css
Normal file
|
@ -0,0 +1,618 @@
|
||||||
|
.navbar-transparent #logoWhite {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-transparent #logoBlack {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topnav .navbar-fixed-top .navbar-collapse {
|
||||||
|
max-height: 740px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-default .navbar-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-right .highlights-dropdown .dropdown-menu {
|
||||||
|
left: 0 !important;
|
||||||
|
min-width: 155px;
|
||||||
|
margin-left: 15px;
|
||||||
|
padding: 0 5px 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
.navbar-default .navbar-nav>li a,
|
||||||
|
.navbar-right .highlights-dropdown .dropdown-menu>li a {
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
.navbar-right .highlights-dropdown .dropdown-menu {
|
||||||
|
border-width: 0 0 1px 0;
|
||||||
|
border-color: #e7e7e7;
|
||||||
|
box-shadow: -8px 14px 20px -5px rgba(77, 77, 77, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-right .highlights-dropdown .dropdown-menu>li a {
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'Lato', sans-serif;
|
||||||
|
padding: 1px 10px 1px 18px !important;
|
||||||
|
background: transparent;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-right .highlights-dropdown .dropdown-menu>li a:hover,
|
||||||
|
.navbar-right .highlights-dropdown .dropdown-menu>li a:focus,
|
||||||
|
.navbar-right .highlights-dropdown .dropdown-menu>li a:active {
|
||||||
|
background: transparent;
|
||||||
|
text-decoration: underline !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.un-icon {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/***** DCL payment page **********/
|
||||||
|
|
||||||
|
.dcl-order-container {
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-place-order-text {
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-warning-content {
|
||||||
|
font-weight: 300;
|
||||||
|
border: 1px solid #a1a1a1;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 5px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-warning-error {
|
||||||
|
border: 1px solid #EB4D5C;
|
||||||
|
color: #EB4D5C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-warning-addtional-margin {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-cvc-element label {
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-element {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-element label {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-input {
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-cvc-element .my-input {
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#card-errors {
|
||||||
|
clear: both;
|
||||||
|
padding: 0 0 10px;
|
||||||
|
color: #eb4d5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-card-goup {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.card-expiry-element {
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-cvc-element {
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#billing-form .form-control {
|
||||||
|
box-shadow: none !important;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.dcl-order-container {
|
||||||
|
width: 990px;
|
||||||
|
padding: 0 15px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-vm p.copyright {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-default .navbar-nav>.open>a,
|
||||||
|
.navbar-default .navbar-nav>.open>a:focus,
|
||||||
|
.navbar-default .navbar-nav>.open>a:hover,
|
||||||
|
.navbar-default .navbar-nav>.active>a,
|
||||||
|
.navbar-default .navbar-nav>.active>a:focus,
|
||||||
|
.navbar-default .navbar-nav>.active>a:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.navbar-default .navbar-nav .open .dropdown-menu>.active a,
|
||||||
|
.navbar-default .navbar-nav .open .dropdown-menu>.active a:focus,
|
||||||
|
.navbar-default .navbar-nav .open .dropdown-menu>.active a:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* bootstrap input box-shadow disable */
|
||||||
|
|
||||||
|
.has-error .form-control:focus,
|
||||||
|
.has-error .form-control:active,
|
||||||
|
.has-success .form-control:focus,
|
||||||
|
.has-success .form-control:active {
|
||||||
|
box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-dashboard {
|
||||||
|
min-height: calc(100vh - 96px);
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 1120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.content-dashboard {
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
select {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus,
|
||||||
|
.btn:active:focus {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/***********Styles for Model********************/
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
border-radius: 0px;
|
||||||
|
font-family: Lato, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
width: 100%;
|
||||||
|
float: left;
|
||||||
|
border-radius: 0;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
min-height: 30px;
|
||||||
|
border-bottom: 0px solid #e5e5e5;
|
||||||
|
padding: 0px 15px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header .close {
|
||||||
|
font-size: 75px;
|
||||||
|
font-weight: 300;
|
||||||
|
margin-top: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 11px;
|
||||||
|
z-index: 10;
|
||||||
|
line-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header .close span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header .close:focus {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
float: left;
|
||||||
|
padding: 0px 30px 15px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body .modal-icon i {
|
||||||
|
font-size: 80px;
|
||||||
|
font-weight: 100;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body .modal-icon {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.42857143;
|
||||||
|
font-size: 25px;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-text {
|
||||||
|
padding-top: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-text p:not(:last-of-type) {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title+.modal-footer {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
border-top: 0px solid #e5e5e5;
|
||||||
|
width: 100%;
|
||||||
|
float: left;
|
||||||
|
text-align: center;
|
||||||
|
padding: 15px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 40%;
|
||||||
|
margin: 15px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 991px) {
|
||||||
|
.modal-dialog {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.modal-dialog {
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media(min-width: 576px) {
|
||||||
|
.modal:before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
height: 100%;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: -4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ========= */
|
||||||
|
|
||||||
|
.btn-wide {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn {
|
||||||
|
min-width: 110px;
|
||||||
|
background-color: #3C5480;
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid #3C5480;
|
||||||
|
padding: 4px 10px;
|
||||||
|
transition: 0.3s all ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn:focus,
|
||||||
|
.choice-btn:hover,
|
||||||
|
.choice-btn:active {
|
||||||
|
color: #3C5480;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.choice-btn {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-container {
|
||||||
|
padding-top: 70px;
|
||||||
|
padding-bottom: 11%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-payment-section {
|
||||||
|
max-width: 391px;
|
||||||
|
margin: 0 auto 30px;
|
||||||
|
padding: 0 10px 30px;
|
||||||
|
border-bottom: 1px solid #edebeb;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-payment-section hr {
|
||||||
|
margin-top: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-payment-section .top-hr {
|
||||||
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-payment-section h3 {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-payment-section p {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-payment-section .card-warning-content {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-payment-order strong {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-payment-order p {
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-payment-section .form-group {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-payment-section .form-control {
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-payment-user {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dcl-payment-user h4 {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.dcl-payment-grid {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.dcl-payment-box {
|
||||||
|
width: 50%;
|
||||||
|
position: relative;
|
||||||
|
padding: 0 30px;
|
||||||
|
}
|
||||||
|
.dcl-payment-box:nth-child(2) {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
.dcl-payment-box:nth-child(4) {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
.dcl-payment-section {
|
||||||
|
padding-top: 15px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-bottom-width: 5px;
|
||||||
|
}
|
||||||
|
.dcl-payment-box:nth-child(2n) .dcl-payment-section {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.dcl-payment-box:nth-child(1):after,
|
||||||
|
.dcl-payment-box:nth-child(2):after {
|
||||||
|
content: ' ';
|
||||||
|
display: block;
|
||||||
|
background: #eee;
|
||||||
|
width: 1px;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
top: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#virtual_machine_create_form {
|
||||||
|
padding: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-vm-contact {
|
||||||
|
color: #fff;
|
||||||
|
background: #A3C0E2;
|
||||||
|
border: 2px solid #A3C0E2;
|
||||||
|
padding: 5px 25px;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-vm-contact:hover,
|
||||||
|
.btn-vm-contact:focus {
|
||||||
|
background: #fff;
|
||||||
|
color: #a3c0e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* hosting-order */
|
||||||
|
|
||||||
|
.order-detail-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 100px auto 40px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 30px 30px 20px;
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-container .dashboard-title-thin {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-left: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-container .dashboard-title-thin .un-icon {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-container .dashboard-container-head {
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-container .order-details {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-container h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-container p {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-container hr {
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-container .thin-hr {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-container .subtotal-price {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-container .subtotal-price .text-primary {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-detail-container .total-price {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.order-detail-container {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
.order-confirm-btn {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.order-detail-container .dashboard-container-options {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: -4px;
|
||||||
|
}
|
||||||
|
.order-detail-container .dashboard-container-options .svg-img {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.order_detail_footer {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order_detail_footer strong {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order_detail_footer small {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title-thin {
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title-thin .un-icon {
|
||||||
|
height: 34px;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-top: -2px;
|
||||||
|
width: 34px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width:767px) {
|
||||||
|
.dashboard-title-thin {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.dashboard-title-thin .un-icon {
|
||||||
|
height: 22px;
|
||||||
|
width: 22px;
|
||||||
|
margin-top: -3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale_date {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale_date.done {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-vm-back {
|
||||||
|
color: #fff;
|
||||||
|
background: #C4CEDA;
|
||||||
|
border: 2px solid #C4CEDA;
|
||||||
|
padding: 5px 25px;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-vm-back:hover,
|
||||||
|
.btn-vm-back:focus {
|
||||||
|
color: #fff;
|
||||||
|
background: #8da4c0;
|
||||||
|
border-color: #8da4c0;
|
||||||
|
}
|
||||||
|
|
46
matrixhosting/static/matrixhosting/js/main.js
Normal file
46
matrixhosting/static/matrixhosting/js/main.js
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
(function($) {
|
||||||
|
"use strict"; // Start of use strict
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
function fetch_pricing() {
|
||||||
|
var url = '/matrix/pricing/' + $('#pricing_name').val() + '/calculate/';
|
||||||
|
var cores = $('#cores').val();
|
||||||
|
var memory = $('#memory').val();
|
||||||
|
var storage = $('#storage').val();
|
||||||
|
$.ajax({
|
||||||
|
type: 'GET',
|
||||||
|
url: url,
|
||||||
|
data: { cores: cores, memory: memory, storage: storage},
|
||||||
|
dataType: 'json',
|
||||||
|
success: function (data) {
|
||||||
|
if (data && data['price']) {
|
||||||
|
$('#total').text(data['price']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function incrementValue(e) {
|
||||||
|
var valueElement = $(e.target).parent().parent().find('input');
|
||||||
|
var step = $(valueElement).attr('step');
|
||||||
|
var min = parseInt($(valueElement).attr('min'));
|
||||||
|
var max = parseInt($(valueElement).attr('max'));
|
||||||
|
var new_value = 0;
|
||||||
|
if (e.data.inc == 1) {
|
||||||
|
new_value = Math.min(parseInt($(valueElement).val()) + parseInt(step) * e.data.inc, max);
|
||||||
|
} else {
|
||||||
|
new_value = Math.max(parseInt($(valueElement).val()) + parseInt(step) * e.data.inc, min);
|
||||||
|
}
|
||||||
|
$(valueElement).val(new_value);
|
||||||
|
fetch_pricing();
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if ($('#pricing_name') != undefined) {
|
||||||
|
fetch_pricing();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('.fa-plus-circle.right').bind('click', {inc: 1}, incrementValue);
|
||||||
|
|
||||||
|
$('.fa-minus-circle.left').bind('click', {inc: -1}, incrementValue);
|
||||||
|
});
|
||||||
|
})(jQuery);
|
36
matrixhosting/static/matrixhosting/js/order.js
Normal file
36
matrixhosting/static/matrixhosting/js/order.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
$( document ).ready(function() {
|
||||||
|
var create_vm_form = $('#virtual_machine_create_form');
|
||||||
|
create_vm_form.submit(placeOrderPayment);
|
||||||
|
function placeOrderPayment(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
$.ajax({
|
||||||
|
url: create_vm_form.attr('action'),
|
||||||
|
type: 'POST',
|
||||||
|
data: create_vm_form.serialize(),
|
||||||
|
init: function () {
|
||||||
|
ok_btn = $('#createvm-modal-done-btn');
|
||||||
|
close_btn = $('#createvm-modal-close-btn');
|
||||||
|
ok_btn.addClass('btn btn-success btn-ok btn-wide hide');
|
||||||
|
close_btn.addClass('btn btn-danger btn-ok btn-wide hide');
|
||||||
|
},
|
||||||
|
success: function (data) {
|
||||||
|
fa_icon = $('.modal-icon').find('.fa-cog');
|
||||||
|
modal_btn = $('#createvm-modal-done-btn');
|
||||||
|
if (data.error) {
|
||||||
|
// Display error.message in your UI.
|
||||||
|
modal_btn.attr('href', error_url).removeClass('visually-hidden');
|
||||||
|
fa_icon.attr('class', 'fa fa-close');
|
||||||
|
modal_btn.attr('class', '').addClass('btn btn-danger btn-ok btn-wide');
|
||||||
|
$('#createvm-modal-title').text("Error Occurred");
|
||||||
|
$('#createvm-modal-body').html(data.error.message);
|
||||||
|
} else {
|
||||||
|
// The payment has succeeded
|
||||||
|
// Display a success message
|
||||||
|
modal_btn.attr('href', data.redirect).removeClass('visually-hidden');
|
||||||
|
$('#createvm-modal-title').text("Order Succeeded");
|
||||||
|
$('#createvm-modal-body').html("Order has been added and the instance will be ready soon");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
204
matrixhosting/static/matrixhosting/js/payment.js
Normal file
204
matrixhosting/static/matrixhosting/js/payment.js
Normal file
|
@ -0,0 +1,204 @@
|
||||||
|
var cardBrandToPfClass = {
|
||||||
|
'visa': 'pf-visa',
|
||||||
|
'mastercard': 'pf-mastercard',
|
||||||
|
'amex': 'pf-american-express',
|
||||||
|
'discover': 'pf-discover',
|
||||||
|
'diners': 'pf-diners',
|
||||||
|
'jcb': 'pf-jcb',
|
||||||
|
'unknown': 'pf-credit-card'
|
||||||
|
};
|
||||||
|
function setBrandIcon(brand) {
|
||||||
|
var brandIconElement = document.getElementById('brand-icon');
|
||||||
|
var pfClass = 'pf-credit-card';
|
||||||
|
if (brand in cardBrandToPfClass) {
|
||||||
|
pfClass = cardBrandToPfClass[brand];
|
||||||
|
}
|
||||||
|
for (var i = brandIconElement.classList.length - 1; i >= 0; i--) {
|
||||||
|
brandIconElement.classList.remove(brandIconElement.classList[i]);
|
||||||
|
}
|
||||||
|
brandIconElement.classList.add('pf');
|
||||||
|
brandIconElement.classList.add(pfClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$(document).ready(function () {
|
||||||
|
$.ajaxSetup({
|
||||||
|
beforeSend: function (xhr, settings) {
|
||||||
|
function getCookie(name) {
|
||||||
|
var cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie != '') {
|
||||||
|
var cookies = document.cookie.split(';');
|
||||||
|
for (var i = 0; i < cookies.length; i++) {
|
||||||
|
var cookie = jQuery.trim(cookies[i]);
|
||||||
|
// Does this cookie string begin with the name we want?
|
||||||
|
if (cookie.substring(0, name.length + 1) == (name + '=')) {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(/^http:.*/.test(settings.url) || /^https:.*/.test(settings.url))) {
|
||||||
|
// Only send the token to relative URLs i.e. locally.
|
||||||
|
xhr.setRequestHeader("X-CSRFToken", getCookie('csrftoken'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
var hasCreditcard = window.hasCreditcard || false;
|
||||||
|
if (!hasCreditcard && window.stripeKey) {
|
||||||
|
var stripe = Stripe(window.stripeKey);
|
||||||
|
if (window.pm_id) {
|
||||||
|
|
||||||
|
} else {
|
||||||
|
var element_style = {
|
||||||
|
fonts: [{
|
||||||
|
family: 'lato-light',
|
||||||
|
src: 'url(https://cdn.jsdelivr.net/font-lato/2.0/Lato/Lato-Light.woff) format("woff2")'
|
||||||
|
}, {
|
||||||
|
family: 'lato-regular',
|
||||||
|
src: 'url(https://cdn.jsdelivr.net/font-lato/2.0/Lato/Lato-Regular.woff) format("woff2")'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
locale: window.current_lan
|
||||||
|
};
|
||||||
|
var elements = stripe.elements(element_style);
|
||||||
|
var credit_card_text_style = {
|
||||||
|
base: {
|
||||||
|
iconColor: '#666EE8',
|
||||||
|
color: '#31325F',
|
||||||
|
lineHeight: '25px',
|
||||||
|
fontWeight: 300,
|
||||||
|
fontFamily: "'lato-light', sans-serif",
|
||||||
|
fontSize: '14px',
|
||||||
|
'::placeholder': {
|
||||||
|
color: '#777'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
invalid: {
|
||||||
|
iconColor: '#eb4d5c',
|
||||||
|
color: '#eb4d5c',
|
||||||
|
lineHeight: '25px',
|
||||||
|
fontWeight: 300,
|
||||||
|
fontFamily: "'lato-regular', sans-serif",
|
||||||
|
fontSize: '14px',
|
||||||
|
'::placeholder': {
|
||||||
|
color: '#eb4d5c',
|
||||||
|
fontWeight: 400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var enter_ccard_text = "Enter your credit card number";
|
||||||
|
if (typeof window.enter_your_card_text !== 'undefined') {
|
||||||
|
enter_ccard_text = window.enter_your_card_text;
|
||||||
|
}
|
||||||
|
var cardNumberElement = elements.create('cardNumber', {
|
||||||
|
style: credit_card_text_style,
|
||||||
|
placeholder: enter_ccard_text
|
||||||
|
});
|
||||||
|
cardNumberElement.mount('#card-number-element');
|
||||||
|
|
||||||
|
var cardExpiryElement = elements.create('cardExpiry', {
|
||||||
|
style: credit_card_text_style
|
||||||
|
});
|
||||||
|
cardExpiryElement.mount('#card-expiry-element');
|
||||||
|
|
||||||
|
var cardCvcElement = elements.create('cardCvc', {
|
||||||
|
style: credit_card_text_style
|
||||||
|
});
|
||||||
|
cardCvcElement.mount('#card-cvc-element');
|
||||||
|
cardNumberElement.on('change', function (event) {
|
||||||
|
if (event.brand) {
|
||||||
|
setBrandIcon(event.brand);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitBillingForm(pmId) {
|
||||||
|
var billing_form = $('#billing-form');
|
||||||
|
billing_form.append('<input type="hidden" name="id_payment_method" value="' + pmId + '" />');
|
||||||
|
billing_form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
var $form_new = $('#payment-form-new');
|
||||||
|
$form_new.submit(payWithPaymentIntent);
|
||||||
|
window.result = "";
|
||||||
|
window.card = "";
|
||||||
|
function payWithPaymentIntent(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
function stripePMHandler(paymentMethod) {
|
||||||
|
// Insert the token ID into the form so it gets submitted to the server
|
||||||
|
console.log(paymentMethod);
|
||||||
|
$('#id_payment_method').val(paymentMethod.id);
|
||||||
|
submitBillingForm(paymentMethod.id);
|
||||||
|
}
|
||||||
|
stripe.createPaymentMethod({
|
||||||
|
type: 'card',
|
||||||
|
card: cardNumberElement,
|
||||||
|
})
|
||||||
|
.then(function(result) {
|
||||||
|
// Handle result.error or result.paymentMethod
|
||||||
|
window.result = result;
|
||||||
|
if(result.error) {
|
||||||
|
var errorElement = document.getElementById('card-errors');
|
||||||
|
errorElement.textContent = result.error.message;
|
||||||
|
} else {
|
||||||
|
console.log("created paymentMethod " + result.paymentMethod.id);
|
||||||
|
stripePMHandler(result.paymentMethod);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.card = cardNumberElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form validation */
|
||||||
|
$.validator.addMethod("month", function (value, element) {
|
||||||
|
return this.optional(element) || /^(01|02|03|04|05|06|07|08|09|10|11|12)$/.test(value);
|
||||||
|
}, "Please specify a valid 2-digit month.");
|
||||||
|
|
||||||
|
$.validator.addMethod("year", function (value, element) {
|
||||||
|
return this.optional(element) || /^[0-9]{2}$/.test(value);
|
||||||
|
}, "Please specify a valid 2-digit year.");
|
||||||
|
|
||||||
|
validator = $form_new.validate({
|
||||||
|
rules: {
|
||||||
|
cardNumber: {
|
||||||
|
required: true,
|
||||||
|
creditcard: true,
|
||||||
|
digits: true
|
||||||
|
},
|
||||||
|
expMonth: {
|
||||||
|
required: true,
|
||||||
|
month: true
|
||||||
|
},
|
||||||
|
expYear: {
|
||||||
|
required: true,
|
||||||
|
year: true
|
||||||
|
},
|
||||||
|
cvCode: {
|
||||||
|
required: true,
|
||||||
|
digits: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
highlight: function (element) {
|
||||||
|
$(element).closest('.form-control').removeClass('success').addClass('error');
|
||||||
|
},
|
||||||
|
unhighlight: function (element) {
|
||||||
|
$(element).closest('.form-control').removeClass('error').addClass('success');
|
||||||
|
},
|
||||||
|
errorPlacement: function (error, element) {
|
||||||
|
$(element).closest('.form-group').append(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.credit-card-info .btn.choice-btn').click(function () {
|
||||||
|
var id = this.dataset['id_card'];
|
||||||
|
$('#id_card').val(id);
|
||||||
|
submitBillingForm(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
64
matrixhosting/tasks.py
Normal file
64
matrixhosting/tasks.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import logging
|
||||||
|
from datetime import date, timedelta, timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django_q.tasks import async_task, schedule
|
||||||
|
from django_q.models import Schedule
|
||||||
|
from django.db.models import Q
|
||||||
|
from uncloud_pay.models import Bill, Payment
|
||||||
|
from uncloud_pay.selectors import has_enough_balance, get_balance_for_user
|
||||||
|
from .models import VMInstance
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def send_warning_email(bill, html_message):
|
||||||
|
schedule('django.core.mail.send_mail',
|
||||||
|
'Renewal Warning',
|
||||||
|
None,
|
||||||
|
settings.RENEWAL_FROM_EMAIL,
|
||||||
|
[bill.owner.email],
|
||||||
|
html_message,
|
||||||
|
schedule_type=Schedule.ONCE,
|
||||||
|
next_run=timezone.now() + timedelta(hours=1))
|
||||||
|
|
||||||
|
def charge_open_bills():
|
||||||
|
un_paid_bills = Bill.objects.filter(is_closed=False)
|
||||||
|
for bill in un_paid_bills:
|
||||||
|
date_diff = (date.today() - bill.due_date.date()).days
|
||||||
|
# If there is not enough money in the account 7 days before renewal, the system sends a warning
|
||||||
|
# If there is not enough money in the account 3 days before renewal, the system sends a 2nd warning
|
||||||
|
# If on renewal date there is not enough money in the account, delete the instance
|
||||||
|
if date_diff == 7:
|
||||||
|
if not has_enough_balance(bill.owner):
|
||||||
|
context = {'name': bill.owner.name, 'message': "You don't have enough balance for renewal... upload to your account _here"}
|
||||||
|
html_message = render_to_string('matrixhosting/emails/renewal_warning.html', context)
|
||||||
|
send_warning_email(bill, html_message)
|
||||||
|
elif date_diff == 3:
|
||||||
|
if not has_enough_balance(bill.owner):
|
||||||
|
context = {'name': bill.owner.name, 'message': "You don't have enough balance for renewal... Your instance will be deleted in 3 days"}
|
||||||
|
html_message = render_to_string('matrixhosting/emails/renewal_warning.html', context)
|
||||||
|
send_warning_email(bill, html_message)
|
||||||
|
elif date_diff <= 0:
|
||||||
|
if not has_enough_balance(bill.owner):
|
||||||
|
VMInstance.delete_for_bill(bill)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
balance = get_balance_for_user(bill.owner)
|
||||||
|
if balance < 0:
|
||||||
|
payment = Payment.objects.create(owner=bill.owner, amount=balance, source='stripe')
|
||||||
|
if payment:
|
||||||
|
bill.close()
|
||||||
|
bill.close()
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"It seems that there is issue in payment for {bill.owner.name}", e)
|
||||||
|
# do nothing
|
||||||
|
|
||||||
|
|
||||||
|
def process_recurring_orders():
|
||||||
|
"""
|
||||||
|
Check for pending recurring and charge it and generate bills or send the customer warning
|
||||||
|
"""
|
||||||
|
Bill.create_bills_for_all_users()
|
||||||
|
|
||||||
|
def delete_instance(instance_id):
|
||||||
|
VMInstance.objects.delete(instance_id)
|
60
matrixhosting/templates/matrixhosting/base.html
Normal file
60
matrixhosting/templates/matrixhosting/base.html
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
{% load static i18n %}
|
||||||
|
{% get_current_language as LANGUAGE_CODE %}
|
||||||
|
{% load bootstrap5 %}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{LANGUAGE_CODE}}">
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="Matrix Hosting by ungleich">
|
||||||
|
<meta name="author" content="ungleich glarus ag">
|
||||||
|
<title>Matrix Hosting - {% block title %} made in Switzerland{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Vendor CSS -->
|
||||||
|
<!-- Bootstrap Core CSS -->
|
||||||
|
{% bootstrap_css %}
|
||||||
|
<!-- Icon Fonts -->
|
||||||
|
<link href="{% static 'fontawesome_free/css/all.min.css' %}" rel="stylesheet" type="text/css">
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link href="{% static 'matrixhosting/css/common.css' %}" rel="stylesheet">
|
||||||
|
{% block css_extra %}
|
||||||
|
{% endblock css_extra %}
|
||||||
|
|
||||||
|
<!-- External Fonts -->
|
||||||
|
<link href="//fonts.googleapis.com/css?family=Lato:300,400,600,700" rel="stylesheet" type="text/css">
|
||||||
|
|
||||||
|
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||||
|
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||||
|
<!--[if lt IE 9]>
|
||||||
|
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
||||||
|
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
|
||||||
|
<![endif]-->
|
||||||
|
|
||||||
|
<!-- Google analytics -->
|
||||||
|
<!-- End Google Analytics -->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{% block navbar %}
|
||||||
|
{% include "matrixhosting/includes/_navbar.html" %}
|
||||||
|
{% endblock navbar %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% include "matrixhosting/includes/_footer.html" %}
|
||||||
|
|
||||||
|
<!-- jQuery -->
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
||||||
|
<script src="{% static 'fontawesome_free/js/all.min.js' %}"></script>
|
||||||
|
<!-- Bootstrap Core JavaScript -->
|
||||||
|
{% bootstrap_javascript %}
|
||||||
|
<!-- Custom JS -->
|
||||||
|
<script src="{% static 'matrixhosting/js/main.js' %}"></script>
|
||||||
|
{% block js_extra %}
|
||||||
|
{% endblock js_extra %}
|
||||||
|
</body>
|
||||||
|
</html>
|
127
matrixhosting/templates/matrixhosting/dashboard.html
Normal file
127
matrixhosting/templates/matrixhosting/dashboard.html
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
{% extends "matrixhosting/base.html" %} {% load static i18n %}
|
||||||
|
{% block content%}
|
||||||
|
<!-- Page Content -->
|
||||||
|
{% csrf_token %}
|
||||||
|
<div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">#</th>
|
||||||
|
<th scope="col">Description</th>
|
||||||
|
<th scope="col">Starting At</th>
|
||||||
|
<th scope="col">Config</th>
|
||||||
|
<th scope="col">Pricing Plan</th>
|
||||||
|
<th scope="col">OneTime Price</th>
|
||||||
|
<th scope="col">Recurring Price</th>
|
||||||
|
<th scope="col">Ending At</th>
|
||||||
|
<th scope="col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for object in object_list %}
|
||||||
|
<tr data-id="{{object.id}}">
|
||||||
|
<th scope="row">{{ object.id }}</th>
|
||||||
|
<td>{{ object.description }}</td>
|
||||||
|
<td>{{ object.starting_date }}</td>
|
||||||
|
<td>{{ object.config }}</td>
|
||||||
|
<td>{{ object.pricing_plan}}</td>
|
||||||
|
<td>{{ object.one_time_price }}</td>
|
||||||
|
<td>{{ object.recurring_price }}</td>
|
||||||
|
<td>{{ object.ending_date }}</td>
|
||||||
|
{% if object.ending_date %}
|
||||||
|
<td></td>
|
||||||
|
{% else %}
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger btn-sm cancel-subscription"
|
||||||
|
type="submit"
|
||||||
|
name="action"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="modal fade"
|
||||||
|
tabindex="-1"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="mySmallModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
id="mi-modal"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="myModalLabel">Cancel Subscription</h4>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>
|
||||||
|
Are you sure that you want to cancel this subscription?. </p>
|
||||||
|
<p>
|
||||||
|
The instance will be active till the end date of the last bill and will be deleted
|
||||||
|
after that.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-default" id="modal-btn-yes">
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="modal-btn-no">
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert" role="alert" id="result"></div>
|
||||||
|
<!-- /.banner -->
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_extra %}
|
||||||
|
<script type="text/javascript">
|
||||||
|
var modalConfirm = function (callback) {
|
||||||
|
$(".cancel-subscription").on("click", function (event) {
|
||||||
|
$('.selected').removeClass('selected');
|
||||||
|
$(event.target).parent().parent().addClass('selected');
|
||||||
|
$("#mi-modal").modal("show");
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#modal-btn-yes").on("click", function () {
|
||||||
|
callback(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#modal-btn-no").on("click", function () {
|
||||||
|
callback(false);
|
||||||
|
$("#mi-modal").modal("hide");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
modalConfirm(function (confirm) {
|
||||||
|
if (confirm) {
|
||||||
|
var selected_order = $('.selected').data('id');
|
||||||
|
$.ajax({
|
||||||
|
url: '{% url "matrix:dashboard" %}',
|
||||||
|
type: 'POST',
|
||||||
|
data: {'order_id': selected_order, 'csrfmiddlewaretoken': '{{ csrf_token }}',},
|
||||||
|
success: function (data) {
|
||||||
|
$("#mi-modal").modal("hide");
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Renewal Warning</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
hello <strong>{{name}},</strong>
|
||||||
|
{{message}}
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,101 @@
|
||||||
|
{% load static i18n %}
|
||||||
|
|
||||||
|
<form id="order_form" method="POST" action="{% url 'matrix:index' %}" data-toggle="validator" role="form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="title">
|
||||||
|
<h3>{% trans "Matrix Chat hosting" %} </h3>
|
||||||
|
</div>
|
||||||
|
<div class="price">
|
||||||
|
<span id="total"> {{ matrix_vm_pricing.name }}</span>
|
||||||
|
<span>CHF/{% trans "month" %}</span>
|
||||||
|
<div class="price-text">
|
||||||
|
<p>
|
||||||
|
{% if matrix_vm_pricing.set_up_fees %}{{ matrix_vm_pricing.set_up_fees }} CHF Setup<br>{% endif %}
|
||||||
|
{% if matrix_vm_pricing.vat_inclusive %}{% trans "VAT included" %} <br>{% endif %}
|
||||||
|
{% if matrix_vm_pricing.discount_amount %}
|
||||||
|
{% trans "You save" %} {{ matrix_vm_pricing.discount_amount }} CHF
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="descriptions">
|
||||||
|
<div class="description form-group">
|
||||||
|
<p>{% trans "Hosted in Switzerland" %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="description input">
|
||||||
|
<i class="fa fa-minus-circle left" data-minus="cores" aria-hidden="true"></i>
|
||||||
|
<input class="input-price select-number" type="number" min="1" max="48" id="cores" step="1" name="cores"
|
||||||
|
{% if form.cores.value != None %}value="{{ form.cores.value }}"{% endif %} data-error="{% trans 'Please enter a value in range 1 - 48.' %}" required>
|
||||||
|
<span> Core</span>
|
||||||
|
<i class="fa fa-plus-circle right" data-plus="cores" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<div class="help-block with-errors">
|
||||||
|
{% for message in messages %}
|
||||||
|
{% if 'cores' in message.tags %}
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li>{{ message|safe }}</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="description input">
|
||||||
|
<i class="fa fa-minus-circle left" data-minus="memory" aria-hidden="true"></i>
|
||||||
|
<input id="memory" class="input-price select-number" type="number" min="2" max="200" name="memory"
|
||||||
|
{% if form.memory.value != None %}value="{{ form.memory.value }}"{% endif %} data-error="{% blocktrans with min_ram=min_ram %}Please enter a value in range {{min_ram}} - 200.{% endblocktrans %}" required step="1">
|
||||||
|
<span> GB RAM</span>
|
||||||
|
<i class="fa fa-plus-circle right" data-plus="memory" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<div class="help-block with-errors">
|
||||||
|
{% for message in messages %}
|
||||||
|
{% if 'memory' in message.tags %}
|
||||||
|
<ul class="list-unstyled"><li>
|
||||||
|
{{ message|safe }}
|
||||||
|
</li></ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="description input">
|
||||||
|
<i class="fa fa-minus-circle left" data-minus="storage" aria-hidden="true"></i>
|
||||||
|
<input id="storage" class="input-price select-number" type="number" min="100" max="10000" step="100"
|
||||||
|
name="storage" {% if form.storage.value != None %}value="{{ form.storage.value }}"{% endif %} data-error="{% trans 'Please enter a value in range 100 - 10000.' %}" required>
|
||||||
|
<span>{% trans "GB Storage (SSD)" %}</span>
|
||||||
|
<i class="fa fa-plus-circle right" data-plus="storage" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<div class="help-block with-errors">
|
||||||
|
{% for message in messages %}
|
||||||
|
{% if 'storage' in message.tags %}
|
||||||
|
<ul class="list-unstyled"><li>
|
||||||
|
{{ message|safe }}
|
||||||
|
</li></ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="description domain select-configuration input form-group justify-center">
|
||||||
|
<input type="text" id="matrix_domain" name="matrix_domain" placeholder="Matrix Domain" {% if form.matrix_domain.value != None %}value="{{ form.matrix_domain.value }}"{% endif %}></input>
|
||||||
|
<p class="text-danger">{{ form.matrix_domain.errors }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="description domain select-configuration input form-group justify-center">
|
||||||
|
<input type="text" id="homeserver_domain" name="homeserver_domain" placeholder="Homeserver Domain" {% if form.homeserver_domain.value != None %}value="{{ form.homeserver_domain.value }}"{% endif %} ></input>
|
||||||
|
<p class="text-danger">{{ form.homeserver_domain.errors }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="description domain select-configuration input form-group justify-center">
|
||||||
|
<input type="text" id="webclient_domain" name="webclient_domain" placeholder="Webclient Domain" {% if form.webclient_domain.value != None %}value="{{ form.webclient_domain.value }}"{% endif %}></input>
|
||||||
|
<p class="text-danger">{{ form.webclient_domain.errors }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="description input form-group">
|
||||||
|
<div class="fieldWrapper">
|
||||||
|
<span>Is open registration possible:</span>
|
||||||
|
{{ form.is_open_registration }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="pricing_name" id="pricing_name" value="{% if matrix_vm_pricing.name %}{{matrix_vm_pricing.name}}{% else %}unknown{% endif%}"></input>
|
||||||
|
<input type="submit" class="btn btn-primary" value="{% trans 'Continue' %}"></input>
|
||||||
|
</form>
|
43
matrixhosting/templates/matrixhosting/includes/_card.html
Normal file
43
matrixhosting/templates/matrixhosting/includes/_card.html
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
{% load i18n %}
|
||||||
|
<form action="" id="payment-form-new" method="POST">
|
||||||
|
<input type="hidden" name="token"/>
|
||||||
|
<input type="hidden" name="id_card" id="id_card" value=""/>
|
||||||
|
<div class="group">
|
||||||
|
<div class="credit-card-goup">
|
||||||
|
<div class="card-element card-number-element">
|
||||||
|
<label>{%trans "Card Number" %}</label>
|
||||||
|
<div id="card-number-element" class="field my-input"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xs-5 card-element card-expiry-element">
|
||||||
|
<label>{%trans "Expiry Date" %}</label>
|
||||||
|
<div id="card-expiry-element" class="field my-input"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-3 col-xs-offset-4 card-element card-cvc-element">
|
||||||
|
<label>{%trans "CVC" %}</label>
|
||||||
|
<div id="card-cvc-element" class="field my-input"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-element brand">
|
||||||
|
<label>{%trans "Card Type" %}</label>
|
||||||
|
<i class="pf pf-credit-card" id="brand-icon"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="card-errors"></div>
|
||||||
|
<div id='payment_error'>
|
||||||
|
{% for message in messages %}
|
||||||
|
{% if 'failed_payment' in message.tags or 'make_charge_error' in message.tags or 'error' in message.tags %}
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li><p class="card-warning-content card-warning-error">{{ message|safe }}</p></li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<button class="btn btn-vm-contact btn-wide" type="submit" name="payment-form">{%trans "SUBMIT" %}</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:none;">
|
||||||
|
<p class="payment-errors"></p>
|
||||||
|
</div>
|
||||||
|
</form>
|
18
matrixhosting/templates/matrixhosting/includes/_footer.html
Normal file
18
matrixhosting/templates/matrixhosting/includes/_footer.html
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{% load i18n %}
|
||||||
|
<footer>
|
||||||
|
<div class="container">
|
||||||
|
<ul class="list-inline">
|
||||||
|
<li>
|
||||||
|
<a class="url-init" href="">{% trans "Home" %}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="url-init" href="">{% trans "Contact" %}</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="url-init" href="">{% trans "Terms of Service" %}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="copyright text-muted small">Copyright © ungleich glarus ag {% now "Y" %}. {% trans "All Rights Reserved" %}</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
33
matrixhosting/templates/matrixhosting/includes/_navbar.html
Normal file
33
matrixhosting/templates/matrixhosting/includes/_navbar.html
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
{% load static i18n %}
|
||||||
|
{% get_current_language as LANGUAGE_CODE %}
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="{% url 'uncloudindex' %}">Matrix Hosting</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNavDropdown">
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" aria-current="page" href="{% url 'matrix:index' %}">Home</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#">Features</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#">Pricing</a>
|
||||||
|
</li>
|
||||||
|
{% if not request.user.is_authenticated %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'account_login' %}">{% trans "Login" %} <i class="fa fa-sign-in-alt"></i></a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'matrix:dashboard' %}">{% trans "Dashboard" %}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
21
matrixhosting/templates/matrixhosting/index.html
Normal file
21
matrixhosting/templates/matrixhosting/index.html
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{% extends "matrixhosting/base.html" %}
|
||||||
|
{% load static i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Page Content -->
|
||||||
|
|
||||||
|
<div class="split-section pricing-section section-gradient" id="price">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="price-calc-section">
|
||||||
|
<div class="card">
|
||||||
|
{% include "matrixhosting/includes/_calculator_form.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- /.banner -->
|
||||||
|
{% endblock %}
|
268
matrixhosting/templates/matrixhosting/order_detail.html
Normal file
268
matrixhosting/templates/matrixhosting/order_detail.html
Normal file
|
@ -0,0 +1,268 @@
|
||||||
|
{% load static i18n %}
|
||||||
|
{% load bootstrap5 %}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="Matrix Hosting by ungleich">
|
||||||
|
<meta name="author" content="ungleich glarus ag">
|
||||||
|
<title>Matrix Hosting - {% block title %} made in Switzerland{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Vendor CSS -->
|
||||||
|
<!-- Bootstrap Core CSS -->
|
||||||
|
{% bootstrap_css %}
|
||||||
|
|
||||||
|
<!-- External Fonts -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/paymentfont/1.2.5/css/paymentfont.min.css"/>
|
||||||
|
<link href="//fonts.googleapis.com/css?family=Lato:300,400,600,700" rel="stylesheet" type="text/css">
|
||||||
|
|
||||||
|
<link href="{% static 'matrixhosting/css/hosting.css' %}" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||||
|
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||||
|
<!--[if lt IE 9]>
|
||||||
|
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
||||||
|
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
window.paymentIntentSecret = "{{payment_intent_secret}}";
|
||||||
|
</script>
|
||||||
|
<div id="order-detail{{order.pk}}" class="order-detail-container">
|
||||||
|
{% if messages %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{% for message in messages %}
|
||||||
|
<span>{{ message }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not error %}
|
||||||
|
<div class="dashboard-container-head">
|
||||||
|
<h1 class="dashboard-title-thin">
|
||||||
|
{% blocktrans with page_header_text=page_header_text|default:"Order" %}{{page_header_text}}{% endblocktrans %}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="order-details">
|
||||||
|
<hr>
|
||||||
|
<div>
|
||||||
|
<address>
|
||||||
|
<h4>{% trans "Billed to" %}:</h4>
|
||||||
|
<p>
|
||||||
|
{% with request.session.billing_address_data as billing_address %}
|
||||||
|
{{billing_address.full_name}}<br>
|
||||||
|
{{billing_address.street}}, {{billing_address.postal_code}}<br>
|
||||||
|
{{billing_address.city}}, {{billing_address.country}}
|
||||||
|
{% if billing_address.vat_number %}
|
||||||
|
<br/>{% trans "VAT Number" %} {{billing_address.vat_number}}
|
||||||
|
{% if pricing.vat_country != "ch" and pricing.vat_validation_status != "not_needed" %}
|
||||||
|
{% if pricing.vat_validation_status == "verified" %}
|
||||||
|
<span class="fa fa-fw fa-check-circle" aria-hidden="true" title='{% trans "Your VAT number has been verified" %}'></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="fa fa-fw fa-info-circle" aria-hidden="true" title='{% trans "Your VAT number is under validation. VAT will be adjusted, once the validation is complete." %}'></span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</p>
|
||||||
|
</address>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div>
|
||||||
|
<h4>{% trans "Payment method" %}:</h4>
|
||||||
|
<p>
|
||||||
|
{{card.brand|default:_('Credit Card')}} {% trans "ending in" %} ****{{card.last4}}<br>
|
||||||
|
{% trans "Expiry" %} {{card.exp_year}}/{{card.exp_month}}<br/>
|
||||||
|
{{request.user.email}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div>
|
||||||
|
<h4>{% trans "Order summary" %}</h4>
|
||||||
|
<style>
|
||||||
|
@media screen and (max-width:400px){
|
||||||
|
.header-no-left-padding {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-width:767px){
|
||||||
|
.cmf-ord-heading {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.order-detail-container .order-details {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width:367px){
|
||||||
|
.cmf-ord-heading {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.order-detail-container .order-details {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<p>
|
||||||
|
<strong>{% trans "Product" %}:</strong>
|
||||||
|
Matrix Chat Hosting
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<p>
|
||||||
|
<span>{% trans "Cores" %}: </span>
|
||||||
|
<strong class="pull-right">{{order.cores}}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span>{% trans "Memory" %}: </span>
|
||||||
|
<strong class="pull-right">{{order.memory}} GB</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span>{% trans "Disk space" %}: </span>
|
||||||
|
<strong class="pull-right">{{order.storage}} GB</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<hr class="thin-hr">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<p>
|
||||||
|
<strong class="text-uppercase">{% trans "Price Before VAT" %}</strong>
|
||||||
|
<strong class="pull-right">{{pricing.subtotal|floatformat:2}} CHF</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<hr class="thin-hr">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 col-sm-4 col-xs-4">
|
||||||
|
<p><span></span></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-3 col-xs-4">
|
||||||
|
<p class="text-right"><strong class="cmf-ord-heading">{% trans "Pre VAT" %}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5 col-sm-5 col-xs-4 header-no-left-padding">
|
||||||
|
<p class="text-right"><strong class="cmf-ord-heading">{% trans "With VAT for" %} {{pricing.vat_country}} ({{pricing.vat_percent}}%)</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 col-sm-4 col-xs-4">
|
||||||
|
<p><span>Subtotal</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-3 col-xs-4">
|
||||||
|
<p><span class="pull-right" >{{pricing.subtotal|floatformat:2}} CHF</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5 col-sm-5 col-xs-4">
|
||||||
|
<p><span class="pull-right">{{pricing.price_with_vat|floatformat:2}} CHF</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if pricing.discount.amount > 0 %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 col-sm-4 col-xs-4">
|
||||||
|
<p><span>{{pricing.discount.name}}</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-3 col-xs-4">
|
||||||
|
<p><span class="pull-right">-{{pricing.discount.amount|floatformat:2}} CHF</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5 col-sm-5 col-xs-4">
|
||||||
|
<p><span class="pull-right">-{{pricing.discount.amount_with_vat|floatformat:2}} CHF</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<hr class="thin-hr">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 col-sm-4 col-xs-4">
|
||||||
|
<p><strong>Total</strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-3 col-xs-4">
|
||||||
|
<p><strong class="pull-right">{{pricing.subtotal_after_discount|floatformat:2}} CHF</strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5 col-sm-5 col-xs-4">
|
||||||
|
<p><strong class="pull-right">{{pricing.price_after_discount_with_vat|floatformat:2}} CHF</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<hr class="thin-hr">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<strong class="text-uppercase align-center">{% trans "Your Price in Total" %}</strong>
|
||||||
|
<strong class="total-price pull-right">{{pricing.total_price|floatformat:2}} CHF</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="thin-hr">
|
||||||
|
</div>
|
||||||
|
<form id="virtual_machine_create_form" action="{% url 'matrix:order_details' %}" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<div class="dcl-place-order-text">{% blocktrans with vm_total_price=vm.total_price|floatformat:2 %}By clicking "Place order" you agree to our <a href="">Terms of Service</a> and this plan will charge your credit card account with {{ vm_total_price }} CHF/month{% endblocktrans %}.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4 order-confirm-btn text-right">
|
||||||
|
<button class="btn choice-btn" id="btn-create-vm" data-bs-toggle="modal" data-bs-target="#createvm-modal">
|
||||||
|
{% trans "Place order" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Create VM Modal -->
|
||||||
|
<div class="modal fade" id="createvm-modal" tabindex="-1" role="dialog"
|
||||||
|
aria-hidden="true" data-backdrop="static" data-keyboard="false">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="modal-icon">
|
||||||
|
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
||||||
|
<span class="sr-only">{% trans "Processing..." %}</span>
|
||||||
|
</div>
|
||||||
|
<h4 class="modal-title" id="createvm-modal-title"></h4>
|
||||||
|
<div class="modal-text" id="createvm-modal-body">
|
||||||
|
{% trans "Hold tight, we are processing your request" %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<a id="createvm-modal-done-btn" class="btn btn-success btn-ok btn-wide visually-hidden" href="">{% trans "OK" %}</a>
|
||||||
|
<button id="createvm-modal-close-btn" type="button" class="btn btn-danger btn-ok btn-wide visually-hidden" data-dismiss="modal" aria-label="create-vm-close">{% trans "Close" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- / Create VM Modal -->
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var create_vm_error_message = 'Some problem encountered. Please try again later';
|
||||||
|
var pm_id = '{{id_payment_method}}';
|
||||||
|
var error_url = '{{ error_msg.redirect }}';
|
||||||
|
var success_url = '{{ success_msg.redirect }}';
|
||||||
|
window.stripeKey = "{{stripe_key}}";
|
||||||
|
window.isSubscription = ("{{is_subscription}}" === 'true');
|
||||||
|
</script>
|
||||||
|
<!-- jQuery -->
|
||||||
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.3/jquery.validate.min.js"></script>
|
||||||
|
|
||||||
|
<script src="{% static 'fontawesome_free/js/all.min.js' %}"></script>
|
||||||
|
<!-- Bootstrap Core JavaScript -->
|
||||||
|
{% bootstrap_javascript %}
|
||||||
|
<!-- Custom JS -->
|
||||||
|
<script type="text/javascript" src="{% static 'matrixhosting/js/order.js' %}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
169
matrixhosting/templates/matrixhosting/payment.html
Normal file
169
matrixhosting/templates/matrixhosting/payment.html
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
{% load static i18n %}
|
||||||
|
{% load bootstrap5 %}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="Matrix Hosting by ungleich">
|
||||||
|
<meta name="author" content="ungleich glarus ag">
|
||||||
|
<title>Matrix Hosting - {% block title %} made in Switzerland{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Vendor CSS -->
|
||||||
|
<!-- Bootstrap Core CSS -->
|
||||||
|
{% bootstrap_css %}
|
||||||
|
|
||||||
|
<!-- External Fonts -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/paymentfont/1.2.5/css/paymentfont.min.css"/>
|
||||||
|
<link href="//fonts.googleapis.com/css?family=Lato:300,400,600,700" rel="stylesheet" type="text/css">
|
||||||
|
|
||||||
|
<link href="{% static 'matrixhosting/css/hosting.css' %}" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||||
|
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||||
|
<!--[if lt IE 9]>
|
||||||
|
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
||||||
|
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="row">
|
||||||
|
<div class="dcl-payment-section">
|
||||||
|
<h3>{%trans "Your Order" %}</h3>
|
||||||
|
<hr class="top-hr">
|
||||||
|
<div class="dcl-payment-order">
|
||||||
|
<p>{% trans "Cores"%} <strong class="float-end">{{request.session.order.cores|floatformat}}</strong></p>
|
||||||
|
<hr>
|
||||||
|
<p>{% trans "Memory"%} <strong class="float-end">{{request.session.order.memory|floatformat}} GB</strong></p>
|
||||||
|
<hr>
|
||||||
|
<p>{% trans "Disk space"%} <strong class="float-end">{{request.session.order.storage|floatformat}} GB</strong></p>
|
||||||
|
<hr>
|
||||||
|
<p>
|
||||||
|
<strong>{%trans "Total" %}</strong>
|
||||||
|
<small>
|
||||||
|
({% if matrix_vm_pricing.vat_inclusive %}{%trans "including VAT" %}{% else %}{%trans "excluding VAT" %}{% endif %})
|
||||||
|
</small>
|
||||||
|
<strong class="float-end">{{request.session.order.subtotal|floatformat}} CHF / {% trans "Month" %}</strong>
|
||||||
|
</p>
|
||||||
|
<hr>
|
||||||
|
{% if matrix_vm_pricing.discount_amount %}
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>{{ request.session.order.discount.name }}</strong>
|
||||||
|
<strong class="float-end text-success">- {{ request.session.order.discount.amount }} CHF / {% trans "Month" %}</strong>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="dcl-payment-section">
|
||||||
|
<h2><b>{%trans "Billing Address"%}</b></h2>
|
||||||
|
<hr class="top-hr">
|
||||||
|
{% for message in messages %}
|
||||||
|
{% if 'vat_error' in message.tags %}
|
||||||
|
<ul class="list-unstyled"><li>
|
||||||
|
{{ message|safe }}
|
||||||
|
</li></ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<form role="form" id="billing-form" method="post" action="" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for field in billing_address_form %}
|
||||||
|
{% if field.html_name in 'active,owner' %}
|
||||||
|
{{ field.as_hidden }}
|
||||||
|
{%else %}
|
||||||
|
{% bootstrap_field field show_label=False type='fields'%}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="dcl-payment-section">
|
||||||
|
{% with cards_len=cards|length %}
|
||||||
|
<h3><b>{%trans "Credit Card"%}</b></h3>
|
||||||
|
<hr class="top-hr">
|
||||||
|
<p>
|
||||||
|
{% if cards_len > 0 %}
|
||||||
|
{% blocktrans %}Please select one of the cards that you used before or fill in your credit card information below. We are using <a href="https://stripe.com" target="_blank">Stripe</a> for payment and do not store your information in our database.{% endblocktrans %}
|
||||||
|
{% else %}
|
||||||
|
{% blocktrans %}Please fill in your credit card information below. We are using <a href="https://stripe.com" target="_blank">Stripe</a> for payment and do not store your information in our database.{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
{% for card in cards %}
|
||||||
|
<div class="credit-card-info">
|
||||||
|
<div class="col-xs-6 no-padding">
|
||||||
|
<h5 class="billing-head">{% trans "Credit Card" %}</h5>
|
||||||
|
<h5 class="membership-lead">{% trans "Last" %} 4: ***** {{card.last4}}</h5>
|
||||||
|
<h5 class="membership-lead">{% trans "Type" %}: {{card.brand}}</h5>
|
||||||
|
<h5 class="membership-lead">{% trans "Expiry" %}: {{card.month}}/{{card.year}}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-6 text-right align-bottom">
|
||||||
|
<a class="btn choice-btn choice-btn-faded" href="#" data-id_card="{{card.id}}">{% trans "SELECT" %}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if cards_len > 0 %}
|
||||||
|
<div class="new-card-head">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xs-6">
|
||||||
|
<h4>{% trans "Add a new credit card" %}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-6 text-right new-card-button-margin">
|
||||||
|
<button data-bs-toggle="collapse" data-bs-target="#newcard" class="btn choice-btn">
|
||||||
|
<span class="fa fa-plus"></span> {% trans "NEW CARD" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="newcard" class="collapse">
|
||||||
|
<hr class="thick-hr">
|
||||||
|
<div class="card-details-box">
|
||||||
|
<h3>{%trans "New Credit Card" %}</h3>
|
||||||
|
<hr>
|
||||||
|
{% include "matrixhosting/includes/_card.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else%}
|
||||||
|
{% include "matrixhosting/includes/_card.html" %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if stripe_key %}
|
||||||
|
{% get_current_language as LANGUAGE_CODE %}
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.processing_text = '{%trans "Processing" %}';
|
||||||
|
window.enter_your_card_text = '{%trans "Enter your credit card number" %}';
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
window.stripeKey = "{{stripe_key}}";
|
||||||
|
window.current_lan = "{{LANGUAGE_CODE}}";
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{%endif%}
|
||||||
|
|
||||||
|
<!-- jQuery -->
|
||||||
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.3/jquery.validate.min.js"></script>
|
||||||
|
|
||||||
|
<script src="{% static 'fontawesome_free/js/all.min.js' %}"></script>
|
||||||
|
<!-- Bootstrap Core JavaScript -->
|
||||||
|
{% bootstrap_javascript %}
|
||||||
|
<!-- Custom JS -->
|
||||||
|
<script type="text/javascript" src="{% static 'matrixhosting/js/payment.js' %}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
67
matrixhosting/tests.py
Normal file
67
matrixhosting/tests.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import VMInstance
|
||||||
|
from uncloud_pay.models import Order, PricingPlan, BillingAddress, Product, RecurringPeriod
|
||||||
|
|
||||||
|
|
||||||
|
vm_product_config = {
|
||||||
|
'features': {
|
||||||
|
'cores':
|
||||||
|
{ 'min': 1,
|
||||||
|
'max': 48
|
||||||
|
},
|
||||||
|
'ram_gb':
|
||||||
|
{ 'min': 2,
|
||||||
|
'max': 200
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
class VMInstanceTestCase(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
RecurringPeriod.populate_db_defaults()
|
||||||
|
self.user = get_user_model().objects.create(
|
||||||
|
username='random_user',
|
||||||
|
email='jane.random@domain.tld')
|
||||||
|
self.config = json.dumps({
|
||||||
|
'cores': 1,
|
||||||
|
'memory': 2,
|
||||||
|
'storage': 100,
|
||||||
|
'homeserver_domain': '',
|
||||||
|
'webclient_domain': '',
|
||||||
|
'matrix_domain': '',
|
||||||
|
})
|
||||||
|
self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
|
||||||
|
ram_unit_price=4, storage_unit_price=0.02)
|
||||||
|
self.ba = BillingAddress.objects.create(
|
||||||
|
owner=self.user,
|
||||||
|
organization = 'Test org',
|
||||||
|
street="unknown",
|
||||||
|
city="unknown",
|
||||||
|
postal_code="somewhere else",
|
||||||
|
active=True)
|
||||||
|
|
||||||
|
self.product = Product.objects.create(name="Testproduct",
|
||||||
|
description="Only for testing",
|
||||||
|
config=vm_product_config)
|
||||||
|
self.default_recurring_period = RecurringPeriod.objects.get(name="Per 30 days")
|
||||||
|
self.product.recurring_periods.add(self.default_recurring_period,
|
||||||
|
through_defaults= { 'is_default': True })
|
||||||
|
|
||||||
|
def test_create_matrix_vm(self):
|
||||||
|
order = Order.objects.create(owner=self.user,
|
||||||
|
recurring_period=self.default_recurring_period,
|
||||||
|
billing_address=self.ba,
|
||||||
|
pricing_plan = self.pricing_plan,
|
||||||
|
product=self.product,
|
||||||
|
config=self.config)
|
||||||
|
instances = VMInstance.objects.filter(order=order)
|
||||||
|
self.assertEqual(len(instances), 1)
|
||||||
|
|
||||||
|
|
15
matrixhosting/urls.py
Normal file
15
matrixhosting/urls.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
from django.urls import path, include
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
|
||||||
|
from .views import IndexView, PricingView, OrderPaymentView, OrderDetailsView, Dashboard
|
||||||
|
|
||||||
|
app_name = 'matrixhosting'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('pricing/<slug:name>/calculate/', PricingView.as_view(), name='pricing_calculator'),
|
||||||
|
path('payment/', OrderPaymentView.as_view(), name='payment'),
|
||||||
|
path('order/details/', OrderDetailsView.as_view(), name='order_details'),
|
||||||
|
path('dashboard/', Dashboard.as_view(), name='dashboard'),
|
||||||
|
path('', IndexView.as_view(), name='index'),
|
||||||
|
]
|
34
matrixhosting/validators.py
Normal file
34
matrixhosting/validators.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
from django.core.validators import RegexValidator
|
||||||
|
|
||||||
|
|
||||||
|
def _validator():
|
||||||
|
|
||||||
|
ul = '\u00a1-\uffff' # unicode letters range (must not be a raw string)
|
||||||
|
|
||||||
|
# IP patterns
|
||||||
|
ipv4_re = r'(?:25[0-5]|2[0-4]\d|[0-1]?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}'
|
||||||
|
ipv6_re = r'\[[0-9a-f:\.]+\]' # (simple regex, validated later)
|
||||||
|
|
||||||
|
# Host patterns
|
||||||
|
hostname_re = r'[a-z' + ul + \
|
||||||
|
r'0-9](?:[a-z' + ul + r'0-9-]{0,61}[a-z' + ul + r'0-9])?'
|
||||||
|
# Max length for domain name labels is 63 characters per RFC 1034 sec. 3.1
|
||||||
|
domain_re = r'(?:\.(?!-)[a-z' + ul + r'0-9-]{1,63}(?<!-))*'
|
||||||
|
tld_re = (
|
||||||
|
r'\.' # dot
|
||||||
|
r'(?!-)' # can't start with a dash
|
||||||
|
r'(?:[a-z' + ul + '-]{2,63}' # domain label
|
||||||
|
r'|xn--[a-z0-9]{1,59})' # or punycode label
|
||||||
|
r'(?<!-)' # can't end with a dash
|
||||||
|
r'\.?' # may have a trailing dot
|
||||||
|
r'/?'
|
||||||
|
)
|
||||||
|
host_re = '(' + hostname_re + domain_re + tld_re + ')'
|
||||||
|
regex = (
|
||||||
|
r'(?:' + ipv4_re + '|' + ipv6_re + '|' + host_re + ')'
|
||||||
|
r'(?::\d{2,5})?' # port
|
||||||
|
r'\Z')
|
||||||
|
return RegexValidator(regex, message='Enter a valid Domain (Not a URL)', code='invalid_domain')
|
||||||
|
|
||||||
|
|
||||||
|
domain_name_validator = _validator()
|
301
matrixhosting/views.py
Normal file
301
matrixhosting/views.py
Normal file
|
@ -0,0 +1,301 @@
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.utils.translation import get_language, ugettext_lazy as _
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.views.decorators.cache import cache_control
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views import View
|
||||||
|
from django.views.generic import FormView, DetailView
|
||||||
|
from django.views.generic.list import ListView
|
||||||
|
from matrixhosting.forms import RequestHostedVMForm, BillingAddressForm
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import (
|
||||||
|
HttpResponseRedirect, JsonResponse
|
||||||
|
)
|
||||||
|
from rest_framework import viewsets, permissions
|
||||||
|
|
||||||
|
from uncloud_pay.models import PricingPlan
|
||||||
|
from uncloud_pay.utils import get_order_total_with_vat
|
||||||
|
from uncloud_pay.models import *
|
||||||
|
from uncloud_pay.utils import validate_vat_number
|
||||||
|
from uncloud_pay.selectors import get_billing_address_for_user
|
||||||
|
import uncloud_pay.stripe as uncloud_stripe
|
||||||
|
from .models import VMInstance
|
||||||
|
from .serializers import *
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class PricingView(View):
|
||||||
|
def get(self, request, **args):
|
||||||
|
subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, discount = get_order_total_with_vat(
|
||||||
|
request.GET.get('cores'),
|
||||||
|
request.GET.get('memory'),
|
||||||
|
request.GET.get('storage'),
|
||||||
|
pricing_name = args['name']
|
||||||
|
)
|
||||||
|
return JsonResponse({'subtotal': subtotal})
|
||||||
|
|
||||||
|
class IndexView(FormView):
|
||||||
|
template_name = "matrixhosting/index.html"
|
||||||
|
form_class = RequestHostedVMForm
|
||||||
|
success_url = "/matrixhosting#requestform"
|
||||||
|
success_message = "Thank you, we will contact you as soon as possible"
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['matrix_vm_pricing'] = PricingPlan.get_default_pricing()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
self.request.session['order'] = form.cleaned_data
|
||||||
|
subtotal, subtotal_with_discount, total, vat, vat_percent, discount = get_order_total_with_vat(
|
||||||
|
form.cleaned_data['cores'],
|
||||||
|
form.cleaned_data['memory'],
|
||||||
|
form.cleaned_data['storage'],
|
||||||
|
form.cleaned_data['pricing_name'],
|
||||||
|
False
|
||||||
|
)
|
||||||
|
self.request.session['pricing'] = {'name': form.cleaned_data['pricing_name'],
|
||||||
|
'subtotal': subtotal, 'vat': vat,
|
||||||
|
'vat_percent': vat_percent, 'discount': discount}
|
||||||
|
return HttpResponseRedirect(reverse('matrix:payment'))
|
||||||
|
|
||||||
|
|
||||||
|
class OrderPaymentView(FormView):
|
||||||
|
template_name = 'matrixhosting/payment.html'
|
||||||
|
success_url = 'matrix:order_confirmation'
|
||||||
|
form_class = BillingAddressForm
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super(OrderPaymentView, self).get_context_data(**kwargs)
|
||||||
|
if 'billing_address_data' in self.request.session:
|
||||||
|
billing_address_form = BillingAddressForm(
|
||||||
|
initial=self.request.session['billing_address_data']
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
old_active = get_billing_address_for_user(self.request.user)
|
||||||
|
billing_address_form = BillingAddressForm(
|
||||||
|
instance=old_active
|
||||||
|
) if old_active else BillingAddressForm(
|
||||||
|
initial={'active': True, 'owner': self.request.user.id}
|
||||||
|
)
|
||||||
|
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
|
||||||
|
cards = uncloud_stripe.get_customer_cards(customer_id)
|
||||||
|
context.update({
|
||||||
|
'matrix_vm_pricing': PricingPlan.get_by_name(self.request.session.get('pricing', {'name': 'unknown'})['name']),
|
||||||
|
'billing_address_form': billing_address_form,
|
||||||
|
'cards': cards,
|
||||||
|
'stripe_key': settings.STRIPE_PUBLIC_KEY
|
||||||
|
})
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
@cache_control(no_cache=True, must_revalidate=True, no_store=True)
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
for k in ['vat_validation_status', 'token', 'id_payment_method']:
|
||||||
|
if request.session.get(k):
|
||||||
|
request.session.pop(k)
|
||||||
|
if 'order' not in request.session:
|
||||||
|
return HttpResponseRedirect(reverse('matrix:index'))
|
||||||
|
return self.render_to_response(self.get_context_data())
|
||||||
|
|
||||||
|
def form_valid(self, address_form):
|
||||||
|
id_payment_method = self.request.POST.get('id_payment_method', None)
|
||||||
|
self.request.session["id_payment_method"] = id_payment_method
|
||||||
|
this_user = {
|
||||||
|
'email': self.request.user.email,
|
||||||
|
'username': self.request.user.username
|
||||||
|
}
|
||||||
|
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
|
||||||
|
uncloud_stripe.attach_payment_method(id_payment_method, customer_id)
|
||||||
|
address = get_billing_address_for_user(self.request.user)
|
||||||
|
if address:
|
||||||
|
form = BillingAddressForm(self.request.POST, instance=address)
|
||||||
|
else:
|
||||||
|
form = BillingAddressForm(self.request.POST)
|
||||||
|
if form.is_valid:
|
||||||
|
billing_address_ins = form.save()
|
||||||
|
self.request.session["billing_address_id"] = billing_address_ins.id
|
||||||
|
self.request.session['billing_address_data'] = address_form.cleaned_data
|
||||||
|
self.request.session['billing_address_data']['owner'] = self.request.user.id
|
||||||
|
self.request.session['user'] = this_user
|
||||||
|
self.request.session['customer'] = customer_id
|
||||||
|
|
||||||
|
vat_number = address_form.cleaned_data.get('vat_number').strip()
|
||||||
|
if vat_number:
|
||||||
|
validate_result = validate_vat_number(
|
||||||
|
stripe_customer_id=customer_id,
|
||||||
|
billing_address_id=billing_address_ins.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'error' in validate_result and validate_result['error']:
|
||||||
|
messages.add_message(
|
||||||
|
self.request, messages.ERROR, validate_result["error"],
|
||||||
|
extra_tags='vat_error'
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse('matrix:payment') + '#vat_error'
|
||||||
|
)
|
||||||
|
self.request.session["vat_validation_status"] = validate_result["status"]
|
||||||
|
|
||||||
|
return HttpResponseRedirect(reverse('matrix:order_details'))
|
||||||
|
|
||||||
|
|
||||||
|
class OrderDetailsView(DetailView):
|
||||||
|
template_name = "matrixhosting/order_detail.html"
|
||||||
|
context_object_name = "order"
|
||||||
|
model = Order
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
@cache_control(no_cache=True, must_revalidate=True, no_store=True)
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
context = {}
|
||||||
|
if ('order' not in request.session or 'user' not in request.session):
|
||||||
|
return HttpResponseRedirect(reverse('matrix:index'))
|
||||||
|
if 'id_payment_method' in self.request.session:
|
||||||
|
card = uncloud_stripe.get_card_from_payment(self.request.user, self.request.session['id_payment_method'])
|
||||||
|
if not card:
|
||||||
|
return HttpResponseRedirect(reverse('matrix:payment'))
|
||||||
|
context['card'] = card
|
||||||
|
elif 'id_payment_method' not in self.request.session or 'vat_validation_status' not in self.request.session:
|
||||||
|
return HttpResponseRedirect(reverse('matrix:payment'))
|
||||||
|
specs = request.session.get('order')
|
||||||
|
pricing = request.session.get('pricing')
|
||||||
|
billing_address = BillingAddress.objects.get(id=request.session.get('billing_address_id'))
|
||||||
|
vat_rate = VATRate.get_vat_rate(billing_address)
|
||||||
|
vat_validation_status = "verified" if billing_address.vat_number_validated_on and billing_address.vat_number_verified else False
|
||||||
|
subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, discount = get_order_total_with_vat(
|
||||||
|
specs['cores'], specs['memory'], specs['storage'], request.session['pricing']['name'],
|
||||||
|
vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status
|
||||||
|
)
|
||||||
|
pricing = {
|
||||||
|
"subtotal": subtotal, "discount": discount, "vat": vat, "vat_percent": vat_percent,
|
||||||
|
"vat_country": billing_address.country.lower(),
|
||||||
|
"subtotal_after_discount": subtotal_after_discount,
|
||||||
|
"price_after_discount_with_vat": price_after_discount_with_vat
|
||||||
|
|
||||||
|
}
|
||||||
|
pricing["price_with_vat"] = round(subtotal * (1 + pricing["vat_percent"] * 0.01), 2)
|
||||||
|
discount["amount_with_vat"] = round(pricing["price_with_vat"] - pricing["price_after_discount_with_vat"], 2)
|
||||||
|
pricing["total_price"] = pricing["price_after_discount_with_vat"]
|
||||||
|
self.request.session['total_price'] = pricing["price_after_discount_with_vat"]
|
||||||
|
payment_intent_response = uncloud_stripe.get_payment_intent(request.user, pricing["price_after_discount_with_vat"])
|
||||||
|
context.update({
|
||||||
|
'payment_intent_secret': payment_intent_response.client_secret,
|
||||||
|
'order': specs,
|
||||||
|
'pricing': pricing,
|
||||||
|
'stripe_key': settings.STRIPE_PUBLIC_KEY,
|
||||||
|
})
|
||||||
|
return render(request, self.template_name, context)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
customer = StripeCustomer.objects.get(owner=self.request.user)
|
||||||
|
billing_address = BillingAddress.objects.get(id=request.session.get('billing_address_id'))
|
||||||
|
if 'id_payment_method' in request.session:
|
||||||
|
card = uncloud_stripe.get_card_from_payment(self.request.user, self.request.session['id_payment_method'])
|
||||||
|
if not card:
|
||||||
|
return show_error("There was a payment related error.", self.request)
|
||||||
|
else:
|
||||||
|
return show_error("There was a payment related error.", self.request)
|
||||||
|
|
||||||
|
order = finalize_order(request, customer,
|
||||||
|
billing_address,
|
||||||
|
self.request.session['total_price'],
|
||||||
|
PricingPlan.get_by_name(self.request.session['pricing']['name']),
|
||||||
|
request.session.get('order'))
|
||||||
|
if order:
|
||||||
|
bill = Bill.create_next_bill_for_user_address(billing_address)
|
||||||
|
payment= Payment.objects.create(owner=request.user, amount=self.request.session['total_price'], source='stripe')
|
||||||
|
if payment:
|
||||||
|
#Close the bill as the payment has been added
|
||||||
|
bill.close()
|
||||||
|
response = {
|
||||||
|
'status': True,
|
||||||
|
'redirect': (reverse('matrix:dashboard')),
|
||||||
|
'msg_title': str(_('Thank you for the order.')),
|
||||||
|
'msg_body': str(
|
||||||
|
_('Your VM will be up and running in a few moments.'
|
||||||
|
' We will send you a confirmation email as soon as'
|
||||||
|
' it is ready.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse(response)
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_order(request, customer, billing_address,
|
||||||
|
one_time_price, pricing_plan,
|
||||||
|
specs):
|
||||||
|
product = Product.objects.first()
|
||||||
|
recurring_period_product = ProductToRecurringPeriod.objects.filter(product=product, is_default=True).first()
|
||||||
|
order = Order.objects.create(
|
||||||
|
owner=request.user,
|
||||||
|
customer=customer,
|
||||||
|
billing_address=billing_address,
|
||||||
|
one_time_price=one_time_price,
|
||||||
|
pricing_plan=pricing_plan,
|
||||||
|
recurring_period= recurring_period_product.recurring_period,
|
||||||
|
product = product,
|
||||||
|
config=json.dumps(specs)
|
||||||
|
)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
class Dashboard(ListView):
|
||||||
|
template_name = "matrixhosting/dashboard.html"
|
||||||
|
model = Order
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Order.objects.filter(owner=self.request.user)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
order = Order.objects.get(id=request.POST.get('order_id', 0))
|
||||||
|
order.cancel()
|
||||||
|
return JsonResponse({'message': 'Successfully Cancelled'})
|
||||||
|
|
||||||
|
|
||||||
|
def get_error_response_dict(request):
|
||||||
|
response = {
|
||||||
|
'status': False,
|
||||||
|
'redirect': "{url}#{section}".format(
|
||||||
|
url=(reverse('matrix:payment')),
|
||||||
|
section='payment_error'
|
||||||
|
),
|
||||||
|
'msg_title': str(_('Error.')),
|
||||||
|
'msg_body': str(
|
||||||
|
_('There was a payment related error.'
|
||||||
|
' On close of this popup, you will be redirected back to'
|
||||||
|
' the payment page.'))
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def show_error(msg, request):
|
||||||
|
messages.add_message(request, messages.ERROR, msg,
|
||||||
|
extra_tags='failed_payment')
|
||||||
|
return JsonResponse(get_error_response_dict(request))
|
||||||
|
|
||||||
|
|
||||||
|
class MachineViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
serializer_class = VMInstanceSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return VMInstance.objects.filter(owner=self.request.user)
|
||||||
|
|
||||||
|
|
15
matrixhosting/yaml/deployment.yaml.tmpl
Normal file
15
matrixhosting/yaml/deployment.yaml.tmpl
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ name }}-matrix
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: {{ name }}-matrix
|
||||||
|
replicas: 1
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: {{ name }}-matrix
|
||||||
|
use-as-service: {{ name }}
|
|
@ -1,16 +1,15 @@
|
||||||
# Django basics
|
# Django basics
|
||||||
django
|
Django==3.2.4
|
||||||
djangorestframework
|
djangorestframework
|
||||||
django-auth-ldap
|
django-auth-ldap
|
||||||
django-bootstrap-v5
|
django-bootstrap-v5
|
||||||
|
fontawesome-free
|
||||||
|
|
||||||
psycopg2
|
psycopg2
|
||||||
ldap3
|
ldap3
|
||||||
|
django-allauth
|
||||||
xmltodict
|
xmltodict
|
||||||
|
|
||||||
parsedatetime
|
parsedatetime
|
||||||
|
|
||||||
# Follow are for creating graph models
|
# Follow are for creating graph models
|
||||||
pyparsing
|
pyparsing
|
||||||
pydot
|
pydot
|
||||||
|
@ -22,12 +21,14 @@ django-hardcopy
|
||||||
# schema support
|
# schema support
|
||||||
pyyaml
|
pyyaml
|
||||||
uritemplate
|
uritemplate
|
||||||
|
tldextract
|
||||||
# Payment & VAT
|
# Payment & VAT
|
||||||
vat-validator
|
vat-validator
|
||||||
stripe
|
stripe
|
||||||
|
|
||||||
|
#Jobs
|
||||||
# Tasks
|
django-q
|
||||||
celery
|
|
||||||
redis
|
redis
|
||||||
|
|
||||||
|
jinja2
|
||||||
|
python-gitlab
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
import decimal
|
import decimal
|
||||||
from .celery import app as celery_app
|
|
||||||
|
|
||||||
# Define DecimalField properties, used to represent amounts of money.
|
# Define DecimalField properties, used to represent amounts of money.
|
||||||
AMOUNT_MAX_DIGITS=10
|
AMOUNT_MAX_DIGITS=10
|
||||||
|
@ -251,4 +250,4 @@ COUNTRIES = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ('celery_app',)
|
__all__ = ()
|
||||||
|
|
|
@ -2,5 +2,5 @@ from django.contrib import admin
|
||||||
|
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
for m in [ UncloudProvider, UncloudNetwork, UncloudTask ]:
|
for m in [ UncloudProvider, UncloudNetwork ]:
|
||||||
admin.site.register(m)
|
admin.site.register(m)
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from celery import Celery
|
|
||||||
|
|
||||||
# set the default Django settings module for the 'celery' program.
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'uncloud.settings')
|
|
||||||
|
|
||||||
app = Celery('uncloud')
|
|
||||||
|
|
||||||
# Using a string here means the worker doesn't have to serialize
|
|
||||||
# the configuration object to child processes.
|
|
||||||
# - namespace='CELERY' means all celery-related configuration keys
|
|
||||||
# should have a `CELERY_` prefix.
|
|
||||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
|
||||||
|
|
||||||
# Load task modules from all registered Django app configs.
|
|
||||||
app.autodiscover_tasks()
|
|
8
uncloud/forms.py
Normal file
8
uncloud/forms.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from django import forms
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserDeleteForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = []
|
16
uncloud/migrations/0005_delete_uncloudtask.py
Normal file
16
uncloud/migrations/0005_delete_uncloudtask.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-07 15:11
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud', '0004_auto_20210101_1308'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='UncloudTask',
|
||||||
|
),
|
||||||
|
]
|
|
@ -57,10 +57,10 @@ class CountryField(models.CharField):
|
||||||
|
|
||||||
|
|
||||||
class UncloudAddress(models.Model):
|
class UncloudAddress(models.Model):
|
||||||
full_name = models.CharField(max_length=256)
|
full_name = models.CharField(max_length=256, null=False)
|
||||||
organization = models.CharField(max_length=256, blank=True, null=True)
|
organization = models.CharField(max_length=256, blank=True, null=True)
|
||||||
street = models.CharField(max_length=256)
|
street = models.CharField(max_length=256, null=False)
|
||||||
city = models.CharField(max_length=256)
|
city = models.CharField(max_length=256, null=False)
|
||||||
postal_code = models.CharField(max_length=64)
|
postal_code = models.CharField(max_length=64)
|
||||||
country = CountryField(blank=False, null=False)
|
country = CountryField(blank=False, null=False)
|
||||||
|
|
||||||
|
@ -207,18 +207,3 @@ class UncloudProvider(UncloudAddress):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.full_name} {self.country}"
|
return f"{self.full_name} {self.country}"
|
||||||
|
|
||||||
|
|
||||||
class UncloudTask(models.Model):
|
|
||||||
"""
|
|
||||||
Class to store dispatched tasks to be handled
|
|
||||||
"""
|
|
||||||
|
|
||||||
task_id = models.UUIDField(primary_key=True)
|
|
||||||
|
|
||||||
# class UncloudRequestLog(models.Model):
|
|
||||||
# """
|
|
||||||
# Class to store requests and logs
|
|
||||||
# """
|
|
||||||
|
|
||||||
|
|
||||||
# log = models.CharField(max_length=256)
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.0/ref/settings/
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import ldap
|
import ldap
|
||||||
|
import sys
|
||||||
|
|
||||||
from django.core.management.utils import get_random_secret_key
|
from django.core.management.utils import get_random_secret_key
|
||||||
from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
|
from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion
|
||||||
|
@ -23,6 +24,8 @@ LOGGING = {}
|
||||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
sys.modules['fontawesome_free'] = __import__('fontawesome-free')
|
||||||
|
|
||||||
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
|
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
|
@ -38,7 +41,7 @@ DATABASES = {
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
|
SITE_ID = 1
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
|
@ -48,10 +51,16 @@ INSTALLED_APPS = [
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
|
'django.contrib.sites',
|
||||||
|
'allauth',
|
||||||
|
'allauth.account',
|
||||||
|
'allauth.socialaccount',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'django_extensions',
|
'django_extensions',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'bootstrap5',
|
'bootstrap5',
|
||||||
|
'django_q',
|
||||||
|
'fontawesome_free',
|
||||||
'uncloud',
|
'uncloud',
|
||||||
'uncloud_pay',
|
'uncloud_pay',
|
||||||
'uncloud_auth',
|
'uncloud_auth',
|
||||||
|
@ -59,7 +68,8 @@ INSTALLED_APPS = [
|
||||||
'uncloud_storage',
|
'uncloud_storage',
|
||||||
'uncloud_vm',
|
'uncloud_vm',
|
||||||
'uncloud_service',
|
'uncloud_service',
|
||||||
'opennebula'
|
'opennebula',
|
||||||
|
'matrixhosting',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
@ -110,7 +120,13 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
###############################################################################
|
||||||
|
# Authall Settings
|
||||||
|
ACCOUNT_AUTHENTICATION_METHOD = "username"
|
||||||
|
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 1
|
||||||
|
ACCOUNT_EMAIL_REQUIRED = False
|
||||||
|
ACCOUNT_EMAIL_VERIFICATION = "optional"
|
||||||
|
ACCOUNT_UNIQUE_EMAIL = False
|
||||||
################################################################################
|
################################################################################
|
||||||
# AUTH/LDAP
|
# AUTH/LDAP
|
||||||
|
|
||||||
|
@ -131,7 +147,8 @@ AUTH_LDAP_USER_ATTR_MAP = {
|
||||||
# AUTH/Django
|
# AUTH/Django
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
"django_auth_ldap.backend.LDAPBackend",
|
"django_auth_ldap.backend.LDAPBackend",
|
||||||
"django.contrib.auth.backends.ModelBackend"
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
'allauth.account.auth_backends.AuthenticationBackend',
|
||||||
]
|
]
|
||||||
|
|
||||||
AUTH_USER_MODEL = 'uncloud_auth.User'
|
AUTH_USER_MODEL = 'uncloud_auth.User'
|
||||||
|
@ -170,6 +187,13 @@ STATICFILES_FINDERS = [
|
||||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
#VM Deployment TEMPLATE
|
||||||
|
GITLAB_SERVER = 'https://code.ungleich.ch'
|
||||||
|
GITLAB_OAUTH_TOKEN = ''
|
||||||
|
GITLAB_PROJECT_ID = 388
|
||||||
|
GITLAB_AUTHOR_EMAIL = ''
|
||||||
|
GITLAB_AUTHOR_NAME = ''
|
||||||
|
GITLAB_YAML_DIR = ''
|
||||||
|
|
||||||
# XML-RPC interface of opennebula
|
# XML-RPC interface of opennebula
|
||||||
OPENNEBULA_URL = 'https://opennebula.example.com:2634/RPC2'
|
OPENNEBULA_URL = 'https://opennebula.example.com:2634/RPC2'
|
||||||
|
@ -180,7 +204,7 @@ OPENNEBULA_USER_PASS = 'user:password'
|
||||||
# Stripe (Credit Card payments)
|
# Stripe (Credit Card payments)
|
||||||
STRIPE_KEY=""
|
STRIPE_KEY=""
|
||||||
STRIPE_PUBLIC_KEY=""
|
STRIPE_PUBLIC_KEY=""
|
||||||
|
BILL_PAYMENT_DELAY = 0
|
||||||
# The django secret key
|
# The django secret key
|
||||||
SECRET_KEY=get_random_secret_key()
|
SECRET_KEY=get_random_secret_key()
|
||||||
|
|
||||||
|
@ -206,43 +230,34 @@ AUTH_LDAP_USER_SEARCH = LDAPSearch("dc=example,dc=com",
|
||||||
# where to create customers
|
# where to create customers
|
||||||
LDAP_CUSTOMER_DN="ou=customer,dc=example,dc=com"
|
LDAP_CUSTOMER_DN="ou=customer,dc=example,dc=com"
|
||||||
|
|
||||||
# def route_task(name, args, kwargs, options, task=None, **kw):
|
EMAIL_USE_TLS = True
|
||||||
# print(f"{name} - {args} - {kwargs}")
|
EMAIL_HOST = ''
|
||||||
# # if name == 'myapp.tasks.compress_video':
|
EMAIL_PORT = 465
|
||||||
# return {'queue': 'vpn1' }
|
EMAIL_HOST_USER = DEFAULT_FROM_EMAIL = ''
|
||||||
# # 'exchange_type': 'topic',
|
EMAIL_HOST_PASSWORD = ''
|
||||||
# # 'routing_key': 'video.compress'}
|
DEFAULT_FROM_EMAIL = ''
|
||||||
|
RENEWAL_FROM_EMAIL = 'test@example.com'
|
||||||
|
# Should be removed in production
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||||
|
|
||||||
|
##############
|
||||||
# CELERY_TASK_ROUTES = (route_task,)
|
# Jobs
|
||||||
|
Q_CLUSTER = {
|
||||||
# CELERY_TASK_ROUTES = {
|
'name': 'matrixhosting',
|
||||||
# '*': {
|
'workers': 1,
|
||||||
# 'queue': 'vpn1'
|
'recycle': 500,
|
||||||
# }
|
'timeout': 60,
|
||||||
# }
|
'compress': True,
|
||||||
|
'cpu_affinity': 1,
|
||||||
|
'save_limit': 250,
|
||||||
CELERY_BROKER_URL = 'redis://:uncloud.example.com:6379/0'
|
'queue_limit': 500,
|
||||||
CELERY_RESULT_BACKEND = 'redis://:uncloud.example.com:6379/0'
|
'label': 'Django Q',
|
||||||
|
'redis': {
|
||||||
CELERY_TASK_ROUTES = {
|
'host': '127.0.0.1',
|
||||||
re.compile(r'.*.tasks.cdist.*'): { 'queue': 'cdist' } # cdist tasks go into cdist queue
|
'port': 6379,
|
||||||
|
'db': 0, }
|
||||||
}
|
}
|
||||||
|
|
||||||
CELERY_BEAT_SCHEDULE = {
|
|
||||||
'cleanup_tasks': {
|
|
||||||
'task': 'uncloud.tasks.cleanup_tasks',
|
|
||||||
'schedule': 10
|
|
||||||
},
|
|
||||||
'check_balance': {
|
|
||||||
'task': 'uncloud_pay.tasks.check_balance',
|
|
||||||
'schedule': 15
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# CELERY_TASK_CREATE_MISSING_QUEUES = False
|
|
||||||
|
|
||||||
# Overwrite settings with local settings, if existing
|
# Overwrite settings with local settings, if existing
|
||||||
try:
|
try:
|
||||||
from uncloud.local_settings import *
|
from uncloud.local_settings import *
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
from celery import shared_task
|
|
||||||
from celery.result import AsyncResult
|
|
||||||
|
|
||||||
from .models import UncloudTask
|
|
||||||
|
|
||||||
@shared_task(bind=True)
|
|
||||||
def cleanup_tasks(self):
|
|
||||||
print(f"Cleanup time from {self}: {self.request.id}")
|
|
||||||
for task in UncloudTask.objects.all():
|
|
||||||
print(f"Pruning {task}...")
|
|
||||||
|
|
||||||
if str(task.task_id) == str(self.request.id):
|
|
||||||
print("Skipping myself")
|
|
||||||
continue
|
|
||||||
|
|
||||||
res = AsyncResult(id=str(task.task_id))
|
|
||||||
print(f"Task {task}: {res.state}")
|
|
||||||
if res.ready():
|
|
||||||
print(res.get())
|
|
||||||
task.delete()
|
|
||||||
|
|
||||||
res.forget()
|
|
|
@ -4,6 +4,7 @@
|
||||||
<nav class="navbar sticky-top navbar-expand-lg navbar-light bg-light">
|
<nav class="navbar sticky-top navbar-expand-lg navbar-light bg-light">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="{% url 'uncloudindex' %}">uncloud</a>
|
<a class="navbar-brand" href="{% url 'uncloudindex' %}">uncloud</a>
|
||||||
|
<a class="navbar-brand" href="{% url 'matrix:index' %}">Matrix Hosting</a>
|
||||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -14,11 +15,11 @@
|
||||||
<span class="navbar-text">Logged in as {{ user }}. Your balance:
|
<span class="navbar-text">Logged in as {{ user }}. Your balance:
|
||||||
{{ balance }} CHF. </span>
|
{{ balance }} CHF. </span>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'logout' %}">Logout</a>
|
<a class="nav-link" href="{% url 'account_logout' %}">Logout</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'login' %}">Login</a>
|
<a class="nav-link" href="{% url 'account_login' %}">Login</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
<li>First you need
|
<li>First you need
|
||||||
to <a href="https://account.ungleich.ch">register an
|
to <a href="https://account.ungleich.ch">register an
|
||||||
account</a>. If you already have one, you can
|
account</a>. If you already have one, you can
|
||||||
<a href="{% url 'login' %}">login</a>.
|
<a href="{% url 'account_login' %}">login</a>.
|
||||||
<li>If you have forgotten your password or other issues with
|
<li>If you have forgotten your password or other issues with
|
||||||
logging in, you can contact the ungleich support
|
logging in, you can contact the ungleich support
|
||||||
via <strong>support at ungleich.ch</strong>.
|
via <strong>support at ungleich.ch</strong>.
|
||||||
|
@ -107,8 +107,11 @@
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{% url 'billingaddress-list' %}">Create or list
|
<li><a href="{% url 'billingaddress-list' %}">Create or list
|
||||||
your billing addresses</a>
|
your billing addresses</a>
|
||||||
|
<li><a href="{% url 'orders-list' %}">List your Orders</a>
|
||||||
|
<li><a href="{% url 'bills-list' %}">List your Bills</a>
|
||||||
<li><a href="{% url 'payment-list' %}">Make a payment or list your payments</a>
|
<li><a href="{% url 'payment-list' %}">Make a payment or list your payments</a>
|
||||||
<li><a href="{% url 'payment-balance-list' %}">Show your balance</a>
|
<li><a href="{% url 'payment-balance-list' %}">Show your balance</a>
|
||||||
|
<li><a href="{% url 'machines-list' %}">Show your VM Instances</a>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -138,11 +141,30 @@
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<ul>
|
<ul>
|
||||||
<li>Payments are only possible in CHF.
|
<li>Payments are only possible in CHF.
|
||||||
<li>Bills are not yet visible (payments are, though)
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<div id="account-settings" class="row">
|
||||||
|
<div class="col"><h3>Account Settings</h3></div>
|
||||||
|
<div class="col-8">
|
||||||
|
<ul>
|
||||||
|
<form method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
<fieldset class="form-group">
|
||||||
|
<legend class="border-bottom mb-4">Delete User Account</legend>
|
||||||
|
<p>Are you sure you want to delete your account? This will permanently delete your
|
||||||
|
profile and any orders you have generated.</p>
|
||||||
|
{{ delete_form }}
|
||||||
|
</fieldset>
|
||||||
|
<div class="form-group">
|
||||||
|
<button class="btn btn-danger btn-lg" type="submit" name="action">Delete Account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -19,6 +19,7 @@ from uncloud_net import views as netviews
|
||||||
from uncloud_pay import views as payviews
|
from uncloud_pay import views as payviews
|
||||||
from uncloud_vm import views as vmviews
|
from uncloud_vm import views as vmviews
|
||||||
from uncloud_service import views as serviceviews
|
from uncloud_service import views as serviceviews
|
||||||
|
from matrixhosting import views as matrixviews
|
||||||
|
|
||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
|
|
||||||
|
@ -37,6 +38,9 @@ router.register(r'v2/payment/credit-card', payviews.CreditCardViewSet, basename=
|
||||||
router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment')
|
router.register(r'v2/payment/payment', payviews.PaymentViewSet, basename='payment')
|
||||||
router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance')
|
router.register(r'v2/payment/balance', payviews.BalanceViewSet, basename='payment-balance')
|
||||||
router.register(r'v2/payment/address', payviews.BillingAddressViewSet, basename='billingaddress')
|
router.register(r'v2/payment/address', payviews.BillingAddressViewSet, basename='billingaddress')
|
||||||
|
router.register(r'v2/orders', payviews.OrderViewSet, basename='orders')
|
||||||
|
router.register(r'v2/bill', payviews.BillViewSet, basename='bills')
|
||||||
|
router.register(r'v2/machines', matrixviews.MachineViewSet, basename='machines')
|
||||||
|
|
||||||
# Generic helper views that are usually not needed
|
# Generic helper views that are usually not needed
|
||||||
router.register(r'v2/generic/vat-rate', payviews.VATRateViewSet, basename='vatrate')
|
router.register(r'v2/generic/vat-rate', payviews.VATRateViewSet, basename='vatrate')
|
||||||
|
@ -54,9 +58,8 @@ urlpatterns = [
|
||||||
|
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
|
||||||
path('login/', authviews.LoginView.as_view(), name="login"),
|
path('accounts/', include('allauth.urls')),
|
||||||
path('logout/', authviews.LogoutView.as_view(), name="logout"),
|
|
||||||
path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"),
|
path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"),
|
||||||
|
path('matrix/', include('matrixhosting.urls', namespace='matrix')),
|
||||||
path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"),
|
path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,13 +1,23 @@
|
||||||
from django.views.generic.base import TemplateView
|
from django.views.generic.base import TemplateView
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
|
||||||
from uncloud_pay.selectors import get_balance_for_user
|
from uncloud_pay.selectors import get_balance_for_user
|
||||||
|
from .forms import UserDeleteForm
|
||||||
|
|
||||||
class UncloudIndex(TemplateView):
|
class UncloudIndex(TemplateView):
|
||||||
template_name = "uncloud/index.html"
|
template_name = "uncloud/index.html"
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
print(context)
|
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
context['balance'] = get_balance_for_user(self.request.user)
|
context['balance'] = get_balance_for_user(self.request.user)
|
||||||
|
context['delete_form'] = UserDeleteForm(instance=self.request.user)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
UserDeleteForm(request.POST, instance=request.user)
|
||||||
|
user = request.user
|
||||||
|
user.delete()
|
||||||
|
messages.info(request, 'Your account has been deleted.')
|
||||||
|
return redirect('uncloudindex')
|
|
@ -3,7 +3,7 @@ from django.db import transaction
|
||||||
from .models import *
|
from .models import *
|
||||||
from .selectors import *
|
from .selectors import *
|
||||||
from .tasks import *
|
from .tasks import *
|
||||||
|
from django_q.tasks import async_task, result
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create_wireguard_vpn(owner, public_key, network_mask):
|
def create_wireguard_vpn(owner, public_key, network_mask):
|
||||||
|
@ -60,7 +60,6 @@ def create_wireguard_vpn_tech(owner, public_key, network_mask):
|
||||||
server = pool.vpn_server_hostname
|
server = pool.vpn_server_hostname
|
||||||
wg_name = pool.wg_name
|
wg_name = pool.wg_name
|
||||||
|
|
||||||
configure_wireguard_server_on_host.apply_async((wg_name, config),
|
async_task(configure_wireguard_server_on_host, (wg_name, config), queue=server)
|
||||||
queue=server)
|
|
||||||
|
|
||||||
return vpn
|
return vpn
|
||||||
|
|
|
@ -1,17 +1,14 @@
|
||||||
from celery import shared_task
|
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
from uncloud.models import UncloudTask
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from django_q.tasks import async_task, result
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def configure_wireguard_server_on_host(wg_name, config):
|
def configure_wireguard_server_on_host(wg_name, config):
|
||||||
"""
|
"""
|
||||||
- Create wireguard config (DB query -> string)
|
- Create wireguard config (DB query -> string)
|
||||||
|
@ -47,11 +44,9 @@ def configure_wireguard_server_via_cdist(wireguardvpnpool):
|
||||||
|
|
||||||
log.info(f"Configuring VPN server {server} (async)")
|
log.info(f"Configuring VPN server {server} (async)")
|
||||||
|
|
||||||
task_id = uuid.UUID(cdist_configure_wireguard_server.apply_async((config, server)).id)
|
async_task(cdist_configure_wireguard_server,config, server).id
|
||||||
UncloudTask.objects.create(task_id=task_id)
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def cdist_configure_wireguard_server(config, server):
|
def cdist_configure_wireguard_server(config, server):
|
||||||
"""
|
"""
|
||||||
Create config and configure server.
|
Create config and configure server.
|
||||||
|
|
|
@ -37,7 +37,7 @@ class VPNTests(TestCase):
|
||||||
|
|
||||||
self.vpn_wireguard_public_key = 'B2b78eWBIXPMM1x4DDjkCDZepS0qDgcLN3T3PjcgXkY='
|
self.vpn_wireguard_public_key = 'B2b78eWBIXPMM1x4DDjkCDZepS0qDgcLN3T3PjcgXkY='
|
||||||
|
|
||||||
self.vpnpool = VPNPool.objects.get_or_create(network=self.pool_network,
|
self.vpnpool = WireGuardVPNPool.objects.get_or_create(network=self.pool_network,
|
||||||
network_size=self.pool_network_size,
|
network_size=self.pool_network_size,
|
||||||
subnetwork_size=self.pool_subnetwork_size,
|
subnetwork_size=self.pool_subnetwork_size,
|
||||||
vpn_hostname=self.pool_vpn_hostname,
|
vpn_hostname=self.pool_vpn_hostname,
|
||||||
|
@ -47,55 +47,6 @@ class VPNTests(TestCase):
|
||||||
self.factory = APIRequestFactory()
|
self.factory = APIRequestFactory()
|
||||||
|
|
||||||
|
|
||||||
def test_create_vpnpool(self):
|
|
||||||
url = reverse("vpnpool-list")
|
|
||||||
view = VPNPoolViewSet.as_view({'post': 'create'})
|
|
||||||
request = self.factory.post(url, { 'network': self.pool_network2,
|
|
||||||
'network_size': self.pool_network_size,
|
|
||||||
'subnetwork_size': self.pool_subnetwork_size,
|
|
||||||
'vpn_hostname': self.pool_vpn_hostname,
|
|
||||||
'wireguard_private_key': self.pool_wireguard_private_key
|
|
||||||
|
|
||||||
})
|
|
||||||
force_authenticate(request, user=self.admin_user)
|
|
||||||
response = view(request)
|
|
||||||
|
|
||||||
# This raises an exception if the request was not successful
|
|
||||||
# No assert needed
|
|
||||||
pool = VPNPool.objects.get(network=self.pool_network2)
|
|
||||||
|
|
||||||
# def test_create_vpn(self):
|
|
||||||
# url = reverse("vpnnetwork-list")
|
|
||||||
# view = VPNNetworkViewSet.as_view({'post': 'create'})
|
|
||||||
# request = self.factory.post(url, { 'network_size': self.pool_subnetwork_size,
|
|
||||||
# 'wireguard_public_key': self.vpn_wireguard_public_key
|
|
||||||
|
|
||||||
# })
|
|
||||||
# force_authenticate(request, user=self.user)
|
|
||||||
|
|
||||||
|
|
||||||
# # we don't have a billing address -> should raise an error
|
|
||||||
# # with self.assertRaises(ValidationError):
|
|
||||||
# # response = view(request)
|
|
||||||
|
|
||||||
# addr = BillingAddress.objects.get_or_create(
|
|
||||||
# owner=self.user,
|
|
||||||
# active=True,
|
|
||||||
# defaults={'organization': 'ungleich',
|
|
||||||
# 'name': 'Nico Schottelius',
|
|
||||||
# 'street': 'Hauptstrasse 14',
|
|
||||||
# 'city': 'Luchsingen',
|
|
||||||
# 'postal_code': '8775',
|
|
||||||
# 'country': 'CH' }
|
|
||||||
# )
|
|
||||||
|
|
||||||
# # This should work now
|
|
||||||
# response = view(request)
|
|
||||||
|
|
||||||
# # Verify that an order was created successfully - there should only be one order at
|
|
||||||
# # this point in time
|
|
||||||
# order = Order.objects.get(owner=self.user)
|
|
||||||
|
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.user.delete()
|
self.user.delete()
|
||||||
|
|
|
@ -61,10 +61,3 @@ class WireGuardVPNSizes(viewsets.ViewSet):
|
||||||
print(sizes)
|
print(sizes)
|
||||||
|
|
||||||
return Response(WireGuardVPNSizesSerializer(sizes, many=True).data)
|
return Response(WireGuardVPNSizesSerializer(sizes, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# class VPNPoolViewSet(viewsets.ModelViewSet):
|
|
||||||
# serializer_class = VPNPoolSerializer
|
|
||||||
# permission_classes = [permissions.IsAdminUser]
|
|
||||||
# queryset = VPNPool.objects.all()
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ from django.urls import path
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.conf.urls import url
|
from django.conf.urls import url
|
||||||
|
|
||||||
from uncloud_pay.views import BillViewSet
|
|
||||||
from hardcopy import bytestring_to_pdf
|
from hardcopy import bytestring_to_pdf
|
||||||
from django.core.files.temp import NamedTemporaryFile
|
from django.core.files.temp import NamedTemporaryFile
|
||||||
from django.http import FileResponse
|
from django.http import FileResponse
|
||||||
|
@ -90,14 +89,15 @@ admin.site.register(Bill, BillAdmin)
|
||||||
admin.site.register(Product, ProductAdmin)
|
admin.site.register(Product, ProductAdmin)
|
||||||
|
|
||||||
for m in [
|
for m in [
|
||||||
BillRecord,
|
|
||||||
BillingAddress,
|
BillingAddress,
|
||||||
Order,
|
Order,
|
||||||
|
BillRecord,
|
||||||
Payment,
|
Payment,
|
||||||
ProductToRecurringPeriod,
|
ProductToRecurringPeriod,
|
||||||
RecurringPeriod,
|
RecurringPeriod,
|
||||||
StripeCreditCard,
|
StripeCreditCard,
|
||||||
StripeCustomer,
|
StripeCustomer,
|
||||||
VATRate,
|
PricingPlan,
|
||||||
|
VATRate
|
||||||
]:
|
]:
|
||||||
admin.site.register(m)
|
admin.site.register(m)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from uncloud_auth.models import User
|
from uncloud_auth.models import User
|
||||||
from uncloud_pay.models import Order, Bill, PaymentMethod, get_balance_for_user
|
from uncloud_pay.models import Order, Bill, get_balance_for_user
|
||||||
|
import uncloud_pay.stripe as uncloud_stripe
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -18,14 +19,10 @@ class Command(BaseCommand):
|
||||||
balance = get_balance_for_user(user)
|
balance = get_balance_for_user(user)
|
||||||
if balance < 0:
|
if balance < 0:
|
||||||
print("User {} has negative balance ({}), charging.".format(user.username, balance))
|
print("User {} has negative balance ({}), charging.".format(user.username, balance))
|
||||||
payment_method = PaymentMethod.get_primary_for(user)
|
amount_to_be_charged = abs(balance)
|
||||||
if payment_method != None:
|
result = uncloud_stripe.charge_customer(user, amount_to_be_charged)
|
||||||
amount_to_be_charged = abs(balance)
|
if result.status != 'succeeded':
|
||||||
charge_ok = payment_method.charge(amount_to_be_charged)
|
print("ERR: charging {} with method {} failed"
|
||||||
if not charge_ok:
|
.format(user.username, result)
|
||||||
print("ERR: charging {} with method {} failed"
|
)
|
||||||
.format(user.username, payment_method.uuid)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print("ERR: no payment method registered for {}".format(user.username))
|
|
||||||
print("=> Done.")
|
print("=> Done.")
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from uncloud_pay.models import VATRate
|
from uncloud_pay.models import VATRate
|
||||||
|
|
||||||
|
import logging
|
||||||
import urllib
|
import urllib
|
||||||
import csv
|
import csv
|
||||||
import sys
|
import sys
|
||||||
import io
|
import io
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv'''
|
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"
|
vat_url = "https://raw.githubusercontent.com/ungleich/vat-rates/main/vat_rates.csv"
|
||||||
|
@ -23,13 +26,25 @@ class Command(BaseCommand):
|
||||||
reader = csv.DictReader(csv_file)
|
reader = csv.DictReader(csv_file)
|
||||||
|
|
||||||
for row in reader:
|
for row in reader:
|
||||||
# print(row)
|
if row["territory_codes"] and len(row["territory_codes"].splitlines()) > 1:
|
||||||
obj, created = VATRate.objects.get_or_create(
|
for code in row["territory_codes"].splitlines():
|
||||||
starting_date=row["start_date"],
|
VATRate.objects.get_or_create(
|
||||||
ending_date=row["stop_date"] if row["stop_date"] != "" else None,
|
starting_date=row["start_date"],
|
||||||
territory_codes=row["territory_codes"],
|
ending_date=row["stop_date"] if row["stop_date"] != "" else None,
|
||||||
currency_code=row["currency_code"],
|
territory_codes=code,
|
||||||
rate=row["rate"],
|
currency_code=row["currency_code"],
|
||||||
rate_type=row["rate_type"],
|
rate=row["rate"],
|
||||||
description=row["description"]
|
rate_type=row["rate_type"],
|
||||||
)
|
description=row["description"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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"]
|
||||||
|
)
|
||||||
|
logger.info('All VAT Rates have been added!')
|
||||||
|
|
23
uncloud_pay/migrations/0012_auto_20210630_0742.py
Normal file
23
uncloud_pay/migrations/0012_auto_20210630_0742.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-06-30 07:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0011_auto_20210101_1308'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='billingaddress',
|
||||||
|
name='vat_number_verified',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='source',
|
||||||
|
field=models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral')], max_length=256),
|
||||||
|
),
|
||||||
|
]
|
21
uncloud_pay/migrations/0013_alter_billingaddress_owner.py
Normal file
21
uncloud_pay/migrations/0013_alter_billingaddress_owner.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-03 15:23
|
||||||
|
|
||||||
|
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', '0012_auto_20210630_0742'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='billingaddress',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='billing_addresses', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
23
uncloud_pay/migrations/0014_auto_20210703_1747.py
Normal file
23
uncloud_pay/migrations/0014_auto_20210703_1747.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-03 17:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0013_alter_billingaddress_owner'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='billingaddress',
|
||||||
|
name='stripe_tax_id',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='billingaddress',
|
||||||
|
name='vat_number_validated_on',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
34
uncloud_pay/migrations/0015_auto_20210705_0849.py
Normal file
34
uncloud_pay/migrations/0015_auto_20210705_0849.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-05 08:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0014_auto_20210703_1747'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='customer',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.stripecustomer'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('draft', 'Draft'), ('declined', 'Declined'), ('approved', 'Approved')], default='draft', max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='stripe_charge_id',
|
||||||
|
field=models.CharField(max_length=100, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='vm_id',
|
||||||
|
field=models.IntegerField(default=0),
|
||||||
|
),
|
||||||
|
]
|
29
uncloud_pay/migrations/0016_pricingplan.py
Normal file
29
uncloud_pay/migrations/0016_pricingplan.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-06 13:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0015_auto_20210705_0849'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PricingPlan',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, unique=True)),
|
||||||
|
('vat_inclusive', models.BooleanField(default=True)),
|
||||||
|
('vat_percentage', models.DecimalField(blank=True, decimal_places=5, default=0, max_digits=7)),
|
||||||
|
('set_up_fees', models.DecimalField(decimal_places=2, default=0, max_digits=7)),
|
||||||
|
('cores_unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=7)),
|
||||||
|
('ram_unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=7)),
|
||||||
|
('storage_unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=7)),
|
||||||
|
('discount_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=6)),
|
||||||
|
('stripe_coupon_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
23
uncloud_pay/migrations/0017_auto_20210706_1728.py
Normal file
23
uncloud_pay/migrations/0017_auto_20210706_1728.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-06 17:28
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0016_pricingplan'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='paymentmethod',
|
||||||
|
name='owner',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Payment',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='PaymentMethod',
|
||||||
|
),
|
||||||
|
]
|
30
uncloud_pay/migrations/0018_payment.py
Normal file
30
uncloud_pay/migrations/0018_payment.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-06 17:47
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('uncloud_pay', '0017_auto_20210706_1728'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Payment',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
|
||||||
|
('source', models.CharField(choices=[('wire', 'Wire Transfer'), ('stripe', 'Stripe'), ('voucher', 'Voucher'), ('referral', 'Referral')], max_length=256)),
|
||||||
|
('timestamp', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('currency', models.CharField(choices=[('CHF', 'Swiss Franc')], default='CHF', max_length=32)),
|
||||||
|
('external_reference', models.CharField(blank=True, default='', max_length=256, null=True)),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
19
uncloud_pay/migrations/0019_order_pricing_plan.py
Normal file
19
uncloud_pay/migrations/0019_order_pricing_plan.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-06 19:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0018_payment'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='pricing_plan',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.pricingplan'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-07 20:18
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0019_order_pricing_plan'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='bill',
|
||||||
|
old_name='is_final',
|
||||||
|
new_name='is_closed',
|
||||||
|
),
|
||||||
|
]
|
21
uncloud_pay/migrations/0021_auto_20210709_0914.py
Normal file
21
uncloud_pay/migrations/0021_auto_20210709_0914.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-09 09:14
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0020_rename_is_final_bill_is_closed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='stripe_charge_id',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='vm_id',
|
||||||
|
),
|
||||||
|
]
|
17
uncloud_pay/migrations/0022_remove_order_status.py
Normal file
17
uncloud_pay/migrations/0022_remove_order_status.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 3.2.4 on 2021-07-11 08:32
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('uncloud_pay', '0021_auto_20210709_0914'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='status',
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,5 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
from math import ceil
|
from math import ceil
|
||||||
from calendar import monthrange
|
from calendar import monthrange
|
||||||
|
@ -9,18 +10,22 @@ from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django_q.tasks import schedule
|
||||||
|
from django_q.models import Schedule
|
||||||
# Verify whether or not to use them here
|
# Verify whether or not to use them here
|
||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
|
|
||||||
|
import uncloud_pay
|
||||||
from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
|
from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS
|
||||||
from uncloud.models import UncloudAddress
|
from uncloud.models import UncloudAddress, UncloudProvider
|
||||||
|
from uncloud.selectors import filter_for_when
|
||||||
from .services import *
|
from .services import *
|
||||||
|
|
||||||
# Used to generate bill due dates.
|
# Used to generate bill due dates.
|
||||||
BILL_PAYMENT_DELAY=datetime.timedelta(days=10)
|
BILL_PAYMENT_DELAY=datetime.timedelta(days=settings.BILL_PAYMENT_DELAY)
|
||||||
|
|
||||||
# Initialize logger.
|
# Initialize logger.
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -96,84 +101,18 @@ class Payment(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}"
|
return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}"
|
||||||
|
|
||||||
###
|
def save(self, *args, **kwargs):
|
||||||
# Payments and Payment Methods.
|
# Try to charge the user via the active card before saving otherwise throw payment Error
|
||||||
|
|
||||||
|
|
||||||
class PaymentMethod(models.Model):
|
|
||||||
"""
|
|
||||||
Not sure if this is still in use
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
owner = models.ForeignKey(get_user_model(),
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
editable=False)
|
|
||||||
source = models.CharField(max_length=256,
|
|
||||||
choices = (
|
|
||||||
('stripe', 'Stripe'),
|
|
||||||
('unknown', 'Unknown'),
|
|
||||||
),
|
|
||||||
default='stripe')
|
|
||||||
description = models.TextField()
|
|
||||||
primary = models.BooleanField(default=False, editable=False)
|
|
||||||
|
|
||||||
# Only used for "Stripe" source
|
|
||||||
stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True)
|
|
||||||
stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def active(self):
|
|
||||||
if self.source == 'stripe' and self.stripe_payment_method_id != None:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def charge(self, amount):
|
|
||||||
if not self.active:
|
|
||||||
raise Exception('This payment method is inactive.')
|
|
||||||
|
|
||||||
if amount < 0: # Make sure we don't charge negative amount by errors...
|
|
||||||
raise Exception('Cannot charge negative amount.')
|
|
||||||
|
|
||||||
if self.source == 'stripe':
|
if self.source == 'stripe':
|
||||||
stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id
|
try:
|
||||||
stripe_payment = uncloud_pay.stripe.charge_customer(
|
result = uncloud_pay.stripe.charge_customer(self.owner, self.amount, self.currency,)
|
||||||
amount, stripe_customer, self.stripe_payment_method_id)
|
if not result.status or result.status != 'succeeded':
|
||||||
if 'paid' in stripe_payment and stripe_payment['paid'] == False:
|
raise Exception("The payment has been failed, please try to activate another card")
|
||||||
raise Exception(stripe_payment['error'])
|
super().save(*args, **kwargs)
|
||||||
else:
|
except Exception as e:
|
||||||
payment = Payment.objects.create(
|
raise e
|
||||||
owner=self.owner, source=self.source, amount=amount)
|
|
||||||
|
|
||||||
return payment
|
|
||||||
else:
|
|
||||||
raise Exception('This payment method is unsupported/cannot be charged.')
|
|
||||||
|
|
||||||
def set_as_primary_for(self, user):
|
|
||||||
methods = PaymentMethod.objects.filter(owner=user, primary=True)
|
|
||||||
for method in methods:
|
|
||||||
print(method)
|
|
||||||
method.primary = False
|
|
||||||
method.save()
|
|
||||||
|
|
||||||
self.primary = True
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def get_primary_for(user):
|
|
||||||
methods = PaymentMethod.objects.filter(owner=user)
|
|
||||||
for method in methods:
|
|
||||||
# Do we want to do something with non-primary method?
|
|
||||||
if method.active and method.primary:
|
|
||||||
return method
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
# TODO: limit to one primary method per user.
|
|
||||||
# unique_together is no good since it won't allow more than one
|
|
||||||
# non-primary method.
|
|
||||||
pass
|
|
||||||
|
|
||||||
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
|
# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types
|
||||||
class RecurringPeriodDefaultChoices(models.IntegerChoices):
|
class RecurringPeriodDefaultChoices(models.IntegerChoices):
|
||||||
|
@ -231,9 +170,11 @@ class RecurringPeriod(models.Model):
|
||||||
# Bills.
|
# Bills.
|
||||||
|
|
||||||
class BillingAddress(UncloudAddress):
|
class BillingAddress(UncloudAddress):
|
||||||
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='billing_addresses')
|
||||||
vat_number = models.CharField(max_length=100, default="", blank=True)
|
vat_number = models.CharField(max_length=100, default="", blank=True)
|
||||||
vat_number_verified = models.BooleanField(default=False)
|
vat_number_verified = models.BooleanField(default=False)
|
||||||
|
vat_number_validated_on = models.DateTimeField(blank=True, null=True)
|
||||||
|
stripe_tax_id = models.CharField(max_length=100, default="", blank=True)
|
||||||
active = models.BooleanField(default=False)
|
active = models.BooleanField(default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -273,6 +214,10 @@ class BillingAddress(UncloudAddress):
|
||||||
self.full_name, self.street, self.postal_code, self.city,
|
self.full_name, self.street, self.postal_code, self.city,
|
||||||
self.country)
|
self.country)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_address_for(user):
|
||||||
|
return BillingAddress.objects.get(owner=user)
|
||||||
|
|
||||||
###
|
###
|
||||||
# VAT
|
# VAT
|
||||||
|
|
||||||
|
@ -298,9 +243,43 @@ class VATRate(models.Model):
|
||||||
logger.debug("Did not find VAT rate for %s, returning 0" % country_code)
|
logger.debug("Did not find VAT rate for %s, returning 0" % country_code)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_vat_rate(billing_address, when=None):
|
||||||
|
"""
|
||||||
|
Returns the VAT rate for business to customer.
|
||||||
|
|
||||||
|
B2B is always 0% with the exception of trading within the own country
|
||||||
|
"""
|
||||||
|
|
||||||
|
country = billing_address.country
|
||||||
|
|
||||||
|
# Need to have a provider country
|
||||||
|
providers = UncloudProvider.objects.all()
|
||||||
|
vatrate = filter_for_when(VATRate.objects.filter(territory_codes=country), when).first()
|
||||||
|
|
||||||
|
if not providers and not vatrate:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
uncloud_provider = filter_for_when(providers).get()
|
||||||
|
|
||||||
|
# By default we charge VAT. This affects:
|
||||||
|
# - Same country sales (VAT applied)
|
||||||
|
# - B2C to EU (VAT applied)
|
||||||
|
rate = vatrate.rate if vatrate else 0
|
||||||
|
|
||||||
|
# Exception: if...
|
||||||
|
# - the billing_address is in EU,
|
||||||
|
# - the vat_number has been set
|
||||||
|
# - the vat_number has been verified
|
||||||
|
# Then we do not charge VAT
|
||||||
|
|
||||||
|
if uncloud_provider.country != country and billing_address.vat_number and billing_address.vat_number_verified:
|
||||||
|
rate = 0
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.territory_codes}: {self.starting_date} - {self.ending_date}: {self.rate_type}"
|
return f"{self.territory_codes}: {self.starting_date} - {self.ending_date or ''}: {self.rate_type}"
|
||||||
|
|
||||||
###
|
###
|
||||||
# Products
|
# Products
|
||||||
|
@ -342,30 +321,20 @@ class Product(models.Model):
|
||||||
'features': {
|
'features': {
|
||||||
'cores':
|
'cores':
|
||||||
{ 'min': 1,
|
{ 'min': 1,
|
||||||
'max': 48,
|
'max': 48
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 3
|
|
||||||
},
|
},
|
||||||
'ram_gb':
|
'ram_gb':
|
||||||
{ 'min': 1,
|
{ 'min': 1,
|
||||||
'max': 256,
|
'max': 256
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 4
|
|
||||||
},
|
},
|
||||||
'ssd_gb':
|
'ssd_gb':
|
||||||
{ 'min': 10,
|
{ 'min': 10
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 0.35
|
|
||||||
},
|
},
|
||||||
'hdd_gb':
|
'hdd_gb':
|
||||||
{ 'min': 0,
|
{ 'min': 0,
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 15/1000
|
|
||||||
},
|
},
|
||||||
'additional_ipv4_address':
|
'additional_ipv4_address':
|
||||||
{ 'min': 0,
|
{ 'min': 0,
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 8
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -381,36 +350,23 @@ class Product(models.Model):
|
||||||
'base':
|
'base':
|
||||||
{ 'min': 1,
|
{ 'min': 1,
|
||||||
'max': 1,
|
'max': 1,
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 1
|
|
||||||
},
|
},
|
||||||
'cores':
|
'cores':
|
||||||
{ 'min': 1,
|
{ 'min': 1,
|
||||||
'max': 48,
|
'max': 48,
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 3
|
|
||||||
},
|
},
|
||||||
'ram_gb':
|
'ram_gb':
|
||||||
{ 'min': 1,
|
{ 'min': 1,
|
||||||
'max': 256,
|
'max': 256,
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 4
|
|
||||||
},
|
},
|
||||||
'ssd_gb':
|
'ssd_gb':
|
||||||
{ 'min': 10,
|
{ 'min': 10
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 0.35
|
|
||||||
},
|
},
|
||||||
'hdd_gb':
|
'hdd_gb':
|
||||||
{ 'min': 0,
|
{ 'min': 0
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 15/1000
|
|
||||||
},
|
},
|
||||||
'additional_ipv4_address':
|
'additional_ipv4_address':
|
||||||
{ 'min': 0,
|
{ 'min': 0,},
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 9
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -433,7 +389,7 @@ class Product(models.Model):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def recurring_orders(self):
|
def recurring_orders(self):
|
||||||
return self.orders.order_by('id').exclude(recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"))
|
return self.orders.order_by('id').exclude(recurring_price=0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_recurring_order(self):
|
def last_recurring_order(self):
|
||||||
|
@ -441,56 +397,12 @@ class Product(models.Model):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def one_time_orders(self):
|
def one_time_orders(self):
|
||||||
return self.orders.order_by('id').filter(recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"))
|
return self.orders.order_by('id').filter(recurring_price=0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_one_time_order(self):
|
def last_one_time_order(self):
|
||||||
return self.one_time_orders.last()
|
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:
|
|
||||||
raise ValidationError("Cannot order without a billing address")
|
|
||||||
|
|
||||||
if not when_to_start:
|
|
||||||
when_to_start = timezone.now()
|
|
||||||
|
|
||||||
if not recurring_period:
|
|
||||||
recurring_period = self.default_recurring_period
|
|
||||||
|
|
||||||
|
|
||||||
# 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,
|
|
||||||
price=self.one_time_price,
|
|
||||||
recurring_period=RecurringPeriod.objects.get(name="ONE_TIME"),
|
|
||||||
description=str(self))
|
|
||||||
self.orders.add(one_time_order)
|
|
||||||
else:
|
|
||||||
one_time_order = None
|
|
||||||
|
|
||||||
if recurring_period != RecurringPeriod.objects.get(name="ONE_TIME"):
|
|
||||||
if one_time_order:
|
|
||||||
recurring_order = Order.objects.create(owner=self.owner,
|
|
||||||
billing_address=billing_address,
|
|
||||||
starting_date=when_to_start,
|
|
||||||
price=self.recurring_price,
|
|
||||||
recurring_period=recurring_period,
|
|
||||||
depends_on=one_time_order,
|
|
||||||
description=str(self))
|
|
||||||
else:
|
|
||||||
recurring_order = Order.objects.create(owner=self.owner,
|
|
||||||
billing_address=billing_address,
|
|
||||||
starting_date=when_to_start,
|
|
||||||
price=self.recurring_price,
|
|
||||||
recurring_period=recurring_period,
|
|
||||||
description=str(self))
|
|
||||||
self.orders.add(recurring_order)
|
|
||||||
|
|
||||||
|
|
||||||
# FIXME: this could/should be part of Order (?)
|
# FIXME: this could/should be part of Order (?)
|
||||||
def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None):
|
def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None):
|
||||||
if not self.recurring_price:
|
if not self.recurring_price:
|
||||||
|
@ -618,10 +530,83 @@ class Product(models.Model):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# Pricing
|
||||||
|
######
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class PricingPlan(models.Model):
|
||||||
|
name = models.CharField(max_length=255, unique=True)
|
||||||
|
vat_inclusive = models.BooleanField(default=True)
|
||||||
|
vat_percentage = models.DecimalField(
|
||||||
|
max_digits=7, decimal_places=5, blank=True, default=0
|
||||||
|
)
|
||||||
|
set_up_fees = models.DecimalField(
|
||||||
|
max_digits=7, decimal_places=2, default=0
|
||||||
|
)
|
||||||
|
cores_unit_price = models.DecimalField(
|
||||||
|
max_digits=7, decimal_places=2, default=0
|
||||||
|
)
|
||||||
|
ram_unit_price = models.DecimalField(
|
||||||
|
max_digits=7, decimal_places=2, default=0
|
||||||
|
)
|
||||||
|
storage_unit_price = models.DecimalField(
|
||||||
|
max_digits=7, decimal_places=2, default=0
|
||||||
|
)
|
||||||
|
discount_name = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
discount_amount = models.DecimalField(
|
||||||
|
max_digits=6, decimal_places=2, default=0
|
||||||
|
)
|
||||||
|
stripe_coupon_id = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
display_str = self.name + ' => ' + ' - '.join([
|
||||||
|
'{} Setup'.format(self.set_up_fees.normalize()),
|
||||||
|
'{}/Core'.format(self.cores_unit_price.normalize()),
|
||||||
|
'{}/GB RAM'.format(self.ram_unit_price.normalize()),
|
||||||
|
'{}/GB SSD'.format(self.storage_unit_price.normalize()),
|
||||||
|
'{}% VAT'.format(self.vat_percentage.normalize())
|
||||||
|
if not self.vat_inclusive else 'VAT-Incl',
|
||||||
|
])
|
||||||
|
if self.discount_amount:
|
||||||
|
display_str = ' - '.join([
|
||||||
|
display_str,
|
||||||
|
'{} {}'.format(
|
||||||
|
self.discount_amount,
|
||||||
|
self.discount_name if self.discount_name else 'Discount'
|
||||||
|
)
|
||||||
|
])
|
||||||
|
return display_str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_name(cls, name):
|
||||||
|
try:
|
||||||
|
pricing = PricingPlan.objects.get(name=name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Error getting VMPricing with name {name}. "
|
||||||
|
"Details: {details}. Attempting to return default"
|
||||||
|
"pricing.".format(name=name, details=str(e))
|
||||||
|
)
|
||||||
|
pricing = PricingPlan.get_default_pricing()
|
||||||
|
return pricing
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_default_pricing(cls):
|
||||||
|
""" Returns the default pricing or None """
|
||||||
|
try:
|
||||||
|
default_pricing = PricingPlan.objects.get(name='default')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
default_pricing = None
|
||||||
|
return default_pricing
|
||||||
|
|
||||||
###
|
###
|
||||||
# Orders.
|
# Orders.
|
||||||
|
|
||||||
class Order(models.Model):
|
class Order(models.Model):
|
||||||
"""
|
"""
|
||||||
Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
|
Order are assumed IMMUTABLE and used as SOURCE OF TRUST for generating
|
||||||
|
@ -650,6 +635,8 @@ class Order(models.Model):
|
||||||
billing_address = models.ForeignKey(BillingAddress,
|
billing_address = models.ForeignKey(BillingAddress,
|
||||||
on_delete=models.CASCADE)
|
on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
customer = models.ForeignKey(StripeCustomer, on_delete=models.CASCADE, null=True)
|
||||||
|
|
||||||
description = models.TextField()
|
description = models.TextField()
|
||||||
|
|
||||||
product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE)
|
product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE)
|
||||||
|
@ -686,6 +673,7 @@ class Order(models.Model):
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True)
|
null=True)
|
||||||
|
pricing_plan = models.ForeignKey(PricingPlan, blank=False, null=True, on_delete=models.CASCADE)
|
||||||
|
|
||||||
should_be_billed = models.BooleanField(default=True)
|
should_be_billed = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
@ -751,6 +739,17 @@ class Order(models.Model):
|
||||||
|
|
||||||
return sum([ br.quantity for br in self.bill_records.all() ])
|
return sum([ br.quantity for br in self.bill_records.all() ])
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self.ending_date = timezone.now()
|
||||||
|
self.should_be_billed = False
|
||||||
|
self.save()
|
||||||
|
if self.instance_id:
|
||||||
|
last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last()
|
||||||
|
schedule('matrixhosting.tasks.delete_instance',
|
||||||
|
self.instance_id,
|
||||||
|
schedule_type=Schedule.ONCE,
|
||||||
|
next_run=last_bill_record.ending_date or (timezone.now() + datetime.timedelta(hours=1)))
|
||||||
|
|
||||||
def count_used(self, when=None):
|
def count_used(self, when=None):
|
||||||
"""
|
"""
|
||||||
How many times this order was billed so far.
|
How many times this order was billed so far.
|
||||||
|
@ -790,7 +789,7 @@ class Order(models.Model):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_recurring(self):
|
def is_recurring(self):
|
||||||
return not self.recurring_period == RecurringPeriod.objects.get(name="ONE_TIME")
|
return self.recurring_price > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_one_time(self):
|
def is_one_time(self):
|
||||||
|
@ -814,14 +813,12 @@ class Order(models.Model):
|
||||||
description=self.description,
|
description=self.description,
|
||||||
product=self.product,
|
product=self.product,
|
||||||
config=config,
|
config=config,
|
||||||
|
pricing_plan=self.pricing_plan,
|
||||||
starting_date=starting_date,
|
starting_date=starting_date,
|
||||||
currency=self.currency
|
currency=self.currency
|
||||||
)
|
)
|
||||||
|
|
||||||
(new_order.one_time_price, new_order.recurring_price, new_order.config) = new_order.calculate_prices_and_config()
|
new_order.recurring_price = new_order.calculate_recurring_price()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
new_order.replaces = self
|
new_order.replaces = self
|
||||||
new_order.save()
|
new_order.save()
|
||||||
|
|
||||||
|
@ -834,22 +831,24 @@ class Order(models.Model):
|
||||||
def create_bill_record(self, bill):
|
def create_bill_record(self, bill):
|
||||||
br = None
|
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:
|
if self.recurring_price != 0:
|
||||||
br = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first()
|
records = BillRecord.objects.filter(order=self).all()
|
||||||
|
if not records:
|
||||||
if br:
|
if self.one_time_price:
|
||||||
self.update_bill_record_for_recurring_order(br, bill)
|
br = BillRecord.objects.create(bill=bill,
|
||||||
|
order=self,
|
||||||
|
starting_date=self.starting_date,
|
||||||
|
ending_date=bill.ending_date,
|
||||||
|
is_recurring_record=False)
|
||||||
|
else:
|
||||||
|
br = self.create_new_bill_record_for_recurring_order(bill)
|
||||||
else:
|
else:
|
||||||
br = self.create_new_bill_record_for_recurring_order(bill)
|
opened_recurring_record = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first()
|
||||||
|
if opened_recurring_record:
|
||||||
|
br = opened_recurring_record
|
||||||
|
self.update_bill_record_for_recurring_order(br, bill)
|
||||||
|
else:
|
||||||
|
br = self.create_new_bill_record_for_recurring_order(bill)
|
||||||
return br
|
return br
|
||||||
|
|
||||||
def update_bill_record_for_recurring_order(self,
|
def update_bill_record_for_recurring_order(self,
|
||||||
|
@ -861,22 +860,21 @@ class Order(models.Model):
|
||||||
|
|
||||||
# If the order has an ending date set, we might need to adjust the bill_record
|
# If the order has an ending date set, we might need to adjust the bill_record
|
||||||
if self.ending_date:
|
if self.ending_date:
|
||||||
if bill_record_for_this_bill.ending_date != self.ending_date:
|
if bill_record.ending_date != self.ending_date:
|
||||||
bill_record_for_this_bill.ending_date = self.ending_date
|
bill_record.ending_date = self.ending_date
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# recurring, not terminated, should go until at least end of bill
|
# recurring, not terminated, should go until at least end of bill
|
||||||
if bill_record_for_this_bill.ending_date < bill.ending_date:
|
if bill_record.ending_date < bill.ending_date:
|
||||||
bill_record_for_this_bill.ending_date = bill.ending_date
|
bill_record.ending_date = bill.ending_date
|
||||||
|
|
||||||
bill_record_for_this_bill.save()
|
bill_record.save()
|
||||||
|
|
||||||
def create_new_bill_record_for_recurring_order(self, bill):
|
def create_new_bill_record_for_recurring_order(self, bill):
|
||||||
"""
|
"""
|
||||||
Create a new bill record
|
Create a new bill record
|
||||||
"""
|
"""
|
||||||
|
last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last()
|
||||||
last_bill_record = BillRecord.objects.filter(order=self, is_recurring_record=True).order_by('id').last()
|
|
||||||
|
|
||||||
starting_date=self.starting_date
|
starting_date=self.starting_date
|
||||||
|
|
||||||
|
@ -892,7 +890,6 @@ class Order(models.Model):
|
||||||
return
|
return
|
||||||
|
|
||||||
starting_date = start_after(last_bill_record.ending_date)
|
starting_date = start_after(last_bill_record.ending_date)
|
||||||
|
|
||||||
ending_date = self.get_ending_date_for_bill(bill)
|
ending_date = self.get_ending_date_for_bill(bill)
|
||||||
|
|
||||||
return BillRecord.objects.create(bill=bill,
|
return BillRecord.objects.create(bill=bill,
|
||||||
|
@ -901,47 +898,27 @@ class Order(models.Model):
|
||||||
ending_date=ending_date,
|
ending_date=ending_date,
|
||||||
is_recurring_record=True)
|
is_recurring_record=True)
|
||||||
|
|
||||||
def calculate_prices_and_config(self):
|
def calculate_recurring_price(self):
|
||||||
one_time_price = 0
|
try:
|
||||||
recurring_price = 0
|
config = json.loads(self.config)
|
||||||
|
recurring_price = 0
|
||||||
|
if 'cores' in config:
|
||||||
|
recurring_price += self.pricing_plan.cores_unit_price * int(config['cores'])
|
||||||
|
if 'memory' in config:
|
||||||
|
recurring_price += self.pricing_plan.ram_unit_price * int(config['memory'])
|
||||||
|
if 'storage' in config:
|
||||||
|
recurring_price += self.pricing_plan.storage_unit_price * int(config['storage'])
|
||||||
|
|
||||||
if self.config:
|
vat_rate = VATRate.get_vat_rate(self.billing_address)
|
||||||
config = self.config
|
vat_validation_status = "verified" if self.billing_address.vat_number_validated_on and self.billing_address.vat_number_verified else False
|
||||||
|
subtotal, subtotal_after_discount, price_after_discount_with_vat, vat, vat_percent, discount = uncloud_pay.utils.apply_vat_discount(
|
||||||
if 'features' not in self.config:
|
recurring_price, self.pricing_plan,
|
||||||
self.config['features'] = {}
|
vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status
|
||||||
|
)
|
||||||
else:
|
return price_after_discount_with_vat
|
||||||
config = {
|
except Exception as e:
|
||||||
'features': {}
|
logger.error("An error occurred while parsing the config obj", e)
|
||||||
}
|
return 0
|
||||||
|
|
||||||
# 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):
|
def check_parameters(self):
|
||||||
if 'parameters' in self.product.config:
|
if 'parameters' in self.product.config:
|
||||||
|
@ -955,7 +932,7 @@ class Order(models.Model):
|
||||||
# IMMUTABLE fields -- need to create new order to modify them
|
# IMMUTABLE fields -- need to create new order to modify them
|
||||||
# However this is not enforced here...
|
# However this is not enforced here...
|
||||||
if self._state.adding:
|
if self._state.adding:
|
||||||
(self.one_time_price, self.recurring_price, self.config) = self.calculate_prices_and_config()
|
self.recurring_price = self.calculate_recurring_price()
|
||||||
|
|
||||||
if self.recurring_period_id is None:
|
if self.recurring_period_id is None:
|
||||||
self.recurring_period = self.product.default_recurring_period
|
self.recurring_period = self.product.default_recurring_period
|
||||||
|
@ -975,12 +952,7 @@ class Order(models.Model):
|
||||||
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
try:
|
return f"Order {self.id}: {self.description}"
|
||||||
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):
|
class Bill(models.Model):
|
||||||
"""
|
"""
|
||||||
|
@ -1003,7 +975,7 @@ class Bill(models.Model):
|
||||||
# FIXME: editable=True -> is in the admin, but also editable in DRF
|
# FIXME: editable=True -> is in the admin, but also editable in DRF
|
||||||
# Maybe filter fields in the serializer?
|
# Maybe filter fields in the serializer?
|
||||||
|
|
||||||
is_final = models.BooleanField(default=False)
|
is_closed = models.BooleanField(default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
constraints = [
|
constraints = [
|
||||||
|
@ -1017,8 +989,9 @@ class Bill(models.Model):
|
||||||
"""
|
"""
|
||||||
Close/finish a bill
|
Close/finish a bill
|
||||||
"""
|
"""
|
||||||
|
self.is_closed = True
|
||||||
self.is_final = True
|
if not self.ending_date:
|
||||||
|
self.ending_date = timezone.now()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -1028,34 +1001,7 @@ class Bill(models.Model):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def vat_rate(self):
|
def vat_rate(self):
|
||||||
"""
|
return VATRate.get_vat_rate(self.billing_address, when=self.ending_date)
|
||||||
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
|
@classmethod
|
||||||
|
@ -1075,9 +1021,10 @@ class Bill(models.Model):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
bills = []
|
bills = []
|
||||||
|
|
||||||
for billing_address in BillingAddress.objects.filter(owner=owner):
|
for billing_address in BillingAddress.objects.filter(owner=owner):
|
||||||
bills.append(cls.create_next_bill_for_user_address(billing_address, ending_date))
|
bill = cls.create_next_bill_for_user_address(billing_address, ending_date)
|
||||||
|
if bill:
|
||||||
|
bills.append(bill)
|
||||||
|
|
||||||
return bills
|
return bills
|
||||||
|
|
||||||
|
@ -1089,15 +1036,18 @@ class Bill(models.Model):
|
||||||
|
|
||||||
owner = billing_address.owner
|
owner = billing_address.owner
|
||||||
|
|
||||||
all_orders = Order.objects.filter(owner=owner,
|
all_orders = Order.objects.filter(Q(owner__id=owner.id), Q(should_be_billed=True),
|
||||||
billing_address=billing_address).order_by('id')
|
Q(billing_address__id=billing_address.id)
|
||||||
|
).order_by('id')
|
||||||
|
|
||||||
bill = cls.get_or_create_bill(billing_address, ending_date=ending_date)
|
if len(all_orders) > 0:
|
||||||
|
bill = cls.get_or_create_bill(billing_address, ending_date=ending_date)
|
||||||
for order in all_orders:
|
for order in all_orders:
|
||||||
order.create_bill_record(bill)
|
order.create_bill_record(bill)
|
||||||
|
return bill
|
||||||
return bill
|
else:
|
||||||
|
# This Customer Hasn't any active orders
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -1117,7 +1067,7 @@ class Bill(models.Model):
|
||||||
|
|
||||||
# Get date & bill from previous bill, if it exists
|
# Get date & bill from previous bill, if it exists
|
||||||
if last_bill:
|
if last_bill:
|
||||||
if not last_bill.is_final:
|
if not last_bill.is_closed:
|
||||||
bill = last_bill
|
bill = last_bill
|
||||||
starting_date = last_bill.starting_date
|
starting_date = last_bill.starting_date
|
||||||
ending_date = bill.ending_date
|
ending_date = bill.ending_date
|
||||||
|
@ -1167,9 +1117,11 @@ class BillRecord(models.Model):
|
||||||
if not self.is_recurring_record:
|
if not self.is_recurring_record:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
record_delta = self.ending_date - self.starting_date
|
record_delta = self.ending_date.date() - self.starting_date.date()
|
||||||
|
if self.order.recurring_period and self.order.recurring_period.duration_seconds > 0:
|
||||||
return record_delta.total_seconds()/self.order.recurring_period.duration_seconds
|
return int(record_delta.total_seconds() / self.order.recurring_period.duration_seconds)
|
||||||
|
else:
|
||||||
|
return 1
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sum(self):
|
def sum(self):
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
|
||||||
|
|
||||||
from uncloud.selectors import filter_for_when
|
|
||||||
from uncloud.models import UncloudProvider
|
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
def get_payments_for_user(user):
|
def get_payments_for_user(user):
|
||||||
|
@ -12,12 +8,11 @@ def get_payments_for_user(user):
|
||||||
return sum(payments)
|
return sum(payments)
|
||||||
|
|
||||||
def get_spendings_for_user(user):
|
def get_spendings_for_user(user):
|
||||||
orders = Order.objects.filter(owner=user)
|
bills = Bill.objects.filter(owner=user)
|
||||||
|
|
||||||
amount = 0
|
amount = 0
|
||||||
for order in orders:
|
for bill in bills:
|
||||||
amount += order.one_time_price
|
amount += bill.sum
|
||||||
amount += order.recurring_price * order.count_used(when=timezone.now())
|
|
||||||
|
|
||||||
return amount
|
return amount
|
||||||
|
|
||||||
|
@ -25,34 +20,12 @@ def get_spendings_for_user(user):
|
||||||
def get_balance_for_user(user):
|
def get_balance_for_user(user):
|
||||||
return get_payments_for_user(user) - get_spendings_for_user(user)
|
return get_payments_for_user(user) - get_spendings_for_user(user)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def has_enough_balance(user, due_amount):
|
||||||
|
balance = get_balance_for_user(user)
|
||||||
|
if balance >= due_amount:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def get_billing_address_for_user(user):
|
def get_billing_address_for_user(user):
|
||||||
return BillingAddress.objects.get(owner=user, active=True)
|
return BillingAddress.objects.filter(owner=user, active=True).first()
|
||||||
|
|
||||||
def get_vat_rate(billing_address, when=None):
|
|
||||||
"""
|
|
||||||
Returns the VAT rate for business to customer.
|
|
||||||
|
|
||||||
B2B is always 0% with the exception of trading within the own country
|
|
||||||
"""
|
|
||||||
|
|
||||||
country = billing_address.country
|
|
||||||
|
|
||||||
# Need to have a provider country
|
|
||||||
uncloud_provider = filter_for_when(UncloudProvider.objects.all()).get()
|
|
||||||
vatrate = filter_for_when(VATRate.objects.filter(territory_codes=country), when).first()
|
|
||||||
|
|
||||||
# By default we charge VAT. This affects:
|
|
||||||
# - Same country sales (VAT applied)
|
|
||||||
# - B2C to EU (VAT applied)
|
|
||||||
rate = vatrate.rate
|
|
||||||
|
|
||||||
# Exception: if...
|
|
||||||
# - the billing_address is in EU,
|
|
||||||
# - the vat_number has been set
|
|
||||||
# - the vat_number has been verified
|
|
||||||
# Then we do not charge VAT
|
|
||||||
|
|
||||||
if uncloud_provider.country != country and billing_address.vat_number and billing_address.vat_number_verified:
|
|
||||||
rate = 0
|
|
||||||
|
|
||||||
return rate
|
|
||||||
|
|
|
@ -86,8 +86,9 @@ class OrderSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
read_only_fields = ['replaced_by', 'depends_on']
|
read_only_fields = ['replaced_by', 'depends_on']
|
||||||
fields = ['uuid', 'owner', 'description', 'creation_date', 'starting_date', 'ending_date',
|
fields = ['owner', 'description', 'creation_date', 'starting_date', 'ending_date',
|
||||||
'bill', 'recurring_period', 'recurring_price', 'one_time_price'] + read_only_fields
|
'recurring_period', 'recurring_price', 'one_time_price',
|
||||||
|
'config', 'pricing_plan', 'should_be_billed'] + read_only_fields
|
||||||
|
|
||||||
|
|
||||||
###
|
###
|
||||||
|
@ -114,13 +115,13 @@ class BillSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Bill
|
model = Bill
|
||||||
fields = ['uuid', 'reference', 'owner', 'amount', 'vat_amount', 'total',
|
fields = ['owner', 'sum', 'vat_rate',
|
||||||
'due_date', 'creation_date', 'starting_date', 'ending_date',
|
'due_date', 'creation_date', 'starting_date', 'ending_date',
|
||||||
'records', 'final', 'billing_address']
|
'records', 'is_closed', 'billing_address']
|
||||||
|
|
||||||
# We do not want users to mutate the country / VAT number of an address, as it
|
# We do not want users to mutate the country / VAT number of an address, as it
|
||||||
# will change VAT on existing bills.
|
# will change VAT on existing bills.
|
||||||
class UpdateBillingAddressSerializer(serializers.ModelSerializer):
|
class UpdateBillingAddressSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = BillingAddress
|
model = BillingAddress
|
||||||
fields = ['uuid', 'street', 'city', 'postal_code']
|
fields = ['street', 'city', 'postal_code']
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import datetime
|
||||||
|
from calendar import monthrange
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,8 @@ from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from .models import StripeCustomer, StripeCreditCard
|
from .models import StripeCustomer, StripeCreditCard
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CURRENCY = 'chf'
|
CURRENCY = 'chf'
|
||||||
|
|
||||||
stripe.api_key = settings.STRIPE_KEY
|
stripe.api_key = settings.STRIPE_KEY
|
||||||
|
@ -77,9 +79,24 @@ def create_setup_intent(customer_id):
|
||||||
def get_setup_intent(setup_intent_id):
|
def get_setup_intent(setup_intent_id):
|
||||||
return stripe.SetupIntent.retrieve(setup_intent_id)
|
return stripe.SetupIntent.retrieve(setup_intent_id)
|
||||||
|
|
||||||
|
@handle_stripe_error
|
||||||
def get_payment_method(payment_method_id):
|
def get_payment_method(payment_method_id):
|
||||||
return stripe.PaymentMethod.retrieve(payment_method_id)
|
return stripe.PaymentMethod.retrieve(payment_method_id)
|
||||||
|
|
||||||
|
@handle_stripe_error
|
||||||
|
def get_card_from_payment(user, payment_method_id):
|
||||||
|
payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
|
||||||
|
if payment_method:
|
||||||
|
if 'card' in payment_method:
|
||||||
|
sync_cards_for_user(user)
|
||||||
|
return payment_method['card']
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@handle_stripe_error
|
||||||
|
def attach_payment_method(payment_method_id, customer_id):
|
||||||
|
return stripe.PaymentMethod.attach(payment_method_id, customer=customer_id)
|
||||||
|
|
||||||
@handle_stripe_error
|
@handle_stripe_error
|
||||||
def create_customer(name, email):
|
def create_customer(name, email):
|
||||||
return stripe.Customer.create(name=name, email=email)
|
return stripe.Customer.create(name=name, email=email)
|
||||||
|
@ -142,7 +159,7 @@ def sync_cards_for_user(user):
|
||||||
)
|
)
|
||||||
|
|
||||||
@handle_stripe_error
|
@handle_stripe_error
|
||||||
def charge_customer(user, amount, currency='CHF'):
|
def charge_customer(user, amount, currency='CHF', card=False):
|
||||||
# Amount is in CHF but stripes requires smallest possible unit.
|
# Amount is in CHF but stripes requires smallest possible unit.
|
||||||
# https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount
|
# https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount
|
||||||
# FIXME: might need to be adjusted for other currencies
|
# FIXME: might need to be adjusted for other currencies
|
||||||
|
@ -153,7 +170,7 @@ def charge_customer(user, amount, currency='CHF'):
|
||||||
return Exception("Programming error: unsupported currency")
|
return Exception("Programming error: unsupported currency")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
card = StripeCreditCard.objects.get(owner=user,
|
card = card or StripeCreditCard.objects.get(owner=user,
|
||||||
active=True)
|
active=True)
|
||||||
|
|
||||||
except StripeCreditCard.DoesNotExist:
|
except StripeCreditCard.DoesNotExist:
|
||||||
|
@ -169,3 +186,64 @@ def charge_customer(user, amount, currency='CHF'):
|
||||||
off_session=True,
|
off_session=True,
|
||||||
confirm=True,
|
confirm=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@handle_stripe_error
|
||||||
|
def get_payment_intent(user, amount, currency='CHF', card=False):
|
||||||
|
# Amount is in CHF but stripes requires smallest possible unit.
|
||||||
|
# https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount
|
||||||
|
# FIXME: might need to be adjusted for other currencies
|
||||||
|
|
||||||
|
if currency == 'CHF':
|
||||||
|
adjusted_amount = int(amount * 100)
|
||||||
|
else:
|
||||||
|
return Exception("Programming error: unsupported currency")
|
||||||
|
|
||||||
|
try:
|
||||||
|
card = card or StripeCreditCard.objects.get(owner=user,
|
||||||
|
active=True)
|
||||||
|
|
||||||
|
except StripeCreditCard.DoesNotExist:
|
||||||
|
raise ValidationError("No active credit card - cannot create payment")
|
||||||
|
|
||||||
|
customer_id = get_customer_id_for(user)
|
||||||
|
|
||||||
|
return stripe.PaymentIntent.create(
|
||||||
|
amount=adjusted_amount,
|
||||||
|
currency=currency,
|
||||||
|
customer=customer_id,
|
||||||
|
payment_method=card.card_id,
|
||||||
|
setup_future_usage='off_session',
|
||||||
|
confirm=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@handle_stripe_error
|
||||||
|
def get_or_create_tax_id_for_user(stripe_customer_id, vat_number,
|
||||||
|
type="eu_vat", country=""):
|
||||||
|
def compare_vat_numbers(vat1, vat2):
|
||||||
|
_vat1 = vat1.replace(" ", "").replace(".", "").replace("-","")
|
||||||
|
_vat2 = vat2.replace(" ", "").replace(".", "").replace("-","")
|
||||||
|
return True if _vat1 == _vat2 else False
|
||||||
|
|
||||||
|
tax_ids_list = stripe.Customer.list_tax_ids(
|
||||||
|
stripe_customer_id,
|
||||||
|
limit=100,
|
||||||
|
)
|
||||||
|
for tax_id_obj in tax_ids_list.data:
|
||||||
|
if compare_vat_numbers(tax_id_obj.value, vat_number):
|
||||||
|
return tax_id_obj
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"{val1} is not equal to {val2} or {con1} not same as "
|
||||||
|
"{con2}".format(val1=tax_id_obj.value, val2=vat_number,
|
||||||
|
con1=tax_id_obj.country.lower(),
|
||||||
|
con2=country.lower().strip()))
|
||||||
|
logger.debug(
|
||||||
|
"tax id obj does not exist for {val}. Creating a new one".format(
|
||||||
|
val=vat_number
|
||||||
|
))
|
||||||
|
tax_id_obj = stripe.Customer.create_tax_id(
|
||||||
|
stripe_customer_id,
|
||||||
|
type=type,
|
||||||
|
value=vat_number,
|
||||||
|
)
|
||||||
|
return tax_id_obj
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
from celery import shared_task
|
|
||||||
from .models import *
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from uncloud.models import UncloudTask
|
|
||||||
|
|
||||||
@shared_task(bind=True)
|
|
||||||
def check_balance(self):
|
|
||||||
UncloudTask.objects.create(task_id=self.request.id)
|
|
||||||
print("for each user res is 50")
|
|
||||||
return 50
|
|
|
@ -1,12 +1,11 @@
|
||||||
{% extends 'uncloud/base.html' %}
|
{% extends 'uncloud/base.html' %}
|
||||||
|
|
||||||
{% block bootstrap5_extra_head %}
|
{% block bootstrap5_extra_head %}
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
||||||
<script src="https://js.stripe.com/v3/"></script>
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bootstrap5_content %}
|
{% block bootstrap5_content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
{% csrf_token %}
|
||||||
<div id="content">
|
<div id="content">
|
||||||
<h1>Register Credit Card with Stripe</h1>
|
<h1>Register Credit Card with Stripe</h1>
|
||||||
<p>
|
<p>
|
||||||
|
@ -18,10 +17,15 @@
|
||||||
|
|
||||||
<span id="message"></span>
|
<span id="message"></span>
|
||||||
|
|
||||||
<div id="card-element"></div>
|
<input id="cardholder-name" type="text">
|
||||||
<div id="card-errors" role="alert"></div>
|
<!-- placeholder for Elements -->
|
||||||
<button type='button' id="card-button">Save</button>
|
<form id="setup-form" data-secret="{{ client_secret }}">
|
||||||
|
<div id="card-element"></div>
|
||||||
|
<div id="card-errors" role="alert"></div>
|
||||||
|
<button id="card-button">
|
||||||
|
Save Card
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<div id="ungleichmessage">The card will be registered with stripe.</div>
|
<div id="ungleichmessage">The card will be registered with stripe.</div>
|
||||||
|
|
||||||
<div id="goback" style="display: none;">
|
<div id="goback" style="display: none;">
|
||||||
|
@ -32,44 +36,43 @@
|
||||||
|
|
||||||
<!-- Enable Stripe from UI elements - standard code -->
|
<!-- Enable Stripe from UI elements - standard code -->
|
||||||
<script>
|
<script>
|
||||||
|
var cardholderName = document.getElementById('cardholder-name');
|
||||||
|
var setupForm = document.getElementById('setup-form');
|
||||||
|
var clientSecret = setupForm.dataset.secret;
|
||||||
var stripe = Stripe('{{ stripe_pk }}');
|
var stripe = Stripe('{{ stripe_pk }}');
|
||||||
var elements = stripe.elements();
|
|
||||||
|
|
||||||
var cardElement = elements.create('card');
|
|
||||||
cardElement.mount('#card-element');
|
|
||||||
|
|
||||||
var cardButton = document.getElementById('card-button');
|
var cardButton = document.getElementById('card-button');
|
||||||
var messageContainer = document.getElementById('message');
|
var messageContainer = document.getElementById('message');
|
||||||
var backmessage = document.getElementById('goback');
|
var backmessage = document.getElementById('goback');
|
||||||
var clientSecret = '{{ client_secret }}';
|
var clientSecret = '{{ client_secret }}';
|
||||||
|
|
||||||
cardButton.addEventListener('click', function(ev) {
|
var elements = stripe.elements();
|
||||||
document.getElementById("ungleichmessage").innerHTML
|
var cardElement = elements.create('card');
|
||||||
= "Registering card with Stripe, please wait ...";
|
cardElement.mount('#card-element');
|
||||||
|
|
||||||
|
setupForm.addEventListener('submit', function(ev) {
|
||||||
stripe.confirmCardSetup(
|
ev.preventDefault();
|
||||||
clientSecret,
|
stripe.confirmCardSetup(
|
||||||
{
|
clientSecret,
|
||||||
payment_method: {
|
{
|
||||||
card: cardElement,
|
payment_method: {
|
||||||
billing_details: { name: "{{username}}", },
|
card: cardElement,
|
||||||
},
|
billing_details: {
|
||||||
}
|
name: cardholderName.value,
|
||||||
).then(function(result) {
|
},
|
||||||
if (result.error) {
|
},
|
||||||
|
}
|
||||||
|
).then(function(result) {
|
||||||
|
if (result.error) {
|
||||||
var message = document.createTextNode('Error:' + result.error.message);
|
var message = document.createTextNode('Error:' + result.error.message);
|
||||||
messageContainer.appendChild(message);
|
messageContainer.appendChild(message);
|
||||||
} else {
|
} else {
|
||||||
// Return to API on success.
|
document.getElementById("ungleichmessage").innerHTML
|
||||||
document.getElementById("ungleichmessage").innerHTML
|
|
||||||
= "Registered credit card with Stripe."
|
= "Registered credit card with Stripe."
|
||||||
|
|
||||||
backmessage.style.display = "block";
|
backmessage.style.display = "block";
|
||||||
|
// Return to API on success.
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -5,52 +5,28 @@ from django.utils import timezone
|
||||||
|
|
||||||
from .models import *
|
from .models import *
|
||||||
from uncloud_service.models import GenericServiceProduct
|
from uncloud_service.models import GenericServiceProduct
|
||||||
from uncloud.models import UncloudProvider
|
from uncloud.models import UncloudProvider, UncloudNetwork
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
chocolate_product_config = {
|
|
||||||
'features': {
|
|
||||||
'gramm':
|
|
||||||
{ 'min': 100,
|
|
||||||
'max': 5000,
|
|
||||||
'one_time_price_per_unit': 0.2,
|
|
||||||
'recurring_price_per_unit': 0
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
chocolate_order_config = {
|
|
||||||
'features': {
|
|
||||||
'gramm': 500,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chocolate_one_time_price = chocolate_order_config['features']['gramm'] * chocolate_product_config['features']['gramm']['one_time_price_per_unit']
|
|
||||||
|
|
||||||
vm_product_config = {
|
vm_product_config = {
|
||||||
'features': {
|
'features': {
|
||||||
'cores':
|
'cores':
|
||||||
{ 'min': 1,
|
{ 'min': 1,
|
||||||
'max': 48,
|
'max': 48
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 4
|
|
||||||
},
|
},
|
||||||
'ram_gb':
|
'ram_gb':
|
||||||
{ 'min': 1,
|
{ 'min': 1,
|
||||||
'max': 256,
|
'max': 256
|
||||||
'one_time_price_per_unit': 0,
|
|
||||||
'recurring_price_per_unit': 4
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
vm_order_config = {
|
vm_order_config = json.dumps({
|
||||||
'features': {
|
'cores': 1,
|
||||||
'cores': 2,
|
'memory': 2,
|
||||||
'ram_gb': 2
|
'storage': 100
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
vm_order_downgrade_config = {
|
vm_order_downgrade_config = {
|
||||||
'features': {
|
'features': {
|
||||||
|
@ -92,7 +68,6 @@ class ProductTestCase(TestCase):
|
||||||
"""
|
"""
|
||||||
Create a sample product
|
Create a sample product
|
||||||
"""
|
"""
|
||||||
|
|
||||||
p = Product.objects.create(name="Testproduct",
|
p = Product.objects.create(name="Testproduct",
|
||||||
description="Only for testing",
|
description="Only for testing",
|
||||||
config=vm_product_config)
|
config=vm_product_config)
|
||||||
|
@ -107,6 +82,8 @@ class OrderTestCase(TestCase):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
|
||||||
|
ram_unit_price=4, storage_unit_price=0.02)
|
||||||
self.user = get_user_model().objects.create(
|
self.user = get_user_model().objects.create(
|
||||||
username='random_user',
|
username='random_user',
|
||||||
email='jane.random@domain.tld')
|
email='jane.random@domain.tld')
|
||||||
|
@ -135,23 +112,35 @@ class OrderTestCase(TestCase):
|
||||||
Order a products with a recurringperiod that is not added to the product
|
Order a products with a recurringperiod that is not added to the product
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
order_config = json.dumps({
|
||||||
|
'cores': 1,
|
||||||
|
'memory':2,
|
||||||
|
'storage': 100
|
||||||
|
})
|
||||||
o = Order.objects.create(owner=self.user,
|
o = Order.objects.create(owner=self.user,
|
||||||
billing_address=self.ba,
|
billing_address=self.ba,
|
||||||
|
pricing_plan = self.pricing_plan,
|
||||||
product=self.product,
|
product=self.product,
|
||||||
config=vm_order_config)
|
config=order_config)
|
||||||
|
|
||||||
|
|
||||||
def test_order_product(self):
|
def test_order_product(self):
|
||||||
"""
|
"""
|
||||||
Order a product, ensure the order has correct price setup
|
Order a product, ensure the order has correct price setup
|
||||||
"""
|
"""
|
||||||
|
order_config = json.dumps({
|
||||||
|
'cores': 1,
|
||||||
|
'memory':2,
|
||||||
|
'storage': 100
|
||||||
|
})
|
||||||
o = Order.objects.create(owner=self.user,
|
o = Order.objects.create(owner=self.user,
|
||||||
billing_address=self.ba,
|
billing_address=self.ba,
|
||||||
product=self.product)
|
pricing_plan = self.pricing_plan,
|
||||||
|
product=self.product,
|
||||||
|
config=order_config)
|
||||||
|
|
||||||
self.assertEqual(o.one_time_price, 0)
|
self.assertEqual(o.one_time_price, 0)
|
||||||
self.assertEqual(o.recurring_price, 16)
|
self.assertEqual(o.recurring_price, 13.0)
|
||||||
|
|
||||||
def test_change_order(self):
|
def test_change_order(self):
|
||||||
"""
|
"""
|
||||||
|
@ -159,14 +148,19 @@ class OrderTestCase(TestCase):
|
||||||
- a new order is created
|
- a new order is created
|
||||||
- the price is correct in the new order
|
- the price is correct in the new order
|
||||||
"""
|
"""
|
||||||
|
order_config = json.dumps({
|
||||||
|
'cores': 2,
|
||||||
|
'memory':4,
|
||||||
|
'storage': 200
|
||||||
|
})
|
||||||
order1 = Order.objects.create(owner=self.user,
|
order1 = Order.objects.create(owner=self.user,
|
||||||
billing_address=self.ba,
|
billing_address=self.ba,
|
||||||
|
pricing_plan = self.pricing_plan,
|
||||||
product=self.product,
|
product=self.product,
|
||||||
config=vm_order_config)
|
config=order_config)
|
||||||
|
|
||||||
|
|
||||||
self.assertEqual(order1.one_time_price, 0)
|
self.assertEqual(order1.one_time_price, 0)
|
||||||
self.assertEqual(order1.recurring_price, 16)
|
self.assertEqual(order1.recurring_price, 26.0)
|
||||||
|
|
||||||
|
|
||||||
class ModifyOrderTestCase(TestCase):
|
class ModifyOrderTestCase(TestCase):
|
||||||
|
@ -181,7 +175,18 @@ class ModifyOrderTestCase(TestCase):
|
||||||
self.user = get_user_model().objects.create(
|
self.user = get_user_model().objects.create(
|
||||||
username='random_user',
|
username='random_user',
|
||||||
email='jane.random@domain.tld')
|
email='jane.random@domain.tld')
|
||||||
|
self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
|
||||||
|
ram_unit_price=4, storage_unit_price=0.02)
|
||||||
|
self.order1_config = json.dumps({
|
||||||
|
'cores': 2,
|
||||||
|
'memory':4,
|
||||||
|
'storage': 200
|
||||||
|
})
|
||||||
|
self.order2_config = json.dumps({
|
||||||
|
'cores': 1,
|
||||||
|
'memory':2,
|
||||||
|
'storage': 100
|
||||||
|
})
|
||||||
self.ba = BillingAddress.objects.create(
|
self.ba = BillingAddress.objects.create(
|
||||||
owner=self.user,
|
owner=self.user,
|
||||||
organization = 'Test org',
|
organization = 'Test org',
|
||||||
|
@ -226,10 +231,11 @@ class ModifyOrderTestCase(TestCase):
|
||||||
order1 = Order.objects.create(owner=self.user,
|
order1 = Order.objects.create(owner=self.user,
|
||||||
billing_address=BillingAddress.get_address_for(self.user),
|
billing_address=BillingAddress.get_address_for(self.user),
|
||||||
product=self.product,
|
product=self.product,
|
||||||
config=vm_order_config,
|
config=self.order1_config,
|
||||||
|
pricing_plan=self.pricing_plan,
|
||||||
starting_date=starting_date)
|
starting_date=starting_date)
|
||||||
|
|
||||||
order1.update_order(vm_order_downgrade_config, starting_date=change1_date)
|
order1.update_order(self.order2_config, starting_date=change1_date)
|
||||||
|
|
||||||
bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
|
bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
|
||||||
|
|
||||||
|
@ -270,24 +276,26 @@ class ModifyOrderTestCase(TestCase):
|
||||||
first_order_should_end_at = starting_date + datetime.timedelta(days=30)
|
first_order_should_end_at = starting_date + datetime.timedelta(days=30)
|
||||||
change1_date = start_after(starting_date + datetime.timedelta(days=15))
|
change1_date = start_after(starting_date + datetime.timedelta(days=15))
|
||||||
bill_ending_date = change1_date + datetime.timedelta(days=1)
|
bill_ending_date = change1_date + datetime.timedelta(days=1)
|
||||||
|
|
||||||
order1 = Order.objects.create(owner=self.user,
|
order1 = Order.objects.create(owner=self.user,
|
||||||
billing_address=BillingAddress.get_address_for(self.user),
|
billing_address=BillingAddress.get_address_for(self.user),
|
||||||
product=self.product,
|
product=self.product,
|
||||||
config=vm_order_config,
|
pricing_plan=self.pricing_plan,
|
||||||
|
config=self.order1_config,
|
||||||
starting_date=starting_date)
|
starting_date=starting_date)
|
||||||
|
|
||||||
order1.update_order(vm_order_downgrade_config, starting_date=change1_date)
|
|
||||||
|
|
||||||
bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
|
bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
|
||||||
|
|
||||||
bill = bills[0]
|
bill = bills[0]
|
||||||
bill_records = BillRecord.objects.filter(bill=bill)
|
bill_records = BillRecord.objects.filter(bill=bill)
|
||||||
|
|
||||||
self.assertEqual(len(bill_records), 2)
|
self.assertEqual(len(bill_records), 1)
|
||||||
|
|
||||||
self.assertEqual(bill_records[0].starting_date, starting_date)
|
self.assertEqual(bill_records[0].starting_date, starting_date)
|
||||||
self.assertEqual(bill_records[0].order.ending_date, first_order_should_end_at)
|
|
||||||
|
order1.update_order(self.order2_config, starting_date=change1_date)
|
||||||
|
bills = Bill.create_next_bills_for_user(user, ending_date=bill_ending_date)
|
||||||
|
bill_records = BillRecord.objects.filter(bill=bill)
|
||||||
|
self.assertEqual(len(bill_records), 2)
|
||||||
|
self.assertEqual(bill_records[0].order.ending_date.date(), change1_date.date())
|
||||||
|
|
||||||
|
|
||||||
class BillTestCase(TestCase):
|
class BillTestCase(TestCase):
|
||||||
|
@ -298,6 +306,9 @@ class BillTestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
RecurringPeriod.populate_db_defaults()
|
RecurringPeriod.populate_db_defaults()
|
||||||
|
|
||||||
|
self.pricing_plan = PricingPlan.objects.create(name="PricingSample", set_up_fees=35, cores_unit_price=3,
|
||||||
|
ram_unit_price=4, storage_unit_price=0.02)
|
||||||
|
|
||||||
self.user_without_address = get_user_model().objects.create(
|
self.user_without_address = get_user_model().objects.create(
|
||||||
username='no_home_person',
|
username='no_home_person',
|
||||||
email='far.away@domain.tld')
|
email='far.away@domain.tld')
|
||||||
|
@ -331,12 +342,12 @@ class BillTestCase(TestCase):
|
||||||
'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)),
|
'starting_date': timezone.make_aware(datetime.datetime(2020,3,3)),
|
||||||
'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)),
|
'ending_date': timezone.make_aware(datetime.datetime(2020,4,17)),
|
||||||
'price': 15,
|
'price': 15,
|
||||||
'description': 'One chocolate bar'
|
'description': ''
|
||||||
}
|
}
|
||||||
|
|
||||||
self.chocolate = Product.objects.create(name="Swiss Chocolate",
|
self.product = Product.objects.create(name="Product Sample",
|
||||||
description="Not only for testing, but for joy",
|
description="Not only for testing, but for joy",
|
||||||
config=chocolate_product_config)
|
config=vm_product_config)
|
||||||
|
|
||||||
|
|
||||||
self.vm = Product.objects.create(name="Super Fast VM",
|
self.vm = Product.objects.create(name="Super Fast VM",
|
||||||
|
@ -349,7 +360,7 @@ class BillTestCase(TestCase):
|
||||||
|
|
||||||
self.onetime_recurring_period = RecurringPeriod.objects.get(name="Onetime")
|
self.onetime_recurring_period = RecurringPeriod.objects.get(name="Onetime")
|
||||||
|
|
||||||
self.chocolate.recurring_periods.add(self.onetime_recurring_period,
|
self.product.recurring_periods.add(self.onetime_recurring_period,
|
||||||
through_defaults= { 'is_default': True })
|
through_defaults= { 'is_default': True })
|
||||||
|
|
||||||
self.vm.recurring_periods.add(self.default_recurring_period,
|
self.vm.recurring_periods.add(self.default_recurring_period,
|
||||||
|
@ -364,15 +375,16 @@ class BillTestCase(TestCase):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def order_chocolate(self):
|
def order_product(self):
|
||||||
return Order.objects.create(
|
return Order.objects.create(
|
||||||
owner=self.user,
|
owner=self.user,
|
||||||
recurring_period=RecurringPeriod.objects.get(name="Onetime"),
|
recurring_period=RecurringPeriod.objects.get(name="Onetime"),
|
||||||
product=self.chocolate,
|
product=self.product,
|
||||||
billing_address=BillingAddress.get_address_for(self.user),
|
billing_address=BillingAddress.get_address_for(self.user),
|
||||||
starting_date=self.order_meta[1]['starting_date'],
|
starting_date=self.order_meta[1]['starting_date'],
|
||||||
ending_date=self.order_meta[1]['ending_date'],
|
ending_date=self.order_meta[1]['ending_date'],
|
||||||
config=chocolate_order_config)
|
pricing_plan=self.pricing_plan,
|
||||||
|
config=vm_order_config)
|
||||||
|
|
||||||
def order_vm(self, owner=None):
|
def order_vm(self, owner=None):
|
||||||
|
|
||||||
|
@ -383,27 +395,52 @@ class BillTestCase(TestCase):
|
||||||
owner=owner,
|
owner=owner,
|
||||||
product=self.vm,
|
product=self.vm,
|
||||||
config=vm_order_config,
|
config=vm_order_config,
|
||||||
|
pricing_plan=self.pricing_plan,
|
||||||
billing_address=BillingAddress.get_address_for(self.recurring_user),
|
billing_address=BillingAddress.get_address_for(self.recurring_user),
|
||||||
starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
|
starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
|
||||||
)
|
)
|
||||||
|
|
||||||
return Order.objects.create(
|
def test_bill_one_time_with_recurring(self):
|
||||||
|
"""
|
||||||
|
Validate that if the order contains one_time_price and recurring_pricing
|
||||||
|
One Bill records should be created
|
||||||
|
"""
|
||||||
|
|
||||||
|
order = Order.objects.create(
|
||||||
owner=self.user,
|
owner=self.user,
|
||||||
recurring_period=RecurringPeriod.objects.get(name="Onetime"),
|
product=self.vm,
|
||||||
product=self.chocolate,
|
config=vm_order_config,
|
||||||
|
pricing_plan=self.pricing_plan,
|
||||||
|
one_time_price = 35,
|
||||||
billing_address=BillingAddress.get_address_for(self.user),
|
billing_address=BillingAddress.get_address_for(self.user),
|
||||||
starting_date=self.order_meta[1]['starting_date'],
|
starting_date=timezone.make_aware(datetime.datetime(2020,3,3)),
|
||||||
ending_date=self.order_meta[1]['ending_date'],
|
)
|
||||||
config=chocolate_order_config)
|
|
||||||
|
|
||||||
|
bill = Bill.create_next_bill_for_user_address(self.user_addr)
|
||||||
|
|
||||||
|
self.assertEqual(order.billrecord_set.count(), 1)
|
||||||
|
record = order.billrecord_set.first()
|
||||||
|
self.assertEqual(record.is_recurring_record, False)
|
||||||
|
self.assertEqual(record.price, 35)
|
||||||
|
self.assertEqual(record.quantity, 1)
|
||||||
|
self.assertEqual(record.sum, 35)
|
||||||
|
#close the bill as it has been paid
|
||||||
|
bill.close()
|
||||||
|
bill2 = Bill.create_next_bill_for_user_address(self.user_addr)
|
||||||
|
self.assertNotEqual(bill.id, bill2.id)
|
||||||
|
self.assertEqual(order.billrecord_set.count(), 2)
|
||||||
|
record = BillRecord.objects.filter(bill=bill2, order=order).first()
|
||||||
|
self.assertEqual(record.is_recurring_record, True)
|
||||||
|
self.assertEqual(record.price, 13)
|
||||||
|
self.assertEqual(record.quantity, 1)
|
||||||
|
self.assertEqual(record.sum, 13)
|
||||||
|
|
||||||
def test_bill_one_time_one_bill_record(self):
|
def test_bill_one_time_one_bill_record(self):
|
||||||
"""
|
"""
|
||||||
Ensure there is only 1 bill record per order
|
Ensure there is only 1 bill record per order
|
||||||
"""
|
"""
|
||||||
|
|
||||||
order = self.order_chocolate()
|
order = self.order_product()
|
||||||
|
|
||||||
bill = Bill.create_next_bill_for_user_address(self.user_addr)
|
bill = Bill.create_next_bill_for_user_address(self.user_addr)
|
||||||
|
|
||||||
|
@ -414,9 +451,14 @@ class BillTestCase(TestCase):
|
||||||
Check the bill sum for a single one time order
|
Check the bill sum for a single one time order
|
||||||
"""
|
"""
|
||||||
|
|
||||||
order = self.order_chocolate()
|
order = self.order_product()
|
||||||
|
self.assertEqual(order.recurring_price, 13.0)
|
||||||
bill = Bill.create_next_bill_for_user_address(self.user_addr)
|
bill = Bill.create_next_bill_for_user_address(self.user_addr)
|
||||||
self.assertEqual(bill.sum, chocolate_one_time_price)
|
self.assertEqual(order.billrecord_set.count(), 1)
|
||||||
|
record = order.billrecord_set.first()
|
||||||
|
self.assertEqual(record.price, 13)
|
||||||
|
self.assertEqual(record.quantity, 1)
|
||||||
|
self.assertEqual(bill.sum, 13)
|
||||||
|
|
||||||
|
|
||||||
def test_bill_creates_record_for_recurring_order(self):
|
def test_bill_creates_record_for_recurring_order(self):
|
||||||
|
@ -461,7 +503,7 @@ class BillingAddressTestCase(TestCase):
|
||||||
Raise an error, when there is no address
|
Raise an error, when there is no address
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.assertRaises(uncloud_pay.models.BillingAddress.DoesNotExist,
|
self.assertRaises(BillingAddress.DoesNotExist,
|
||||||
BillingAddress.get_address_for,
|
BillingAddress.get_address_for,
|
||||||
self.user)
|
self.user)
|
||||||
|
|
||||||
|
@ -479,6 +521,7 @@ class VATRatesTestCase(TestCase):
|
||||||
postal_code="unknown",
|
postal_code="unknown",
|
||||||
active=True)
|
active=True)
|
||||||
|
|
||||||
|
UncloudNetwork.populate_db_defaults()
|
||||||
UncloudProvider.populate_db_defaults()
|
UncloudProvider.populate_db_defaults()
|
||||||
|
|
||||||
|
|
||||||
|
|
155
uncloud_pay/utils.py
Normal file
155
uncloud_pay/utils.py
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
import logging
|
||||||
|
import decimal
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from . import stripe as uncloud_stripe
|
||||||
|
import stripe
|
||||||
|
from .models import PricingPlan, BillingAddress
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
eu_countries = ['at', 'be', 'bg', 'ch', 'cy', 'cz', 'hr', 'dk',
|
||||||
|
'ee', 'fi', 'fr', 'mc', 'de', 'gr', 'hu', 'ie', 'it',
|
||||||
|
'lv', 'lu', 'mt', 'nl', 'po', 'pt', 'ro','sk', 'si', 'es',
|
||||||
|
'se', 'gb']
|
||||||
|
|
||||||
|
def validate_vat_number(stripe_customer_id, billing_address_id):
|
||||||
|
try:
|
||||||
|
billing_address = BillingAddress.objects.get(id=billing_address_id)
|
||||||
|
except BillingAddress.DoesNotExist as dne:
|
||||||
|
billing_address = None
|
||||||
|
except BillingAddress.MultipleObjectsReturned as mor:
|
||||||
|
billing_address = BillingAddress.objects.filter(id=billing_address_id).order_by('-id').first()
|
||||||
|
if billing_address is not None:
|
||||||
|
logger.debug("BillingAddress found: %s %s" % (
|
||||||
|
billing_address_id, str(billing_address)))
|
||||||
|
if billing_address.country.lower().strip() not in eu_countries:
|
||||||
|
return {
|
||||||
|
"validated_on": "",
|
||||||
|
"status": "not_needed"
|
||||||
|
}
|
||||||
|
if billing_address.vat_number_validated_on and billing_address.vat_number_verified:
|
||||||
|
return {
|
||||||
|
"validated_on": billing_address.vat_number_validated_on,
|
||||||
|
"status": "verified"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
if billing_address.stripe_tax_id:
|
||||||
|
logger.debug("We have a tax id %s" % billing_address.stripe_tax_id)
|
||||||
|
tax_id_obj = stripe.Customer.retrieve_tax_id(
|
||||||
|
stripe_customer_id,
|
||||||
|
billing_address.stripe_tax_id,
|
||||||
|
)
|
||||||
|
if tax_id_obj.verification.status == "verified":
|
||||||
|
logger.debug("Latest status on Stripe=%s. Updating" %
|
||||||
|
tax_id_obj.verification.status)
|
||||||
|
# update billing address
|
||||||
|
billing_address.vat_number_validated_on = datetime.datetime.now()
|
||||||
|
billing_address.vat_number_verified = True
|
||||||
|
billing_address.save()
|
||||||
|
return {
|
||||||
|
"status": "verified",
|
||||||
|
"validated_on": billing_address.vat_number_validated_on
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
billing_address.vat_number_validated_on = datetime.datetime.now()
|
||||||
|
billing_address.vat_number_verified = False
|
||||||
|
billing_address.save()
|
||||||
|
else:
|
||||||
|
logger.debug("Creating a tax id")
|
||||||
|
tax_id_obj = create_tax_id(
|
||||||
|
stripe_customer_id, billing_address_id,
|
||||||
|
"ch_vat" if billing_address.country.lower() == "ch" else "eu_vat",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("invalid billing address")
|
||||||
|
return {
|
||||||
|
"status": "invalid billing address",
|
||||||
|
"validated_on": ""
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": tax_id_obj.verification.status if 'verification' in tax_id_obj else "unknown",
|
||||||
|
"validated_on": datetime.datetime.now() if tax_id_obj.verification.status == "verified" else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
def create_tax_id(stripe_customer_id, billing_address_id, type):
|
||||||
|
try:
|
||||||
|
billing_address = BillingAddress.objects.get(id=billing_address_id)
|
||||||
|
except BillingAddress.DoesNotExist as dne:
|
||||||
|
billing_address = None
|
||||||
|
logger.debug("BillingAddress does not exist for %s" % billing_address_id)
|
||||||
|
except BillingAddress.MultipleObjectsReturned as mor:
|
||||||
|
logger.debug("Multiple BillingAddress exist for %s" % billing_address_id)
|
||||||
|
billing_address = BillingAddress.objects.filter(billing_address_id).order_by('-id').first()
|
||||||
|
|
||||||
|
tax_id_obj = None
|
||||||
|
if billing_address:
|
||||||
|
try:
|
||||||
|
tax_id_obj = uncloud_stripe.get_or_create_tax_id_for_user(
|
||||||
|
stripe_customer_id,
|
||||||
|
vat_number=billing_address.vat_number,
|
||||||
|
type=type,
|
||||||
|
country=billing_address.country
|
||||||
|
)
|
||||||
|
billing_address.stripe_tax_id = tax_id_obj.id
|
||||||
|
billing_address.vat_number_verified = True if tax_id_obj.verification.status == "verified" else False
|
||||||
|
billing_address.save()
|
||||||
|
return tax_id_obj
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Received none in tax_id_obj")
|
||||||
|
return {
|
||||||
|
'verification': None,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def apply_vat_discount(subtotal, pricing_plan, vat_rate=False, vat_validation_status=False):
|
||||||
|
vat_percent = vat_rate or pricing_plan.vat_percentage
|
||||||
|
if pricing_plan.vat_inclusive or (vat_validation_status and vat_validation_status in ["verified", "not_needed"]):
|
||||||
|
vat_percent = decimal.Decimal(0)
|
||||||
|
vat = decimal.Decimal(0)
|
||||||
|
else:
|
||||||
|
vat = subtotal * decimal.Decimal(vat_rate) * decimal.Decimal(0.01)
|
||||||
|
discount_amount = 0
|
||||||
|
discount_amount_with_vat = 0
|
||||||
|
if pricing_plan.discount_amount:
|
||||||
|
discount_amount = round(float(pricing_plan.discount_amount), 2)
|
||||||
|
discount_amount_with_vat = decimal.Decimal(discount_amount) * (1 + decimal.Decimal(vat_rate) * decimal.Decimal(0.01))
|
||||||
|
discount_amount_with_vat = discount_amount_with_vat
|
||||||
|
|
||||||
|
subtotal = round(float(subtotal), 2)
|
||||||
|
vat_percent = round(float(vat_percent), 2)
|
||||||
|
discount = {
|
||||||
|
'name': pricing_plan.discount_name,
|
||||||
|
'amount': discount_amount,
|
||||||
|
'amount_with_vat': round(float(discount_amount_with_vat), 2)
|
||||||
|
}
|
||||||
|
subtotal_after_discount = subtotal - discount["amount"]
|
||||||
|
price_after_discount_with_vat = round((subtotal - discount['amount']) * (1 + vat_percent * 0.01), 2)
|
||||||
|
|
||||||
|
return (subtotal, round(float(subtotal_after_discount), 2), price_after_discount_with_vat,
|
||||||
|
round(float(vat), 2), vat_percent, discount)
|
||||||
|
|
||||||
|
|
||||||
|
def get_order_total_with_vat(cores, memory, storage,
|
||||||
|
pricing_name='default', vat_rate=False, vat_validation_status=False):
|
||||||
|
try:
|
||||||
|
pricing = PricingPlan.objects.get(name=pricing_name)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(
|
||||||
|
"Error getting PricingPlan object for {pricing_name}."
|
||||||
|
"Details: {details}".format(
|
||||||
|
pricing_name=pricing_name, details=str(ex)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
subtotal = (
|
||||||
|
pricing.set_up_fees +
|
||||||
|
(decimal.Decimal(cores) * pricing.cores_unit_price) +
|
||||||
|
(decimal.Decimal(memory) * pricing.ram_unit_price) +
|
||||||
|
(decimal.Decimal(storage) * (pricing.storage_unit_price))
|
||||||
|
)
|
||||||
|
return apply_vat_discount(subtotal, pricing, vat_rate, vat_validation_status)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.views.generic.base import TemplateView
|
from django.views.generic.base import TemplateView
|
||||||
|
|
||||||
|
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
@ -29,27 +27,31 @@ from .selectors import *
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from vat_validator import sanitize_vat
|
from vat_validator import sanitize_vat
|
||||||
import uncloud_pay.stripe as uncloud_stripe
|
import uncloud_pay.stripe as uncloud_stripe
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.http import JsonResponse
|
||||||
|
import stripe
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
###
|
###
|
||||||
# 2020-12 checked code
|
# 2020-12 checked code
|
||||||
|
|
||||||
class RegisterCard(LoginRequiredMixin, TemplateView):
|
class RegisterCard(TemplateView):
|
||||||
login_url = '/login/'
|
|
||||||
|
|
||||||
template_name = "uncloud_pay/register_stripe.html"
|
template_name = "uncloud_pay/register_stripe.html"
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
||||||
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
|
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
|
||||||
|
|
||||||
setup_intent = uncloud_stripe.create_setup_intent(customer_id)
|
setup_intent = uncloud_stripe.create_setup_intent(customer_id)
|
||||||
|
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context['client_secret'] = setup_intent.client_secret
|
context['client_secret'] = setup_intent.client_secret
|
||||||
context['username'] = self.request.user
|
context['username'] = self.request.user.username
|
||||||
context['stripe_pk'] = uncloud_stripe.public_api_key
|
context['stripe_pk'] = uncloud_stripe.public_api_key
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
@ -70,7 +72,6 @@ class CreditCardViewSet(mixins.RetrieveModelMixin,
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return StripeCreditCard.objects.filter(owner=self.request.user)
|
return StripeCreditCard.objects.filter(owner=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
class PaymentViewSet(viewsets.ModelViewSet):
|
class PaymentViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = PaymentSerializer
|
serializer_class = PaymentSerializer
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
@ -89,24 +90,13 @@ class BalanceViewSet(viewsets.ViewSet):
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
###
|
class ListCards(TemplateView):
|
||||||
# Payments and Payment Methods.
|
|
||||||
|
|
||||||
|
|
||||||
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
|
||||||
serializer_class = OrderSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return Order.objects.filter(owner=self.request.user)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ListCards(LoginRequiredMixin, TemplateView):
|
|
||||||
login_url = '/login/'
|
|
||||||
|
|
||||||
template_name = "uncloud_pay/list_stripe.html"
|
template_name = "uncloud_pay/list_stripe.html"
|
||||||
|
|
||||||
|
@method_decorator(login_required)
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
|
customer_id = uncloud_stripe.get_customer_id_for(self.request.user)
|
||||||
cards = uncloud_stripe.get_customer_cards(customer_id)
|
cards = uncloud_stripe.get_customer_cards(customer_id)
|
||||||
|
@ -117,140 +107,6 @@ class ListCards(LoginRequiredMixin, TemplateView):
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
class PaymentMethodViewSet(viewsets.ModelViewSet):
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.action == 'create':
|
|
||||||
return CreatePaymentMethodSerializer
|
|
||||||
elif self.action == 'update':
|
|
||||||
return UpdatePaymentMethodSerializer
|
|
||||||
elif self.action == 'charge':
|
|
||||||
return ChargePaymentMethodSerializer
|
|
||||||
else:
|
|
||||||
return PaymentMethodSerializer
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return PaymentMethod.objects.filter(owner=self.request.user)
|
|
||||||
|
|
||||||
# XXX: Handling of errors is far from great down there.
|
|
||||||
@transaction.atomic
|
|
||||||
def create(self, request):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
# Set newly created method as primary if no other method is.
|
|
||||||
if PaymentMethod.get_primary_for(request.user) == None:
|
|
||||||
serializer.validated_data['primary'] = True
|
|
||||||
|
|
||||||
if serializer.validated_data['source'] == "stripe":
|
|
||||||
# Retrieve Stripe customer ID for user.
|
|
||||||
customer_id = uncloud_stripe.get_customer_id_for(request.user)
|
|
||||||
if customer_id == None:
|
|
||||||
return Response(
|
|
||||||
{'error': 'Could not resolve customer stripe ID.'},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
try:
|
|
||||||
setup_intent = uncloud_stripe.create_setup_intent(customer_id)
|
|
||||||
except Exception as e:
|
|
||||||
return Response({'error': str(e)},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
payment_method = PaymentMethod.objects.create(
|
|
||||||
owner=request.user,
|
|
||||||
stripe_setup_intent_id=setup_intent.id,
|
|
||||||
**serializer.validated_data)
|
|
||||||
|
|
||||||
# TODO: find a way to use reverse properly:
|
|
||||||
# https://www.django-rest-framework.org/api-guide/reverse/
|
|
||||||
path = "payment-method/{}/register-stripe-cc".format(
|
|
||||||
payment_method.uuid)
|
|
||||||
stripe_registration_url = reverse('api-root', request=request) + path
|
|
||||||
return Response({'please_visit': stripe_registration_url})
|
|
||||||
else:
|
|
||||||
serializer.save(owner=request.user, **serializer.validated_data)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
|
||||||
def charge(self, request, pk=None):
|
|
||||||
payment_method = self.get_object()
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
amount = serializer.validated_data['amount']
|
|
||||||
try:
|
|
||||||
payment = payment_method.charge(amount)
|
|
||||||
output_serializer = PaymentSerializer(payment)
|
|
||||||
return Response(output_serializer.data)
|
|
||||||
except Exception as e:
|
|
||||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['get'], url_path='register-stripe-cc', renderer_classes=[TemplateHTMLRenderer])
|
|
||||||
def register_stripe_cc(self, request, pk=None):
|
|
||||||
payment_method = self.get_object()
|
|
||||||
|
|
||||||
if payment_method.source != 'stripe':
|
|
||||||
return Response(
|
|
||||||
{'error': 'This is not a Stripe-based payment method.'},
|
|
||||||
template_name='error.html.j2')
|
|
||||||
|
|
||||||
if payment_method.active:
|
|
||||||
return Response(
|
|
||||||
{'error': 'This payment method is already active'},
|
|
||||||
template_name='error.html.j2')
|
|
||||||
|
|
||||||
try:
|
|
||||||
setup_intent = uncloud_stripe.get_setup_intent(
|
|
||||||
payment_method.stripe_setup_intent_id)
|
|
||||||
except Exception as e:
|
|
||||||
return Response(
|
|
||||||
{'error': str(e)},
|
|
||||||
template_name='error.html.j2')
|
|
||||||
|
|
||||||
# TODO: find a way to use reverse properly:
|
|
||||||
# https://www.django-rest-framework.org/api-guide/reverse/
|
|
||||||
callback_path= "payment-method/{}/activate-stripe-cc/".format(
|
|
||||||
payment_method.id)
|
|
||||||
callback = reverse('api-root', request=request) + callback_path
|
|
||||||
|
|
||||||
# Render stripe card registration form.
|
|
||||||
template_args = {
|
|
||||||
'client_secret': setup_intent.client_secret,
|
|
||||||
'stripe_pk': uncloud_stripe.public_api_key,
|
|
||||||
'callback': callback
|
|
||||||
}
|
|
||||||
return Response(template_args, template_name='stripe-payment.html.j2')
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], url_path='activate-stripe-cc')
|
|
||||||
def activate_stripe_cc(self, request, pk=None):
|
|
||||||
payment_method = self.get_object()
|
|
||||||
try:
|
|
||||||
setup_intent = uncloud_stripe.get_setup_intent(
|
|
||||||
payment_method.stripe_setup_intent_id)
|
|
||||||
except Exception as e:
|
|
||||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
# Card had been registered, fetching payment method.
|
|
||||||
print(setup_intent)
|
|
||||||
if setup_intent.payment_method:
|
|
||||||
payment_method.stripe_payment_method_id = setup_intent.payment_method
|
|
||||||
payment_method.save()
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
'uuid': payment_method.uuid,
|
|
||||||
'activated': payment_method.active})
|
|
||||||
else:
|
|
||||||
error = 'Could not fetch payment method from stripe. Please try again.'
|
|
||||||
return Response({'error': error})
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], url_path='set-as-primary')
|
|
||||||
def set_as_primary(self, request, pk=None):
|
|
||||||
payment_method = self.get_object()
|
|
||||||
payment_method.set_as_primary_for(request.user)
|
|
||||||
|
|
||||||
serializer = self.get_serializer(payment_method)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
###
|
###
|
||||||
# Bills and Orders.
|
# Bills and Orders.
|
||||||
|
|
||||||
|
@ -314,7 +170,7 @@ class BillingAddressViewSet(mixins.CreateModelMixin,
|
||||||
return BillingAddressSerializer
|
return BillingAddressSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.user.billingaddress_set.all()
|
return self.request.user.billing_addresses.all()
|
||||||
|
|
||||||
def create(self, request):
|
def create(self, request):
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
|
Loading…
Reference in a new issue