diff --git a/.gitignore b/.gitignore index ab6a151..426f11b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,6 @@ uncloud/version.py build/ venv/ dist/ - +.history/ *.iso *.sqlite3 diff --git a/README.md b/README.md index 8c53654..07f5c91 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,15 @@ Cloud management platform, the ungleich way. ## Useful commands * `./manage.py import-vat-rates path/to/csv` -* `./manage.py make-admin username` +* `./manage.py createsuperuser` ## Development setup Install system dependencies: * On Fedora, you will need the following packages: `python3-virtualenv python3-devel openldap-devel gcc chromium` +* sudo apt-get install libpq-dev python-dev libxml2-dev libxslt1-dev libldap2-dev libsasl2-dev libffi-dev + NOTE: you will need to configure a LDAP server and credentials for authentication. See `uncloud/settings.py`. @@ -53,6 +55,12 @@ Django version 3.0.6, using settings 'uncloud.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. ``` +### Run Background Job Queue +We use Django Q to handle the asynchronous code and Background Cron jobs +To start the workers make sure first that Redis or the Django Q broker is working and you can edit it's settings in the settings file. +``` +./manage.py qcluster +``` ### Note on PGSQL diff --git a/matrixhosting/__init__.py b/matrixhosting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/matrixhosting/admin.py b/matrixhosting/admin.py new file mode 100644 index 0000000..c33589b --- /dev/null +++ b/matrixhosting/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from .models import VMInstance + +admin.site.register(VMInstance) diff --git a/matrixhosting/apps.py b/matrixhosting/apps.py new file mode 100644 index 0000000..ad02796 --- /dev/null +++ b/matrixhosting/apps.py @@ -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 diff --git a/matrixhosting/forms.py b/matrixhosting/forms.py new file mode 100644 index 0000000..793aeeb --- /dev/null +++ b/matrixhosting/forms.py @@ -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'] + + diff --git a/matrixhosting/migrations/0001_initial.py b/matrixhosting/migrations/0001_initial.py new file mode 100644 index 0000000..dce49c3 --- /dev/null +++ b/matrixhosting/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/matrixhosting/migrations/0002_rename_vmpricing_matrixvmpricing.py b/matrixhosting/migrations/0002_rename_vmpricing_matrixvmpricing.py new file mode 100644 index 0000000..f21241d --- /dev/null +++ b/matrixhosting/migrations/0002_rename_vmpricing_matrixvmpricing.py @@ -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', + ), + ] diff --git a/matrixhosting/migrations/0003_auto_20210703_1523.py b/matrixhosting/migrations/0003_auto_20210703_1523.py new file mode 100644 index 0000000..fe45ab0 --- /dev/null +++ b/matrixhosting/migrations/0003_auto_20210703_1523.py @@ -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), + ), + ] diff --git a/matrixhosting/migrations/0004_matrixhostingorder_vmspecs.py b/matrixhosting/migrations/0004_matrixhostingorder_vmspecs.py new file mode 100644 index 0000000..0259c51 --- /dev/null +++ b/matrixhosting/migrations/0004_matrixhostingorder_vmspecs.py @@ -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')), + ], + ), + ] diff --git a/matrixhosting/migrations/0005_auto_20210705_0849.py b/matrixhosting/migrations/0005_auto_20210705_0849.py new file mode 100644 index 0000000..742a63f --- /dev/null +++ b/matrixhosting/migrations/0005_auto_20210705_0849.py @@ -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', + ), + ] diff --git a/matrixhosting/migrations/0006_delete_matrixvmpricing.py b/matrixhosting/migrations/0006_delete_matrixvmpricing.py new file mode 100644 index 0000000..f6b0f01 --- /dev/null +++ b/matrixhosting/migrations/0006_delete_matrixvmpricing.py @@ -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', + ), + ] diff --git a/matrixhosting/migrations/0007_vminstance.py b/matrixhosting/migrations/0007_vminstance.py new file mode 100644 index 0000000..2990e10 --- /dev/null +++ b/matrixhosting/migrations/0007_vminstance.py @@ -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)), + ], + ), + ] diff --git a/matrixhosting/migrations/0008_remove_vminstance_ip.py b/matrixhosting/migrations/0008_remove_vminstance_ip.py new file mode 100644 index 0000000..054359b --- /dev/null +++ b/matrixhosting/migrations/0008_remove_vminstance_ip.py @@ -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', + ), + ] diff --git a/matrixhosting/migrations/0009_vminstance_vm_id.py b/matrixhosting/migrations/0009_vminstance_vm_id.py new file mode 100644 index 0000000..2771f58 --- /dev/null +++ b/matrixhosting/migrations/0009_vminstance_vm_id.py @@ -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), + ), + ] diff --git a/matrixhosting/migrations/__init__.py b/matrixhosting/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/matrixhosting/models.py b/matrixhosting/models.py new file mode 100644 index 0000000..8945f69 --- /dev/null +++ b/matrixhosting/models.py @@ -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 \ No newline at end of file diff --git a/matrixhosting/serializers.py b/matrixhosting/serializers.py new file mode 100644 index 0000000..7711612 --- /dev/null +++ b/matrixhosting/serializers.py @@ -0,0 +1,8 @@ +from rest_framework import serializers + +from .models import * + +class VMInstanceSerializer(serializers.ModelSerializer): + class Meta: + model = VMInstance + fields = '__all__' \ No newline at end of file diff --git a/matrixhosting/signals.py b/matrixhosting/signals.py new file mode 100644 index 0000000..494a1fc --- /dev/null +++ b/matrixhosting/signals.py @@ -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) \ No newline at end of file diff --git a/matrixhosting/static/matrixhosting/css/common.css b/matrixhosting/static/matrixhosting/css/common.css new file mode 100644 index 0000000..6ef2b64 --- /dev/null +++ b/matrixhosting/static/matrixhosting/css/common.css @@ -0,0 +1,1346 @@ +body, +html { + width: 100%; + height: 100%; +} + +body, +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: 'Lato', sans-serif; +} + + +/* bootstrap danger color override from #a94442 */ + +.text-danger, +.has-error .help-block, +.has-error .control-label, +.has-error .radio, +.has-error .checkbox, +.has-error .radio-inline, +.has-error .checkbox-inline, +.has-error.radio label, +.has-error.checkbox label, +.has-error.radio-inline label, +.has-error.checkbox-inline label, +.has-error .form-control-feedback, +.alert-danger, +.list-group-item-danger, +a.list-group-item-danger, +a.list-group-item-danger:hover, +a.list-group-item-danger:focus, +.panel-danger>.panel-heading { + color: #eb4d5c; +} + +.alert-danger { + background: rgba(235, 204, 209, 0.2); +} + +.has-error .form-control, +.has-error .form-control:focus, +.has-error .form-control:active, +.has-error .input-group-addon { + color: #eb4d5c; + border-color: #eb4d5c; +} + +a.list-group-item-danger.active, +a.list-group-item-danger.active:hover, +a.list-group-item-danger.active:focus { + background-color: #eb4d5c; + border-color: #eb4d5c; +} + +.panel-danger>.panel-heading .badge { + background-color: #eb4d5c; +} + +.topnav { + font-size: 14px; +} + +.navbar-default { + background: #fff; + padding: 5px; +} + +.navbar-brand { + padding: 10px; +} + +.navbar-brand > img { + height: 100%; +} + +#logoWhite, +.navbar-transparent #logoBlack { + display: none; +} + +#logoBlack, +.navbar-transparent #logoWhite { + display: block; +} + +@media (min-width: 768px) { + .navbar-right { + margin-right: 10px; + } + .navbar-brand { + padding-right: 15px; + padding-left: 15px; + } +} + +.navbar .dcl-link { + display: block; + padding: 15px; + color: #777; +} + +.navbar .dcl-link:focus, +.navbar .dcl-link:active, +.navbar .dcl-link:hover { + text-decoration: none; +} + +.navbar .dropdown-menu .dcl-link { + padding: 1px 10px; +} + +p.copyright { + margin: 0; +} + +footer { + font-weight: 300; + padding: 25px 0; + background-color: #f8f8f8; +} + +footer .list-inline { + margin-bottom: 15px; +} + +footer a { + color: #777; +} + +footer .dcl-link-separator { + position: relative; + padding-left: 10px; +} + +footer .dcl-link-separator::before { + content: ""; + position: absolute; + display: inline-block; + top: 9px; + bottom: 0; + left: -2px; + right: 0; + width: 2px; + height: 2px; + border-radius: 100%; + background: #777; +} + +.mb-0 { + margin-bottom: 0; +} + +.thin-hr { + margin-top: 10px; + margin-bottom: 10px; +} + +.payment-container .credit-card-info { + padding-bottom: 15px; + border-bottom: 1px solid #eee; +} +.credit-card-info { + display: flex; +} + +.credit-card-info .align-bottom { + align-self: flex-end; + padding-right: 0 !important; +} + +.new-card-head { + margin-top: 10px; +} +.new-card-button-margin button{ + margin-top: 5px; + margin-bottom: 5px; +} + +.input-no-border { + border: none !important; + background: transparent !important; + resize: none; +} + +.existing-keys-title { + font-weight: bold; + font-size: 14px; +} + +@media(max-width:767px) { + .vspace-top { + margin-top: 35px; + } +} + +/* index */ +.btn { + box-shadow: 0 1px 4px rgba(0, 0, 0, .6); +} + +.fa-li.fa-lg { + color: #29427A; + margin-top: 6px; +} + +.btn-transparent { + background: transparent; + border: 2px solid #fff; + color: #fff; + transition: all .2s ease-in; +} + +.btn-primary { + background: #29427A; + border-color: #29427A; + color: #fff; + width: auto; +} + +.btn-primary:hover { + background: rgba(41, 66, 122, 0.8); + border-color: #29427A; +} + +.btn-transparent:hover { + background: #fff; + border: 2px solid #fff; + color: #000; + transition: all .2s ease-in; +} + +.btn-lg { + min-width: 180px; +} + +.lead { + font-size: 18px; +} + +@media (min-width: 768px) { + .lead { + font-size: 21px; + } +} + + +/* Top navbar */ + +.navbar { + transition: all .3s ease-in; + font-weight: 400; +} + +.navbar-default .navbar-nav>.open>a, +.navbar-default .navbar-nav>.open>a:focus, +.navbar-default .navbar-nav>.open>a:hover { + background: transparent; +} + +.navbar-default .navbar-nav>.active>a, +.navbar-default .navbar-nav>.active>a:focus, +.navbar-default .navbar-nav>.active>a:hover { + background: #2D457A; + color: #fff; + border-radius: 6px; +} + +@media (max-width: 767px) { + .navbar-default .navbar-nav>li>a{ + font-weight: 400; + } +} + +.navbar-transparent .navbar-nav>li a, +.navbar-transparent .navbar-nav>.open>a, +.navbar-transparent .navbar-nav>.open>a:focus, +.navbar-transparent .navbar-nav>.open>a:hover { + color: #fff; +} + + +.navbar-transparent .navbar-nav>li a:focus, +.navbar-transparent .navbar-nav>li a:active, +.navbar-transparent .navbar-nav>li a:hover { + color: #fff; + background-color: transparent; + text-decoration: none; +} + +.topnav .nav .open>a, +.topnav .nav .open>a:focus, +.topnav .nav .open>a:hover { + background: transparent; +} + +.navbar-transparent .navbar-nav>li>.on-hover-border { + transition: all 0.3s linear; + box-shadow: none; +} + +.navbar-transparent .navbar-nav>li>.on-hover-border:hover { + box-shadow: 0 0 0 1px #eee; + border-radius: 5px; +} + +.navbar-transparent { + background: transparent; + border: none; + padding: 20px; +} + +.navbar-transparent .nav-language .select-language { + color: #fff; +} + +.nav-language { + position: relative; +} + +.nav-language .select-language { + padding: 15px 10px; + color: #777; +} + +.nav-language .select-language span { + margin-left: 5px; + margin-right: 5px; + font-weight: normal; +} + +.nav-language .drop-language { + top: 45px; + left: auto !important; + width: 100px; + min-width: 100px; + height: 40px; + padding: 9px 10px; + -webkit-box-shadow: -8px 13px 31px -8px rgba(77, 77, 77, 1); + -moz-box-shadow: -8px 13px 31px -8px rgba(77, 77, 77, 1); + box-shadow: -8px 13px 31px -8px rgba(77, 77, 77, 1); + z-index: 100; + text-align: center; + border-radius: 4px; +} + +.nav-language .drop-language a { + cursor: pointer; + padding: 5px 10px !important; +} + +.nav-language .open .drop-language { + width: 100px; + min-width: 100px; +} + +.dropdown-menu { + border: 1px solid #fff; + -webkit-box-shadow: -8px 14px 20px -5px rgba(77, 77, 77, 0.5); + -moz-box-shadow: -8px 14px 20px -5px rgba(77, 77, 77, 0.5); + box-shadow: -8px 14px 20px -5px rgba(77, 77, 77, 0.5); + border-radius: 4px !important; + left: 0 !important; + min-width: 155px; + padding: 5px; + margin-left: 15px; +} + +.dropdown-menu>li a:focus, +.dropdown-menu>li a:hover { + background: transparent; + text-decoration: underline !important; +} + +@media (min-width: 768px) { + .dropdown-menu>li>a { + font-weight: 300; + } +} + +.highlights-dropdown .dropdown-menu>li>a { + font-size: 13px; + padding: 1px 10px; +} + + +/* Show the dropdown menu on hover */ + +@media (min-width: 768px) { + .nav-language .dropdown:hover .dropdown-menu { + display: block; + } +} + +@media (max-width: 767px) { + .nav-language .open .dropdown-menu>li>a { + line-height: 1.42857143; + } +} + +.navbar-transparent .nav-language .drop-language { + background: transparent; + border: 1px solid #fff; +} + +.navbar-transparent .nav-language .drop-language a { + color: #fff; + padding: 5px 10px !important; +} + + +/* dcl header */ +.dcl-header { + padding: 150px 0 150px 0; + text-align: center; + color: #f8f8f8; + background: url(../img/pattern.jpg) no-repeat center center; + background-size: cover; + position: relative; + background-attachment: fixed; +} + +.dcl-header::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: rgba(90, 116, 175, 0.85); +} + +.dcl-header .container { + position: relative; +} + +.dcl-header h1 { + font-size: 65px; + margin: 0; + padding: 0; +} + +@media(max-width:767px) { + .dcl-header h1 { + font-size: 50px; + } +} + +.intro-header { + min-height: 100vh; + text-align: center; + color: #fff; + background: url(../img/configure.jpg) no-repeat center center; + background-size: cover; + position: relative; + display: flex; + justify-content: center; + align-items: center; +} + +.intro-header::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: rgba(38, 59, 107, 0.7); +} + +.intro-header-2 { + padding-top: 50px; + /* If you're making other pages, make sure there is 50px of padding to make sure the navbar doesn't overlap content! */ + padding-bottom: 50px; + color: #f8f8f8; + background: url(../img/pattern.jpg) no-repeat center center; + background-size: cover; + position: relative; +} + +.intro-header-2::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: rgba(41, 66, 122, 0.59); +} + +.intro-message { + position: relative; + width: 80%; + margin: 0 auto; +} + +.intro-message>h1 { + margin: 0; + font-size: 6em; +} + +.intro-divider { + width: 400px; + border-top: 1px solid #f8f8f8; + border-bottom: 1px solid rgba(0, 0, 0, 0.2); +} + +.intro-pricing { + text-align: center; + color: #fff; + background: url(../img/pattern.jpg) no-repeat center center; + background-size: cover; + height: 70vh; + max-height: 400px; + display: flex; + justify-content: center; + align-items: center; + position: relative; +} + +.intro-pricing.success-pricing { + height: 100vh; + max-height: 100vh; +} + +.intro-pricing::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: rgba(90, 116, 175, 0.7); +} + +.intro-pricing .intro-message .section-heading { + font-size: 45px; + width: 80%; + margin: 0 auto; +} + +.split-section { + padding: 70px 0; + border-top: 1px solid #f6f7f8; +} + +.split-section .icon-section { + position: relative; + min-height: 330px; +} + +.split-section .icon-section i { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + font-size: 216px; + color: #5A74AF; +} + +.split-section h2 { + font-size: 36px; + font-weight: 400; +} + +.split-section .split-title-plain h2 { + font-size: 40px; + font-weight: 300; + line-height: 50px; + color: #3a3a3a; +} + +.split-section .split-title { + position: relative; + margin-bottom: 25px; +} + +.split-section .split-title h2 { + font-size: 50px; + font-weight: 300; + padding-bottom: 25px; + letter-spacing: 2px; +} + +.section-gradient { + background: -webkit-linear-gradient(#f0f4f7, #fff) no-repeat; + background: -o-linear-gradient(#f0f4f7, #fff) no-repeat; + background: linear-gradient(#f0f4f7, #fff) no-repeat; +} + +.split-section.left .split-description { + margin-right: auto; +} + +.split-section .split-description .lead { + color: #3a3a3a; +} + +@media (min-width: 768px) { + .split-section .split-description .lead { + font-size: 21px; + } + .split-section .space .split-description .lead { + font-size: 20px; + } +} + +.split-section.right .split-description { + width: 90%; + margin-left: auto; +} + +.split-section.right .split-description.title p { + font-size: 27px; + margin-bottom: 10px; + text-align: left; +} + +.split-section.right .split-text ul, +.split-section.left .split-text, +.split-section.left .space { + text-align: left; +} + +.split-section.right .split-text, +.split-section.right .space { + text-align: right; +} + +.split-section .split-title::before { + content: ""; + position: absolute; + bottom: 0; + background: #29427A; + height: 7px; + width: 70px; + left: auto; +} + +.split-section.right .split-title::before { + right: 0; +} + +.split-section.left .split-title::before { + left: 0; +} + +.section-figure { + display: flex; + flex-wrap: wrap; + justify-content: center; + text-align: center; +} + +.section-figure .section-image { + padding: 20px 40px 30px; + flex-basis: 50%; + flex-grow: 1; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +@media (max-width: 767px) { + .section-figure .section-image { + flex-basis: 100%; + } +} + +.split-section-plain .section-figure .section-image { + flex-grow: 0; + padding: 50px 15px 0; +} + +.split-section-plain .section-figure { + justify-content: flex-start; +} + +@media (min-width: 768px) { + .split-section-plain .split-figure { + width: 41.66666667%; + } + .split-section-plain .split-figure.col-sm-pull-6 { + right: 58.33333333%; + } + .split-section-plain .split-text { + width: 58.33333333%; + } + .split-section-plain .split-text.col-sm-push-6 { + left: 41.66666667%; + } +} + +.section-image img { + margin: auto; +} + +.section-image-caption { + padding-top: 20px; + display: inline-block; + color: #999 !important; + word-break: break-all; +} + +.price-calc-section .card { + width: 350px; + margin: 0 auto; + background: #fff; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23); + padding-bottom: 40px; + border-radius: 7px; + position: relative; +} + +.price-calc-section .card .title { + padding: 15px 40px; +} + +.price-calc-section .card .price { + background: #5A74AF; + padding: 22px; + color: #fff; + font-size: 32px; +} + +.price-calc-section .card .description { + padding: 12px; +} + +.price-calc-section .card .descriptions { + padding: 10px 30px; +} + +.price-calc-section .card .description p { + margin: 0; +} + +.price-calc-section .card .btn { + margin-top: 20px; +} + +@keyframes sending { + 0% { + content: '.'; + } + 50% { + content: '..'; + } + 100% { + content: '...'; + } +} +/*Why DCL*/ + +#tech_stack { + background: #fff; +} + +#tech_stack h3 { + font-size: 42px; + width: 70%; +} + +.space { + max-width: 660px; + margin: auto; +} + +.percent-text { + font-size: 50px; + color: #999; +} + +.space-middle { + /* padding: 45px 0; */ + display: inline-block; +} + +.ssdimg { + margin: 0 15px; +} + +@media (max-width: 767px) { + .ssdimg img { + max-height: 120px; + } +} + +.padding-vertical { + padding: 30px 2px 20px; +} + + +/*Pricing page*/ + +.price-calc-section { + display: flex; + margin-top: 25px; + margin-bottom: 25px; +} + +.price-calc-section .card { + width: 100%; + margin: 0 auto; + background: #fff; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1), 0 0 6px rgba(0, 0, 0, 0.15); + padding-bottom: 40px; + border-radius: 7px; + text-align: center; + max-width: 400px; + position: relative; +} + +.price-calc-section .card .title { + padding: 15px 40px; +} + +.price-calc-section .card .title h3 { + font-weight: normal; +} + +.price-calc-section .card .price { + background: #5A74AF; + padding: 22px; + color: #fff; + font-size: 32px; +} + +.price-calc-section .card .price .price-text { + font-size: 14px; +} + +.price-calc-section .card .description { + padding: 12px; + position: relative; + display: flex; + justify-content: space-around !important; + align-items: center !important; +} + +.price-calc-section .card .description span { + font-size: 16px; + margin-left: 0px; + width: 30%; + text-align: left; +} + +.price-calc-section .card .description .select-number { + font-size: 20px; + text-align: center; + width: 85px; +} + +.price-calc-section .card .description i { + color: #29427A; + cursor: pointer; + font-size: 24px; +} + +.price-calc-section .card .description .left { + margin-right: 7px; +} + +.price-calc-section .card .description .right { + margin-left: 7px; +} + +.price-calc-section .card .descriptions { + padding: 10px 30px; +} + +.price-calc-section .card .description p { + margin: 0; +} + +.price-calc-section .card .btn { + margin-top: 20px; + font-size: 20px; + width: 200px; + border: none; +} + +.price-calc-section .card .select-configuration select { + outline: none; + background: #fff; + border-color: #d0d0d0; + height: 40px; + width: 200px; + text-align: center; + font-size: 16px; + margin-left: 10px; +} + +.price-calc-section .card .check-ip { + font-size: 18px; +} + +.price-calc-section .card .justify-center { + justify-content: center !important; +} + +.price-calc-section .card .description.input label { + font-size: 15px; + font-weight: 700; + margin-bottom: 0; + width: 40px; +} + + +/*Changed class****.price-calc-section .card .description.input input*/ + +.price-calc-section .card .description input { + width: 200px; + font-size: 14px; + text-align: left; + padding: 5px 10px; + border-radius: 4px; + border: 1px solid #d0d0d0; + background: #fff; + margin-left: 10px; +} + +.price-calc-section .card .check-ip input[type=checkbox] { + font-size: 17px; + margin: 0 8px; +} + +.help-block.with-errors { + text-align: center; + margin: 0; + padding: 0; +} + +.form-group { + margin: 0; + border-bottom: 1px solid rgba(128, 128, 128, 0.3); +} + +@media(max-width:767px) { + #tech_stack h3 { + font-size: 30px; + line-height: 40px; + width: 100%; + } + .navbar-nav .open .dropdown-menu { + text-align: left; + font-size: 12px; + } + + .navbar-default .navbar-nav>.open>a, + .navbar-default .navbar-nav>.open>a:focus, + .navbar-default .navbar-nav>.open>a:hover { + background: transparent; + color: #777 !important; + } +} + + +@media(max-width:767px) { + .section-sm-center .split-text, + .section-sm-center .space { + text-align: center !important; + margin-bottom: 40px; + } + .section-sm-center .split-title::before { + left: 50% !important; + transform: translate(-50%, 0); + } + .section-sm-center .split-description { + width: 100% !important; + } +} + +@media(max-width:767px) { + .navbar-transparent li a { + color: #777 !important; + } + .intro-message { + padding-bottom: 15%; + } + .intro-message>h1 { + font-size: 3em; + } + ul.intro-social-buttons>li { + display: block; + margin-bottom: 20px; + padding: 0; + } + .intro-pricing .intro-message .section-heading { + font-size: 35px; + width: 80%; + margin: 0 auto; + } + .intro-pricing .intro-message { + padding-bottom: 0; + } + ul.intro-social-buttons>li:last-child { + margin-bottom: 0; + } + .intro-divider { + width: 100%; + } + .navbar-transparent { + background: #fff; + border: none; + padding: 5px; + } + .navbar-transparent #logoBlack { + display: block; + } + .navbar-transparent #logoWhite { + display: none; + } + .navbar-transparent .nav-language .select-language { + color: #777; + } + .navbar-transparent .nav-language .drop-language a { + color: #777; + } + .navbar-transparent .nav-language .drop-language { + background: #fff; + z-index: 100000; + left: 9px; + border: 1px solid rgba(119, 119, 119, 0.4); + box-shadow: none; + } + .navbar-default .nav-language .drop-language { + background: #fff; + z-index: 100000; + left: 9px; + border: 1px solid rgba(119, 119, 119, 0.4); + box-shadow: none; + } + .navbar-default .nav-language .select-language { + color: #777; + } + .navbar-default .nav-language .drop-language a { + color: #777; + } + .navbar-transparent .navbar-nav>li>a:focus, + .navbar-transparent .navbar-nav>li>a:hover { + color: #333; + background-color: transparent; + } + .navbar-default .navbar-nav>li>a:focus, + .navbar-default .navbar-nav>li>a:hover { + color: #333; + background-color: transparent; + } + .split-section { + padding: 20px 0; + } + .split-section .icon-section { + min-height: 160px; + } + .split-section .icon-section i { + font-size: 120px; + } + .split-section h2 { + font-size: 28px; + } + .split-section .split-title-plain h2 { + font-size: 30px; + line-height: 35px; + } + .split-section .split-title h2 { + font-size: 32px; + line-height: 34px; + } + .contact-section .title { + margin: 0 auto; + } + .contact-section .title h2 { + font-size: 45px; + line-height: 40px; + margin-top: 35px; + } + .contact-section .title h2::before { + left: 50%; + transform: translate(-50%, 0); + } + .contact-section .card .social a { + color: #29427A; + font-size: 30px; + } + .intro-pricing .intro-message .section-heading { + font-size: 30px; + } + .price-calc-section { + flex-direction: column; + /* padding: 60px 10px !important; */ + } + .price-calc-section .card { + width: 90%; + } + .price-calc-section .text { + width: 80%; + text-align: center; + margin: 0 auto; + margin-top: 20px; + } + .price-calc-section .text .section-heading { + font-size: 35px; + line-height: 35px; + padding-bottom: 15px; + text-align: center; + } + .price-calc-section .text .section-heading::before { + left: 50%; + transform: translate(-50%, 0); + } + .price-calc-section .text .description { + font-size: 18px; + text-align: center; + } + .price-calc-section .card .description .select-number { + font-size: 17px; + text-align: center; + width: 60px; + } +} + +@media(max-width:575px) { + .percent-text { + font-weight: normal; + font-size: 37px; + } + .contact-section .card { + width: 90%; + } + .form-beta { + width: 90%; + padding: 25px 10px; + } + .intro-message>h1 { + font-size: 2em; + } + .price-calc-section .text .section-heading { + font-size: 24px; + line-height: 25px; + } + .price-calc-section .card .description span { + font-size: 15px; + } +} + +.network-name { + text-transform: uppercase; + font-size: 14px; + font-weight: 300; + letter-spacing: 2px; + line-height: 24px; + display: block; +} + +.section-heading { + margin-bottom: 30px; +} + +footer { + padding: 50px 20px; +} + +.topnav a:focus { + outline: none; + outline-offset: 0; +} + +.topnav .btn:focus { + outline: none !important; + outline-offset: 0; +} + +.flex-row-rev { + margin-top: 25px; +} + +.flex-row .percent-text { + display: flex; + align-items: center; +} + +@media (min-width: 768px) { + .flex-row { + display: flex; + align-items: center; + justify-content: space-between; + } + .flex-row .percent-text { + flex-shrink: 0; + padding: 0 15px; + } + .flex-row .desc-text { + text-align: right; + } + .flex-row .desc-text, + .flex-row .percent-text { + max-width: 430px; + } + .flex-row-rev .desc-text { + max-width: 600px; + text-align: left; + } + .flex-row-rev .percent-text { + order: 2; + } + .flex-row-rev { + margin-bottom: 25px; + } +} + +.checkmark { + display: inline-block; +} + +.checkmark:after { + /*Add another block-level blank space*/ + content: ''; + display: block; + /*Make it a small rectangle so the border will create an L-shape*/ + width: 25px; + height: 60px; + /*Add a white border on the bottom and left, creating that 'L' */ + border: solid #777; + border-width: 0 3px 3px 0; + /*Rotate the L 45 degrees to turn it into a checkmark*/ + transform: rotate(45deg); +} + + +/* new styles for whydcl section cms plugin (to replace older style) */ + +.banner-list { + border-top: 2px solid #eee; + padding: 50px 0; +} + +.banner-list-heading h2 { + font-size: 42px; +} + +@media (max-width: 767px) { + .banner-list-heading h2 { + font-size: 30px; + } +} + + +/* cms section promo */ + +.promo-section { + padding: 75px 15px; +} + +.promo-section.promo-with-bg { + color: #fff; + background-size: cover; + background-position: center; +} + +.promo-section.promo-with-bg a { + color: #87B6EA; +} + +.promo-section.promo-with-bg a:hover, +.promo-section.promo-with-bg a:focus { + color: #77a6da; +} + +.promo-section h3 { + font-weight: 700; + font-size: 36px; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 10px; + margin-bottom: 25px; +} + +.promo-section h4 { + font-size: 24px; + margin-bottom: 20px; +} + +.promo-section p { + font-size: 18px; + line-height: 1.5; +} + +.promo-section.text-center p { + max-width: 720px; + margin: auto; +} + +.promo-section.text-center h3, +.promo-section.text-center h4 { + margin-bottom: 35px; +} + +.split-text .split-subsection { + margin-top: 25px; + margin-bottom: 25px; +} + +.split-text .promo-section { + padding: 20px 15px; + margin-top: 30px; + margin-bottom: 30px; +} + +.split-text .promo-section .container { + width: auto; +} + +.split-text .promo-section h3, +.split-text .promo-section h4 { + margin-bottom: 15px; +} + +@media (max-width: 767px) { + .split-text .split-subsection { + margin-left: -15px; + margin-right: -15px; + } + .promo-section h3 { + font-size: 29px; + } + .split-text .promo-section { + padding-left: 0; + padding-right: 0; + } +} + +ul.errorlist { + padding-left: 0px; +} +ul.errorlist > li { + color: red; + list-style-type: none; +} +div.domain { + flex-direction: column; +} \ No newline at end of file diff --git a/matrixhosting/static/matrixhosting/css/hosting.css b/matrixhosting/static/matrixhosting/css/hosting.css new file mode 100644 index 0000000..b3e0bbb --- /dev/null +++ b/matrixhosting/static/matrixhosting/css/hosting.css @@ -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; + } + \ No newline at end of file diff --git a/matrixhosting/static/matrixhosting/js/main.js b/matrixhosting/static/matrixhosting/js/main.js new file mode 100644 index 0000000..52ae598 --- /dev/null +++ b/matrixhosting/static/matrixhosting/js/main.js @@ -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); diff --git a/matrixhosting/static/matrixhosting/js/order.js b/matrixhosting/static/matrixhosting/js/order.js new file mode 100644 index 0000000..af733bf --- /dev/null +++ b/matrixhosting/static/matrixhosting/js/order.js @@ -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"); + } + } + }); + } +}); \ No newline at end of file diff --git a/matrixhosting/static/matrixhosting/js/payment.js b/matrixhosting/static/matrixhosting/js/payment.js new file mode 100644 index 0000000..d009b78 --- /dev/null +++ b/matrixhosting/static/matrixhosting/js/payment.js @@ -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(''); + 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); + }); + +}); diff --git a/matrixhosting/tasks.py b/matrixhosting/tasks.py new file mode 100644 index 0000000..c681e8c --- /dev/null +++ b/matrixhosting/tasks.py @@ -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) \ No newline at end of file diff --git a/matrixhosting/templates/matrixhosting/base.html b/matrixhosting/templates/matrixhosting/base.html new file mode 100644 index 0000000..3abc7a2 --- /dev/null +++ b/matrixhosting/templates/matrixhosting/base.html @@ -0,0 +1,60 @@ +{% load static i18n %} +{% get_current_language as LANGUAGE_CODE %} +{% load bootstrap5 %} + + + + + + + + + + + Matrix Hosting - {% block title %} made in Switzerland{% endblock %} + + + + {% bootstrap_css %} + + + + + {% block css_extra %} + {% endblock css_extra %} + + + + + + + + + + + + + + {% block navbar %} + {% include "matrixhosting/includes/_navbar.html" %} + {% endblock navbar %} + + {% block content %} + {% endblock %} + + {% include "matrixhosting/includes/_footer.html" %} + + + + + + {% bootstrap_javascript %} + + + {% block js_extra %} + {% endblock js_extra %} + + diff --git a/matrixhosting/templates/matrixhosting/dashboard.html b/matrixhosting/templates/matrixhosting/dashboard.html new file mode 100644 index 0000000..d183665 --- /dev/null +++ b/matrixhosting/templates/matrixhosting/dashboard.html @@ -0,0 +1,127 @@ +{% extends "matrixhosting/base.html" %} {% load static i18n %} +{% block content%} + +{% csrf_token %} +
+
+
+
+ + + + + + + + + + + + + + + + {% for object in object_list %} + + + + + + + + + + {% if object.ending_date %} + + {% else %} + + {% endif %} + + {% endfor %} + +
#DescriptionStarting AtConfigPricing PlanOneTime PriceRecurring PriceEnding At
{{ object.id }}{{ object.description }}{{ object.starting_date }}{{ object.config }}{{ object.pricing_plan}}{{ object.one_time_price }}{{ object.recurring_price }}{{ object.ending_date }} + +
+
+
+
+
+ + + + + +{% endblock %} + +{% block js_extra %} + +{% endblock %} diff --git a/matrixhosting/templates/matrixhosting/emails/renewal_warning.html b/matrixhosting/templates/matrixhosting/emails/renewal_warning.html new file mode 100644 index 0000000..75a4782 --- /dev/null +++ b/matrixhosting/templates/matrixhosting/emails/renewal_warning.html @@ -0,0 +1,13 @@ + + + + + + + Renewal Warning + + + hello {{name}}, + {{message}} + + \ No newline at end of file diff --git a/matrixhosting/templates/matrixhosting/includes/_calculator_form.html b/matrixhosting/templates/matrixhosting/includes/_calculator_form.html new file mode 100644 index 0000000..fb4ce57 --- /dev/null +++ b/matrixhosting/templates/matrixhosting/includes/_calculator_form.html @@ -0,0 +1,101 @@ +{% load static i18n %} + +
+ {% csrf_token %} +
+

