diff --git a/alplora/static/alplora/js/form.js b/alplora/static/alplora/js/form.js new file mode 100644 index 00000000..0e91b172 --- /dev/null +++ b/alplora/static/alplora/js/form.js @@ -0,0 +1,12 @@ +/*! + * jQuery Form Plugin + * version: 4.2.1 + * Requires jQuery v1.7 or later + * Copyright 2017 Kevin Morris + * Copyright 2006 M. Alsup + * Project repository: https://github.com/jquery-form/form + * Dual licensed under the MIT and LGPLv3 licenses. + * https://github.com/jquery-form/form#license + */ +!function(a){"function"==typeof define&&define.amd?define(["jquery"],a):"object"==typeof module&&module.exports?module.exports=function(b,c){return void 0===c&&(c="undefined"!=typeof window?require("jquery"):require("jquery")(b)),a(c),c}:a(jQuery)}(function(a){"use strict";function b(b){var c=b.data;b.isDefaultPrevented()||(b.preventDefault(),a(b.target).closest("form").ajaxSubmit(c))}function c(b){var c=b.target,d=a(c);if(!d.is("[type=submit],[type=image]")){var e=d.closest("[type=submit]");if(0===e.length)return;c=e[0]}var f=c.form;if(f.clk=c,"image"===c.type)if(void 0!==b.offsetX)f.clk_x=b.offsetX,f.clk_y=b.offsetY;else if("function"==typeof a.fn.offset){var g=d.offset();f.clk_x=b.pageX-g.left,f.clk_y=b.pageY-g.top}else f.clk_x=b.pageX-c.offsetLeft,f.clk_y=b.pageY-c.offsetTop;setTimeout(function(){f.clk=f.clk_x=f.clk_y=null},100)}function d(){if(a.fn.ajaxSubmit.debug){var b="[jquery.form] "+Array.prototype.join.call(arguments,"");window.console&&window.console.log?window.console.log(b):window.opera&&window.opera.postError&&window.opera.postError(b)}}var e={};e.fileapi=void 0!==a('').get(0).files,e.formdata=void 0!==window.FormData;var f=!!a.fn.prop;a.fn.attr2=function(){if(!f)return this.attr.apply(this,arguments);var a=this.prop.apply(this,arguments);return a&&a.jquery||"string"==typeof a?a:this.attr.apply(this,arguments)},a.fn.ajaxSubmit=function(b,c,g,h){function i(c){var d,e,f=a.param(c,b.traditional).split("&"),g=f.length,h=[];for(d=0;d',z).val(k.extraData[j].value).appendTo(x)[0]):i.push(a('',z).val(k.extraData[j]).appendTo(x)[0]));k.iframeTarget||p.appendTo(A),q.attachEvent?q.attachEvent("onload",h):q.addEventListener("load",h,!1),setTimeout(b,15);try{x.submit()}catch(a){var m=document.createElement("form").submit;m.apply(x)}}finally{x.setAttribute("action",f),x.setAttribute("enctype",g),c?x.setAttribute("target",c):o.removeAttr("target"),a(i).remove()}}function h(b){if(!r.aborted&&!F){if(E=e(q),E||(d("cannot access response document"),b=2),1===b&&r)return r.abort("timeout"),void y.reject(r,"timeout");if(2===b&&r)return r.abort("server abort"),void y.reject(r,"error","server abort");if(E&&E.location.href!==k.iframeSrc||v){q.detachEvent?q.detachEvent("onload",h):q.removeEventListener("load",h,!1);var c,f="success";try{if(v)throw"timeout";var g="xml"===k.dataType||E.XMLDocument||a.isXMLDoc(E);if(d("isXml="+g),!g&&window.opera&&(null===E.body||!E.body.innerHTML)&&--G)return d("requeing onLoad callback, DOM not available"),void setTimeout(h,250);var i=E.body?E.body:E.documentElement;r.responseText=i?i.innerHTML:null,r.responseXML=E.XMLDocument?E.XMLDocument:E,g&&(k.dataType="xml"),r.getResponseHeader=function(a){return{"content-type":k.dataType}[a.toLowerCase()]},i&&(r.status=Number(i.getAttribute("status"))||r.status,r.statusText=i.getAttribute("statusText")||r.statusText);var j=(k.dataType||"").toLowerCase(),l=/(json|script|text)/.test(j);if(l||k.textarea){var n=E.getElementsByTagName("textarea")[0];if(n)r.responseText=n.value,r.status=Number(n.getAttribute("status"))||r.status,r.statusText=n.getAttribute("statusText")||r.statusText;else if(l){var o=E.getElementsByTagName("pre")[0],s=E.getElementsByTagName("body")[0];o?r.responseText=o.textContent?o.textContent:o.innerText:s&&(r.responseText=s.textContent?s.textContent:s.innerText)}}else"xml"===j&&!r.responseXML&&r.responseText&&(r.responseXML=H(r.responseText));try{D=J(r,j,k)}catch(a){f="parsererror",r.error=c=a||f}}catch(a){d("error caught: ",a),f="error",r.error=c=a||f}r.aborted&&(d("upload aborted"),f=null),r.status&&(f=r.status>=200&&r.status<300||304===r.status?"success":"error"),"success"===f?(k.success&&k.success.call(k.context,D,"success",r),y.resolve(r.responseText,"success",r),m&&a.event.trigger("ajaxSuccess",[r,k])):f&&(void 0===c&&(c=r.statusText),k.error&&k.error.call(k.context,r,f,c),y.reject(r,"error",c),m&&a.event.trigger("ajaxError",[r,k,c])),m&&a.event.trigger("ajaxComplete",[r,k]),m&&!--a.active&&a.event.trigger("ajaxStop"),k.complete&&k.complete.call(k.context,r,f),F=!0,k.timeout&&clearTimeout(w),setTimeout(function(){k.iframeTarget?p.attr("src",k.iframeSrc):p.remove(),r.responseXML=null},100)}}}var i,j,k,m,n,p,q,r,t,u,v,w,x=o[0],y=a.Deferred();if(y.abort=function(a){r.abort(a)},c)for(j=0;j',z),p.css({position:"absolute",top:"-1000px",left:"-1000px"})),q=p[0],r={aborted:0,responseText:null,responseXML:null,status:0,statusText:"n/a",getAllResponseHeaders:function(){},getResponseHeader:function(){},setRequestHeader:function(){},abort:function(b){var c="timeout"===b?"timeout":"aborted";d("aborting upload... "+c),this.aborted=1;try{q.contentWindow.document.execCommand&&q.contentWindow.document.execCommand("Stop")}catch(a){}p.attr("src",k.iframeSrc),r.error=c,k.error&&k.error.call(k.context,r,c,b),m&&a.event.trigger("ajaxError",[r,k,c]),k.complete&&k.complete.call(k.context,r,c)}},m=k.global,m&&0==a.active++&&a.event.trigger("ajaxStart"),m&&a.event.trigger("ajaxSend",[r,k]),k.beforeSend&&k.beforeSend.call(k.context,r,k)===!1)return k.global&&a.active--,y.reject(),y;if(r.aborted)return y.reject(),y;(t=x.clk)&&(u=t.name)&&!t.disabled&&(k.extraData=k.extraData||{},k.extraData[u]=t.value,"image"===t.type&&(k.extraData[u+".x"]=x.clk_x,k.extraData[u+".y"]=x.clk_y));var B=a("meta[name=csrf-token]").attr("content"),C=a("meta[name=csrf-param]").attr("content");C&&B&&(k.extraData=k.extraData||{},k.extraData[C]=B),k.forceSync?g():setTimeout(g,10);var D,E,F,G=50,H=a.parseXML||function(a,b){return window.ActiveXObject?(b=new ActiveXObject("Microsoft.XMLDOM"),b.async="false",b.loadXML(a)):b=(new DOMParser).parseFromString(a,"text/xml"),b&&b.documentElement&&"parsererror"!==b.documentElement.nodeName?b:null},I=a.parseJSON||function(a){return window.eval("("+a+")")},J=function(b,c,d){var e=b.getResponseHeader("content-type")||"",f=("xml"===c||!c)&&e.indexOf("xml")>=0,g=f?b.responseXML:b.responseText;return f&&"parsererror"===g.documentElement.nodeName&&a.error&&a.error("parsererror"),d&&d.dataFilter&&(g=d.dataFilter(g,c)),"string"==typeof g&&(("json"===c||!c)&&e.indexOf("json")>=0?g=I(g):("script"===c||!c)&&e.indexOf("javascript")>=0&&a.globalEval(g)),g};return y}if(!this.length)return d("ajaxSubmit: skipping submit process - no element selected"),this;var l,m,n,o=this;"function"==typeof b?b={success:b}:"string"==typeof b||b===!1&&arguments.length>0?(b={url:b,data:c,dataType:g},"function"==typeof h&&(b.success=h)):void 0===b&&(b={}),l=b.method||b.type||this.attr2("method"),m=b.url||this.attr2("action"),n="string"==typeof m?a.trim(m):"",n=n||window.location.href||"",n&&(n=(n.match(/^([^#]+)/)||[])[1]),b=a.extend(!0,{url:n,success:a.ajaxSettings.success,type:l||a.ajaxSettings.type,iframeSrc:/^https/i.test(window.location.href||"")?"javascript:false":"about:blank"},b);var p={};if(this.trigger("form-pre-serialize",[this,b,p]),p.veto)return d("ajaxSubmit: submit vetoed via form-pre-serialize trigger"),this;if(b.beforeSerialize&&b.beforeSerialize(this,b)===!1)return d("ajaxSubmit: submit aborted via beforeSerialize callback"),this;var q=b.traditional;void 0===q&&(q=a.ajaxSettings.traditional);var r,s=[],t=this.formToArray(b.semantic,s,b.filtering);if(b.data){var u=a.isFunction(b.data)?b.data(t):b.data;b.extraData=u,r=a.param(u,q)}if(b.beforeSubmit&&b.beforeSubmit(t,this,b)===!1)return d("ajaxSubmit: submit aborted via beforeSubmit callback"),this;if(this.trigger("form-submit-validate",[t,this,b,p]),p.veto)return d("ajaxSubmit: submit vetoed via form-submit-validate trigger"),this;var v=a.param(t,q);r&&(v=v?v+"&"+r:r),"GET"===b.type.toUpperCase()?(b.url+=(b.url.indexOf("?")>=0?"&":"?")+v,b.data=null):b.data=v;var w=[];if(b.resetForm&&w.push(function(){o.resetForm()}),b.clearForm&&w.push(function(){o.clearForm(b.includeHidden)}),!b.dataType&&b.target){var x=b.success||function(){};w.push(function(c,d,e){var f=arguments,g=b.replaceTarget?"replaceWith":"html";a(b.target)[g](c).each(function(){x.apply(this,f)})})}else b.success&&(a.isArray(b.success)?a.merge(w,b.success):w.push(b.success));if(b.success=function(a,c,d){for(var e=b.context||this,f=0,g=w.length;f0,C="multipart/form-data",D=o.attr("enctype")===C||o.attr("encoding")===C,E=e.fileapi&&e.formdata;d("fileAPI :"+E);var F,G=(B||D)&&!E;b.iframe!==!1&&(b.iframe||G)?b.closeKeepAlive?a.get(b.closeKeepAlive,function(){F=k(t)}):F=k(t):F=(B||D)&&E?j(t):a.ajax(b),o.removeData("jqxhr").data("jqxhr",F);for(var H=0;H0)&&(e={url:e,data:f,dataType:g},"function"==typeof h&&(e.success=h)),e=e||{},e.delegation=e.delegation&&a.isFunction(a.fn.on),!e.delegation&&0===this.length){var i={s:this.selector,c:this.context};return!a.isReady&&i.s?(d("DOM not ready, queuing ajaxForm"),a(function(){a(i.s,i.c).ajaxForm(e)}),this):(d("terminating; zero elements found by selector"+(a.isReady?"":" (DOM not ready)")),this)}return e.delegation?(a(document).off("submit.form-plugin",this.selector,b).off("click.form-plugin",this.selector,c).on("submit.form-plugin",this.selector,e,b).on("click.form-plugin",this.selector,e,c),this):this.ajaxFormUnbind().on("submit.form-plugin",e,b).on("click.form-plugin",e,c)},a.fn.ajaxFormUnbind=function(){return this.off("submit.form-plugin click.form-plugin")},a.fn.formToArray=function(b,c,d){var f=[];if(0===this.length)return f;var g,h=this[0],i=this.attr("id"),j=b||void 0===h.elements?h.getElementsByTagName("*"):h.elements;if(j&&(j=a.makeArray(j)),i&&(b||/(Edge|Trident)\//.test(navigator.userAgent))&&(g=a(':input[form="'+i+'"]').get(),g.length&&(j=(j||[]).concat(g))),!j||!j.length)return f;a.isFunction(d)&&(j=a.map(j,d));var k,l,m,n,o,p,q;for(k=0,p=j.length;k + + + diff --git a/alplora/templates/alplora/contact_success.html b/alplora/templates/alplora/contact_success.html new file mode 100644 index 00000000..563b2e89 --- /dev/null +++ b/alplora/templates/alplora/contact_success.html @@ -0,0 +1,21 @@ +{% load i18n %} + + diff --git a/alplora/templates/alplora/index.html b/alplora/templates/alplora/index.html index 495961a4..ae388459 100644 --- a/alplora/templates/alplora/index.html +++ b/alplora/templates/alplora/index.html @@ -53,7 +53,6 @@ -
@@ -419,7 +418,9 @@

{% trans 'How do I get Alplora?'%}

{% trans 'Click the button below and leave us your contact.'%}

{% trans 'Team Alplora will contact you and visit you with a tracking device.'%}


- {% trans 'Contact'%} + {% trans 'Contact'%}
@@ -429,60 +430,9 @@
- @@ -560,6 +510,7 @@ + @@ -572,14 +523,15 @@ - diff --git a/alplora/urls.py b/alplora/urls.py index a84884c4..f792f13f 100644 --- a/alplora/urls.py +++ b/alplora/urls.py @@ -1,11 +1,12 @@ from django.conf.urls import url -from .views import IndexView, LoginView +from .views import IndexView, LoginView, ContactView urlpatterns = [ url(r'^/?$', IndexView.as_view(), name='index'), url(r'/login/', LoginView.as_view(), name='login'), + url(r'/contact', ContactView.as_view(), name='contact'), # url(r'^/beta-program/?$', BetaProgramView.as_view(), name='beta'), # url(r'^/landing/?$', LandingProgramView.as_view(), name='landing'), ] diff --git a/alplora/views.py b/alplora/views.py index 5c6bacd2..ba249887 100644 --- a/alplora/views.py +++ b/alplora/views.py @@ -5,13 +5,12 @@ from django.utils.translation import ugettext_lazy as _ from django.views.generic.edit import FormView from django.contrib import messages from django.core.urlresolvers import reverse_lazy, reverse +from django.shortcuts import render from utils.forms import ContactUsForm -class IndexView(FormView): +class IndexView(TemplateView): template_name = "alplora/index.html" - form_class = ContactUsForm - success_message = _('Message Successfully Sent') def get_context_data(self, *args, **kwargs): context = super(IndexView, self).get_context_data(**kwargs) @@ -19,17 +18,22 @@ class IndexView(FormView): context.update(languages) return context - def get_success_url(self): - success_url = reverse('alplora:index') - success_url += "#requestformsuccess" - return success_url +class ContactView(FormView): + template_name = 'alplora/contact.html' + form_class = ContactUsForm + success_message = _('Message Successfully Sent') + + def get_context_data(self, *args, **kwargs): + context = super(ContactView, self).get_context_data(**kwargs) + languages = getlanguages() + context.update(languages) + return context def form_valid(self, form): form.save() form.send_email(email_to='info@alplora.ch') messages.add_message(self.request, messages.SUCCESS, self.success_message) - return super(IndexView, self).form_valid(form) - + return render(self.request, 'alplora/contact_success.html', {}) class LoginView(TemplateView): template_name = "alplora/login.html" diff --git a/cms_templates/djangocms_blog/_header_post_detail.html b/cms_templates/djangocms_blog/_header_post_detail.html index 669de5bc..2fa8697e 100644 --- a/cms_templates/djangocms_blog/_header_post_detail.html +++ b/cms_templates/djangocms_blog/_header_post_detail.html @@ -17,21 +17,10 @@ {% render_model post "abstract" "" "" 'truncatewords_html:10' %} - Posted - {% if post.author %} - by - - {% if post.author.get_full_name %} - {{ post.author.get_full_name }} - {% else %} - {{ post.author }} - {% endif %} - - {% endif %} - on {{ post.date_published|date:"DATE_FORMAT" }} + Posted on {{ post.date_published|date:"DATE_FORMAT" }} - \ No newline at end of file + diff --git a/digitalglarus/templates/digitalglarus/post_detail.html b/digitalglarus/templates/digitalglarus/post_detail.html index f59cec48..90ba0384 100644 --- a/digitalglarus/templates/digitalglarus/post_detail.html +++ b/digitalglarus/templates/digitalglarus/post_detail.html @@ -16,18 +16,7 @@

diff --git a/hosting/forms.py b/hosting/forms.py index 2a4d67e3..7323bdf3 100644 --- a/hosting/forms.py +++ b/hosting/forms.py @@ -1,10 +1,13 @@ +import random +import string from django import forms from membership.models import CustomUser from django.contrib.auth import authenticate + from utils.stripe_utils import StripeUtils -from .models import HostingOrder, VirtualMachinePlan +from .models import HostingOrder, VirtualMachinePlan, UserHostingKey class HostingOrderAdminForm(forms.ModelForm): @@ -83,3 +86,40 @@ class HostingUserSignupForm(forms.ModelForm): if not confirm_password == password: raise forms.ValidationError("Passwords don't match") return confirm_password + + +class UserHostingKeyForm(forms.ModelForm): + private_key = forms.CharField(widget=forms.PasswordInput(), required=False) + public_key = forms.CharField(widget=forms.PasswordInput(), required=False) + user = forms.models.ModelChoiceField(queryset=CustomUser.objects.all(), required=False) + name = forms.CharField(required=False) + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request") + super(UserHostingKeyForm, self).__init__(*args, **kwargs) + # self.initial['user'].initial = self.request.user.id + # print(self.fields) + + def clean_name(self): + return ''.join(random.choice(string.ascii_lowercase) for i in range(7)) + + def clean_user(self): + return self.request.user + + def clean(self): + cleaned_data = self.cleaned_data + + print(cleaned_data) + + if not cleaned_data.get('public_key'): + private_key, public_key = UserHostingKey.generate_keys() + cleaned_data.update({ + 'private_key': private_key, + 'public_key': public_key + }) + + return cleaned_data + + class Meta: + model = UserHostingKey + fields = ['user', 'public_key', 'name'] diff --git a/hosting/management/commands/create_vm_types.py b/hosting/management/commands/create_vm_types.py index 6be49f19..f92cc3a1 100644 --- a/hosting/management/commands/create_vm_types.py +++ b/hosting/management/commands/create_vm_types.py @@ -7,51 +7,100 @@ class Command(BaseCommand): def get_data(self): + return [ + { + 'base_price': 10, + 'core_price': 5, + 'memory_price': 2, + 'disk_size_price': 0.6, + 'cores': 1, + 'memory': 2, + 'disk_size': 10 + }, + { + 'base_price': 10, + 'core_price': 5, + 'memory_price': 2, + 'disk_size_price': 0.6, + 'cores': 1, + 'memory': 2, + 'disk_size': 100 + }, + { + 'base_price': 10, + 'core_price': 5, + 'memory_price': 2, + 'disk_size_price': 0.6, + 'cores': 2, + 'memory': 4, + 'disk_size': 20 + }, + { + 'base_price': 10, + 'core_price': 5, + 'memory_price': 2, + 'disk_size_price': 0.6, + 'cores': 4, + 'memory': 8, + 'disk_size': 40 + }, + { + 'base_price': 10, + 'core_price': 5, + 'memory_price': 2, + 'disk_size_price': 0.6, + 'cores': 16, + 'memory': 8, + 'disk_size': 40 + }, + ] + + hetzner = { 'base_price': 10, - 'core_price': 10, - 'memory_price': 5, - 'disk_size_price': 1, + 'core_price': 5, + 'memory_price': 2, + 'disk_size_price': 0.6, 'description': 'VM auf einzelner HW, Raid1, kein HA', 'location': 'DE' } - return { - # 'hetzner_nug': { - # 'base_price': 5, - # 'memory_price': 2, - # 'core_price': 2, - # 'disk_size_price': 0.5, - # 'description': 'VM ohne Uptime Garantie' - # }, - 'hetzner': hetzner, - # 'hetzner_raid6': { - # 'base_price': hetzner['base_price']*1.2, - # 'core_price': hetzner['core_price']*1.2, - # 'memory_price': hetzner['memory_price']*1.2, - # 'disk_size_price': hetzner['disk_size_price']*1.2, - # 'description': 'VM auf einzelner HW, Raid1, kein HA' + # return { + # # 'hetzner_nug': { + # # 'base_price': 5, + # # 'memory_price': 2, + # # 'core_price': 2, + # # 'disk_size_price': 0.5, + # # 'description': 'VM ohne Uptime Garantie' + # # }, + # 'hetzner': hetzner, + # # 'hetzner_raid6': { + # # 'base_price': hetzner['base_price']*1.2, + # # 'core_price': hetzner['core_price']*1.2, + # # 'memory_price': hetzner['memory_price']*1.2, + # # 'disk_size_price': hetzner['disk_size_price']*1.2, + # # 'description': 'VM auf einzelner HW, Raid1, kein HA' - # }, - # 'hetzner_glusterfs': { - # 'base_price': hetzner['base_price']*1.4, - # 'core_price': hetzner['core_price']*1.4, - # 'memory_price': hetzner['memory_price']*1.4, - # 'disk_size_price': hetzner['disk_size_price']*1.4, - # 'description': 'VM auf einzelner HW, Raid1, kein HA' - # }, - 'bern': { - 'base_price': 12, - 'core_price': 25, - 'memory_price': 7, - 'disk_size_price': 0.70, - 'description': "VM in Bern, HA Setup ohne HA Garantie", - 'location': 'CH', - } - } + # # }, + # # 'hetzner_glusterfs': { + # # 'base_price': hetzner['base_price']*1.4, + # # 'core_price': hetzner['core_price']*1.4, + # # 'memory_price': hetzner['memory_price']*1.4, + # # 'disk_size_price': hetzner['disk_size_price']*1.4, + # # 'description': 'VM auf einzelner HW, Raid1, kein HA' + # # }, + # 'bern': { + # 'base_price': 12, + # 'core_price': 25, + # 'memory_price': 7, + # 'disk_size_price': 0.70, + # 'description': "VM in Bern, HA Setup ohne HA Garantie", + # 'location': 'CH', + # } + # } def handle(self, *args, **options): - data = self.get_data() - [VirtualMachineType.objects.create(hosting_company=key, **data[key]) - for key in data.keys()] + vm_data = self.get_data() + for vm in vm_data: + VirtualMachineType.objects.create(**vm) diff --git a/hosting/migrations/0028_managevm_userhostingkey.py b/hosting/migrations/0028_managevm_userhostingkey.py new file mode 100644 index 00000000..75bf591a --- /dev/null +++ b/hosting/migrations/0028_managevm_userhostingkey.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2017-04-29 18:28 +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 = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('hosting', '0027_auto_20160711_0210'), + ] + + operations = [ + migrations.CreateModel( + name='ManageVM', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'managed': False, + }, + ), + migrations.CreateModel( + name='UserHostingKey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('public_key', models.TextField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/hosting/migrations/0029_userhostingkey_created_at.py b/hosting/migrations/0029_userhostingkey_created_at.py new file mode 100644 index 00000000..6ab968fd --- /dev/null +++ b/hosting/migrations/0029_userhostingkey_created_at.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2017-04-30 19:04 +from __future__ import unicode_literals + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0028_managevm_userhostingkey'), + ] + + operations = [ + migrations.AddField( + model_name='userhostingkey', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2017, 4, 30, 19, 4, 20, 780173, tzinfo=utc)), + preserve_default=False, + ), + ] diff --git a/hosting/migrations/0030_userhostingkey_name.py b/hosting/migrations/0030_userhostingkey_name.py new file mode 100644 index 00000000..7405d66f --- /dev/null +++ b/hosting/migrations/0030_userhostingkey_name.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2017-04-30 19:09 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0029_userhostingkey_created_at'), + ] + + operations = [ + migrations.AddField( + model_name='userhostingkey', + name='name', + field=models.CharField(default='', max_length=100), + preserve_default=False, + ), + ] diff --git a/hosting/migrations/0031_auto_20170503_0554.py b/hosting/migrations/0031_auto_20170503_0554.py new file mode 100644 index 00000000..acf9ae62 --- /dev/null +++ b/hosting/migrations/0031_auto_20170503_0554.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2017-05-03 05:54 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0030_userhostingkey_name'), + ] + + operations = [ + migrations.RemoveField( + model_name='virtualmachinetype', + name='hosting_company', + ), + migrations.RemoveField( + model_name='virtualmachinetype', + name='location', + ), + ] diff --git a/hosting/migrations/0032_auto_20170504_0315.py b/hosting/migrations/0032_auto_20170504_0315.py new file mode 100644 index 00000000..c16b382a --- /dev/null +++ b/hosting/migrations/0032_auto_20170504_0315.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2017-05-04 03:15 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0031_auto_20170503_0554'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachinetype', + name='cores', + field=models.IntegerField(default=0), + preserve_default=False, + ), + migrations.AddField( + model_name='virtualmachinetype', + name='disk_size', + field=models.IntegerField(default=0), + preserve_default=False, + ), + migrations.AddField( + model_name='virtualmachinetype', + name='memory', + field=models.IntegerField(default=0), + preserve_default=False, + ), + ] diff --git a/hosting/migrations/0033_virtualmachinetype_configuration.py b/hosting/migrations/0033_virtualmachinetype_configuration.py new file mode 100644 index 00000000..ecd6d5f4 --- /dev/null +++ b/hosting/migrations/0033_virtualmachinetype_configuration.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2017-05-04 03:23 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0032_auto_20170504_0315'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachinetype', + name='configuration', + field=models.CharField(choices=[('debian', 'Debian 8'), ('ubuntu', 'Ubuntu 16.06'), ('devuan', 'Devuan 1'), ('centos', 'CentOS 7')], default='ubuntu', max_length=10), + ), + ] diff --git a/hosting/migrations/0034_auto_20170504_0331.py b/hosting/migrations/0034_auto_20170504_0331.py new file mode 100644 index 00000000..0dd66012 --- /dev/null +++ b/hosting/migrations/0034_auto_20170504_0331.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2017-05-04 03:31 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0033_virtualmachinetype_configuration'), + ] + + operations = [ + migrations.RemoveField( + model_name='virtualmachinetype', + name='configuration', + ), + migrations.AlterField( + model_name='virtualmachineplan', + name='configuration', + field=models.CharField(choices=[('debian', 'Debian 8'), ('ubuntu', 'Ubuntu 16.06'), ('devuan', 'Devuan 1'), ('centos', 'CentOS 7')], max_length=20), + ), + migrations.AlterField( + model_name='virtualmachineplan', + name='vm_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='hosting.VirtualMachineType'), + ), + ] diff --git a/hosting/migrations/0035_virtualmachineplan_opennebula_id.py b/hosting/migrations/0035_virtualmachineplan_opennebula_id.py new file mode 100644 index 00000000..9b23875d --- /dev/null +++ b/hosting/migrations/0035_virtualmachineplan_opennebula_id.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2017-05-06 23:02 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0034_auto_20170504_0331'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachineplan', + name='opennebula_id', + field=models.IntegerField(default=0), + preserve_default=False, + ), + ] diff --git a/hosting/migrations/0036_auto_20170506_2312.py b/hosting/migrations/0036_auto_20170506_2312.py new file mode 100644 index 00000000..14449527 --- /dev/null +++ b/hosting/migrations/0036_auto_20170506_2312.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.4 on 2017-05-06 23:12 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosting', '0035_virtualmachineplan_opennebula_id'), + ] + + operations = [ + migrations.AlterField( + model_name='virtualmachineplan', + name='opennebula_id', + field=models.IntegerField(null=True), + ), + ] diff --git a/hosting/mixins.py b/hosting/mixins.py index 2f8de3a5..404c4cb9 100644 --- a/hosting/mixins.py +++ b/hosting/mixins.py @@ -1,23 +1,32 @@ from django.shortcuts import redirect from django.core.urlresolvers import reverse -from .models import VirtualMachinePlan +from .models import VirtualMachinePlan, VirtualMachineType class ProcessVMSelectionMixin(object): def post(self, request, *args, **kwargs): - hosting = request.POST.get('configuration') - configuration_detail = dict(VirtualMachinePlan.VM_CONFIGURATION).get(hosting) - vm_specs = { - 'cores': request.POST.get('cores'), - 'memory': request.POST.get('memory'), - 'disk_size': request.POST.get('disk_space'), - 'hosting_company': request.POST.get('hosting_company'), - 'location_code': request.POST.get('location_code'), - 'configuration': hosting, - 'configuration_detail': configuration_detail, - 'final_price': request.POST.get('final_price') - } + configuration = request.POST.get('configuration') + configuration_display = dict(VirtualMachinePlan.VM_CONFIGURATION).get(configuration) + vm_template = request.POST.get('vm_template') + vm_type = VirtualMachineType.objects.get(id=vm_template) + vm_specs = vm_type.get_specs() + vm_specs.update({ + 'configuration_display': configuration_display, + 'configuration': configuration, + 'final_price': vm_type.final_price, + 'vm_template': vm_template + }) + # vm_specs = { + # # 'cores': request.POST.get('cores'), + # # 'memory': request.POST.get('memory'), + # # 'disk_size': request.POST.get('disk_space'), + # # 'hosting_company': request.POST.get('hosting_company'), + # # 'location_code': request.POST.get('location_code'), + # # 'configuration': hosting, + # # 'configuration_detail': configuration_detail, + # 'final_price': request.POST.get('final_price') + # } request.session['vm_specs'] = vm_specs if not request.user.is_authenticated(): request.session['vm_specs'] = vm_specs diff --git a/hosting/models.py b/hosting/models.py index b24dc855..8a9daaa1 100644 --- a/hosting/models.py +++ b/hosting/models.py @@ -1,13 +1,16 @@ import os +import oca from django.db import models from django.utils.translation import ugettext_lazy as _ from django.utils.functional import cached_property +from django.conf import settings + from Crypto.PublicKey import RSA from stored_messages.settings import stored_messages_settings -from membership.models import StripeCustomer +from membership.models import StripeCustomer, CustomUser from utils.models import BillingAddress from utils.mixins import AssignPermissionsMixin from .managers import VMPlansManager @@ -15,70 +18,71 @@ from .managers import VMPlansManager class VirtualMachineType(models.Model): - HETZNER_NUG = 'hetzner_nug' - HETZNER = 'hetzner' - HETZNER_R6 = 'hetzner_raid6' - HETZNER_G = 'hetzner_glusterfs' - BERN = 'bern' - DE_LOCATION = 'DE' - CH_LOCATION = 'CH' - - HOSTING_TYPES = ( - (HETZNER_NUG, 'Hetzner No Uptime Guarantee'), - (HETZNER, 'Hetzner'), - (HETZNER_R6, 'Hetzner Raid6'), - (HETZNER_G, 'Hetzner Glusterfs'), - (BERN, 'Bern'), - ) - - LOCATIONS_CHOICES = ( - (DE_LOCATION, 'Germany'), - (CH_LOCATION, 'Switzerland'), - ) - description = models.TextField() base_price = models.FloatField() memory_price = models.FloatField() core_price = models.FloatField() disk_size_price = models.FloatField() - hosting_company = models.CharField(max_length=30, choices=HOSTING_TYPES) - location = models.CharField(max_length=3, choices=LOCATIONS_CHOICES) + cores = models.IntegerField() + memory = models.IntegerField() + disk_size = models.IntegerField() def __str__(self): - return "%s" % (self.get_hosting_company_display()) + return "VM Type %s" % (self.id) + + @cached_property + def final_price(self): + price = self.cores * self.core_price + price += self.memory * self.memory_price + price += self.disk_size * self.disk_size_price + return price @classmethod def get_serialized_vm_types(cls): return [vm.get_serialized_data() for vm in cls.objects.all()] - def calculate_price(self, specifications): - price = float(specifications['cores']) * self.core_price - price += float(specifications['memory']) * self.memory_price - price += float(specifications['disk_size']) * self.disk_size_price - price += self.base_price + def calculate_price(self): + price = self.cores * self.core_price + price += self.memory * self.memory_price + price += self.disk_size * self.disk_size_price + # price += self.base_price return price - def defeault_price(self): - price = self.base_price - price += self.core_price - price += self.memory_price - price += self.disk_size_price * 10 - return price + # @classmethod + # def get_price(cls, vm_template): + # return cls.BASE_PRICE * vm_template + + def get_specs(self): + return { + 'memory': self.memory, + 'cores': self.cores, + 'disk_size': self.disk_size + } + + # def calculate_price(self, vm_template): + # price = self.base_price * vm_template + # return price + + # def defeault_price(self): + # price = self.base_price + # price += self.core_price + # price += self.memory_price + # price += self.disk_size_price * 10 + # return price def get_serialized_data(self): return { 'description': self.description, - 'base_price': self.base_price, 'core_price': self.core_price, 'disk_size_price': self.disk_size_price, 'memory_price': self.memory_price, - 'hosting_company_name': self.get_hosting_company_display(), - 'hosting_company': self.hosting_company, - 'default_price': self.defeault_price(), - 'location_code': self.location, - 'location': self.get_location_display(), 'id': self.id, + 'final_price': self.final_price, + 'cores': self.cores, + 'memory': self.memory, + 'disk_size': self.disk_size + } @@ -94,14 +98,21 @@ class VirtualMachinePlan(AssignPermissionsMixin, models.Model): (CANCELED_STATUS, 'Canceled') ) - DJANGO = 'django' - RAILS = 'rails' - NODEJS = 'nodejs' + # DJANGO = 'django' + # RAILS = 'rails' + # NODEJS = 'nodejs' + + # VM_CONFIGURATION = ( + # (DJANGO, 'Ubuntu 14.04, Django'), + # (RAILS, 'Ubuntu 14.04, Rails'), + # (NODEJS, 'Debian, NodeJS'), + # ) VM_CONFIGURATION = ( - (DJANGO, 'Ubuntu 14.04, Django'), - (RAILS, 'Ubuntu 14.04, Rails'), - (NODEJS, 'Debian, NodeJS'), + ('debian', 'Debian 8'), + ('ubuntu', 'Ubuntu 16.06'), + ('devuan', 'Devuan 1'), + ('centos', 'CentOS 7') ) permissions = ('view_virtualmachineplan', @@ -111,12 +122,13 @@ class VirtualMachinePlan(AssignPermissionsMixin, models.Model): cores = models.IntegerField() memory = models.IntegerField() disk_size = models.IntegerField() - vm_type = models.ForeignKey(VirtualMachineType) + vm_type = models.ForeignKey(VirtualMachineType, null=True) price = models.FloatField() public_key = models.TextField(blank=True) status = models.CharField(max_length=20, choices=VM_STATUS_CHOICES, default=PENDING_STATUS) ip = models.CharField(max_length=50, blank=True) configuration = models.CharField(max_length=20, choices=VM_CONFIGURATION) + opennebula_id = models.IntegerField(null=True) objects = VMPlansManager() @@ -129,13 +141,13 @@ class VirtualMachinePlan(AssignPermissionsMixin, models.Model): def __str__(self): return self.name - @cached_property - def hosting_company_name(self): - return self.vm_type.get_hosting_company_display() + # @cached_property + # def hosting_company_name(self): + # return self.vm_type.get_hosting_company_display() - @cached_property - def location(self): - return self.vm_type.get_location_display() + # @cached_property + # def location(self): + # return self.vm_type.get_location_display() @cached_property def name(self): @@ -155,28 +167,65 @@ class VirtualMachinePlan(AssignPermissionsMixin, models.Model): instance.assign_permissions(user) return instance - @staticmethod - def generate_RSA(bits=2048): - ''' - Generate an RSA keypair with an exponent of 65537 in PEM format - param: bits The key length in bits - Return private key and public key - ''' - new_key = RSA.generate(2048, os.urandom) - public_key = new_key.publickey().exportKey("OpenSSH") - private_key = new_key.exportKey("PEM") - return private_key, public_key - - def generate_keys(self): - private_key, public_key = self.generate_RSA() - self.public_key = public_key - self.save(update_fields=['public_key']) - return private_key, public_key - def cancel_plan(self): self.status = self.CANCELED_STATUS self.save(update_fields=['status']) + @classmethod + def get_vms(self, email): + # Get User + user_email = email + + # Connect to open nebula server + # TODO: handle potential connection error + client = oca.Client("{0}:{1}".format( + settings.OPENNEBULA_USERNAME, + settings.OPENNEBULA_PASSWORD), + "{protocol}://{domain}:{port}{endpoint}".format( + protocol=settings.OPENNEBULA_PROTOCOL, + domain=settings.OPENNEBULA_DOMAIN, + port=settings.OPENNEBULA_PORT, + endpoint=settings.OPENNEBULA_ENDPOINT + )) + # Get open nebula user id for given email + user_pool = oca.UserPool(client) + user_pool.info() + # TODO: handle potential name error + user_id = user_pool.get_by_name(user_email).id + + # Get vm_pool for given user_id + vm_pool = oca.VirtualMachinePool(client) + vm_pool.info(filter=user_id) + + # Reset total price + self.total_price = 0 + vms = [] + # Add vm in vm_pool to context + for vm in vm_pool: + name = vm.name + cores = int(vm.template.vcpu) + memory = int(vm.template.memory) / 1024 + # Check if vm has more than one disk + if 'DISK' in vm.template.multiple: + disk_size = 0 + for disk in vm.template.disks: + disk_size += int(disk.size) / 1024 + else: + disk_size = int(vm.template.disk.size) / 1024 + + #TODO: Replace with vm plan + price = 0.6 * disk_size + 2 * memory + 5 * cores + vm = {} + vm['name'] = name + vm['price'] = price + vm['disk_size'] = disk_size + vm['cores'] = cores + vm['memory'] = memory + vms.append(vm) + # self.total_price += price + # self.save() + return vms + class HostingOrder(AssignPermissionsMixin, models.Model): @@ -224,6 +273,32 @@ class HostingOrder(AssignPermissionsMixin, models.Model): self.save() +class UserHostingKey(models.Model): + user = models.ForeignKey(CustomUser) + public_key = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + name = models.CharField(max_length=100) + + @staticmethod + def generate_RSA(bits=2048): + ''' + Generate an RSA keypair with an exponent of 65537 in PEM format + param: bits The key length in bits + Return private key and public key + ''' + new_key = RSA.generate(2048, os.urandom) + public_key = new_key.publickey().exportKey("OpenSSH") + private_key = new_key.exportKey("PEM") + return private_key, public_key + + @classmethod + def generate_keys(cls): + private_key, public_key = cls.generate_RSA() + # self.public_key = public_key + # self.save(update_fields=['public_key']) + return private_key, public_key + + class ManageVM(models.Model): def has_add_permission(self, request): return False diff --git a/hosting/opennebula_functions.py b/hosting/opennebula_functions.py index ed6444e4..7bac64b5 100644 --- a/hosting/opennebula_functions.py +++ b/hosting/opennebula_functions.py @@ -60,7 +60,7 @@ class HostingManageVMAdmin(admin.ModelAdmin): if self.client is None: opennebula_user = request.user.email # TODO: get the password stored in django - opennebula_user_password = get_random_password() + opennebula_user_password ='19737450' self.client = oca.Client("{0}:{1}".format(opennebula_user, opennebula_user_password), "{protocol}://{domain}:{port}{endpoint}".format( protocol=settings.OPENNEBULA_PROTOCOL, @@ -90,6 +90,74 @@ class HostingManageVMAdmin(admin.ModelAdmin): ) return TemplateResponse(request, "hosting/managevms.html", context) + # Function that shows the VMs of the current user + def show_vms_view(self, request): + """ + Implemented by Levi for the API + """ + vm_pool = None + try: + self.init_opennebula_client(request) + vm_pool = oca.VirtualMachinePool(self.client) + vm_pool.info() + except socket.timeout as socket_err: + logger.error("Socket timeout error.".format(socket_err)) + except OpenNebulaException as opennebula_err: + logger.error("OpenNebulaException error: {0}".format(opennebula_err)) + except OSError as os_err: + logger.error("OSError : {0}".format(os_err)) + except ValueError as value_err: + logger.error("ValueError : {0}".format(value_err)) + context = dict( + # Include common variables for rendering the admin template. + # self.admin_site.each_context(request), + vms=vm_pool, + ) + return context + + + def create_vm_view(self, specs): + vm_id = None + try: + # We do have the vm_template param set. Get and parse it + # and check it to be in the desired range. + # We have 8 possible VM templates for the moment which are 1x, 2x, 4x ... + # the basic template of 10GB disk, 1GB ram, 1 vcpu, 0.1 cpu + vm_string_formatter = """ + {memory} + {vcpu} + {cpu} + + {disk_type} + {size} + + + """ + vm_id = oca.VirtualMachine.allocate( + self.client, + vm_string_formatter.format( + memory=1024 * specs.get('memory'), + vcpu=specs.get('cores'), + cpu=0.1 * specs.get('cores'), + disk_type='fs', + size=10000 * specs.get('disk_size') + ) + ) + # message = _("Created with id = " + str(vm_id)) + # messages.add_message(request, messages.SUCCESS, message) + except socket.timeout as socket_err: + logger.error("Socket timeout error: {0}".format(socket_err)) + except OpenNebulaException as opennebula_err: + logger.error("OpenNebulaException error: {0}".format(opennebula_err)) + except OSError as os_err: + logger.error("OSError : {0}".format(os_err)) + except ValueError as value_err: + logger.error("ValueError : {0}".format(value_err)) + + return vm_id + + + # Creating VM by using method allocate(client, template) def create_vm(self, request): # check if the request contains the template parameter, if it is diff --git a/hosting/templates/hosting/base_short.html b/hosting/templates/hosting/base_short.html index 1eacd26a..c6d1772e 100644 --- a/hosting/templates/hosting/base_short.html +++ b/hosting/templates/hosting/base_short.html @@ -72,6 +72,11 @@ {% trans "My Orders"%} +

  • + + {% trans "Keys"%} + +
  • {% trans "Notifications "%} diff --git a/hosting/templates/hosting/create_virtual_machine.html b/hosting/templates/hosting/create_virtual_machine.html new file mode 100644 index 00000000..5d67b305 --- /dev/null +++ b/hosting/templates/hosting/create_virtual_machine.html @@ -0,0 +1,45 @@ +{% extends "hosting/base_short.html" %} +{% load staticfiles bootstrap3 i18n %} +{% block content %} +
    +
    +
    +
    +

    {% trans "New Virtual Machine"%}

    +
    + +
    + {% csrf_token %} +
    + Select VM: + +
    +
    + Select VM Configuration: + +
    +
    + +
    +
    + +
    + +
    +
    + +
    + +{%endblock%} \ No newline at end of file diff --git a/hosting/templates/hosting/includes/_pricing.html b/hosting/templates/hosting/includes/_pricing.html index 92033be8..4c95a73e 100644 --- a/hosting/templates/hosting/includes/_pricing.html +++ b/hosting/templates/hosting/includes/_pricing.html @@ -24,58 +24,17 @@ {% csrf_token %} +
      -
    • - -

      {{vm.location_code}}

      -
      - - -
    • +
    • - - {{vm.location}} -
      -
      -
    • -
    • - - {% if select_configuration %} - - {% else %} - - - -
      -
      - - {{configuration_detail}} -
      -
      - {% endif %} -
    • -
    • - -
      -
      - - +
      @@ -83,30 +42,28 @@
    • - - - GiB +
    • - - - GiB +
    • - -

      {{vm.default_price|floatformat}}CHF

      + + +
    • +
    • + +

      {{vm.final_price|floatformat}}CHF

      per month
    • diff --git a/hosting/templates/hosting/order_detail.html b/hosting/templates/hosting/order_detail.html index 09e81ba2..5eb9bac1 100644 --- a/hosting/templates/hosting/order_detail.html +++ b/hosting/templates/hosting/order_detail.html @@ -65,7 +65,7 @@ {% url 'hosting:payment' as payment_url %} {% if payment_url in request.META.HTTP_REFERER %} {% endif %} diff --git a/hosting/templates/hosting/payment.html b/hosting/templates/hosting/payment.html index 6dd711ab..b0a09812 100644 --- a/hosting/templates/hosting/payment.html +++ b/hosting/templates/hosting/payment.html @@ -86,11 +86,11 @@

      Billing Amount


      -

      Type {{request.session.vm_specs.location_code}}

      -
      + +

      Cores {{request.session.vm_specs.cores}}


      -

      Configuration {{request.session.vm_specs.configuration_detail}}

      +

      Configuration {{request.session.vm_specs.configuration_display}}


      Memory {{request.session.vm_specs.memory}} GiB


      diff --git a/hosting/templates/hosting/virtual_machine_key.html b/hosting/templates/hosting/virtual_machine_key.html index c302576a..8a0221ae 100644 --- a/hosting/templates/hosting/virtual_machine_key.html +++ b/hosting/templates/hosting/virtual_machine_key.html @@ -6,30 +6,75 @@
      - -

      {% trans "SSH Private Key"%}

      +
      + {% csrf_token %} +

      {% trans "Access Key"%}


      + {% if not user_key %} +
      + {% trans "Upload your own key. "%} +
      +
      + + +
      +
      +
      + +
      + {% trans "Or generate a new key pair."%} + +
      +
      +
      + {% else %} +
      Use your created key to access to the machine. If you lost it, contact us.
      + +
      + + + + + + + + + + + + + + + +
      {% trans "Name"%}{% trans "Created at"%} {% trans "Status"%}
      {{user_key.name}}{{user_key.created_at}} + Active + +
      + {% endif %} +
      + {% if private_key %}
      {% trans "Warning!"%}{% trans "You can view your SSH private key once. Copy it or if it wasn't downloaded automatically, just click on Download to start it."%}
      - - +
      -
      + {% else %} -
      + {% endif %} +
      @@ -42,11 +87,12 @@