diff --git a/Changelog b/Changelog index 79fa8b64..04b699a9 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,50 @@ +2.6.9: 2019-11-15 + * feature: Allow creating yearly subscriptions for Generic Products (MR!718) + Notes for deployment: + - do a db migrate for new column added to Generic Product model + ./manage.py migrate hosting +2.6.8: 2019-11-15 + * feature: [EU VAT] Add EU VAT feature for generic products (MR!717) + Notes for deployment: + - do a db migrate a to create VATRates table + ./manage.py migrate hosting + - load vat_rates.csv + ./manage.py import_vat_rates vat_rates.csv +2.6.7: 2019-11-04 + * bugfix: [admin] Improve dumpuser: show proper dates + bugfix + * bugfix: [admin] Improve fetch_stripe_bills: + - fix wrong assigment of string to num_invoice_created + variable, + - return None (do not handle the case) if we don't have an + order + * bugfix: [admin] Improve deleteuser: do not delete order, bill and vm_detail +2.6.6: 2019-11-04 + * feature: [admin] Add dumpuser management command that dumps a user's data in json (MR!716) +2.6.5: 2019-09-24 + * #7169: [hosting] Fix server error while vm terminate takes longer than 30 seconds + * #7170: [hosting] Improve admin email body contents for hosting vm terminate error case +2.6.4: 2019-09-15 + * #7147: [OpenBSD vm] Add an explanatory text for username puffy on OpenBSD (MR!714) +2.6.3: 2019-08-28 + * #7032: [hosting] Bugfix: Reentering the same SSH key used before does allow user to proceed further; complains key exists (MR!712) + * #7070: [check_vm/api] Bugfix: Provide oneadmin credentials to check whether a user is the owner of a VM (MR!713) +2.6.2: 2019-08-22 + * #7068: [django/node/rails] Remove public- prefix from OS template names (MR!711) +2.6.1: 2019-07-09 + * #6941: [hosting dashboard] Show the card's expiry year & month too in the list of added cards (MR!710) +2.6: 2019-07-03 + * #5509: Getting rid of our key by still supporting multiple user keys (MR!709) +2.5.11: 2019-06-11 + * #6672: [api] Check VM belongs to user in the infrastructure directly (MR!707) + * #bugfix: DE translation fix "Learn mehr" -> "Lerne mehr" (MR!708) +2.5.10: 2019-05-16 + * #6672: [api] REST endpoint for ungleich-cli to verify if a VM belongs to a user (MR!705) + * #6670: [hosting/save_ssh_key] Upgrade cdist version to 5.0.1 to manage keys on Alpine linux +2.5.9: 2019-05-09 + * #6669: [hosting] Fix opennebula vm query takes long (MR!703) + * [hosting] Increase VMDetail model's configuration parameter length to 128 (MR!702) +2.5.8: 2019-05-06 + * #6631: Add `deleteuser` management command (MR!701) 2.5.7: 2019-05-05 * #6657: [all] Remove dependency on code.jquery.com, maxcdn.bootstrapcdn.com and oss.maxcdn.com and add them locally (MR!700) 2.5.6: 2019-05-05 diff --git a/datacenterlight/locale/de/LC_MESSAGES/django.po b/datacenterlight/locale/de/LC_MESSAGES/django.po index d43e91ea..6d58673a 100644 --- a/datacenterlight/locale/de/LC_MESSAGES/django.po +++ b/datacenterlight/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-09-26 20:44+0000\n" +"POT-Creation-Date: 2019-11-15 17:33+0000\n" "PO-Revision-Date: 2018-03-30 23:22+0000\n" "Last-Translator: b'Anonymous User '\n" "Language-Team: LANGUAGE \n" @@ -20,12 +20,28 @@ msgstr "" "X-Translated-Using: django-rosetta 0.8.1\n" msgid "CMS Favicon" -msgstr "" +msgstr "CMS Favicon" #, python-format msgid "Your New VM %(vm_name)s at Data Center Light" msgstr "Deine neue VM %(vm_name)s bei Data Center Light" +msgid "Your VM is almost ready!" +msgstr "Deine VM ist fast fertig!" + +msgid "" +"You need to specify your public SSH key to access your VM. You can either " +"add your existing key, or generate a new key pair by clicking the generate " +"button below. After choosing your public SSH key option you’ll be directed " +"to the order confirmation page." +msgstr "" +"Du musst deinen öffentlichen SSH-Schlüssel angeben, um auf deine VM " +"zugreifen zu können. Du kannst entweder deinen vorhandenen Schlüssel " +"hinzufügen oder ein neues Schlüsselpaar generieren, indem du auf die " +"Schaltfläche \"Generieren\" unten klickst. Nachdem du deine öffentliche SSH-" +"Schlüsseloption ausgewählt hast, wirst du zur Bestellbestätigungsseite " +"weitergeleitet. " + msgid "All Rights Reserved" msgstr "Alle Rechte vorbehalten" @@ -36,7 +52,7 @@ msgid "Login" msgstr "Anmelden" msgid "Dashboard" -msgstr "" +msgstr "Dashboard" msgid "Thank you for contacting us." msgstr "Nachricht gesendet." @@ -48,7 +64,7 @@ msgid "Get in touch with us!" msgstr "Sende uns eine Nachricht." msgid "Name" -msgstr "" +msgstr "Name" msgid "Please enter your name." msgstr "Bitte gib Deinen Namen ein." @@ -92,7 +108,7 @@ msgid "Your account details are as follows" msgstr "Deine Account Details sind unten aufgelistet" msgid "Username" -msgstr "Username" +msgstr "Benusername" msgid "Your email address" msgstr "Deine E-Mail-Adresse" @@ -134,8 +150,12 @@ msgstr "Unser Angebot beginnt bei 15 CHF pro Monat. Probier's jetzt aus!" msgid "ORDER VM" msgstr "VM BESTELLEN" +#, python-format +msgid "Please enter a value in range %(min_ram)s - 200." +msgstr "Bitte gib einen Wert von %(min_ram)s bis 200 ein." + msgid "VM hosting" -msgstr "" +msgstr "VM Hosting" msgid "month" msgstr "Monat" @@ -152,9 +172,6 @@ msgstr "Standort: Schweiz" msgid "Please enter a value in range 1 - 48." msgstr "Bitte gib einen Wert von 1 bis 48 ein." -msgid "Please enter a value in range 1 - 200." -msgstr "Bitte gib einen Wert von 1 bis 200 ein." - msgid "Please enter a value in range 10 - 2000." msgstr "Bitte gib einen Wert von 10 bis 2000 ein." @@ -190,14 +207,14 @@ msgstr "" msgid "Only wants you to pay for what you actually need." msgstr "" -"Möchte, dass du nur bezahlst, was du auch wirklich brauchst: Wähle deine " +"Du möchtest nur das bezahlen, was du auch wirklich brauchst: Wähle deine " "Ressourcen individuell aus!
" msgid "" "Is creative, using a modern and alternative design for a data center in " "order to make it more sustainable and affordable at the same time." msgstr "" -"Ist kreativ, indem es sich ein modernes und alternatives Layout zu Nutze " +"Es ist kreativ, da es sich ein modernes und alternatives Layout zu Nutze" "macht um Nachhaltigkeit zu fördern und somit erschwingliche Preise bieten zu " "können.
" @@ -205,9 +222,9 @@ msgid "" "Cuts down the costs for you by using FOSS (Free Open Source Software) " "exclusively, wherefore we can save money from paying licenses." msgstr "" -"Sorgt dafür, dass unnötige Kosten erspart werden, indem es ausschliesslich " -"mit FOSS (Free Open Source Software) arbeitet und wir daher auf " -"Lizenzgebühren verzichten können.
" +"Um unnötige Kosten zu sparen werden, wird ausschliesslich Software auf" +"Basis von FOSS (Free Open Source Software) eingesetzt und dadurch können auf " +"Lizenzgebühren verzichtet werden.
" msgid "Scale out" msgstr "Skalierung" @@ -294,7 +311,7 @@ msgid "Billing Address" msgstr "Rechnungsadresse" msgid "Make a payment" -msgstr "" +msgstr "Tätige eine Bezahlung" msgid "Your Order" msgstr "Deine Bestellung" @@ -358,6 +375,9 @@ msgstr "Letzten" msgid "Type" msgstr "Typ" +msgid "Expiry" +msgstr "Ablaufdatum" + msgid "SELECT" msgstr "AUSWÄHLEN" @@ -398,14 +418,23 @@ msgstr "Bestellungsübersicht" msgid "Product" msgstr "Produkt" +msgid "Price" +msgstr "Preise" + +msgid "VAT for" +msgstr "MwSt für" + +msgid "Total Amount" +msgstr "Gesamtsumme" + msgid "Amount" -msgstr "" +msgstr "Betrag" msgid "Description" -msgstr "" +msgstr "Beschreibung" msgid "Recurring" -msgstr "" +msgstr "Wiederholend" msgid "Subtotal" msgstr "Zwischensumme" @@ -413,13 +442,29 @@ msgstr "Zwischensumme" msgid "VAT" msgstr "Mehrwertsteuer" +#, fuzzy, python-format +#| msgid "" +#| "By clicking \"Place order\" this plan will charge your credit card " +#| "account with %(total_price)s CHF/month" +msgid "" +"By clicking \"Place order\" this plan will charge your credit card account " +"with %(total_price)s CHF/year" +msgstr "" +"Wenn Du \"bestellen\" auswählst, wird Deine Kreditkarte mit %(total_price)s " +"CHF pro Jahr belastet" + + msgid "" "By clicking \"Place order\" this plan will charge your credit card account " "with %(total_price)s CHF/month" msgstr "" -"Wenn Du \"bestellen\" auswählst, wird Deine Kreditkarte mit " -"%(vm_total_price)s CHF pro Monat belastet" +"Wenn Du \"bestellen\" auswählst, wird Deine Kreditkarte mit %(total_price)s " +"CHF pro Monat belastet" +#, fuzzy, python-format +#| msgid "" +#| "By clicking \"Place order\" this payment will charge your credit card " +#| "account with a one time amount of %(total_price)s CHF" msgid "" "By clicking \"Place order\" this payment will charge your credit card " "account with a one time amount of %(total_price)s CHF" @@ -445,10 +490,10 @@ msgid "Hold tight, we are processing your request" msgstr "Bitte warten - wir verarbeiten Deine Anfrage gerade" msgid "OK" -msgstr "" +msgstr "Ok" msgid "Close" -msgstr "" +msgstr "Schliessen" msgid "Some problem encountered. Please try again later." msgstr "Ein Problem ist aufgetreten. Bitte versuche es später noch einmal." @@ -460,7 +505,7 @@ msgid "Tech Stack" msgstr "Tech Stack" msgid "We are seriously open source." -msgstr "Wir sind vollends opensource." +msgstr "Wir sind vollends Open Source." msgid "" " Our full software stack is open source – We don't use anything that isn't " @@ -530,11 +575,14 @@ msgid "Starting from only 15CHF per month. Try now." msgstr "Unser Angebot beginnt bei 15 CHF pro Monat. Probier's jetzt aus!" msgid "Actions speak louder than words. Let's do it, try our VM now." -msgstr "Tagen sagen mehr als Worte – Teste jetzt unsere VM!" +msgstr "Taten sagen mehr als Worte – Teste jetzt unsere VM!" msgid "Invalid number of cores" msgstr "Ungültige Anzahle CPU-Kerne" +msgid "Invalid calculator properties" +msgstr "Ungültige Berechnungseigenschaften" + msgid "Invalid RAM size" msgstr "Ungültige RAM-Grösse" @@ -543,7 +591,7 @@ msgstr "Ungültige Speicher-Grösse" #, python-brace-format msgid "Incorrect pricing name. Please contact support{support_email}" -msgstr "" +msgstr "Ungültige Preisbezeichnung. Bitte kontaktiere den Support{support_email}" #, python-brace-format msgid "{user} does not have permission to access the card" @@ -570,11 +618,14 @@ msgid "An error occurred while associating the card. Details: {details}" msgstr "" "Beim Verbinden der Karte ist ein Fehler aufgetreten. Details: {details}" -msgid "Confirmation of your payment" -msgstr "" - msgid " This is a monthly recurring plan." -msgstr "" +msgstr "Dies ist ein monatlich wiederkehrender Plan." + +msgid " This is an yearly recurring plan." +msgstr "Dies ist ein jährlich wiederkehrender Plan." + +msgid "Confirmation of your payment" +msgstr "Bestätigung deiner Zahlung" #, python-brace-format msgid "" @@ -585,7 +636,8 @@ msgid "" "\n" "Cheers,\n" "Your Data Center Light team" -msgstr "" +msgstr "Hallo {name},\n" "\n" "vielen Dank für deine Bestellung!\n" "Wir haben deine Bezahlung in Höhe von {amount:.2f} CHF erhalten. {recurring}\n" "\n" "Grüsse\n" +"Dein Data Center Light Team" msgid "Thank you for the payment." msgstr "Danke für Deine Bestellung." @@ -593,7 +645,7 @@ msgstr "Danke für Deine Bestellung." msgid "" "You will soon receive a confirmation email of the payment. You can always " "contact us at info@ungleich.ch for any question that you may have." -msgstr "" +msgstr "Du wirst bald eine Bestätigungs-E-Mail über die Zahlung erhalten. Du kannst jederzeit unter info@ungleich.ch kontaktieren." msgid "Thank you for the order." msgstr "Danke für Deine Bestellung." @@ -616,9 +668,6 @@ msgstr "" #~ msgid "Card Number" #~ msgstr "Kreditkartennummer" -#~ msgid "Expiry Date" -#~ msgstr "Ablaufdatum" - #~ msgid "" #~ "You are not making any payment yet. After placing your order, you will be " #~ "taken to the Submit Payment Page." @@ -627,9 +676,6 @@ msgstr "" #~ "ausgelöst, nachdem Du die Bestellung auf der nächsten Seite bestätigt " #~ "hast." -#~ msgid "Pricing" -#~ msgstr "Preise" - #~ msgid "Order VM" #~ msgstr "VM bestellen" diff --git a/datacenterlight/management/commands/deleteuser.py b/datacenterlight/management/commands/deleteuser.py new file mode 100644 index 00000000..d4ccba29 --- /dev/null +++ b/datacenterlight/management/commands/deleteuser.py @@ -0,0 +1,141 @@ +import logging +import sys +import uuid + +import oca +import stripe +from django.core.management.base import BaseCommand + +from hosting.models import ( + UserCardDetail, UserHostingKey +) +from membership.models import CustomUser, DeletedUser +from opennebula_api.models import OpenNebulaManager + +logger = logging.getLogger(__name__) + + +def query_yes_no(question, default="yes"): + """Ask a yes/no question via raw_input() and return their answer. + + "question" is a string that is presented to the user. + "default" is the presumed answer if the user just hits . + It must be "yes" (the default), "no" or None (meaning + an answer is required of the user). + + The "answer" return value is True for "yes" or False for "no". + """ + valid = {"yes": True, "y": True, "ye": True, + "no": False, "n": False} + if default is None: + prompt = " [y/n] " + elif default == "yes": + prompt = " [Y/n] " + elif default == "no": + prompt = " [y/N] " + else: + raise ValueError("invalid default answer: '%s'" % default) + + while True: + sys.stdout.write(question + prompt) + choice = input().lower() + if default is not None and choice == '': + return valid[default] + elif choice in valid: + return valid[choice] + else: + sys.stdout.write("Please respond with 'yes' or 'no' " + "(or 'y' or 'n').\n") + + +class Command(BaseCommand): + help = '''Deletes all resources of the user from the project''' + + def add_arguments(self, parser): + parser.add_argument('customer_email', nargs='+', type=str) + + def handle(self, *args, **options): + try: + for email in options['customer_email']: + r = query_yes_no("Are you sure you want to delete {} ?".format( + email, None + )) + if r: + logger.debug("Deleting user {}".format(email)) + # Get stripe customer instance and delete the customer + try: + cus_user = CustomUser.objects.get(email=email) + except CustomUser.DoesNotExist as dne: + logger.error("CustomUser with email {} does " + "not exist".format(email)) + sys.exit(1) + stripe_customer = cus_user.stripecustomer + c = stripe.Customer.retrieve( + stripe_customer.stripe_id + ) + cus_delete_obj = c.delete() + if cus_delete_obj.deleted: + logger.debug( + "StripeCustomer {} associated with {} deleted" + "".format(stripe_customer.stripe_id, email) + ) + else: + logger.error("Error while deleting the StripeCustomer") + + # delete UserCardDetail + ucds = UserCardDetail.objects.filter( + stripe_customer=stripe_customer + ) + for ucd in ucds: + if ucd is not None: + logger.debug( + "User Card Detail {} associated with {} deleted" + "".format(ucd.id, email) + ) + ucd.delete() + else: + logger.error( + "Error while deleting the User Card Detail") + + # delete UserHostingKey + uhks = UserHostingKey.objects.filter( + user=cus_user + ) + for uhk in uhks: + uhk.delete() + + # delete stripe customer + stripe_customer.delete() + + # add user to deleteduser + DeletedUser.objects.create( + email=cus_user.email, name=cus_user.name, + user_id = cus_user.id + ) + + # reset CustomUser + cus_user.email = str(uuid.uuid4()) + cus_user.validated = 0 + cus_user.save() + + # remove user from OpenNebula + manager = OpenNebulaManager() + user_pool = manager._get_user_pool() + on_user = user_pool.get_by_name(email) + if on_user.id > 0: + logger.debug( + "Deleting user {} => ID={} from opennebula".format( + email, on_user.id) + ) + manager.oneadmin_client.call( + oca.User.METHODS['delete'], on_user.id + ) + else: + logger.error( + "User not found with email {}. " + "Not doing anything".format(email) + ) + + logger.debug("Deleted {} SUCCESSFULLY.".format(email)) + except Exception as e: + print(" *** Error occurred. Details {}".format(str(e))) diff --git a/datacenterlight/management/commands/dumpuser.py b/datacenterlight/management/commands/dumpuser.py new file mode 100644 index 00000000..24857dec --- /dev/null +++ b/datacenterlight/management/commands/dumpuser.py @@ -0,0 +1,134 @@ +import json +import logging +import sys + +from django.core.management.base import BaseCommand +from membership.models import CustomUser +from hosting.models import ( + HostingOrder, VMDetail, UserCardDetail, UserHostingKey +) +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = '''Dumps the data of a customer into a json file''' + + def add_arguments(self, parser): + parser.add_argument('customer_email', nargs='+', type=str) + + def handle(self, *args, **options): + try: + for email in options['customer_email']: + try: + cus_user = CustomUser.objects.get(email=email) + except CustomUser.DoesNotExist as dne: + logger.error("CustomUser with email {} does " + "not exist".format(email)) + sys.exit(1) + + hosting_orders = HostingOrder.objects.filter( + customer=cus_user.stripecustomer.id + ) + + vm_ids = [] + orders_dict = {} + for order in hosting_orders: + order_dict = {} + vm_ids.append(order.vm_id) + order_dict["VM_ID"] = order.vm_id + order_dict["Order Nr."] = order.id + order_dict["Created On"] = str(order.created_at) + order_dict["Price"] = order.price + order_dict["Payment card details"] = { + "last4": order.last4, + "brand": order.cc_brand + } + if order.subscription_id is not None and order.stripe_charge_id is None: + order_dict["Order type"] = "Monthly susbcription" + else: + order_dict["Order type"] = "One time payment" + + # billing address + if order.billing_address is not None: + order_dict["Billing Address"] = { + "Street": order.billing_address.street_address, + "City": order.billing_address.city, + "Country": order.billing_address.country, + "Postal code": order.billing_address.postal_code, + "Card holder name": order.billing_address.cardholder_name + } + else: + logger.error( + "did not find billing_address") + + # Order Detail + if order.order_detail is not None: + order_dict["Specifications"] = { + "RAM": "{} GB".format(order.order_detail.memory), + "Cores": order.order_detail.cores, + "Disk space (SSD)": "{} GB".format( + order.order_detail.ssd_size) + } + else: + logger.error( + "Did not find order_detail. None") + + vm_detail = VMDetail.objects.get(vm_id=order.vm_id) + if vm_detail is not None: + order_dict["VM Details"] = { + "VM_ID": order.vm_id, + "IPv4": vm_detail.ipv4, + "IPv6": vm_detail.ipv6, + "OS": vm_detail.configuration, + } + order_dict["Terminated on"] = str(vm_detail.terminated_at) + + orders_dict[order.vm_id] = order_dict + + + # UserCardDetail + cards = {} + ucds = UserCardDetail.objects.filter( + stripe_customer=cus_user.stripecustomer + ) + for ucd in ucds: + card = {} + if ucd is not None: + card["Last 4"] = ucd.last4 + card["Brand"] = ucd.brand + card["Expiry month"] = ucd.exp_month + card["Expiry year"] = ucd.exp_year + card["Preferred"] = ucd.preferred + cards[ucd.id] = card + else: + logger.error( + "Error while deleting the User Card Detail") + + # UserHostingKey + keys = {} + uhks = UserHostingKey.objects.filter( + user=cus_user + ) + for uhk in uhks: + key = { + "Public key": uhk.public_key, + "Name": uhk.name, + "Created on": str(uhk.created_at) + } + if uhk.private_key: + key["Private key"] = uhk.private_key + keys[uhk.name] = key + output_dict = { + "User details": { + "Name": cus_user.name, + "Email": cus_user.email, + "Activated": "yes" if cus_user.validated == 1 else "no", + "Last login": str(cus_user.last_login) + }, + "Orders": orders_dict, + "Payment cards": cards, + "SSH Keys": keys + } + print(json.dumps(output_dict, indent=4)) + except Exception as e: + print(" *** Error occurred. Details {}".format(str(e))) diff --git a/datacenterlight/static/datacenterlight/css/common.css b/datacenterlight/static/datacenterlight/css/common.css index 00ee52cc..b19b5852 100644 --- a/datacenterlight/static/datacenterlight/css/common.css +++ b/datacenterlight/static/datacenterlight/css/common.css @@ -186,3 +186,8 @@ footer .dcl-link-separator::before { background: transparent !important; resize: none; } + +.existing-keys-title { + font-weight: bold; + font-size: 14px; +} diff --git a/datacenterlight/tasks.py b/datacenterlight/tasks.py index 5f12b7df..8b4626e8 100644 --- a/datacenterlight/tasks.py +++ b/datacenterlight/tasks.py @@ -8,7 +8,6 @@ from django.core.mail import EmailMessage from django.core.urlresolvers import reverse from django.utils import translation from django.utils.translation import ugettext_lazy as _ -from time import sleep from dynamicweb.celery import app from hosting.models import HostingOrder @@ -16,7 +15,7 @@ from membership.models import CustomUser from opennebula_api.models import OpenNebulaManager from opennebula_api.serializers import VirtualMachineSerializer from utils.hosting_utils import ( - get_all_public_keys, get_or_create_vm_detail, ping_ok + get_all_public_keys, get_or_create_vm_detail ) from utils.mailer import BaseEmail from utils.stripe_utils import StripeUtils @@ -79,10 +78,14 @@ def create_vm_task(self, vm_template_id, user, specs, template, order_id): # Create OpenNebulaManager manager = OpenNebulaManager(email=on_user, password=on_pass) + custom_user = CustomUser.objects.get(email=user.get('email')) + pub_keys = get_all_public_keys(custom_user) + if manager.email != settings.OPENNEBULA_USERNAME: + manager.save_key_in_opennebula_user('\n'.join(pub_keys)) vm_id = manager.create_vm( template_id=vm_template_id, specs=specs, - ssh_key=settings.ONEADMIN_USER_SSH_PUBLIC_KEY, + ssh_key='\n'.join(pub_keys), vm_name=vm_name ) @@ -188,65 +191,9 @@ def create_vm_task(self, vm_template_id, user, specs, template, order_id): email = BaseEmail(**email_data) email.send() - # try to see if we have the IPv6 of the new vm and that if the ssh - # keys can be configured - vm_ipv6 = manager.get_ipv6(vm_id) logger.debug("New VM ID is {vm_id}".format(vm_id=vm_id)) - if vm_ipv6 is not None: - custom_user = CustomUser.objects.get(email=user.get('email')) + if vm_id > 0: get_or_create_vm_detail(custom_user, manager, vm_id) - if custom_user is not None: - public_keys = get_all_public_keys(custom_user) - keys = [{'value': key, 'state': True} for key in - public_keys] - if len(keys) > 0: - logger.debug( - "Calling configure on {host} for " - "{num_keys} keys".format( - host=vm_ipv6, num_keys=len(keys) - ) - ) - # Let's wait until the IP responds to ping before we - # run the cdist configure on the host - did_manage_public_key = False - for i in range(0, 15): - if ping_ok(vm_ipv6): - logger.debug( - "{} is pingable. Doing a " - "manage_public_key".format(vm_ipv6) - ) - sleep(10) - manager.manage_public_key( - keys, hosts=[vm_ipv6] - ) - did_manage_public_key = True - break - else: - logger.debug( - "Can't ping {}. Wait 5 secs".format( - vm_ipv6 - ) - ) - sleep(5) - if not did_manage_public_key: - emsg = ("Waited for over 75 seconds for {} to be " - "pingable. But the VM was not reachable. " - "So, gave up manage_public_key. Please do " - "this manually".format(vm_ipv6)) - logger.error(emsg) - email_data = { - 'subject': '{} CELERY TASK INCOMPLETE: {} not ' - 'pingable for 75 seconds'.format( - settings.DCL_TEXT, vm_ipv6 - ), - 'from_email': current_task.request.hostname, - 'to': settings.DCL_ERROR_EMAILS_TO_LIST, - 'body': emsg - } - email = EmailMessage(**email_data) - email.send() - else: - logger.debug("VM's ipv6 is None. Hence not created VMDetail") except Exception as e: logger.error(str(e)) try: diff --git a/datacenterlight/templates/datacenterlight/add_ssh_key.html b/datacenterlight/templates/datacenterlight/add_ssh_key.html new file mode 100644 index 00000000..44048bad --- /dev/null +++ b/datacenterlight/templates/datacenterlight/add_ssh_key.html @@ -0,0 +1,10 @@ + +{% load staticfiles bootstrap3 i18n custom_tags humanize %} + +{% block content %} + {% block userkey_form %} + {% with form_title=_("Your VM is almost ready!") form_sub_title=_("You need to specify your public SSH key to access your VM. You can either add your existing key, or generate a new key pair by clicking the generate button below. After choosing your public SSH key option you’ll be directed to the order confirmation page.") %} + {% include 'hosting/user_key.html' with title=form_title sub_title=form_sub_title %} + {% endwith %} + {% endblock userkey_form %} +{%endblock%} \ No newline at end of file diff --git a/datacenterlight/templates/datacenterlight/landing_payment.html b/datacenterlight/templates/datacenterlight/landing_payment.html index fb6d51b0..4e71eab9 100644 --- a/datacenterlight/templates/datacenterlight/landing_payment.html +++ b/datacenterlight/templates/datacenterlight/landing_payment.html @@ -131,6 +131,7 @@
{% trans "Credit Card" %}
{% trans "Last" %} 4: ***** {{card.last4}}
{% trans "Type" %}: {{card.brand}}
+
{% trans "Expiry" %}: {{card.exp_month}}/{{card.exp_year}}
{% trans "SELECT" %} diff --git a/datacenterlight/templates/datacenterlight/order_detail.html b/datacenterlight/templates/datacenterlight/order_detail.html index 31933e12..bc8e7562 100644 --- a/datacenterlight/templates/datacenterlight/order_detail.html +++ b/datacenterlight/templates/datacenterlight/order_detail.html @@ -41,6 +41,7 @@