{% trans "Matrix Chat hosting" %}

+
+
+ {{ matrix_vm_pricing.name }} + CHF/{% trans "month" %} +
+

+ {% if matrix_vm_pricing.set_up_fees %}{{ matrix_vm_pricing.set_up_fees }} CHF Setup
{% endif %} + {% if matrix_vm_pricing.vat_inclusive %}{% trans "VAT included" %}
{% endif %} + {% if matrix_vm_pricing.discount_amount %} + {% trans "You save" %} {{ matrix_vm_pricing.discount_amount }} CHF + {% endif %} +

+
+
+
+
+

{% trans "Hosted in Switzerland" %}

+
+
+
+ + + Core + +
+
+ {% for message in messages %} + {% if 'cores' in message.tags %} +
    +
  • {{ message|safe }}
  • +
+ {% endif %} + {% endfor %} +
+
+
+
+ + + GB RAM + +
+
+ {% for message in messages %} + {% if 'memory' in message.tags %} +
  • + {{ message|safe }} +
+ {% endif %} + {% endfor %} +
+
+
+
+ + + {% trans "GB Storage (SSD)" %} + +
+
+ {% for message in messages %} + {% if 'storage' in message.tags %} +
  • + {{ message|safe }} +
+ {% endif %} + {% endfor %} +
+
+
+ +

{{ form.matrix_domain.errors }}

+
+
+ +

{{ form.homeserver_domain.errors }}

+
+
+ +

{{ form.webclient_domain.errors }}

+
+
+
+ Is open registration possible: + {{ form.is_open_registration }} +
+
+ +
+ + +
\ No newline at end of file diff --git a/matrixhosting/templates/matrixhosting/includes/_card.html b/matrixhosting/templates/matrixhosting/includes/_card.html new file mode 100644 index 0000000..ec30a7d --- /dev/null +++ b/matrixhosting/templates/matrixhosting/includes/_card.html @@ -0,0 +1,43 @@ +{% load i18n %} +
+ + +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ + +
+
+
+
+
+ {% for message in messages %} + {% if 'failed_payment' in message.tags or 'make_charge_error' in message.tags or 'error' in message.tags %} + + {% endif %} + {% endfor %} +
+
+ +
+
+

+
+
\ No newline at end of file diff --git a/matrixhosting/templates/matrixhosting/includes/_footer.html b/matrixhosting/templates/matrixhosting/includes/_footer.html new file mode 100644 index 0000000..07d9fb6 --- /dev/null +++ b/matrixhosting/templates/matrixhosting/includes/_footer.html @@ -0,0 +1,18 @@ +{% load i18n %} + diff --git a/matrixhosting/templates/matrixhosting/includes/_navbar.html b/matrixhosting/templates/matrixhosting/includes/_navbar.html new file mode 100644 index 0000000..599c6b9 --- /dev/null +++ b/matrixhosting/templates/matrixhosting/includes/_navbar.html @@ -0,0 +1,33 @@ +{% load static i18n %} +{% get_current_language as LANGUAGE_CODE %} + \ No newline at end of file diff --git a/matrixhosting/templates/matrixhosting/index.html b/matrixhosting/templates/matrixhosting/index.html new file mode 100644 index 0000000..ab9085c --- /dev/null +++ b/matrixhosting/templates/matrixhosting/index.html @@ -0,0 +1,21 @@ +{% extends "matrixhosting/base.html" %} +{% load static i18n %} + +{% block content %} + + +
+
+
+
+
+
+ {% include "matrixhosting/includes/_calculator_form.html" %} +
+
+
+
+
+
+ +{% endblock %} diff --git a/matrixhosting/templates/matrixhosting/order_detail.html b/matrixhosting/templates/matrixhosting/order_detail.html new file mode 100644 index 0000000..b6942e3 --- /dev/null +++ b/matrixhosting/templates/matrixhosting/order_detail.html @@ -0,0 +1,268 @@ +{% load static i18n %} +{% load bootstrap5 %} + + + + + + + + + + Matrix Hosting - {% block title %} made in Switzerland{% endblock %} + + + + {% bootstrap_css %} + + + + + + + + + + + + + + +
+ {% if messages %} +
+ {% for message in messages %} + {{ message }} + {% endfor %} +
+ {% endif %} + {% if not error %} +
+