{% trans "Payment method" %}:

{{cc_brand|default:_('Credit Card')}} {% trans "ending in" %} ****{{cc_last4}}
+ {% trans "Expiry" %} {{cc_exp_year}}/{{cc_exp_month}}
{{request.user.email}}

@@ -54,10 +55,25 @@

+ {% if generic_payment_details.vat_rate > 0 %} +

+ {% trans "Price" %}: + CHF {{generic_payment_details.amount_before_vat|floatformat:2|intcomma}} +

+

+ {% trans "VAT for" %} {{generic_payment_details.vat_country}} ({{generic_payment_details.vat_rate}}%) : + CHF {{generic_payment_details.vat_amount|floatformat:2|intcomma}} +

+

+ {% trans "Total Amount" %} : + CHF {{generic_payment_details.amount|floatformat:2|intcomma}} +

+ {% else %}

{% trans "Amount" %}: CHF {{generic_payment_details.amount|floatformat:2|intcomma}}

+ {% endif %} {% if generic_payment_details.description %}

{% trans "Description" %}: @@ -138,7 +154,11 @@

{% if generic_payment_details %} {% if generic_payment_details.recurring %} -
{% blocktrans with total_price=generic_payment_details.amount|floatformat:2|intcomma %}By clicking "Place order" this plan will charge your credit card account with {{total_price}} CHF/month{% endblocktrans %}.
+ {% if generic_payment_details.recurring_interval == 'year' %} +
{% blocktrans with total_price=generic_payment_details.amount|floatformat:2|intcomma %}By clicking "Place order" this plan will charge your credit card account with {{total_price}} CHF/year{% endblocktrans %}.
+ {% else %} +
{% blocktrans with total_price=generic_payment_details.amount|floatformat:2|intcomma %}By clicking "Place order" this plan will charge your credit card account with {{total_price}} CHF/month{% endblocktrans %}.
+ {% endif %} {% else %}
{% blocktrans with total_price=generic_payment_details.amount|floatformat:2|intcomma %}By clicking "Place order" this payment will charge your credit card account with a one time amount of {{total_price}} CHF{% endblocktrans %}.
{% endif %} diff --git a/datacenterlight/urls.py b/datacenterlight/urls.py index 006e7fc3..13296de7 100644 --- a/datacenterlight/urls.py +++ b/datacenterlight/urls.py @@ -1,12 +1,12 @@ from django.conf.urls import url from django.views.generic import TemplateView, RedirectView +from utils.views import AskSSHKeyView from .views import ( IndexView, PaymentOrderView, OrderConfirmationView, WhyDataCenterLightView, ContactUsView ) - urlpatterns = [ url(r'^$', IndexView.as_view(), name='index'), url(r'^t/$', IndexView.as_view(), name='index_t'), @@ -20,6 +20,8 @@ urlpatterns = [ url(r'^payment/?$', PaymentOrderView.as_view(), name='payment'), url(r'^order-confirmation/?$', OrderConfirmationView.as_view(), name='order_confirmation'), + url(r'^add-ssh-key/?$', AskSSHKeyView.as_view(), + name='add_ssh_key'), url(r'^contact/?$', ContactUsView.as_view(), name='contact_us'), url(r'glasfaser/?$', TemplateView.as_view(template_name='ungleich_page/glasfaser.html'), diff --git a/datacenterlight/utils.py b/datacenterlight/utils.py index bbcb16ab..11d2b82e 100644 --- a/datacenterlight/utils.py +++ b/datacenterlight/utils.py @@ -1,4 +1,8 @@ import logging + +import pyotp +import requests +from django.conf import settings from django.contrib.sites.models import Site from datacenterlight.tasks import create_vm_task @@ -11,7 +15,6 @@ from .models import VMPricing, VMTemplate logger = logging.getLogger(__name__) - def get_cms_integration(name): current_site = Site.objects.get_current() try: @@ -97,6 +100,26 @@ def clear_all_session_vars(request): for session_var in ['specs', 'template', 'billing_address', 'billing_address_data', 'card_id', 'token', 'customer', 'generic_payment_type', - 'generic_payment_details', 'product_id']: + 'generic_payment_details', 'product_id', + 'order_confirm_url', 'new_user_hosting_key_id']: if session_var in request.session: del request.session[session_var] + + +def check_otp(name, realm, token): + data = { + "auth_name": settings.AUTH_NAME, + "auth_token": pyotp.TOTP(settings.AUTH_SEED).now(), + "auth_realm": settings.AUTH_REALM, + "name": name, + "realm": realm, + "token": token + } + response = requests.post( + "https://{OTP_SERVER}{OTP_VERIFY_ENDPOINT}".format( + OTP_SERVER=settings.OTP_SERVER, + OTP_VERIFY_ENDPOINT=settings.OTP_VERIFY_ENDPOINT + ), + data=data + ) + return response.status_code diff --git a/datacenterlight/views.py b/datacenterlight/views.py index 5dc3a3d3..44226abb 100644 --- a/datacenterlight/views.py +++ b/datacenterlight/views.py @@ -13,18 +13,22 @@ from django.views.decorators.cache import cache_control from django.views.generic import FormView, CreateView, DetailView from hosting.forms import ( - HostingUserLoginForm, GenericPaymentForm, ProductPaymentForm + HostingUserLoginForm, GenericPaymentForm, ProductPaymentForm, + UserHostingKeyForm ) from hosting.models import ( - HostingBill, HostingOrder, UserCardDetail, GenericProduct + HostingBill, HostingOrder, UserCardDetail, GenericProduct, UserHostingKey ) from membership.models import CustomUser, StripeCustomer +from opennebula_api.models import OpenNebulaManager from opennebula_api.serializers import VMTemplateSerializer from utils.forms import ( BillingAddressForm, BillingAddressFormSignup, UserBillingAddressForm, BillingAddress ) -from utils.hosting_utils import get_vm_price_with_vat +from utils.hosting_utils import ( + get_vm_price_with_vat, get_all_public_keys, get_vat_rate_for_country +) from utils.stripe_utils import StripeUtils from utils.tasks import send_plain_email_task from .cms_models import DCLCalculatorPluginModel @@ -410,10 +414,21 @@ class PaymentOrderView(FormView): product = generic_payment_form.cleaned_data.get( 'product_name' ) + user_country_vat_rate = get_vat_rate_for_country( + address_form.cleaned_data["country"] + ) gp_details = { "product_name": product.product_name, - "amount": generic_payment_form.cleaned_data.get( - 'amount' + "vat_rate": user_country_vat_rate * 100, + "vat_amount": round( + float(product.product_price) * + user_country_vat_rate, 2), + "vat_country": address_form.cleaned_data["country"], + "amount_before_vat": round( + float(product.product_price), 2), + "amount": product.get_actual_price( + vat_rate=get_vat_rate_for_country( + address_form.cleaned_data["country"]) ), "recurring": generic_payment_form.cleaned_data.get( 'recurring' @@ -422,7 +437,9 @@ class PaymentOrderView(FormView): 'description' ), "product_id": product.id, - "product_slug": product.product_slug + "product_slug": product.product_slug, + "recurring_interval": + product.product_subscription_interval } request.session["generic_payment_details"] = ( gp_details @@ -521,20 +538,34 @@ class PaymentOrderView(FormView): request.session['customer'] = customer.stripe_id else: request.session['customer'] = customer - return HttpResponseRedirect( - reverse('datacenterlight:order_confirmation')) + + # For generic payment we take the user directly to confirmation + if ('generic_payment_type' in request.session and + self.request.session['generic_payment_type'] == 'generic'): + return HttpResponseRedirect( + reverse('datacenterlight:order_confirmation')) + else: + self.request.session['order_confirm_url'] = reverse('datacenterlight:order_confirmation') + return HttpResponseRedirect( + reverse('datacenterlight:add_ssh_key')) else: context = self.get_context_data() context['billing_address_form'] = address_form return self.render_to_response(context) -class OrderConfirmationView(DetailView): +class OrderConfirmationView(DetailView, FormView): + form_class = UserHostingKeyForm template_name = "datacenterlight/order_detail.html" payment_template_name = 'datacenterlight/landing_payment.html' context_object_name = "order" model = HostingOrder + def get_form_kwargs(self): + kwargs = super(OrderConfirmationView, self).get_form_kwargs() + kwargs.update({'request': self.request}) + return kwargs + @cache_control(no_cache=True, must_revalidate=True, no_store=True) def get(self, request, *args, **kwargs): context = {} @@ -552,11 +583,15 @@ class OrderConfirmationView(DetailView): card_details_response = card_details['response_object'] context['cc_last4'] = card_details_response['last4'] context['cc_brand'] = card_details_response['brand'] + context['cc_exp_year'] = card_details_response['exp_year'] + context['cc_exp_month'] = '{:02d}'.format(card_details_response['exp_month']) else: card_id = self.request.session.get('card_id') card_detail = UserCardDetail.objects.get(id=card_id) context['cc_last4'] = card_detail.last4 context['cc_brand'] = card_detail.brand + context['cc_exp_year'] = card_detail.exp_year + context['cc_exp_month'] ='{:02d}'.format(card_detail.exp_month) if ('generic_payment_type' in request.session and self.request.session['generic_payment_type'] == 'generic'): @@ -567,6 +602,8 @@ class OrderConfirmationView(DetailView): else: context.update({ 'vm': request.session.get('specs'), + 'form': UserHostingKeyForm(request=self.request), + 'keys': get_all_public_keys(self.request.user) }) context.update({ 'site_url': reverse('datacenterlight:index'), @@ -721,6 +758,7 @@ class OrderConfirmationView(DetailView): if ('generic_payment_type' not in request.session or (request.session['generic_payment_details']['recurring'])): + recurring_interval = 'month' if 'generic_payment_details' in request.session: amount_to_be_charged = ( round( @@ -733,6 +771,10 @@ class OrderConfirmationView(DetailView): amount_to_be_charged ) stripe_plan_id = plan_name + recurring_interval = request.session['generic_payment_details']['recurring_interval'] + if recurring_interval == "year": + plan_name = "{}-yearly".format(plan_name) + stripe_plan_id = plan_name else: template = request.session.get('template') specs = request.session.get('specs') @@ -759,7 +801,9 @@ class OrderConfirmationView(DetailView): stripe_plan = stripe_utils.get_or_create_stripe_plan( amount=amount_to_be_charged, name=plan_name, - stripe_plan_id=stripe_plan_id) + stripe_plan_id=stripe_plan_id, + interval=recurring_interval + ) subscription_result = stripe_utils.subscribe_customer_to_plan( stripe_api_cus_id, [{"plan": stripe_plan.get( @@ -830,6 +874,18 @@ class OrderConfirmationView(DetailView): new_user = authenticate(username=custom_user.email, password=password) login(request, new_user) + if 'new_user_hosting_key_id' in self.request.session: + user_hosting_key = UserHostingKey.objects.get(id=self.request.session['new_user_hosting_key_id']) + user_hosting_key.user = new_user + user_hosting_key.save() + + owner = new_user + manager = OpenNebulaManager( + email=owner.email, + password=owner.password + ) + keys_to_save = get_all_public_keys(new_user) + manager.save_key_in_opennebula_user('\n'.join(keys_to_save)) else: # We assume that if the user is here, his/her StripeCustomer # object already exists @@ -938,6 +994,9 @@ class OrderConfirmationView(DetailView): 'reply_to': [context['email']], } send_plain_email_task.delay(email_data) + recurring_text = _(" This is a monthly recurring plan.") + if gp_details['recurring_interval'] == "year": + recurring_text = _(" This is an yearly recurring plan.") email_data = { 'subject': _("Confirmation of your payment"), @@ -951,7 +1010,7 @@ class OrderConfirmationView(DetailView): name=user.get('name'), amount=gp_details['amount'], recurring=( - _(' This is a monthly recurring plan.') + recurring_text if gp_details['recurring'] else '' ) ) diff --git a/digitalglarus/locale/de/LC_MESSAGES/django.po b/digitalglarus/locale/de/LC_MESSAGES/django.po index 2d0129c4..ec96f5dc 100644 --- a/digitalglarus/locale/de/LC_MESSAGES/django.po +++ b/digitalglarus/locale/de/LC_MESSAGES/django.po @@ -342,7 +342,7 @@ msgstr "" "dieser Website erklärst Du Dich damit einverstanden, diese zu nutzen." msgid "Learn more" -msgstr "Learn mehr" +msgstr "Lerne mehr" msgid "OK" msgstr "" diff --git a/dynamicweb/settings/base.py b/dynamicweb/settings/base.py index b267c31d..1051c4ab 100644 --- a/dynamicweb/settings/base.py +++ b/dynamicweb/settings/base.py @@ -721,6 +721,14 @@ X_FRAME_OPTIONS = ('SAMEORIGIN' if X_FRAME_OPTIONS_ALLOW_FROM_URI is None else DEBUG = bool_env('DEBUG') +READ_VM_REALM = env('READ_VM_REALM') +AUTH_NAME = env('AUTH_NAME') +AUTH_SEED = env('AUTH_SEED') +AUTH_REALM = env('AUTH_REALM') +OTP_SERVER = env('OTP_SERVER') +OTP_VERIFY_ENDPOINT = env('OTP_VERIFY_ENDPOINT') + + if DEBUG: from .local import * # flake8: noqa else: diff --git a/hosting/forms.py b/hosting/forms.py index 16b06fe0..947cee44 100644 --- a/hosting/forms.py +++ b/hosting/forms.py @@ -1,15 +1,14 @@ import datetime import logging import subprocess - import tempfile + from django import forms from django.conf import settings from django.contrib.auth import authenticate from django.utils.translation import ugettext_lazy as _ from membership.models import CustomUser -from utils.hosting_utils import get_all_public_keys from .models import UserHostingKey, GenericProduct logger = logging.getLogger(__name__) @@ -110,9 +109,14 @@ class ProductPaymentForm(GenericPaymentForm): ) ) if self.product.product_is_subscription: + payment_type = "month" + if self.product.product_subscription_interval == "month": + payment_type = _('Monthly subscription') + elif self.product.product_subscription_interval == "year": + payment_type = _('Yearly subscription') self.fields['amount'].label = "{amt} ({payment_type})".format( amt=_('Amount in CHF'), - payment_type=_('Monthly subscription') + payment_type=payment_type ) else: self.fields['amount'].label = "{amt} ({payment_type})".format( @@ -187,20 +191,12 @@ class UserHostingKeyForm(forms.ModelForm): alerts the user of it. :return: """ - if 'generate' in self.request.POST: + if ('generate' in self.request.POST + or not self.fields['public_key'].required): return self.data.get('public_key') KEY_ERROR_MESSAGE = _("Please input a proper SSH key") openssh_pubkey_str = self.data.get('public_key').strip() - if openssh_pubkey_str in get_all_public_keys(self.request.user): - key_name = UserHostingKey.objects.filter( - user_id=self.request.user.id, - public_key=openssh_pubkey_str).first().name - KEY_EXISTS_MESSAGE = _( - "This key exists already with the name \"%(name)s\"") % { - 'name': key_name} - raise forms.ValidationError(KEY_EXISTS_MESSAGE) - with tempfile.NamedTemporaryFile(delete=True) as tmp_public_key_file: tmp_public_key_file.write(openssh_pubkey_str.encode('utf-8')) tmp_public_key_file.flush() @@ -214,10 +210,14 @@ class UserHostingKeyForm(forms.ModelForm): return openssh_pubkey_str def clean_name(self): + INVALID_NAME_MESSAGE = _("Comma not accepted in the name of the key") + if "," in self.data.get('name'): + logger.debug(INVALID_NAME_MESSAGE) + raise forms.ValidationError(INVALID_NAME_MESSAGE) return self.data.get('name') def clean_user(self): - return self.request.user + return self.request.user if self.request.user.is_authenticated() else None def clean(self): cleaned_data = self.cleaned_data diff --git a/hosting/locale/de/LC_MESSAGES/django.po b/hosting/locale/de/LC_MESSAGES/django.po index 0e337cfb..08bcdd7a 100644 --- a/hosting/locale/de/LC_MESSAGES/django.po +++ b/hosting/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-09-08 08:45+0000\n" +"POT-Creation-Date: 2019-11-15 16:40+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -27,6 +27,33 @@ msgstr "Dein Account wurde noch nicht aktiviert." msgid "User does not exist" msgstr "Der Benutzer existiert nicht" +msgid "Choose a product" +msgstr "Wähle ein Produkt" + +msgid "Amount in CHF" +msgstr "Betrag" + +msgid "Recurring monthly" +msgstr "monatlich wiederkehrend" + +msgid "Amount field does not match" +msgstr "Betragsfeld stimmt nicht überein" + +msgid "Recurring field does not match" +msgstr "Betragsfeld stimmt nicht überein" + +msgid "Product name" +msgstr "Produkt" + +msgid "Monthly subscription" +msgstr "Monatliches Abonnement" + +msgid "Yearly subscription" +msgstr "Jährliches Abonnement" + +msgid "One time payment" +msgstr "Einmalzahlung" + msgid "Confirm Password" msgstr "Passwort Bestätigung" @@ -48,9 +75,8 @@ msgstr "Key-Name" msgid "Please input a proper SSH key" msgstr "Bitte verwende einen gültigen SSH-Key" -#, python-format -msgid "This key exists already with the name \"%(name)s\"" -msgstr "Der SSH-Key mit dem Name \"%(name)s\" existiert bereits" +msgid "Comma not accepted in the name of the key" +msgstr "Komma im Namen des Keys wird nicht akzeptiert" msgid "All Rights Reserved" msgstr "Alle Rechte vorbehalten" @@ -209,11 +235,16 @@ msgstr "Du hast eine neue virtuelle Maschine bestellt!" #, python-format msgid "Your order of %(vm_name)s has been charged." -msgstr "Deine Bestellung von %(vm_name)s wurde entgegengenommen." +msgstr "" +"Deine Bestellung von %(vm_name)s wurde entgegengenommen." msgid "You can view your VM detail by clicking the button below." msgstr "Um die Rechnung zu sehen, klicke auf den Button unten." +msgid "You can log in to your VM by the username puffy." +msgstr "" +"Du kannst Dich auf Deiner VM mit dem user puffy einloggen." + msgid "View Detail" msgstr "Details anzeigen" @@ -227,6 +258,9 @@ msgstr "Deine Bestellung von %(vm_name)s wurde entgegengenommen." msgid "You can view your VM detail by following the link below." msgstr "Um die Rechnung zu sehen, klicke auf den Link unten." +msgid "You can log in to your VM by the username puffy." +msgstr "Du kannst Dich auf Deiner VM mit dem user puffy einloggen." + msgid "Password Reset" msgstr "Passwort zurücksetzen" @@ -305,6 +339,103 @@ msgstr "Dashboard" msgid "Logout" msgstr "Abmelden" +#, python-format +msgid "%(page_header_text)s" +msgstr "" + +msgid "Invoice #" +msgstr "Rechnung" + +msgid "Date" +msgstr "Datum" + +msgid "Status" +msgstr "" + +msgid "Terminated" +msgstr "Beendet" + +msgid "Approved" +msgstr "Akzeptiert" + +msgid "Declined" +msgstr "Abgelehnt" + +msgid "Billed to" +msgstr "Rechnungsadresse" + +msgid "Payment method" +msgstr "Bezahlmethode" + +msgid "ending in" +msgstr "endend in" + +msgid "Invoice summary" +msgstr "" + +msgid "Product" +msgstr "Produkt" + +msgid "Period" +msgstr "Periode" + +msgid "Cores" +msgstr "Prozessorkerne" + +msgid "Memory" +msgstr "Arbeitsspeicher" + +msgid "Disk space" +msgstr "Festplattenkapazität" + +msgid "Subtotal" +msgstr "Zwischensumme" + +msgid "VAT" +msgstr "Mehrwertsteuer" + +msgid "Discount" +msgstr "Rabatt" + +msgid "Total" +msgstr "Gesamt" + +msgid "Amount" +msgstr "Betrag" + +msgid "Description" +msgstr "Beschreibung" + +msgid "Recurring" +msgstr "wiederkehrend" + +msgid "of" +msgstr "von" + +msgid "each year" +msgstr "jedes Jahr" + +msgid "of every month" +msgstr "jeden Monat" + +msgid "BACK TO LIST" +msgstr "ZURÜCK ZUR LISTE" + +msgid "Some problem encountered. Please try again later." +msgstr "Ein Problem ist aufgetreten. Bitte versuche es später noch einmal." + +msgid "VM ID" +msgstr "" + +msgid "IP Address" +msgstr "IP-Adresse" + +msgid "See Invoice" +msgstr "Siehe Rechnung" + +msgid "Page" +msgstr "Seite" + msgid "Log in" msgstr "Anmelden" @@ -338,67 +469,15 @@ msgstr "Als gelesen markieren" msgid "All notifications" msgstr "Alle Benachrichtigungen" -#, python-format -msgid "%(page_header_text)s" -msgstr "" - -msgid "Date" -msgstr "Datum" - -msgid "Status" -msgstr "" - -msgid "Terminated" -msgstr "Beendet" - -msgid "Approved" -msgstr "Akzeptiert" - -msgid "Declined" -msgstr "Abgelehnt" - -msgid "Billed to" -msgstr "Rechnungsadresse" - -msgid "Payment method" -msgstr "Bezahlmethode" - -msgid "ending in" -msgstr "endend in" - msgid "Credit Card" msgstr "Kreditkarte" +msgid "Expiry" +msgstr "Gültig bis" + msgid "Order summary" msgstr "Bestellungsübersicht" -msgid "Product" -msgstr "Produkt" - -msgid "Period" -msgstr "Periode" - -msgid "Cores" -msgstr "Prozessorkerne" - -msgid "Memory" -msgstr "Arbeitsspeicher" - -msgid "Disk space" -msgstr "Festplattenkapazität" - -msgid "Subtotal" -msgstr "Zwischensumme" - -msgid "VAT" -msgstr "Mehrwertsteuer" - -msgid "Discount" -msgstr "Rabatt" - -msgid "Total" -msgstr "Gesamt" - #, python-format msgid "" "By clicking \"Place order\" this plan will charge your credit card account " @@ -410,9 +489,6 @@ msgstr "" msgid "Place order" msgstr "Bestellen" -msgid "BACK TO LIST" -msgstr "ZURÜCK ZUR LISTE" - msgid "Processing..." msgstr "Abarbeitung..." @@ -420,29 +496,14 @@ msgid "Hold tight, we are processing your request" msgstr "Bitte warten - wir bearbeiten Deine Anfrage gerade" msgid "OK" -msgstr "" +msgstr "Ok" msgid "Close" msgstr "Schliessen" -msgid "Some problem encountered. Please try again later." -msgstr "Ein Problem ist aufgetreten. Bitte versuche es später noch einmal." - msgid "Order Nr." msgstr "Bestellung Nr." -msgid "Amount" -msgstr "Betrag" - -msgid "See Invoice" -msgstr "Siehe Rechnung" - -msgid "Page" -msgstr "" - -msgid "of" -msgstr "" - msgid "Your Order" msgstr "Deine Bestellung" @@ -539,9 +600,6 @@ msgstr "" "Wir nutzen Stripe für " "die Bezahlung und speichern keine Informationen in unserer Datenbank." -msgid "Add your public SSH key" -msgstr "Füge deinen öffentlichen SSH-Key hinzu" - msgid "Use your created key to access to the VM" msgstr "Benutze deinen erstellten SSH-Key um auf deine VM zugreifen zu können" @@ -783,6 +841,9 @@ msgstr "" msgid "Invalid number of cores" msgstr "Ungültige Anzahle CPU-Kerne" +msgid "Invalid calculator properties" +msgstr "" + msgid "Invalid RAM size" msgstr "Ungültige RAM-Grösse" @@ -791,7 +852,7 @@ msgstr "Ungültige Speicher-Grösse" #, python-brace-format msgid "Incorrect pricing name. Please contact support{support_email}" -msgstr "" +msgstr "Ungültige Preisbezeichnung. Bitte kontaktiere den Support{support_email}" msgid "" "We could not find the requested VM. Please " @@ -810,7 +871,7 @@ msgstr "Fehler beenden VM" msgid "" "VM terminate action timed out. Please contact support@datacenterlight.ch for " "further information." -msgstr "" +msgstr "VM beendet wegen Zeitüberschreitung. Bitte kontaktiere support@datacenterlight.ch für weitere Informationen." #, python-format msgid "Virtual Machine %(vm_name)s Cancelled" @@ -821,6 +882,13 @@ msgstr "" "Es gab einen Fehler bei der Bearbeitung Deine Anfrage. Bitte versuche es " "noch einmal." +#, python-format +#~ msgid "This key exists already with the name \"%(name)s\"" +#~ msgstr "Der SSH-Key mit dem Name \"%(name)s\" existiert bereits" + +#~ msgid "Add your public SSH key" +#~ msgstr "Füge deinen öffentlichen SSH-Key hinzu" + #~ msgid "Do you want to cancel your Virtual Machine" #~ msgstr "Bist Du sicher, dass Du Deine virtuelle Maschine beenden willst" @@ -830,9 +898,6 @@ msgstr "" #~ msgid "My VM page" #~ msgstr "Meine VM page" -#~ msgid "Invoice Date" -#~ msgstr "Rechnung Datum" - #~ msgid "VM %(VM_ID)s terminated successfully" #~ msgstr "VM %(VM_ID)s erfolgreich beendet" diff --git a/hosting/management/commands/fetch_stripe_bills.py b/hosting/management/commands/fetch_stripe_bills.py index df30535c..1e4d1ab3 100644 --- a/hosting/management/commands/fetch_stripe_bills.py +++ b/hosting/management/commands/fetch_stripe_bills.py @@ -45,7 +45,17 @@ class Command(BaseCommand): num_invoice_created = 0 for invoice in all_invoices: invoice['customer'] = user.stripecustomer - num_invoice_created += 1 if MonthlyHostingBill.create(invoice) is not None else logger.error("Did not import invoice for %s" % str(invoice)) + try: + existing_mhb = MonthlyHostingBill.objects.get(invoice_id=invoice['invoice_id']) + logger.debug("Invoice %s exists already. Not importing." % invoice['invoice_id']) + except MonthlyHostingBill.DoesNotExist as dne: + logger.debug("Invoice id %s does not exist" % invoice['invoice_id']) + + if MonthlyHostingBill.create(invoice) is not None: + num_invoice_created += 1 + else: + logger.error("Did not import invoice for %s" + "" % str(invoice)) self.stdout.write( self.style.SUCCESS("Number of invoices imported = %s" % num_invoice_created) ) diff --git a/hosting/management/commands/import_vat_rates.py b/hosting/management/commands/import_vat_rates.py new file mode 100644 index 00000000..f779133d --- /dev/null +++ b/hosting/management/commands/import_vat_rates.py @@ -0,0 +1,44 @@ +from django.core.management.base import BaseCommand +import csv +from hosting.models import VATRates + + +class Command(BaseCommand): + help = '''Imports VAT Rates. Assume vat rates of format https://github.com/kdeldycke/vat-rates/blob/master/vat_rates.csv''' + + def add_arguments(self, parser): + parser.add_argument('csv_file', nargs='+', type=str) + + def handle(self, *args, **options): + try: + for c_file in options['csv_file']: + print("c_file = %s" % c_file) + with open(c_file, mode='r') as csv_file: + csv_reader = csv.DictReader(csv_file) + line_count = 0 + for row in csv_reader: + if line_count == 0: + line_count += 1 + obj, created = VATRates.objects.get_or_create( + start_date=row["start_date"], + stop_date=row["stop_date"] if row["stop_date"] is not "" else None, + territory_codes=row["territory_codes"], + currency_code=row["currency_code"], + rate=row["rate"], + rate_type=row["rate_type"], + description=row["description"] + ) + if created: + self.stdout.write(self.style.SUCCESS( + '%s. %s - %s - %s - %s' % ( + line_count, + obj.start_date, + obj.stop_date, + obj.territory_codes, + obj.rate + ) + )) + line_count+=1 + + except Exception as e: + print(" *** Error occurred. Details {}".format(str(e))) diff --git a/hosting/migrations/0054_auto_20190508_2141.py b/hosting/migrations/0054_auto_20190508_2141.py new file mode 100644 index 00000000..b3a99223 --- /dev/null +++ b/hosting/migrations/0054_auto_20190508_2141.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2019-05-08 21:41 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0053_hostingbilllineitem_stripe_plan'), + ] + + operations = [ + migrations.AlterField( + model_name='vmdetail', + name='configuration', + field=models.CharField(default='', max_length=128), + ), + ] diff --git a/hosting/migrations/0055_auto_20190701_1614.py b/hosting/migrations/0055_auto_20190701_1614.py new file mode 100644 index 00000000..4a2744fb --- /dev/null +++ b/hosting/migrations/0055_auto_20190701_1614.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2019-07-01 16:14 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0054_auto_20190508_2141'), + ] + + operations = [ + migrations.AlterField( + model_name='userhostingkey', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/hosting/migrations/0056_auto_20191026_0454.py b/hosting/migrations/0056_auto_20191026_0454.py new file mode 100644 index 00000000..490964bc --- /dev/null +++ b/hosting/migrations/0056_auto_20191026_0454.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2019-10-26 04:54 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0055_auto_20190701_1614'), + ] + + operations = [ + migrations.AlterField( + model_name='hostingbilllineitem', + name='amount', + field=models.IntegerField(), + ), + ] diff --git a/hosting/migrations/0057_vatrates.py b/hosting/migrations/0057_vatrates.py new file mode 100644 index 00000000..494974c6 --- /dev/null +++ b/hosting/migrations/0057_vatrates.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2019-11-15 05:16 +from __future__ import unicode_literals + +from django.db import migrations, models +import utils.mixins + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0056_auto_20191026_0454'), + ] + + operations = [ + migrations.CreateModel( + name='VATRates', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_date', models.DateField(blank=True, null=True)), + ('stop_date', models.DateField(blank=True, null=True)), + ('territory_codes', models.TextField(blank=True, default='')), + ('currency_code', models.CharField(max_length=10)), + ('rate', models.FloatField()), + ('rate_type', models.TextField(blank=True, default='')), + ('description', models.TextField(blank=True, default='')), + ], + bases=(utils.mixins.AssignPermissionsMixin, models.Model), + ), + ] diff --git a/hosting/migrations/0058_genericproduct_product_subscription_interval.py b/hosting/migrations/0058_genericproduct_product_subscription_interval.py new file mode 100644 index 00000000..c994ef16 --- /dev/null +++ b/hosting/migrations/0058_genericproduct_product_subscription_interval.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2019-11-15 14:57 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0057_vatrates'), + ] + + operations = [ + migrations.AddField( + model_name='genericproduct', + name='product_subscription_interval', + field=models.CharField(default='month', help_text='Choose between `year` and `month`', max_length=10), + ), + ] diff --git a/hosting/models.py b/hosting/models.py index d2011654..6050339a 100644 --- a/hosting/models.py +++ b/hosting/models.py @@ -1,11 +1,12 @@ +import decimal import json import logging import os -import pytz +from datetime import datetime +import pytz from Crypto.PublicKey import RSA from dateutil.relativedelta import relativedelta -from datetime import datetime from django.db import models from django.utils import timezone from django.utils.functional import cached_property @@ -78,13 +79,17 @@ class GenericProduct(AssignPermissionsMixin, models.Model): product_price = models.DecimalField(max_digits=6, decimal_places=2) product_vat = models.DecimalField(max_digits=6, decimal_places=4, default=0) product_is_subscription = models.BooleanField(default=True) + product_subscription_interval = models.CharField( + max_length=10, default="month", + help_text="Choose between `year` and `month`") def __str__(self): return self.product_name - def get_actual_price(self): + def get_actual_price(self, vat_rate=None): + VAT = vat_rate if vat_rate is not None else self.product_vat return round( - self.product_price + (self.product_price * self.product_vat), 2 + float(self.product_price) + float(self.product_price) * float(VAT), 2 ) @@ -187,7 +192,7 @@ class HostingOrder(AssignPermissionsMixin, models.Model): class UserHostingKey(models.Model): - user = models.ForeignKey(CustomUser) + user = models.ForeignKey(CustomUser, blank=True, null=True) public_key = models.TextField() private_key = models.FileField(upload_to='private_keys', blank=True) created_at = models.DateTimeField(auto_now_add=True) @@ -212,6 +217,15 @@ class UserHostingKey(models.Model): # self.save(update_fields=['public_key']) return private_key, public_key + def delete(self,*args,**kwargs): + if bool(self.private_key) and os.path.isfile(self.private_key.path): + logger.debug("Removing private key {}".format(self.private_key.path)) + os.remove(self.private_key.path) + else: + logger.debug("No private_key to remove") + + super(UserHostingKey, self).delete(*args,**kwargs) + class HostingBill(AssignPermissionsMixin, models.Model): customer = models.ForeignKey(StripeCustomer) @@ -310,7 +324,10 @@ class MonthlyHostingBill(AssignPermissionsMixin, models.Model): logger.debug("Neither subscription id nor vm_id available") logger.debug("Can't import invoice") return None - + if args['order'] is None: + logger.error( + "Order is None for {}".format(args['invoice_id'])) + return None instance = cls.objects.create( created=datetime.utcfromtimestamp( args['created']).replace(tzinfo=pytz.utc), @@ -457,7 +474,7 @@ class HostingBillLineItem(AssignPermissionsMixin, models.Model): on_delete=models.CASCADE) stripe_plan = models.ForeignKey(StripePlan, null=True, on_delete=models.CASCADE) - amount = models.PositiveSmallIntegerField() + amount = models.IntegerField() description = models.CharField(max_length=255) discountable = models.BooleanField() metadata = models.CharField(max_length=128) @@ -529,7 +546,7 @@ class VMDetail(models.Model): disk_size = models.FloatField(default=0.0) cores = models.FloatField(default=0.0) memory = models.FloatField(default=0.0) - configuration = models.CharField(default='', max_length=25) + configuration = models.CharField(default='', max_length=128) ipv4 = models.TextField(default='') ipv6 = models.TextField(default='') created_at = models.DateTimeField(auto_now_add=True) @@ -588,6 +605,8 @@ class UserCardDetail(AssignPermissionsMixin, models.Model): for card in user_card_details: cards_list.append({ 'last4': card.last4, 'brand': card.brand, 'id': card.id, + 'exp_year': card.exp_year, + 'exp_month': '{:02d}'.format(card.exp_month), 'preferred': card.preferred }) return cards_list @@ -693,3 +712,13 @@ class UserCardDetail(AssignPermissionsMixin, models.Model): return ucd except UserCardDetail.DoesNotExist: return None + + +class VATRates(AssignPermissionsMixin, models.Model): + start_date = models.DateField(blank=True, null=True) + stop_date = models.DateField(blank=True, null=True) + territory_codes = models.TextField(blank=True, default='') + currency_code = models.CharField(max_length=10) + rate = models.FloatField() + rate_type = models.TextField(blank=True, default='') + description = models.TextField(blank=True, default='') \ No newline at end of file diff --git a/hosting/static/hosting/js/virtual_machine_detail.js b/hosting/static/hosting/js/virtual_machine_detail.js index 8f90933b..28592883 100644 --- a/hosting/static/hosting/js/virtual_machine_detail.js +++ b/hosting/static/hosting/js/virtual_machine_detail.js @@ -109,8 +109,11 @@ $(document).ready(function() { modal_btn = $('#createvm-modal-done-btn'); $('#createvm-modal-title').text(data.msg_title); $('#createvm-modal-body').html(data.msg_body); - modal_btn.attr('href', data.redirect) - .removeClass('hide'); + if (data.redirect) { + modal_btn.attr('href', data.redirect).removeClass('hide'); + } else { + modal_btn.attr('href', ""); + } if (data.status === true) { fa_icon.attr('class', 'checkmark'); } else { diff --git a/hosting/templates/hosting/emails/new_booked_vm.html b/hosting/templates/hosting/emails/new_booked_vm.html index 7bc0cf3a..9fad05fd 100644 --- a/hosting/templates/hosting/emails/new_booked_vm.html +++ b/hosting/templates/hosting/emails/new_booked_vm.html @@ -33,6 +33,11 @@

{% blocktrans %}You can view your VM detail by clicking the button below.{% endblocktrans %}

+ {% if 'OpenBSD' in vm_name %} +

+ {% blocktrans %}You can log in to your VM by the username puffy.{% endblocktrans %} +

+ {% endif %} diff --git a/hosting/templates/hosting/emails/new_booked_vm.txt b/hosting/templates/hosting/emails/new_booked_vm.txt index cfd9c63a..42b48e6a 100644 --- a/hosting/templates/hosting/emails/new_booked_vm.txt +++ b/hosting/templates/hosting/emails/new_booked_vm.txt @@ -5,6 +5,9 @@ {% blocktrans %}You have ordered a new virtual machine!{% endblocktrans %} {% blocktrans %}Your order of {{vm_name}} has been charged.{% endblocktrans %} {% blocktrans %}You can view your VM detail by following the link below.{% endblocktrans %} +{% if 'OpenBSD' in vm_name %} + {% blocktrans %}You can log in to your VM by the username puffy.{% endblocktrans %} +{% endif %} {{ base_url }}{{ order_url }} diff --git a/hosting/templates/hosting/includes/_pricing.html b/hosting/templates/hosting/includes/_pricing.html index 8abbf26f..41a5fcf2 100644 --- a/hosting/templates/hosting/includes/_pricing.html +++ b/hosting/templates/hosting/includes/_pricing.html @@ -57,7 +57,7 @@ diff --git a/hosting/templates/hosting/invoice_detail.html b/hosting/templates/hosting/invoice_detail.html index e84b03ea..b9a3e742 100644 --- a/hosting/templates/hosting/invoice_detail.html +++ b/hosting/templates/hosting/invoice_detail.html @@ -93,10 +93,10 @@ {% for line_item in line_items %} - + {% endfor %} - +
ProductPeriodQtyUnit PriceTotal
{% if line_item.description|length > 0 %}{{line_item.description}}{% elif line_item.stripe_plan.stripe_plan_name|length > 0 %}{{line_item.stripe_plan.stripe_plan_name}}{% else %}{{line_item.get_item_detail_str|safe}}{% endif %}{{ line_item.period_start | date:'Y-m-d' }} — {{ line_item.period_end | date:'Y-m-d' }}{{line_item.quantity}}{{line_item.unit_amount_in_chf}}{{line_item.amount_in_chf}}
{% if line_item.description|length > 0 %}{{line_item.description}}{% elif line_item.stripe_plan.stripe_plan_name|length > 0 %}{{line_item.stripe_plan.stripe_plan_name}}{% else %}{{line_item.get_item_detail_str|safe}}{% endif %}{{ line_item.period_start | date:'Y-m-d' }} — {{ line_item.period_end | date:'Y-m-d' }}{{line_item.quantity}}{{line_item.unit_amount_in_chf}}{{line_item.amount_in_chf|floatformat:2}}
Grand Total{{total_in_chf}}
Grand Total{{total_in_chf|floatformat:2}}
{% else %}

@@ -195,8 +195,13 @@ {% if invoice.order.subscription_id %}

{% trans "Recurring" %}: - {{invoice.order.created_at|date:'d'|ordinal}} + {% if invoice.order.generic_product.product_subscription_interval == 'year' %} + {{invoice.order.created_at|date:'d'|ordinal}} {% trans "of" %} {{invoice.order.created_at|date:'b'|title}} + {% trans "each year" %} + {% else %} + {{invoice.order.created_at|date:'d'|ordinal}} {% trans "of every month" %} + {% endif %}

{% endif %}
diff --git a/hosting/templates/hosting/order_detail.html b/hosting/templates/hosting/order_detail.html index 4a62e9fa..2775882d 100644 --- a/hosting/templates/hosting/order_detail.html +++ b/hosting/templates/hosting/order_detail.html @@ -82,6 +82,7 @@ {{user.email}} {% else %} {{cc_brand|default:_('Credit Card')}} {% trans "ending in" %} ****{{cc_last4}}
+ {% trans "Expiry" %} {{cc_exp_year}}/{{cc_exp_month}}
{% if request.user.is_authenticated %} {{request.user.email}} {% else %} @@ -185,7 +186,13 @@ {% if order.subscription_id %}

{% trans "Recurring" %}: - {{order.created_at|date:'d'|ordinal}} {% trans "of every month" %} + {% if order.generic_product.product_subscription_interval == 'year' %} + {{order.created_at|date:'d'|ordinal}} {% trans "of" %} {{order.created_at|date:'b'|title}} + {% trans "each year" %} + {% else %} + {{order.created_at|date:'d'|ordinal}} + {% trans "of every month" %} + {% endif %}

{% endif %}
diff --git a/hosting/templates/hosting/payment.html b/hosting/templates/hosting/payment.html index e09775cf..f0512fdb 100644 --- a/hosting/templates/hosting/payment.html +++ b/hosting/templates/hosting/payment.html @@ -131,6 +131,7 @@
{% trans "Credit Card" %}
{% trans "Last" %} 4: ***** {{card.last4}}
{% trans "Type" %}: {{card.brand}}
+
{% trans "Expiry" %}: {{card.exp_month}}/{{card.exp_year}}
{% trans "SELECT" %} diff --git a/hosting/templates/hosting/settings.html b/hosting/templates/hosting/settings.html index 56818cbf..5cdd830c 100644 --- a/hosting/templates/hosting/settings.html +++ b/hosting/templates/hosting/settings.html @@ -37,6 +37,7 @@
{% trans "Credit Card" %}
{% trans "Last" %} 4: ***** {{card.last4}}
{% trans "Type" %}: {{card.brand}}
+
{% trans "Expiry" %}: {{card.exp_month}}/{{card.exp_year}}
{% if card_list_len > 1 %} diff --git a/hosting/templates/hosting/user_key.html b/hosting/templates/hosting/user_key.html index 804d661a..247551b5 100644 --- a/hosting/templates/hosting/user_key.html +++ b/hosting/templates/hosting/user_key.html @@ -8,7 +8,8 @@
{% csrf_token %} {% if messages %}
diff --git a/hosting/urls.py b/hosting/urls.py index a3579f06..5b2b87b0 100644 --- a/hosting/urls.py +++ b/hosting/urls.py @@ -1,5 +1,7 @@ from django.conf.urls import url from django.contrib.auth import views as auth_views + +from utils.views import SSHKeyCreateView, AskSSHKeyView from .views import ( DjangoHostingView, RailsHostingView, PaymentVMView, NodeJSHostingView, LoginView, SignupView, SignupValidateView, SignupValidatedView, IndexView, @@ -7,15 +9,15 @@ from .views import ( VirtualMachinesPlanListView, VirtualMachineView, OrdersHostingDeleteView, MarkAsReadNotificationView, PasswordResetView, PasswordResetConfirmView, HostingPricingView, CreateVirtualMachinesView, HostingBillListView, - HostingBillDetailView, SSHKeyDeleteView, SSHKeyCreateView, SSHKeyListView, + HostingBillDetailView, SSHKeyDeleteView, SSHKeyListView, SSHKeyChoiceView, DashboardView, SettingsView, ResendActivationEmailView, - InvoiceListView, InvoiceDetailView + InvoiceListView, InvoiceDetailView, CheckUserVM ) - urlpatterns = [ url(r'index/?$', IndexView.as_view(), name='index'), url(r'django/?$', DjangoHostingView.as_view(), name='djangohosting'), + url(r'checkvm/?$', CheckUserVM.as_view(), name='check_vm'), url(r'dashboard/?$', DashboardView.as_view(), name='dashboard'), url(r'nodejs/?$', NodeJSHostingView.as_view(), name='nodejshosting'), url(r'rails/?$', RailsHostingView.as_view(), name='railshosting'), @@ -26,6 +28,8 @@ urlpatterns = [ url(r'invoices/?$', InvoiceListView.as_view(), name='invoices'), url(r'order-confirmation/?$', OrdersHostingDetailView.as_view(), name='order-confirmation'), + url(r'^add-ssh-key/?$', AskSSHKeyView.as_view(), + name='add_ssh_key'), url(r'orders/(?P\d+)/?$', OrdersHostingDetailView.as_view(), name='orders'), url(r'invoice/(?P[-\w]+)/?$', InvoiceDetailView.as_view(), diff --git a/hosting/views.py b/hosting/views.py index 92dd5aa8..25303b99 100644 --- a/hosting/views.py +++ b/hosting/views.py @@ -28,13 +28,16 @@ from django.views.generic import ( ) from guardian.mixins import PermissionRequiredMixin from oca.pool import WrongIdError +from rest_framework.renderers import JSONRenderer +from rest_framework.response import Response +from rest_framework.views import APIView from stored_messages.api import mark_read from stored_messages.models import Message from stored_messages.settings import stored_messages_settings from datacenterlight.cms_models import DCLCalculatorPluginModel from datacenterlight.models import VMTemplate, VMPricing -from datacenterlight.utils import create_vm, get_cms_integration +from datacenterlight.utils import create_vm, get_cms_integration, check_otp from hosting.models import UserCardDetail from membership.models import CustomUser, StripeCustomer from opennebula_api.models import OpenNebulaManager @@ -46,6 +49,7 @@ from utils.forms import ( BillingAddressForm, PasswordResetRequestForm, UserBillingAddressForm, ResendActivationEmailForm ) +from utils.hosting_utils import get_all_public_keys from utils.hosting_utils import get_vm_price_with_vat, HostingUtils from utils.mailer import BaseEmail from utils.stripe_utils import StripeUtils @@ -66,9 +70,12 @@ from .models import ( logger = logging.getLogger(__name__) + CONNECTION_ERROR = "Your VMs cannot be displayed at the moment due to a \ backend connection error. please try again in a few \ minutes." + + decorators = [never_cache] @@ -460,7 +467,9 @@ class SSHKeyDeleteView(LoginRequiredMixin, DeleteView): pk = self.kwargs.get('pk') # Get user ssh key public_key = UserHostingKey.objects.get(pk=pk).public_key - manager.manage_public_key([{'value': public_key, 'state': False}]) + keys = UserHostingKey.objects.filter(user=self.request.user) + keys_to_save = [k.public_key for k in keys if k.public_key != public_key] + manager.save_key_in_opennebula_user('\n'.join(keys_to_save), update_type=0) return super(SSHKeyDeleteView, self).delete(request, *args, **kwargs) @@ -509,74 +518,11 @@ class SSHKeyChoiceView(LoginRequiredMixin, View): email=owner.email, password=owner.password ) - public_key_str = public_key.decode() - manager.manage_public_key([{'value': public_key_str, 'state': True}]) + keys = get_all_public_keys(request.user) + manager.save_key_in_opennebula_user('\n'.join(keys)) return redirect(reverse_lazy('hosting:ssh_keys'), foo='bar') -@method_decorator(decorators, name='dispatch') -class SSHKeyCreateView(LoginRequiredMixin, FormView): - form_class = UserHostingKeyForm - model = UserHostingKey - template_name = 'hosting/user_key.html' - login_url = reverse_lazy('hosting:login') - context_object_name = "virtual_machine" - success_url = reverse_lazy('hosting:ssh_keys') - - def get_form_kwargs(self): - kwargs = super(SSHKeyCreateView, self).get_form_kwargs() - kwargs.update({'request': self.request}) - return kwargs - - def form_valid(self, form): - form.save() - if settings.DCL_SSH_KEY_NAME_PREFIX in form.instance.name: - content = ContentFile(form.cleaned_data.get('private_key')) - filename = form.cleaned_data.get( - 'name') + '_' + str(uuid.uuid4())[:8] + '_private.pem' - form.instance.private_key.save(filename, content) - context = self.get_context_data() - - next_url = self.request.session.get( - 'next', - reverse('hosting:create_virtual_machine') - ) - - if 'next' in self.request.session: - context.update({ - 'next_url': next_url - }) - del (self.request.session['next']) - - if form.cleaned_data.get('private_key'): - context.update({ - 'private_key': form.cleaned_data.get('private_key'), - 'key_name': form.cleaned_data.get('name'), - 'form': UserHostingKeyForm(request=self.request), - }) - - owner = self.request.user - manager = OpenNebulaManager( - email=owner.email, - password=owner.password - ) - public_key = form.cleaned_data['public_key'] - if type(public_key) is bytes: - public_key = public_key.decode() - manager.manage_public_key([{'value': public_key, 'state': True}]) - return HttpResponseRedirect(self.success_url) - - def post(self, request, *args, **kwargs): - form = self.get_form() - required = 'add_ssh' in self.request.POST - form.fields['name'].required = required - form.fields['public_key'].required = required - if form.is_valid(): - return self.form_valid(form) - else: - return self.form_invalid(form) - - @method_decorator(decorators, name='dispatch') class SettingsView(LoginRequiredMixin, FormView): template_name = "hosting/settings.html" @@ -823,21 +769,27 @@ class PaymentVMView(LoginRequiredMixin, FormView): reverse('hosting:payment') + '#payment_error') request.session['token'] = token request.session['billing_address_data'] = billing_address_data - return HttpResponseRedirect("{url}?{query_params}".format( - url=reverse('hosting:order-confirmation'), - query_params='page=payment') - ) + self.request.session['order_confirm_url'] = "{url}?{query_params}".format( + url=reverse('hosting:order-confirmation'), + query_params='page=payment') + return HttpResponseRedirect(reverse('hosting:add_ssh_key')) else: return self.form_invalid(form) -class OrdersHostingDetailView(LoginRequiredMixin, DetailView): +class OrdersHostingDetailView(LoginRequiredMixin, DetailView, FormView): + form_class = UserHostingKeyForm template_name = "hosting/order_detail.html" context_object_name = "order" login_url = reverse_lazy('hosting:login') permission_required = ['view_hostingorder'] model = HostingOrder + def get_form_kwargs(self): + kwargs = super(OrdersHostingDetailView, self).get_form_kwargs() + kwargs.update({'request': self.request}) + return kwargs + def get_object(self, queryset=None): order_id = self.kwargs.get('pk') try: @@ -862,6 +814,8 @@ class OrdersHostingDetailView(LoginRequiredMixin, DetailView): if self.request.GET.get('page') == 'payment': context['page_header_text'] = _('Confirm Order') + context['form'] = UserHostingKeyForm(request=self.request) + context['keys'] = get_all_public_keys(self.request.user) else: context['page_header_text'] = _('Invoice') if not self.request.user.has_perm( @@ -952,11 +906,15 @@ class OrdersHostingDetailView(LoginRequiredMixin, DetailView): card_details_response = card_details['response_object'] context['cc_last4'] = card_details_response['last4'] context['cc_brand'] = card_details_response['brand'] + context['cc_exp_year'] = card_details_response['exp_year'] + context['cc_exp_month'] = card_details_response['exp_month'] else: card_id = self.request.session.get('card_id') card_detail = UserCardDetail.objects.get(id=card_id) context['cc_last4'] = card_detail.last4 context['cc_brand'] = card_detail.brand + context['cc_exp_year'] = card_detail.exp_year + context['cc_exp_month'] = '{:02d}'.format(card_detail.exp_month) context['site_url'] = reverse('hosting:create_virtual_machine') context['vm'] = self.request.session.get('specs') return context @@ -1310,6 +1268,10 @@ class InvoiceDetailView(LoginRequiredMixin, DetailView): context['vm']['total_price'] = ( price + vat - discount['amount'] ) + except TypeError: + logger.error("Type error. Probably we " + "came from a generic product. " + "Invoice ID %s" % obj.invoice_id) except WrongIdError: logger.error("WrongIdError while accessing " "invoice {}".format(obj.invoice_id)) @@ -1567,7 +1529,8 @@ class VirtualMachineView(LoginRequiredMixin, View): 'virtual_machine': serializer.data, 'order': HostingOrder.objects.get( vm_id=serializer.data['vm_id'] - ) + ), + 'keys': UserHostingKey.objects.filter(user=request.user) } except Exception as ex: logger.debug("Exception generated {}".format(str(ex))) @@ -1599,6 +1562,7 @@ class VirtualMachineView(LoginRequiredMixin, View): # Cancel Stripe subscription stripe_utils = StripeUtils() + hosting_order = None try: hosting_order = HostingOrder.objects.get( vm_id=vm.id @@ -1633,7 +1597,7 @@ class VirtualMachineView(LoginRequiredMixin, View): "manager.delete_vm returned False. Hence, error making " "xml-rpc call to delete vm failed." ) - response['text'] = ugettext('Error terminating VM') + vm.id + response['text'] = str(_('Error terminating VM')) + str(vm.id) else: for t in range(15): try: @@ -1660,9 +1624,10 @@ class VirtualMachineView(LoginRequiredMixin, View): else: sleep(2) if not response['status']: - response['text'] = _("VM terminate action timed out. Please " - "contact support@datacenterlight.ch for " - "further information.") + response['text'] = str(_("VM terminate action timed out. " + "Please contact " + "support@datacenterlight.ch for " + "further information.")) context = { 'vm_name': vm_name, 'base_url': "{0}://{1}".format( @@ -1683,6 +1648,11 @@ class VirtualMachineView(LoginRequiredMixin, View): email = BaseEmail(**email_data) email.send() admin_email_body.update(response) + admin_email_body["customer_email"] = owner.email + admin_email_body["VM_ID"] = vm.id + admin_email_body["VM_created_at"] = (str(hosting_order.created_at) if + hosting_order is not None + else "unknown") admin_msg_sub = "VM and Subscription for VM {} and user: {}".format( vm.id, owner.email @@ -1755,3 +1725,40 @@ def forbidden_view(request, exception=None, reason=''): 'again.') messages.add_message(request, messages.ERROR, err_msg) return HttpResponseRedirect(request.get_full_path()) + + +class CheckUserVM(APIView): + renderer_classes = (JSONRenderer, ) + + def get(self, request): + try: + email = request.data['email'] + ip = request.data['ip'] + user = request.data['user'] + realm = request.data['realm'] + token = request.data['token'] + if realm != settings.READ_VM_REALM: + return Response("User not allowed", 403) + response = check_otp(user, realm, token) + if response != 200: + return Response('Invalid token', 403) + manager = OpenNebulaManager(settings.OPENNEBULA_USERNAME, + settings.OPENNEBULA_PASSWORD) + # not the best way to lookup vms by ip + # TODO: make this optimal + vms = manager.get_vms() + users_vms = [vm for vm in vms if vm.uname == email] + if len(users_vms) == 0: + return Response('No VM found with the given email address', + 404) + for vm in users_vms: + for nic in vm.template.nics: + if hasattr(nic, 'ip6_global'): + if nic.ip6_global == ip: + return Response('success', 200) + elif hasattr(nic, 'ip'): + if nic.ip == ip: + return Response('success', 200) + return Response('No VM found matching the ip address provided', 404) + except KeyError: + return Response('Not enough data provided', 400) diff --git a/membership/migrations/0008_change_user_id_to_customer_id_in_djangocms_blog.py b/membership/migrations/0008_change_user_id_to_customer_id_in_djangocms_blog.py new file mode 100644 index 00000000..4a8530e9 --- /dev/null +++ b/membership/migrations/0008_change_user_id_to_customer_id_in_djangocms_blog.py @@ -0,0 +1,13 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [('membership', '0007_auto_20180213_0128'), + ('djangocms_blog', '0032_auto_20180109_0023'), + ] + + operations = [ + migrations.RunSQL( + "ALTER TABLE djangocms_blog_authorentriesplugin_authors " + "RENAME COLUMN user_id TO customuser_id;"), + ] diff --git a/membership/migrations/0009_deleteduser.py b/membership/migrations/0009_deleteduser.py new file mode 100644 index 00000000..146d3847 --- /dev/null +++ b/membership/migrations/0009_deleteduser.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2019-05-06 06:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('membership', '0008_change_user_id_to_customer_id_in_djangocms_blog'), + ] + + operations = [ + migrations.CreateModel( + name='DeletedUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.PositiveIntegerField()), + ('name', models.CharField(max_length=254)), + ('email', models.EmailField(max_length=254, unique=True)), + ('deleted_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/membership/models.py b/membership/models.py index c5e83735..1a622bd5 100644 --- a/membership/models.py +++ b/membership/models.py @@ -265,6 +265,15 @@ class CreditCards(models.Model): pass +class DeletedUser(models.Model): + user_id = models.PositiveIntegerField() + + # why 254 ? => to be consistent with legacy code + name = models.CharField(max_length=254) + email = models.EmailField(unique=True, max_length=254) + deleted_at = models.DateTimeField(auto_now_add=True) + + class Calendar(models.Model): datebooked = models.DateField() user = models.ForeignKey(CustomUser) diff --git a/opennebula_api/models.py b/opennebula_api/models.py index 73dc4405..f8ef6481 100644 --- a/opennebula_api/models.py +++ b/opennebula_api/models.py @@ -168,31 +168,47 @@ class OpenNebulaManager(): raise return user_pool - def _get_vm_pool(self, vm_id=None, infoextended=True): + def _get_vm_pool(self, infoextended=True): """ - vm_id: int - the id of the VM that needs to looked up in the vm pool; - when set to None, looks for everything in the infoextended + # filter: + # -4: Resources belonging to the user’s primary group + # -3: Resources belonging to the user + # -2: All resources + # -1: Resources belonging to the user and any of his groups + # >= 0: UID User’s Resources + + # vm states: + # *-2 Any state, including DONE + # *-1 Any state, except DONE (Default) + # *0 INIT + # *1 PENDING + # *2 HOLD + # *3 ACTIVE + # *4 STOPPED + # *5 SUSPENDED + # *6 DONE + # *7 FAILED + # *8 POWEROFF + # *9 UNDEPLOYED + + :param infoextended: When True calls infoextended api method introduced + in OpenNebula 5.8 else falls back to info which has limited attributes + of a VM + + :return: the oca VirtualMachinePool object """ try: vm_pool = oca.VirtualMachinePool(self.client) if infoextended: vm_pool.infoextended( - filter_key_value_str='ID={vm_id}'.format(vm_id=vm_id) if - vm_id is not None else '', - vm_state=-1 # Look for VMs in any state, except DONE + filter=-1, # User's resources and any of his groups + vm_state=-1 # Look for VMs in any state, except DONE ) else: vm_pool.info() return vm_pool - except AttributeError: - logger.error('Could not connect via client, using oneadmin instead') - try: - vm_pool = oca.VirtualMachinePool(self.oneadmin_client) - vm_pool.info(filter=-2) - return vm_pool - except: - raise ConnectionRefusedError - + except AttributeError as ae: + logger.error("AttributeError : %s" % str(ae)) except ConnectionRefusedError: logger.error( 'Could not connect to host: {host} via protocol {protocol}'.format( @@ -213,7 +229,7 @@ class OpenNebulaManager(): def get_vm(self, vm_id): vm_id = int(vm_id) try: - vm_pool = self._get_vm_pool(vm_id) + vm_pool = self._get_vm_pool() return vm_pool.get_by_id(vm_id) except WrongIdError: raise WrongIdError @@ -347,6 +363,31 @@ class OpenNebulaManager(): return vm_terminated + def save_key_in_opennebula_user(self, ssh_key, update_type=1): + """ + Save the given ssh key in OpenNebula user + + # Update type: 0: Replace the whole template. + 1: Merge new template with the existing one. + :param ssh_key: The ssh key to be saved + :param update_type: The update type as explained above + + :return: + """ + return_value = self.oneadmin_client.call( + 'user.update', + self.opennebula_user if type(self.opennebula_user) == int else self.opennebula_user.id, + '%s' % ssh_key, + update_type + ) + if type(return_value) == int: + logger.debug( + "Saved the key in opennebula successfully : %s" % return_value) + else: + logger.error( + "Could not save the key in opennebula. %s" % return_value) + return + def _get_template_pool(self): try: template_pool = oca.VmTemplatePool(self.oneadmin_client) diff --git a/requirements.txt b/requirements.txt index fe70299b..c60c83e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -96,5 +96,6 @@ pyflakes==1.5.0 billiard==3.5.0.3 amqp==2.2.1 vine==1.1.4 -cdist==4.7.0 +cdist==5.0.1 git+https://github.com/ungleich/djangocms-multisite.git#egg=djangocms_multisite +pyotp diff --git a/utils/hosting_utils.py b/utils/hosting_utils.py index ec97a320..9c0243e4 100644 --- a/utils/hosting_utils.py +++ b/utils/hosting_utils.py @@ -5,7 +5,7 @@ import subprocess from oca.pool import WrongIdError from datacenterlight.models import VMPricing -from hosting.models import UserHostingKey, VMDetail +from hosting.models import UserHostingKey, VMDetail, VATRates from opennebula_api.serializers import VirtualMachineSerializer logger = logging.getLogger(__name__) @@ -18,7 +18,7 @@ def get_all_public_keys(customer): :return: A list of public keys """ return UserHostingKey.objects.filter(user_id=customer.id).values_list( - "public_key", flat=True) + "public_key", flat=True).distinct() def get_or_create_vm_detail(user, manager, vm_id): @@ -150,6 +150,20 @@ def ping_ok(host_ipv6): return True +def get_vat_rate_for_country(country): + vat_rate = None + try: + vat_rate = VATRates.objects.get( + territory_codes=country, start_date__isnull=False, stop_date=None + ) + logger.debug("VAT rate for %s is %s" % (country, vat_rate.rate)) + return vat_rate.rate + except VATRates.DoesNotExist as dne: + logger.debug(str(dne)) + logger.debug("Did not find VAT rate for %s, returning 0" % country) + return 0 + + class HostingUtils: @staticmethod def clear_items_from_list(from_list, items_list): diff --git a/utils/stripe_utils.py b/utils/stripe_utils.py index 4334d6cf..e2bdb983 100644 --- a/utils/stripe_utils.py +++ b/utils/stripe_utils.py @@ -226,7 +226,8 @@ class StripeUtils(object): return charge @handleStripeError - def get_or_create_stripe_plan(self, amount, name, stripe_plan_id): + def get_or_create_stripe_plan(self, amount, name, stripe_plan_id, + interval=""): """ This function checks if a StripePlan with the given stripe_plan_id already exists. If it exists then the function @@ -238,6 +239,10 @@ class StripeUtils(object): :param stripe_plan_id: The id of the Stripe plan to be created. Use get_stripe_plan_id_string function to obtain the name of the plan to be created + :param interval: str representing the interval of the Plan + Specifies billing frequency. Either day, week, month or year. + Ref: https://stripe.com/docs/api/plans/create#create_plan-interval + The default is month :return: The StripePlan object if it exists else creates a Plan object in Stripe and a local StripePlan and returns it. Returns None in case of Stripe error @@ -245,6 +250,7 @@ class StripeUtils(object): _amount = float(amount) amount = int(_amount * 100) # stripe amount unit, in cents stripe_plan_db_obj = None + plan_interval = interval if interval is not "" else self.INTERVAL try: stripe_plan_db_obj = StripePlan.objects.get( stripe_plan_id=stripe_plan_id) @@ -252,7 +258,7 @@ class StripeUtils(object): try: self.stripe.Plan.create( amount=amount, - interval=self.INTERVAL, + interval=plan_interval, name=name, currency=self.CURRENCY, id=stripe_plan_id) diff --git a/utils/views.py b/utils/views.py index 394a9fc2..05d0fdc2 100644 --- a/utils/views.py +++ b/utils/views.py @@ -1,16 +1,25 @@ +import uuid + from django.conf import settings from django.contrib import messages from django.contrib.auth import authenticate, login from django.contrib.auth.tokens import default_token_generator +from django.core.files.base import ContentFile from django.core.urlresolvers import reverse_lazy from django.http import HttpResponseRedirect +from django.shortcuts import render from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode from django.utils.translation import ugettext_lazy as _ -from django.views.generic import FormView, CreateView from django.views.decorators.cache import cache_control +from django.views.generic import FormView, CreateView +from datacenterlight.utils import get_cms_integration +from hosting.forms import UserHostingKeyForm +from hosting.models import UserHostingKey from membership.models import CustomUser +from opennebula_api.models import OpenNebulaManager +from utils.hosting_utils import get_all_public_keys from .forms import SetPasswordForm from .mailer import BaseEmail @@ -174,3 +183,87 @@ class PasswordResetConfirmViewMixin(FormView): form.add_error(None, _('The reset password link is no longer valid.')) return self.form_invalid(form) + + +class SSHKeyCreateView(FormView): + form_class = UserHostingKeyForm + model = UserHostingKey + template_name = 'hosting/user_key.html' + login_url = reverse_lazy('hosting:login') + context_object_name = "virtual_machine" + success_url = reverse_lazy('hosting:ssh_keys') + + def get_form_kwargs(self): + kwargs = super(SSHKeyCreateView, self).get_form_kwargs() + kwargs.update({'request': self.request}) + return kwargs + + def form_valid(self, form): + form.save() + if settings.DCL_SSH_KEY_NAME_PREFIX in form.instance.name: + content = ContentFile(form.cleaned_data.get('private_key')) + filename = form.cleaned_data.get( + 'name') + '_' + str(uuid.uuid4())[:8] + '_private.pem' + form.instance.private_key.save(filename, content) + context = self.get_context_data() + + next_url = self.request.session.get( + 'next', + reverse_lazy('hosting:create_virtual_machine') + ) + + if 'next' in self.request.session: + context.update({ + 'next_url': next_url + }) + del (self.request.session['next']) + + if form.cleaned_data.get('private_key'): + context.update({ + 'private_key': form.cleaned_data.get('private_key'), + 'key_name': form.cleaned_data.get('name'), + 'form': UserHostingKeyForm(request=self.request), + }) + + if self.request.user.is_authenticated(): + owner = self.request.user + manager = OpenNebulaManager( + email=owner.email, + password=owner.password + ) + keys_to_save = get_all_public_keys(self.request.user) + manager.save_key_in_opennebula_user('\n'.join(keys_to_save)) + else: + self.request.session["new_user_hosting_key_id"] = form.instance.id + return HttpResponseRedirect(self.success_url) + + def post(self, request, *args, **kwargs): + form = self.get_form() + required = 'add_ssh' in self.request.POST + form.fields['name'].required = required + form.fields['public_key'].required = required + if form.is_valid(): + return self.form_valid(form) + else: + return self.form_invalid(form) + + +class AskSSHKeyView(SSHKeyCreateView): + form_class = UserHostingKeyForm + template_name = "datacenterlight/add_ssh_key.html" + success_url = reverse_lazy('datacenterlight:order_confirmation') + context_object_name = "dcl_vm_buy_add_ssh_key" + + @cache_control(no_cache=True, must_revalidate=True, no_store=True) + def get(self, request, *args, **kwargs): + context = { + 'site_url': reverse_lazy('datacenterlight:index'), + 'cms_integration': get_cms_integration('default'), + 'form': UserHostingKeyForm(request=self.request), + 'keys': get_all_public_keys(self.request.user) + } + return render(request, self.template_name, context) + + def post(self, request, *args, **kwargs): + self.success_url = self.request.session.get("order_confirm_url") + return super(AskSSHKeyView, self).post(self, request, *args, **kwargs) \ No newline at end of file diff --git a/vat_rates.csv b/vat_rates.csv new file mode 100644 index 00000000..4a3ec440 --- /dev/null +++ b/vat_rates.csv @@ -0,0 +1,318 @@ +start_date,stop_date,territory_codes,currency_code,rate,rate_type,description +2011-01-04,,AI,XCD,0,standard,Anguilla (British overseas territory) is exempted of VAT. +1984-01-01,,AT,EUR,0.2,standard,Austria (member state) standard VAT rate. +1976-01-01,1984-01-01,AT,EUR,0.18,standard, +1973-01-01,1976-01-01,AT,EUR,0.16,standard, +1984-01-01,,"AT-6691 +DE-87491",EUR,0.19,standard,Jungholz (Austrian town) special VAT rate. +1984-01-01,,"AT-6991 +AT-6992 +AT-6993 +DE-87567 +DE-87568 +DE-87569",EUR,0.19,standard,Mittelberg (Austrian town) special VAT rate. +1996-01-01,,BE,EUR,0.21,standard,Belgium (member state) standard VAT rate. +1994-01-01,1996-01-01,BE,EUR,0.205,standard, +1992-04-01,1994-01-01,BE,EUR,0.195,standard, +1983-01-01,1992-04-01,BE,EUR,0.19,standard, +1981-07-01,1983-01-01,BE,EUR,0.17,standard, +1978-07-01,1981-07-01,BE,EUR,0.16,standard, +1971-07-01,1978-07-01,BE,EUR,0.18,standard, +1999-01-01,,BG,BGN,0.2,standard,Bulgaria (member state) standard VAT rate. +1996-07-01,1999-01-01,BG,BGN,0.22,standard, +1994-04-01,1996-07-01,BG,BGN,0.18,standard, +2011-01-04,,BM,BMD,0,standard,Bermuda (British overseas territory) is exempted of VAT. +2014-01-13,,"CY +GB-BFPO 57 +GB-BFPO 58 +GB-BFPO 59 +UK-BFPO 57 +UK-BFPO 58 +UK-BFPO 59",EUR,0.19,standard,"Cyprus (member state) standard VAT rate. +Akrotiri and Dhekelia (British overseas territory) is subjected to Cyprus' standard VAT rate." +2013-01-14,2014-01-13,CY,EUR,0.18,standard, +2012-03-01,2013-01-14,CY,EUR,0.17,standard, +2003-01-01,2012-03-01,CY,EUR,0.15,standard, +2002-07-01,2003-01-01,CY,EUR,0.13,standard, +2000-07-01,2002-07-01,CY,EUR,0.1,standard, +1993-10-01,2000-07-01,CY,EUR,0.08,standard, +1992-07-01,1993-10-01,CY,EUR,0.05,standard, +2013-01-01,,CZ,CZK,0.21,standard,Czech Republic (member state) standard VAT rate. +2010-01-01,2013-01-01,CZ,CZK,0.2,standard, +2004-05-01,2010-01-01,CZ,CZK,0.19,standard, +1995-01-01,2004-05-01,CZ,CZK,0.22,standard, +1993-01-01,1995-01-01,CZ,CZK,0.23,standard, +2007-01-01,,DE,EUR,0.19,standard,Germany (member state) standard VAT rate. +1998-04-01,2007-01-01,DE,EUR,0.16,standard, +1993-01-01,1998-04-01,DE,EUR,0.15,standard, +1983-07-01,1993-01-01,DE,EUR,0.14,standard, +1979-07-01,1983-07-01,DE,EUR,0.13,standard, +1978-01-01,1979-07-01,DE,EUR,0.12,standard, +1968-07-01,1978-01-01,DE,EUR,0.11,standard, +1968-01-01,1968-07-01,DE,EUR,0.1,standard, +2007-01-01,,DE-27498,EUR,0,standard,Heligoland (German island) is exempted of VAT. +2007-01-01,,"DE-78266 +CH-8238",EUR,0,standard,Busingen am Hochrhein (German territory) is exempted of VAT. +1992-01-01,,DK,DKK,0.25,standard,Denmark (member state) standard VAT rate. +1980-06-30,1992-01-01,DK,DKK,0.22,standard, +1978-10-30,1980-06-30,DK,DKK,0.2025,standard, +1977-10-03,1978-10-30,DK,DKK,0.18,standard, +1970-06-29,1977-10-03,DK,DKK,0.15,standard, +1968-04-01,1970-06-29,DK,DKK,0.125,standard, +1967-07-03,1968-04-01,DK,DKK,0.1,standard, +2009-07-01,,EE,EUR,0.2,standard,Estonia (member state) standard VAT rate. +1993-01-01,2009-07-01,EE,EUR,0.18,standard, +1991-01-01,1993-01-01,EE,EUR,0.1,standard, +2016-06-01,,"GR +EL",EUR,0.24,standard,Greece (member state) standard VAT rate. +2010-07-01,2016-06-01,"GR +EL",EUR,0.23,standard, +2010-03-15,2010-07-01,"GR +EL",EUR,0.21,standard, +2005-04-01,2010-03-15,"GR +EL",EUR,0.19,standard, +1990-04-28,2005-04-01,"GR +EL",EUR,0.18,standard, +1988-01-01,1990-04-28,"GR +EL",EUR,0.16,standard, +1987-01-01,1988-01-01,"GR +EL",EUR,0.18,standard, +2012-09-01,,ES,EUR,0.21,standard,Spain (member state) standard VAT rate. +2010-07-01,2012-09-01,ES,EUR,0.18,standard, +1995-01-01,2010-07-01,ES,EUR,0.16,standard, +1992-08-01,1995-01-01,ES,EUR,0.15,standard, +1992-01-01,1992-08-01,ES,EUR,0.13,standard, +1986-01-01,1992-01-01,ES,EUR,0.12,standard, +2012-09-01,,"ES-CN +ES-GC +ES-TF +IC",EUR,0,standard,Canary Islands (Spanish autonomous community) is exempted of VAT. +2012-09-01,,"ES-ML +ES-CE +EA",EUR,0,standard,Ceuta and Melilla (Spanish autonomous cities) is exempted of VAT. +2013-01-01,,FI,EUR,0.24,standard,Finland (member state) standard VAT rate. +2010-07-01,2013-01-01,FI,EUR,0.23,standard, +1994-06-01,2010-07-01,FI,EUR,0.22,standard, +2013-01-01,,"FI-01 +AX",EUR,0,standard,Aland Islands (Finish autonomous region) is exempted of VAT. +2011-01-04,,FK,FKP,0,standard,Falkland Islands (British overseas territory) is exempted of VAT. +1992-01-01,,FO,DKK,0,standard,Faroe Islands (Danish autonomous country) is exempted of VAT. +2014-01-01,,"FR +MC",EUR,0.2,standard,"France (member state) standard VAT rate. +Monaco (sovereign city-state) is member of the EU VAT area and subjected to France's standard VAT rate." +2000-04-01,2014-01-01,"FR +MC",EUR,0.196,standard, +1995-08-01,2000-04-01,"FR +MC",EUR,0.206,standard, +1982-07-01,1995-08-01,"FR +MC",EUR,0.186,standard, +1977-01-01,1982-07-01,"FR +MC",EUR,0.176,standard, +1973-01-01,1977-01-01,"FR +MC",EUR,0.2,standard, +1970-01-01,1973-01-01,"FR +MC",EUR,0.23,standard, +1968-12-01,1970-01-01,"FR +MC",EUR,0.19,standard, +1968-01-01,1968-12-01,"FR +MC",EUR,0.1666,standard, +2014-01-01,,"FR-BL +BL",EUR,0,standard,Saint Barthelemy (French overseas collectivity) is exempted of VAT. +2014-01-01,,"FR-GF +GF",EUR,0,standard,Guiana (French overseas department) is exempted of VAT. +2014-01-01,,"FR-GP +GP",EUR,0.085,standard,Guadeloupe (French overseas department) special VAT rate. +2014-01-01,,"FR-MF +MF",EUR,0,standard,Saint Martin (French overseas collectivity) is subjected to France's standard VAT rate. +2014-01-01,,"FR-MQ +MQ",EUR,0.085,standard,Martinique (French overseas department) special VAT rate. +2014-01-01,,"FR-NC +NC",XPF,0,standard,New Caledonia (French special collectivity) is exempted of VAT. +2014-01-01,,"FR-PF +PF",XPF,0,standard,French Polynesia (French overseas collectivity) is exempted of VAT. +2014-01-01,,"FR-PM +PM",EUR,0,standard,Saint Pierre and Miquelon (French overseas collectivity) is exempted of VAT. +2014-01-01,,"FR-RE +RE",EUR,0.085,standard,Reunion (French overseas department) special VAT rate. +2014-01-01,,"FR-TF +TF",EUR,0,standard,French Southern and Antarctic Lands (French overseas territory) is exempted of VAT. +2014-01-01,,"FR-WF +WF",XPF,0,standard,Wallis and Futuna (French overseas collectivity) is exempted of VAT. +2014-01-01,,"FR-YT +YT",EUR,0,standard,Mayotte (French overseas department) is exempted of VAT. +2011-01-04,,GG,GBP,0,standard,Guernsey (British Crown dependency) is exempted of VAT. +2011-01-04,,GI,GIP,0,standard,Gibraltar (British overseas territory) is exempted of VAT. +1992-01-01,,GL,DKK,0,standard,Greenland (Danish autonomous country) is exempted of VAT. +2010-07-01,2016-06-01,"GR-34007 +EL-34007",EUR,0.16,standard,Skyros (Greek island) special VAT rate. +2010-07-01,2016-06-01,"GR-37002 +GR-37003 +GR-37005 +EL-37002 +EL-37003 +EL-37005",EUR,0.16,standard,Northern Sporades (Greek islands) special VAT rate. +2010-07-01,2016-06-01,"GR-64004 +EL-64004",EUR,0.16,standard,Thasos (Greek island) special VAT rate. +2010-07-01,2016-06-01,"GR-68002 +EL-68002",EUR,0.16,standard,Samothrace (Greek island) special VAT rate. +2010-07-01,,"GR-69 +EL-69",EUR,0,standard,Mount Athos (Greek self-governed part) is exempted of VAT. +2010-07-01,2016-06-01,"GR-81 +EL-81",EUR,0.16,standard,Dodecanese (Greek department) special VAT rate. +2010-07-01,2016-06-01,"GR-82 +EL-82",EUR,0.16,standard,Cyclades (Greek department) special VAT rate. +2010-07-01,2016-06-01,"GR-83 +EL-83",EUR,0.16,standard,Lesbos (Greek department) special VAT rate. +2010-07-01,2016-06-01,"GR-84 +EL-84",EUR,0.16,standard,Samos (Greek department) special VAT rate. +2010-07-01,2016-06-01,"GR-85 +EL-85",EUR,0.16,standard,Chios (Greek department) special VAT rate. +2011-01-04,,GS,GBP,0,standard,South Georgia and the South Sandwich Islands (British overseas territory) is exempted of VAT. +2012-03-01,,HR,HRK,0.25,standard,Croatia (member state) standard VAT rate. +2009-08-01,2012-03-01,HR,HRK,0.23,standard, +1998-08-01,2009-08-01,HR,HRK,0.22,standard, +2012-01-01,,HU,HUF,0.27,standard,Hungary (member state) standard VAT rate. +2009-07-01,2012-01-01,HU,HUF,0.25,standard, +2006-01-01,2009-07-01,HU,HUF,0.2,standard, +1988-01-01,2006-01-01,HU,HUF,0.25,standard, +2012-01-01,,IE,EUR,0.23,standard,Republic of Ireland (member state) standard VAT rate. +2010-01-01,2012-01-01,IE,EUR,0.21,standard, +2008-12-01,2010-01-01,IE,EUR,0.215,standard, +2002-03-01,2008-12-01,IE,EUR,0.21,standard, +2001-01-01,2002-03-01,IE,EUR,0.2,standard, +1991-03-01,2001-01-01,IE,EUR,0.21,standard, +1990-03-01,1991-03-01,IE,EUR,0.23,standard, +1986-03-01,1990-03-01,IE,EUR,0.25,standard, +1983-05-01,1986-03-01,IE,EUR,0.23,standard, +1983-03-01,1983-05-01,IE,EUR,0.35,standard, +1982-05-01,1983-03-01,IE,EUR,0.3,standard, +1980-05-01,1982-05-01,IE,EUR,0.25,standard, +1976-03-01,1980-05-01,IE,EUR,0.2,standard, +1973-09-03,1976-03-01,IE,EUR,0.195,standard, +1972-11-01,1973-09-03,IE,EUR,0.1637,standard, +2011-01-04,,IO,GBP,0,standard,British Indian Ocean Territory (British overseas territory) is exempted of VAT. +2013-10-01,,IT,EUR,0.22,standard,Italy (member state) standard VAT rate. +2011-09-17,2013-10-01,IT,EUR,0.21,standard, +1997-10-01,2011-09-17,IT,EUR,0.2,standard, +1988-08-01,1997-10-01,IT,EUR,0.19,standard, +1982-08-05,1988-08-01,IT,EUR,0.18,standard, +1981-01-01,1982-08-05,IT,EUR,0.15,standard, +1980-11-01,1981-01-01,IT,EUR,0.14,standard, +1980-07-03,1980-11-01,IT,EUR,0.15,standard, +1977-02-08,1980-07-03,IT,EUR,0.14,standard, +1973-01-01,1977-02-08,IT,EUR,0.12,standard, +2013-10-01,,"IT-22060 +CH-6911",CHF,0,standard,Campione (Italian town) is exempted of VAT. +2013-10-01,,IT-23030,EUR,0,standard,Livigno (Italian town) is exempted of VAT. +2011-01-04,,JE,GBP,0,standard,Jersey (British Crown dependency) is exempted of VAT. +2011-01-04,,KY,KYD,0,standard,Cayman Islands (British overseas territory) is exempted of VAT. +2009-09-01,,LT,EUR,0.21,standard,Lithuania (member state) standard VAT rate. +2009-01-01,2009-09-01,LT,EUR,0.19,standard, +1994-05-01,2009-01-01,LT,EUR,0.18,standard, +2015-01-01,,LU,EUR,0.17,standard,Luxembourg (member state) standard VAT rate. +1992-01-01,2015-01-01,LU,EUR,0.15,standard, +1983-07-01,1992-01-01,LU,EUR,0.12,standard, +1971-01-01,1983-07-01,LU,EUR,0.1,standard, +1970-01-01,1971-01-01,LU,EUR,0.8,standard, +2012-07-01,,LV,EUR,0.21,standard,Latvia (member state) standard VAT rate. +2011-01-01,2012-07-01,LV,EUR,0.22,standard, +2009-01-01,2011-01-01,LV,EUR,0.21,standard, +1995-05-01,2009-01-01,LV,EUR,0.18,standard, +2011-01-04,,MS,XCD,0,standard,Montserrat (British overseas territory) is exempted of VAT. +2004-01-01,,MT,EUR,0.18,standard,Malta (member state) standard VAT rate. +1995-01-01,2004-01-01,MT,EUR,0.15,standard, +2012-10-01,,NL,EUR,0.21,standard,Netherlands (member state) standard VAT rate. +2001-01-01,2012-10-01,NL,EUR,0.19,standard, +1992-10-01,2001-01-01,NL,EUR,0.175,standard, +1989-01-01,1992-10-01,NL,EUR,0.185,standard, +1986-10-01,1989-01-01,NL,EUR,0.2,standard, +1984-01-01,1986-10-01,NL,EUR,0.19,standard, +1976-01-01,1984-01-01,NL,EUR,0.18,standard, +1973-01-01,1976-01-01,NL,EUR,0.16,standard, +1971-01-01,1973-01-01,NL,EUR,0.14,standard, +1969-01-01,1971-01-01,NL,EUR,0.12,standard, +2012-10-01,,"NL-AW +AW",AWG,0,standard,Aruba (Dutch country) are exempted of VAT. +2012-10-01,,"NL-CW +NL-SX +CW +SX",ANG,0,standard,Curacao and Sint Maarten (Dutch countries) are exempted of VAT. +2012-10-01,,"NL-BQ1 +NL-BQ2 +NL-BQ3 +BQ +BQ-BO +BQ-SA +BQ-SE",USD,0,standard,"Bonaire, Saba and Sint Eustatius (Dutch special municipalities) are exempted of VAT." +2011-01-01,,PL,PLN,0.23,standard,Poland (member state) standard VAT rate. +1993-01-08,2011-01-01,PL,PLN,0.22,standard, +2011-01-04,,PN,NZD,0,standard,Pitcairn Islands (British overseas territory) is exempted of VAT. +2011-01-01,,PT,EUR,0.23,standard,Portugal (member state) standard VAT rate. +2010-07-01,2011-01-01,PT,EUR,0.21,standard, +2008-07-01,2010-07-01,PT,EUR,0.2,standard, +2005-07-01,2008-07-01,PT,EUR,0.21,standard, +2002-06-05,2005-07-01,PT,EUR,0.19,standard, +1995-01-01,2002-06-05,PT,EUR,0.17,standard, +1992-03-24,1995-01-01,PT,EUR,0.16,standard, +1988-02-01,1992-03-24,PT,EUR,0.17,standard, +1986-01-01,1988-02-01,PT,EUR,0.16,standard, +2011-01-01,,PT-20,EUR,0.18,standard,Azores (Portuguese autonomous region) special VAT rate. +2011-01-01,,PT-30,EUR,0.22,standard,Madeira (Portuguese autonomous region) special VAT rate. +2017-01-01,,RO,RON,0.19,standard,Romania (member state) standard VAT rate. +2016-01-01,2017-01-01,RO,RON,0.2,standard,Romania (member state) standard VAT rate. +2010-07-01,2016-01-01,RO,RON,0.24,standard, +2000-01-01,2010-07-01,RO,RON,0.19,standard, +1998-02-01,2000-01-01,RO,RON,0.22,standard, +1993-07-01,1998-02-01,RO,RON,0.18,standard, +1990-07-01,,SE,SEK,0.25,standard,Sweden (member state) standard VAT rate. +1983-01-01,1990-07-01,SE,SEK,0.2346,standard, +1981-11-16,1983-01-01,SE,SEK,0.2151,standard, +1980-09-08,1981-11-16,SE,SEK,0.2346,standard, +1977-06-01,1980-09-08,SE,SEK,0.2063,standard, +1971-01-01,1977-06-01,SE,SEK,0.1765,standard, +1969-01-01,1971-01-01,SE,SEK,0.1111,standard, +2011-01-04,,"AC +SH +SH-AC +SH-HL",SHP,0,standard,Ascension and Saint Helena (British overseas territory) is exempted of VAT. +2011-01-04,,"TA +SH-TA",GBP,0,standard,Tristan da Cunha (British oversea territory) is exempted of VAT. +2013-07-01,,SI,EUR,0.22,standard,Slovenia (member state) standard VAT rate. +2002-01-01,2013-07-01,SI,EUR,0.2,standard, +1999-07-01,2002-01-01,SI,EUR,0.19,standard, +2011-01-01,,SK,EUR,0.2,standard,Slovakia (member state) standard VAT rate. +2004-01-01,2011-01-01,SK,EUR,0.19,standard, +2003-01-01,2004-01-01,SK,EUR,0.2,standard, +1996-01-01,2003-01-01,SK,EUR,0.23,standard, +1993-08-01,1996-01-01,SK,EUR,0.25,standard, +1993-01-01,1993-08-01,SK,EUR,0.23,standard, +2011-01-04,,TC,USD,0,standard,Turks and Caicos Islands (British overseas territory) is exempted of VAT. +2011-01-04,,"GB +UK +IM",GBP,0.2,standard,"United Kingdom (member state) standard VAT rate. +Isle of Man (British self-governing dependency) is member of the EU VAT area and subjected to UK's standard VAT rate." +2010-01-01,2011-01-04,"GB +UK +IM",GBP,0.175,standard, +2008-12-01,2010-01-01,"GB +UK +IM",GBP,0.15,standard, +1991-04-01,2008-12-01,"GB +UK +IM",GBP,0.175,standard, +1979-06-18,1991-04-01,"GB +UK +IM",GBP,0.15,standard, +1974-07-29,1979-06-18,"GB +UK +IM",GBP,0.08,standard, +1973-04-01,1974-07-29,"GB +UK +IM",GBP,0.1,standard, +2011-01-04,,VG,USD,0,standard,British Virgin Islands (British overseas territory) is exempted of VAT. +2014-01-01,,CP,EUR,0,standard,Clipperton Island (French overseas possession) is exempted of VAT. +2019-11-15,,CH,CHF,0.077,standard,Switzerland standard VAT (added manually) +2019-11-15,,MC,EUR,0.196,standard,Monaco standard VAT (added manually) +2019-11-15,,FR,EUR,0.2,standard,France standard VAT (added manually) +2019-11-15,,GR,EUR,0.24,standard,Greece standard VAT (added manually) +2019-11-15,,GB,EUR,0.2,standard,UK standard VAT (added manually)