+ {% blocktrans with page_header_text=page_header_text|default:"Order" %}{{page_header_text}}{% endblocktrans %} +

+
+
+
+
+
+

{% trans "Billed to" %}:

+

+ {% with request.session.billing_address_data as billing_address %} + {{billing_address.full_name}}
+ {{billing_address.street}}, {{billing_address.postal_code}}
+ {{billing_address.city}}, {{billing_address.country}} + {% if billing_address.vat_number %} +
{% 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" %} + + {% else %} + + {% endif %} + {% endif %} + {% endif %} + {% endwith %} +

+
+
+
+
+

{% trans "Payment method" %}:

+

+ {{card.brand|default:_('Credit Card')}} {% trans "ending in" %} ****{{card.last4}}
+ {% trans "Expiry" %} {{card.exp_year}}/{{card.exp_month}}
+ {{request.user.email}} +

+
+
+
+

{% trans "Order summary" %}

+ +

+ {% trans "Product" %}:  + Matrix Chat Hosting +

+
+
+

+ {% trans "Cores" %}: + {{order.cores}} +

+

+ {% trans "Memory" %}: + {{order.memory}} GB +

+

+ {% trans "Disk space" %}: + {{order.storage}} GB +

+
+
+
+
+
+

+ {% trans "Price Before VAT" %} + {{pricing.subtotal|floatformat:2}} CHF +

+
+
+
+
+
+
+
+

+
+
+

{% trans "Pre VAT" %}

+
+
+

{% trans "With VAT for" %} {{pricing.vat_country}} ({{pricing.vat_percent}}%)

+
+
+
+
+

Subtotal

+
+
+

{{pricing.subtotal|floatformat:2}} CHF

+
+
+

{{pricing.price_with_vat|floatformat:2}} CHF

+
+
+ {% if pricing.discount.amount > 0 %} +
+
+

{{pricing.discount.name}}

+
+
+

-{{pricing.discount.amount|floatformat:2}} CHF

+
+
+

-{{pricing.discount.amount_with_vat|floatformat:2}} CHF

+
+
+ {% endif %} +
+
+
+
+
+
+
+

Total

+
+
+

{{pricing.subtotal_after_discount|floatformat:2}} CHF

+
+
+

{{pricing.price_after_discount_with_vat|floatformat:2}} CHF

+
+
+
+
+
+
+
+ {% trans "Your Price in Total" %} + {{pricing.total_price|floatformat:2}} CHF +
+
+
+
+
+
+ {% csrf_token %} +
+
+
{% blocktrans with vm_total_price=vm.total_price|floatformat:2 %}By clicking "Place order" you agree to our Terms of Service and this plan will charge your credit card account with {{ vm_total_price }} CHF/month{% endblocktrans %}.
+
+
+ +
+
+
+ {% endif %} +
+ + + + + + + + + + + + + + + {% bootstrap_javascript %} + + + + diff --git a/matrixhosting/templates/matrixhosting/payment.html b/matrixhosting/templates/matrixhosting/payment.html new file mode 100644 index 0000000..e296d0c --- /dev/null +++ b/matrixhosting/templates/matrixhosting/payment.html @@ -0,0 +1,169 @@ +{% load static i18n %} +{% load bootstrap5 %} + + + + + + + + + + + Matrix Hosting - {% block title %} made in Switzerland{% endblock %} + + + + {% bootstrap_css %} + + + + + + + + + + + + + +
+
+
+
+
+

{%trans "Your Order" %}

+
+
+

{% trans "Cores"%} {{request.session.order.cores|floatformat}}

+
+

{% trans "Memory"%} {{request.session.order.memory|floatformat}} GB

+
+

{% trans "Disk space"%} {{request.session.order.storage|floatformat}} GB

+
+

+ {%trans "Total" %}   + + ({% if matrix_vm_pricing.vat_inclusive %}{%trans "including VAT" %}{% else %}{%trans "excluding VAT" %}{% endif %}) + + {{request.session.order.subtotal|floatformat}} CHF / {% trans "Month" %} +

+
+ {% if matrix_vm_pricing.discount_amount %} +

+ {{ request.session.order.discount.name }}   + - {{ request.session.order.discount.amount }} CHF / {% trans "Month" %} +

+ {% endif %} +
+
+
+
+

{%trans "Billing Address"%}

+
+ {% for message in messages %} + {% if 'vat_error' in message.tags %} +
  • + {{ message|safe }} +
+ {% endif %} + {% endfor %} +
+ {% 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 %} +
+
+
+
+
+
+
+ {% with cards_len=cards|length %} +

{%trans "Credit Card"%}

+
+

+ {% 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 Stripe 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 Stripe for payment and do not store your information in our database.{% endblocktrans %} + {% endif %} +

+
+ {% for card in cards %} +
+
+
{% trans "Credit Card" %}
+
{% trans "Last" %} 4: ***** {{card.last4}}
+
{% trans "Type" %}: {{card.brand}}
+
{% trans "Expiry" %}: {{card.month}}/{{card.year}}
+
+ +
+ {% endfor %} + {% if cards_len > 0 %} +
+
+
+

{% trans "Add a new credit card" %}

+
+
+ +
+
+
+
+
+
+

{%trans "New Credit Card" %}

+
+ {% include "matrixhosting/includes/_card.html" %} +
+
+ {% else%} + {% include "matrixhosting/includes/_card.html" %} + {% endif %} +
+ {% endwith %} +
+
+
+
+ {% if stripe_key %} + {% get_current_language as LANGUAGE_CODE %} + + {%endif%} + + + + + + + + + {% bootstrap_javascript %} + + + + diff --git a/matrixhosting/tests.py b/matrixhosting/tests.py new file mode 100644 index 0000000..27468b3 --- /dev/null +++ b/matrixhosting/tests.py @@ -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) + + diff --git a/matrixhosting/urls.py b/matrixhosting/urls.py new file mode 100644 index 0000000..318ef8b --- /dev/null +++ b/matrixhosting/urls.py @@ -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//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'), +] diff --git a/matrixhosting/validators.py b/matrixhosting/validators.py new file mode 100644 index 0000000..08cc818 --- /dev/null +++ b/matrixhosting/validators.py @@ -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}(?
uncloud + Matrix Hosting @@ -14,11 +15,11 @@ Logged in as {{ user }}. Your balance: {{ balance }} CHF. {% else %} {% endif %} diff --git a/uncloud/templates/uncloud/index.html b/uncloud/templates/uncloud/index.html index 19fc436..b8b5828 100644 --- a/uncloud/templates/uncloud/index.html +++ b/uncloud/templates/uncloud/index.html @@ -38,7 +38,7 @@
  • First you need to register an account. If you already have one, you can - login. + login.
  • If you have forgotten your password or other issues with logging in, you can contact the ungleich support via support at ungleich.ch. @@ -107,8 +107,11 @@
  • @@ -138,11 +141,30 @@
    - + {% if user.is_authenticated %} +
    +

    Account Settings

    +
    +
      +
      + {% csrf_token %} +
      + Delete User Account +

      Are you sure you want to delete your account? This will permanently delete your + profile and any orders you have generated.

      + {{ delete_form }} +
      +
      + +
      +
      +
    +
    +
    + {% endif %} {% endblock %} diff --git a/uncloud/urls.py b/uncloud/urls.py index f72a286..14e45fd 100644 --- a/uncloud/urls.py +++ b/uncloud/urls.py @@ -19,6 +19,7 @@ from uncloud_net import views as netviews from uncloud_pay import views as payviews from uncloud_vm import views as vmviews from uncloud_service import views as serviceviews +from matrixhosting import views as matrixviews router = routers.DefaultRouter() @@ -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/balance', payviews.BalanceViewSet, basename='payment-balance') router.register(r'v2/payment/address', payviews.BillingAddressViewSet, basename='billingaddress') +router.register(r'v2/orders', payviews.OrderViewSet, basename='orders') +router.register(r'v2/bill', payviews.BillViewSet, basename='bills') +router.register(r'v2/machines', matrixviews.MachineViewSet, basename='machines') # Generic helper views that are usually not needed router.register(r'v2/generic/vat-rate', payviews.VATRateViewSet, basename='vatrate') @@ -54,9 +58,8 @@ urlpatterns = [ path('admin/', admin.site.urls), - path('login/', authviews.LoginView.as_view(), name="login"), - path('logout/', authviews.LogoutView.as_view(), name="logout"), + path('accounts/', include('allauth.urls')), path('cc/reg/', payviews.RegisterCard.as_view(), name="cc_register"), - + path('matrix/', include('matrixhosting.urls', namespace='matrix')), path('', uncloudviews.UncloudIndex.as_view(), name="uncloudindex"), ] diff --git a/uncloud/views.py b/uncloud/views.py index 37542bb..a4bf683 100644 --- a/uncloud/views.py +++ b/uncloud/views.py @@ -1,13 +1,23 @@ from django.views.generic.base import TemplateView +from django.contrib import messages +from django.shortcuts import redirect from uncloud_pay.selectors import get_balance_for_user +from .forms import UserDeleteForm class UncloudIndex(TemplateView): template_name = "uncloud/index.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - print(context) if self.request.user.is_authenticated: context['balance'] = get_balance_for_user(self.request.user) + context['delete_form'] = UserDeleteForm(instance=self.request.user) return context + + def post(self, request, *args, **kwargs): + UserDeleteForm(request.POST, instance=request.user) + user = request.user + user.delete() + messages.info(request, 'Your account has been deleted.') + return redirect('uncloudindex') \ No newline at end of file diff --git a/uncloud_net/services.py b/uncloud_net/services.py index 9149f01..8449394 100644 --- a/uncloud_net/services.py +++ b/uncloud_net/services.py @@ -3,7 +3,7 @@ from django.db import transaction from .models import * from .selectors import * from .tasks import * - +from django_q.tasks import async_task, result @transaction.atomic def create_wireguard_vpn(owner, public_key, network_mask): @@ -60,7 +60,6 @@ def create_wireguard_vpn_tech(owner, public_key, network_mask): server = pool.vpn_server_hostname wg_name = pool.wg_name - configure_wireguard_server_on_host.apply_async((wg_name, config), - queue=server) + async_task(configure_wireguard_server_on_host, (wg_name, config), queue=server) return vpn diff --git a/uncloud_net/tasks.py b/uncloud_net/tasks.py index 7d94f3b..5684871 100644 --- a/uncloud_net/tasks.py +++ b/uncloud_net/tasks.py @@ -1,17 +1,14 @@ -from celery import shared_task from .models import * -from uncloud.models import UncloudTask - import os import subprocess import logging import uuid - +from django_q.tasks import async_task, result log = logging.getLogger(__name__) -@shared_task + def configure_wireguard_server_on_host(wg_name, config): """ - 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)") - task_id = uuid.UUID(cdist_configure_wireguard_server.apply_async((config, server)).id) - UncloudTask.objects.create(task_id=task_id) + async_task(cdist_configure_wireguard_server,config, server).id -@shared_task def cdist_configure_wireguard_server(config, server): """ Create config and configure server. diff --git a/uncloud_net/tests.py b/uncloud_net/tests.py index 4491551..75bdafa 100644 --- a/uncloud_net/tests.py +++ b/uncloud_net/tests.py @@ -37,7 +37,7 @@ class VPNTests(TestCase): self.vpn_wireguard_public_key = 'B2b78eWBIXPMM1x4DDjkCDZepS0qDgcLN3T3PjcgXkY=' - self.vpnpool = VPNPool.objects.get_or_create(network=self.pool_network, + self.vpnpool = WireGuardVPNPool.objects.get_or_create(network=self.pool_network, network_size=self.pool_network_size, subnetwork_size=self.pool_subnetwork_size, vpn_hostname=self.pool_vpn_hostname, @@ -47,55 +47,6 @@ class VPNTests(TestCase): self.factory = APIRequestFactory() - def test_create_vpnpool(self): - url = reverse("vpnpool-list") - view = VPNPoolViewSet.as_view({'post': 'create'}) - request = self.factory.post(url, { 'network': self.pool_network2, - 'network_size': self.pool_network_size, - 'subnetwork_size': self.pool_subnetwork_size, - 'vpn_hostname': self.pool_vpn_hostname, - 'wireguard_private_key': self.pool_wireguard_private_key - - }) - force_authenticate(request, user=self.admin_user) - response = view(request) - - # This raises an exception if the request was not successful - # No assert needed - pool = VPNPool.objects.get(network=self.pool_network2) - - # def test_create_vpn(self): - # url = reverse("vpnnetwork-list") - # view = VPNNetworkViewSet.as_view({'post': 'create'}) - # request = self.factory.post(url, { 'network_size': self.pool_subnetwork_size, - # 'wireguard_public_key': self.vpn_wireguard_public_key - - # }) - # force_authenticate(request, user=self.user) - - - # # we don't have a billing address -> should raise an error - # # with self.assertRaises(ValidationError): - # # response = view(request) - - # addr = BillingAddress.objects.get_or_create( - # owner=self.user, - # active=True, - # defaults={'organization': 'ungleich', - # 'name': 'Nico Schottelius', - # 'street': 'Hauptstrasse 14', - # 'city': 'Luchsingen', - # 'postal_code': '8775', - # 'country': 'CH' } - # ) - - # # This should work now - # response = view(request) - - # # Verify that an order was created successfully - there should only be one order at - # # this point in time - # order = Order.objects.get(owner=self.user) - def tearDown(self): self.user.delete() diff --git a/uncloud_net/views.py b/uncloud_net/views.py index 7dadbf4..8e7e81b 100644 --- a/uncloud_net/views.py +++ b/uncloud_net/views.py @@ -61,10 +61,3 @@ class WireGuardVPNSizes(viewsets.ViewSet): print(sizes) return Response(WireGuardVPNSizesSerializer(sizes, many=True).data) - - - -# class VPNPoolViewSet(viewsets.ModelViewSet): -# serializer_class = VPNPoolSerializer -# permission_classes = [permissions.IsAdminUser] -# queryset = VPNPool.objects.all() diff --git a/uncloud_pay/admin.py b/uncloud_pay/admin.py index f604283..cb7b650 100644 --- a/uncloud_pay/admin.py +++ b/uncloud_pay/admin.py @@ -4,7 +4,6 @@ from django.urls import path from django.shortcuts import render from django.conf.urls import url -from uncloud_pay.views import BillViewSet from hardcopy import bytestring_to_pdf from django.core.files.temp import NamedTemporaryFile from django.http import FileResponse @@ -90,14 +89,15 @@ admin.site.register(Bill, BillAdmin) admin.site.register(Product, ProductAdmin) for m in [ - BillRecord, BillingAddress, Order, + BillRecord, Payment, ProductToRecurringPeriod, RecurringPeriod, StripeCreditCard, StripeCustomer, - VATRate, + PricingPlan, + VATRate ]: admin.site.register(m) diff --git a/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud_pay/management/commands/charge-negative-balance.py index 8ee8736..8405bd3 100644 --- a/uncloud_pay/management/commands/charge-negative-balance.py +++ b/uncloud_pay/management/commands/charge-negative-balance.py @@ -1,6 +1,7 @@ from django.core.management.base import BaseCommand 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 django.utils import timezone @@ -18,14 +19,10 @@ class Command(BaseCommand): balance = get_balance_for_user(user) if balance < 0: print("User {} has negative balance ({}), charging.".format(user.username, balance)) - payment_method = PaymentMethod.get_primary_for(user) - if payment_method != None: - amount_to_be_charged = abs(balance) - charge_ok = payment_method.charge(amount_to_be_charged) - if not charge_ok: - print("ERR: charging {} with method {} failed" - .format(user.username, payment_method.uuid) - ) - else: - print("ERR: no payment method registered for {}".format(user.username)) + amount_to_be_charged = abs(balance) + result = uncloud_stripe.charge_customer(user, amount_to_be_charged) + if result.status != 'succeeded': + print("ERR: charging {} with method {} failed" + .format(user.username, result) + ) print("=> Done.") diff --git a/uncloud_pay/management/commands/import-vat-rates.py b/uncloud_pay/management/commands/import-vat-rates.py index 46848cd..a741740 100644 --- a/uncloud_pay/management/commands/import-vat-rates.py +++ b/uncloud_pay/management/commands/import-vat-rates.py @@ -1,11 +1,14 @@ from django.core.management.base import BaseCommand from uncloud_pay.models import VATRate +import logging import urllib import csv import sys import io +logger = logging.getLogger(__name__) + class Command(BaseCommand): help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv''' vat_url = "https://raw.githubusercontent.com/ungleich/vat-rates/main/vat_rates.csv" @@ -23,13 +26,25 @@ class Command(BaseCommand): reader = csv.DictReader(csv_file) for row in reader: -# print(row) - obj, created = VATRate.objects.get_or_create( - starting_date=row["start_date"], - ending_date=row["stop_date"] if row["stop_date"] != "" else None, - territory_codes=row["territory_codes"], - currency_code=row["currency_code"], - rate=row["rate"], - rate_type=row["rate_type"], - description=row["description"] - ) + if row["territory_codes"] and len(row["territory_codes"].splitlines()) > 1: + for code in row["territory_codes"].splitlines(): + VATRate.objects.get_or_create( + starting_date=row["start_date"], + ending_date=row["stop_date"] if row["stop_date"] != "" else None, + territory_codes=code, + currency_code=row["currency_code"], + rate=row["rate"], + 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!') diff --git a/uncloud_pay/migrations/0012_auto_20210630_0742.py b/uncloud_pay/migrations/0012_auto_20210630_0742.py new file mode 100644 index 0000000..45e3dfe --- /dev/null +++ b/uncloud_pay/migrations/0012_auto_20210630_0742.py @@ -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), + ), + ] diff --git a/uncloud_pay/migrations/0013_alter_billingaddress_owner.py b/uncloud_pay/migrations/0013_alter_billingaddress_owner.py new file mode 100644 index 0000000..7597129 --- /dev/null +++ b/uncloud_pay/migrations/0013_alter_billingaddress_owner.py @@ -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), + ), + ] diff --git a/uncloud_pay/migrations/0014_auto_20210703_1747.py b/uncloud_pay/migrations/0014_auto_20210703_1747.py new file mode 100644 index 0000000..1c004d0 --- /dev/null +++ b/uncloud_pay/migrations/0014_auto_20210703_1747.py @@ -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), + ), + ] diff --git a/uncloud_pay/migrations/0015_auto_20210705_0849.py b/uncloud_pay/migrations/0015_auto_20210705_0849.py new file mode 100644 index 0000000..dfb6d80 --- /dev/null +++ b/uncloud_pay/migrations/0015_auto_20210705_0849.py @@ -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), + ), + ] diff --git a/uncloud_pay/migrations/0016_pricingplan.py b/uncloud_pay/migrations/0016_pricingplan.py new file mode 100644 index 0000000..505c141 --- /dev/null +++ b/uncloud_pay/migrations/0016_pricingplan.py @@ -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)), + ], + ), + ] diff --git a/uncloud_pay/migrations/0017_auto_20210706_1728.py b/uncloud_pay/migrations/0017_auto_20210706_1728.py new file mode 100644 index 0000000..1571b10 --- /dev/null +++ b/uncloud_pay/migrations/0017_auto_20210706_1728.py @@ -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', + ), + ] diff --git a/uncloud_pay/migrations/0018_payment.py b/uncloud_pay/migrations/0018_payment.py new file mode 100644 index 0000000..47d6e3a --- /dev/null +++ b/uncloud_pay/migrations/0018_payment.py @@ -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)), + ], + ), + ] diff --git a/uncloud_pay/migrations/0019_order_pricing_plan.py b/uncloud_pay/migrations/0019_order_pricing_plan.py new file mode 100644 index 0000000..5392ce6 --- /dev/null +++ b/uncloud_pay/migrations/0019_order_pricing_plan.py @@ -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'), + ), + ] diff --git a/uncloud_pay/migrations/0020_rename_is_final_bill_is_closed.py b/uncloud_pay/migrations/0020_rename_is_final_bill_is_closed.py new file mode 100644 index 0000000..f3419eb --- /dev/null +++ b/uncloud_pay/migrations/0020_rename_is_final_bill_is_closed.py @@ -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', + ), + ] diff --git a/uncloud_pay/migrations/0021_auto_20210709_0914.py b/uncloud_pay/migrations/0021_auto_20210709_0914.py new file mode 100644 index 0000000..66e3dcb --- /dev/null +++ b/uncloud_pay/migrations/0021_auto_20210709_0914.py @@ -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', + ), + ] diff --git a/uncloud_pay/migrations/0022_remove_order_status.py b/uncloud_pay/migrations/0022_remove_order_status.py new file mode 100644 index 0000000..2b51be8 --- /dev/null +++ b/uncloud_pay/migrations/0022_remove_order_status.py @@ -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', + ), + ] diff --git a/uncloud_pay/models.py b/uncloud_pay/models.py index 49e8da2..a4cf007 100644 --- a/uncloud_pay/models.py +++ b/uncloud_pay/models.py @@ -1,5 +1,6 @@ import logging import datetime +import json from math import ceil from calendar import monthrange @@ -9,18 +10,22 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator from django.db import models +from django.db.models import Q from django.utils.translation import gettext_lazy as _ 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 from django.core.exceptions import ObjectDoesNotExist, ValidationError +import uncloud_pay 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 * # 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. logger = logging.getLogger(__name__) @@ -96,84 +101,18 @@ class Payment(models.Model): def __str__(self): return f"{self.amount}{self.currency} from {self.owner} via {self.source} on {self.timestamp}" -### -# Payments and Payment Methods. - - -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.') - + def save(self, *args, **kwargs): + # Try to charge the user via the active card before saving otherwise throw payment Error if self.source == 'stripe': - stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id - stripe_payment = uncloud_pay.stripe.charge_customer( - amount, stripe_customer, self.stripe_payment_method_id) - if 'paid' in stripe_payment and stripe_payment['paid'] == False: - raise Exception(stripe_payment['error']) - else: - payment = Payment.objects.create( - 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 + try: + result = uncloud_pay.stripe.charge_customer(self.owner, self.amount, self.currency,) + if not result.status or result.status != 'succeeded': + raise Exception("The payment has been failed, please try to activate another card") + super().save(*args, **kwargs) + except Exception as e: + raise e + + # See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types class RecurringPeriodDefaultChoices(models.IntegerChoices): @@ -231,9 +170,11 @@ class RecurringPeriod(models.Model): # Bills. 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_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) class Meta: @@ -272,6 +213,10 @@ class BillingAddress(UncloudAddress): self.owner, self.full_name, self.street, self.postal_code, self.city, self.country) + + @staticmethod + def get_address_for(user): + return BillingAddress.objects.get(owner=user) ### # VAT @@ -297,10 +242,44 @@ class VATRate(models.Model): logger.debug(str(dne)) logger.debug("Did not find VAT rate for %s, returning 0" % country_code) 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): - 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 @@ -342,30 +321,20 @@ class Product(models.Model): 'features': { 'cores': { 'min': 1, - 'max': 48, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 3 + 'max': 48 }, 'ram_gb': { 'min': 1, - 'max': 256, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 4 + 'max': 256 }, 'ssd_gb': - { 'min': 10, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 0.35 + { 'min': 10 }, 'hdd_gb': { 'min': 0, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 15/1000 }, 'additional_ipv4_address': { 'min': 0, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 8 }, } } @@ -381,36 +350,23 @@ class Product(models.Model): 'base': { 'min': 1, 'max': 1, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 1 }, 'cores': { 'min': 1, 'max': 48, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 3 }, 'ram_gb': { 'min': 1, 'max': 256, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 4 }, 'ssd_gb': - { 'min': 10, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 0.35 + { 'min': 10 }, 'hdd_gb': - { 'min': 0, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 15/1000 + { 'min': 0 }, 'additional_ipv4_address': - { 'min': 0, - 'one_time_price_per_unit': 0, - 'recurring_price_per_unit': 9 - }, + { 'min': 0,}, } } ) @@ -433,7 +389,7 @@ class Product(models.Model): @property 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 def last_recurring_order(self): @@ -441,56 +397,12 @@ class Product(models.Model): @property 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 def last_one_time_order(self): return self.one_time_orders.last() - def create_order(self, when_to_start=None, recurring_period=None): - billing_address = BillingAddress.get_address_for(self.owner) - - if not billing_address: - 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 (?) def create_or_update_recurring_order(self, when_to_start=None, recurring_period=None): if not self.recurring_price: @@ -618,10 +530,83 @@ class Product(models.Model): 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. - class Order(models.Model): """ 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, on_delete=models.CASCADE) + customer = models.ForeignKey(StripeCustomer, on_delete=models.CASCADE, null=True) + description = models.TextField() product = models.ForeignKey(Product, blank=False, null=False, on_delete=models.CASCADE) @@ -686,6 +673,7 @@ class Order(models.Model): on_delete=models.CASCADE, blank=True, null=True) + pricing_plan = models.ForeignKey(PricingPlan, blank=False, null=True, on_delete=models.CASCADE) should_be_billed = models.BooleanField(default=True) @@ -750,6 +738,17 @@ class Order(models.Model): """ 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): """ @@ -790,7 +789,7 @@ class Order(models.Model): @property def is_recurring(self): - return not self.recurring_period == RecurringPeriod.objects.get(name="ONE_TIME") + return self.recurring_price > 0 @property def is_one_time(self): @@ -814,14 +813,12 @@ class Order(models.Model): description=self.description, product=self.product, config=config, + pricing_plan=self.pricing_plan, starting_date=starting_date, currency=self.currency ) - (new_order.one_time_price, new_order.recurring_price, new_order.config) = new_order.calculate_prices_and_config() - - - + new_order.recurring_price = new_order.calculate_recurring_price() new_order.replaces = self new_order.save() @@ -830,26 +827,28 @@ class Order(models.Model): return new_order - + def create_bill_record(self, bill): br = None - # Note: check for != 0 not > 0, as we allow discounts to be expressed with < 0 - if self.one_time_price != 0 and self.billrecord_set.count() == 0: - br = BillRecord.objects.create(bill=bill, - order=self, - starting_date=self.starting_date, - ending_date=self.starting_date, - is_recurring_record=False) - if self.recurring_price != 0: - br = BillRecord.objects.filter(bill=bill, order=self, is_recurring_record=True).first() - - if br: - self.update_bill_record_for_recurring_order(br, bill) + records = BillRecord.objects.filter(order=self).all() + if not records: + if self.one_time_price: + 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: - 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 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 self.ending_date: - if bill_record_for_this_bill.ending_date != self.ending_date: - bill_record_for_this_bill.ending_date = self.ending_date + if bill_record.ending_date != self.ending_date: + bill_record.ending_date = self.ending_date else: # recurring, not terminated, should go until at least end of bill - if bill_record_for_this_bill.ending_date < bill.ending_date: - bill_record_for_this_bill.ending_date = bill.ending_date + if bill_record.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): """ Create a new bill record """ - - last_bill_record = BillRecord.objects.filter(order=self, is_recurring_record=True).order_by('id').last() + last_bill_record = BillRecord.objects.filter(order=self).order_by('id').last() starting_date=self.starting_date @@ -892,7 +890,6 @@ class Order(models.Model): return starting_date = start_after(last_bill_record.ending_date) - ending_date = self.get_ending_date_for_bill(bill) return BillRecord.objects.create(bill=bill, @@ -901,47 +898,27 @@ class Order(models.Model): ending_date=ending_date, is_recurring_record=True) - def calculate_prices_and_config(self): - one_time_price = 0 - recurring_price = 0 + def calculate_recurring_price(self): + try: + 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: - config = self.config - - if 'features' not in self.config: - self.config['features'] = {} - - else: - config = { - 'features': {} - } - - # FIXME: adjust prices to the selected recurring_period to the - - if 'features' in self.product.config: - for feature in self.product.config['features']: - - # Set min to 0 if not specified - min_val = self.product.config['features'][feature].get('min', 0) - - # We might not even have 'features' cannot use .get() on it - try: - value = self.config['features'][feature] - except (KeyError, TypeError): - value = self.product.config['features'][feature]['min'] - - # Set max to current value if not specified - max_val = self.product.config['features'][feature].get('max', value) - - - if value < min_val or value > max_val: - raise ValidationError(f"Feature '{feature}' must be at least {min_val} and at maximum {max_val}. Value is: {value}") - - one_time_price += self.product.config['features'][feature]['one_time_price_per_unit'] * value - recurring_price += self.product.config['features'][feature]['recurring_price_per_unit'] * value - config['features'][feature] = value - - return (one_time_price, recurring_price, config) + vat_rate = VATRate.get_vat_rate(self.billing_address) + 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( + recurring_price, self.pricing_plan, + vat_rate=vat_rate * 100, vat_validation_status = vat_validation_status + ) + return price_after_discount_with_vat + except Exception as e: + logger.error("An error occurred while parsing the config obj", e) + return 0 def check_parameters(self): if 'parameters' in self.product.config: @@ -955,7 +932,7 @@ class Order(models.Model): # IMMUTABLE fields -- need to create new order to modify them # However this is not enforced here... if self._state.adding: - (self.one_time_price, self.recurring_price, self.config) = self.calculate_prices_and_config() + self.recurring_price = self.calculate_recurring_price() if self.recurring_period_id is None: self.recurring_period = self.product.default_recurring_period @@ -975,12 +952,7 @@ class Order(models.Model): def __str__(self): - try: - conf = " ".join([ f"{key}:{val}" for key,val in self.config['features'].items() if val != 0 ]) - except KeyError: - conf = "" - - return f"Order {self.id}: {self.description} {conf}" + return f"Order {self.id}: {self.description}" class Bill(models.Model): """ @@ -1003,7 +975,7 @@ class Bill(models.Model): # FIXME: editable=True -> is in the admin, but also editable in DRF # Maybe filter fields in the serializer? - is_final = models.BooleanField(default=False) + is_closed = models.BooleanField(default=False) class Meta: constraints = [ @@ -1017,8 +989,9 @@ class Bill(models.Model): """ Close/finish a bill """ - - self.is_final = True + self.is_closed = True + if not self.ending_date: + self.ending_date = timezone.now() self.save() @property @@ -1028,34 +1001,7 @@ class Bill(models.Model): @property def vat_rate(self): - """ - Handling VAT is a tricky business - thus we only implement the cases - that we clearly now and leave it open to fellow developers to implement - correct handling for other cases. - - Case CH: - - - If the customer is in .ch -> apply standard rate - - If the customer is in EU AND private -> apply country specific rate - - If the customer is in EU AND business -> do not apply VAT - - If the customer is outside EU and outside CH -> do not apply VAT - """ - - provider = UncloudProvider.objects.get() - - # Assume always VAT inside the country - if provider.country == self.billing_address.country: - vat_rate = VATRate.objects.get(country=provider.country, - when=self.ending_date) - elif self.billing_address.country in EU: - # FIXME: need to check for validated vat number - if self.billing_address.vat_number: - return 0 - else: - return VATRate.objects.get(country=self.biling_address.country, - when=self.ending_date) - else: # non-EU, non-national - return 0 + return VATRate.get_vat_rate(self.billing_address, when=self.ending_date) @classmethod @@ -1075,9 +1021,10 @@ class Bill(models.Model): """ bills = [] - 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 @@ -1089,15 +1036,18 @@ class Bill(models.Model): owner = billing_address.owner - all_orders = Order.objects.filter(owner=owner, - billing_address=billing_address).order_by('id') - - bill = cls.get_or_create_bill(billing_address, ending_date=ending_date) - - for order in all_orders: - order.create_bill_record(bill) - - return bill + all_orders = Order.objects.filter(Q(owner__id=owner.id), Q(should_be_billed=True), + Q(billing_address__id=billing_address.id) + ).order_by('id') + + if len(all_orders) > 0: + bill = cls.get_or_create_bill(billing_address, ending_date=ending_date) + for order in all_orders: + order.create_bill_record(bill) + return bill + else: + # This Customer Hasn't any active orders + return False @classmethod @@ -1117,7 +1067,7 @@ class Bill(models.Model): # Get date & bill from previous bill, if it exists if last_bill: - if not last_bill.is_final: + if not last_bill.is_closed: bill = last_bill starting_date = last_bill.starting_date ending_date = bill.ending_date @@ -1142,7 +1092,7 @@ class Bill(models.Model): return bill - + def __str__(self): return f"{self.owner}-{self.id}" @@ -1167,9 +1117,11 @@ class BillRecord(models.Model): if not self.is_recurring_record: return 1 - record_delta = self.ending_date - self.starting_date - - return record_delta.total_seconds()/self.order.recurring_period.duration_seconds + record_delta = self.ending_date.date() - self.starting_date.date() + if self.order.recurring_period and self.order.recurring_period.duration_seconds > 0: + return int(record_delta.total_seconds() / self.order.recurring_period.duration_seconds) + else: + return 1 @property def sum(self): diff --git a/uncloud_pay/selectors.py b/uncloud_pay/selectors.py index 2a5ad4a..a634e30 100644 --- a/uncloud_pay/selectors.py +++ b/uncloud_pay/selectors.py @@ -1,9 +1,5 @@ from django.utils import timezone 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 * def get_payments_for_user(user): @@ -12,12 +8,11 @@ def get_payments_for_user(user): return sum(payments) def get_spendings_for_user(user): - orders = Order.objects.filter(owner=user) + bills = Bill.objects.filter(owner=user) amount = 0 - for order in orders: - amount += order.one_time_price - amount += order.recurring_price * order.count_used(when=timezone.now()) + for bill in bills: + amount += bill.sum return amount @@ -25,34 +20,12 @@ def get_spendings_for_user(user): def get_balance_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): - return BillingAddress.objects.get(owner=user, active=True) - -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 + return BillingAddress.objects.filter(owner=user, active=True).first() diff --git a/uncloud_pay/serializers.py b/uncloud_pay/serializers.py index b5de192..4ea4104 100644 --- a/uncloud_pay/serializers.py +++ b/uncloud_pay/serializers.py @@ -86,8 +86,9 @@ class OrderSerializer(serializers.ModelSerializer): class Meta: model = Order read_only_fields = ['replaced_by', 'depends_on'] - fields = ['uuid', 'owner', 'description', 'creation_date', 'starting_date', 'ending_date', - 'bill', 'recurring_period', 'recurring_price', 'one_time_price'] + read_only_fields + fields = ['owner', 'description', 'creation_date', 'starting_date', 'ending_date', + '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: model = Bill - fields = ['uuid', 'reference', 'owner', 'amount', 'vat_amount', 'total', + fields = ['owner', 'sum', 'vat_rate', '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 # will change VAT on existing bills. class UpdateBillingAddressSerializer(serializers.ModelSerializer): class Meta: model = BillingAddress - fields = ['uuid', 'street', 'city', 'postal_code'] + fields = ['street', 'city', 'postal_code'] diff --git a/uncloud_pay/services.py b/uncloud_pay/services.py index ed97c39..84a7c8d 100644 --- a/uncloud_pay/services.py +++ b/uncloud_pay/services.py @@ -1,3 +1,5 @@ +import datetime +from calendar import monthrange from django.utils import timezone diff --git a/uncloud_pay/stripe.py b/uncloud_pay/stripe.py index ed95c82..a59456e 100644 --- a/uncloud_pay/stripe.py +++ b/uncloud_pay/stripe.py @@ -9,6 +9,8 @@ from django.contrib.auth import get_user_model from .models import StripeCustomer, StripeCreditCard +logger = logging.getLogger(__name__) + CURRENCY = 'chf' stripe.api_key = settings.STRIPE_KEY @@ -77,9 +79,24 @@ def create_setup_intent(customer_id): def get_setup_intent(setup_intent_id): return stripe.SetupIntent.retrieve(setup_intent_id) +@handle_stripe_error def get_payment_method(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 def create_customer(name, email): return stripe.Customer.create(name=name, email=email) @@ -142,7 +159,7 @@ def sync_cards_for_user(user): ) @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. # https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount # FIXME: might need to be adjusted for other currencies @@ -153,14 +170,14 @@ def charge_customer(user, amount, currency='CHF'): return Exception("Programming error: unsupported currency") try: - card = StripeCreditCard.objects.get(owner=user, + 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, @@ -169,3 +186,64 @@ def charge_customer(user, amount, currency='CHF'): off_session=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 diff --git a/uncloud_pay/tasks.py b/uncloud_pay/tasks.py deleted file mode 100644 index c372366..0000000 --- a/uncloud_pay/tasks.py +++ /dev/null @@ -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 diff --git a/uncloud_pay/templates/uncloud_pay/register_stripe.html b/uncloud_pay/templates/uncloud_pay/register_stripe.html index 9613701..0eed76a 100644 --- a/uncloud_pay/templates/uncloud_pay/register_stripe.html +++ b/uncloud_pay/templates/uncloud_pay/register_stripe.html @@ -1,12 +1,11 @@ {% extends 'uncloud/base.html' %} - {% block bootstrap5_extra_head %} + {% endblock %} - {% block bootstrap5_content %}
    - + {% csrf_token %}

    Register Credit Card with Stripe

    @@ -18,10 +17,15 @@ -

    - - - + + +
    +
    + + +
    The card will be registered with stripe.