From 9a06a8902f0453b9d58b0ec38284632e4eb9a392 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 23 Mar 2021 21:10:29 +0100 Subject: [PATCH 1/4] can view plans and plans have a price --- rowers/models.py | 2 ++ rowers/views/planviews.py | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/rowers/models.py b/rowers/models.py index 7aec7bbd..e744788a 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -1563,6 +1563,7 @@ class InstantPlan(models.Model): hoursperweek = models.IntegerField(default=4,verbose_name='Hours Per Week') sessionsperweek = models.IntegerField(default=3,verbose_name='Number of sessions per week') yaml = models.FileField(upload_to=get_file_path,verbose_name="Plan YAML file",null=True,blank=True) + price = models.IntegerField(default=0,verbose_name="Price in EURO") def __str__(self): return self.name @@ -1590,6 +1591,7 @@ class InstantPlanForm(ModelForm): model = InstantPlan fields = [ 'name', + 'price', 'goal', 'description', 'duration', diff --git a/rowers/views/planviews.py b/rowers/views/planviews.py index 89767447..4017c547 100644 --- a/rowers/views/planviews.py +++ b/rowers/views/planviews.py @@ -2677,9 +2677,6 @@ def add_instantplan_view(request): } ) -@user_passes_test(can_plan,login_url="/rowers/paidplans", - message="This functionality requires a Coach or Self-Coach plan", - redirect_field_name=None) def rower_select_instantplan(request,id=0): r = getrequestrower(request,userid=id) themanager = getrower(request.user) From a9091bfed78b77a08f320f6a7e46bf8bc92d6265 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 24 Mar 2021 06:35:25 +0100 Subject: [PATCH 2/4] works but cannot change address --- rowers/braintreestuff.py | 11 +- rowers/fakturoid.py | 7 +- rowers/forms.py | 13 ++ rowers/models.py | 2 +- rowers/templates/buy_trainingplan.html | 43 +++++ rowers/templates/confirm_trainingplan.html | 114 ++++++++++++ rowers/templates/instantplan.html | 13 ++ rowers/templates/instantplans.html | 5 + rowers/urls.py | 5 +- rowers/views/paymentviews.py | 206 +++++++++++++++++++++ rowers/views/planviews.py | 9 +- rowers/views/statements.py | 1 + 12 files changed, 419 insertions(+), 10 deletions(-) create mode 100644 rowers/templates/buy_trainingplan.html create mode 100644 rowers/templates/confirm_trainingplan.html diff --git a/rowers/braintreestuff.py b/rowers/braintreestuff.py index 043542b9..00fc602f 100644 --- a/rowers/braintreestuff.py +++ b/rowers/braintreestuff.py @@ -183,7 +183,7 @@ def make_payment(rower,data): return False,0 amount = data['amount'] - amount = '{amount:.f2}'.format(amount=amount) + amount = '{amount}'.format(amount=amount) result = gateway.transaction.sale({ "amount": amount, @@ -200,13 +200,18 @@ def make_payment(rower,data): l = rower.user.last_name, ) + fakturoid_contact_id = fakturoid.get_contacts(rower) + if not fakturoid_contact_id: + fakturoid_contact_id = fakturoid.create_contact(rower) + id = fakturoid.create_invoice(rower,amount,transaction.id,dosend=True,contact_id=fakturoid_contact_id, + name='Rowsandall Purchase') job = myqueue(queuehigh,handle_send_email_transaction, name, rower.user.email, amount) - return amount,'' + return amount,True else: - return 0,'' + return 0,False def update_subscription(rower,data,method='up'): planid = data['plan'] diff --git a/rowers/fakturoid.py b/rowers/fakturoid.py index 551d582e..8fb46167 100644 --- a/rowers/fakturoid.py +++ b/rowers/fakturoid.py @@ -83,11 +83,14 @@ def create_contact(rower): # this should be triggered by a Braintree webhook def create_invoice(rower,amount,braintreeid,dosend=True, - contact_id=None): + contact_id=None,name=None): if not contact_id: contact_id = get_contacts(rower) + if not name: + name = 'Rowsandall Subscription' + with open('braintreewebhooks.log','a') as f: f.write('Creating invoice for contact iD '+str(contact_id)+'\n') @@ -104,7 +107,7 @@ def create_invoice(rower,amount,braintreeid,dosend=True, 'paid_amount': str(amount), 'status': 'paid', 'lines': [{ - 'name': 'Rowsandall Subscription', + 'name': name, 'quantity': '1', 'unit_price': str(amount), 'vat_rate': 0, diff --git a/rowers/forms.py b/rowers/forms.py index f2ad4f96..8606abe2 100644 --- a/rowers/forms.py +++ b/rowers/forms.py @@ -101,6 +101,19 @@ class BillingForm(forms.Form): paymenttype = forms.CharField(max_length=255,required=True) tac= forms.BooleanField(required=True,initial=False) +# TrainingPlanBillingForm form +class TrainingPlanBillingForm(forms.Form): + amount = FlexibleDecimalField(required=True,decimal_places=2, + max_digits=8) + plan = forms.IntegerField(widget=forms.HiddenInput()) + payment_method_nonce = forms.CharField(max_length=255,required=True) + paymenttype = forms.CharField(max_length=255,required=True) + enddate = forms.DateField(widget=forms.HiddenInput) + name = forms.CharField(max_length=255,required=False) + notes = forms.CharField(max_length=255,required=True) + status = forms.CharField(max_length=255,required=True) + tac= forms.BooleanField(required=True,initial=False) + # login form class LoginForm(forms.Form): diff --git a/rowers/models.py b/rowers/models.py index e744788a..f11ecefa 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -1612,7 +1612,7 @@ class TrainingPlan(models.Model): rowers = models.ManyToManyField(Rower,related_name='planathletes', verbose_name='Athletes') manager = models.ForeignKey(Rower,related_name='planmanager',null=True,on_delete=models.SET_NULL) - name = models.CharField(max_length=150,blank=True) + name = models.CharField(max_length=150,blank=True, verbose_name="Plan Name") status = models.BooleanField(default=True,verbose_name='Active') target = models.ForeignKey(TrainingTarget,blank=True,null=True,on_delete=models.SET_NULL) startdate = models.DateField(default=current_day) diff --git a/rowers/templates/buy_trainingplan.html b/rowers/templates/buy_trainingplan.html new file mode 100644 index 00000000..6be1d6bf --- /dev/null +++ b/rowers/templates/buy_trainingplan.html @@ -0,0 +1,43 @@ +{% extends "newbase.html" %} +{% block title %}Rowsandall Purchase Training Plan{% endblock title %} +{% load rowerfilters %} +{% block main %} + +

Purchase Training Plan

+ +
+
    +
  • +

    Billing Details

    +

    For tax reasons, we need your country of residence. You should + update this when it is incorrect.

    + + {{ form.as_table }} + {{ billingaddressform.as_table }} +
    +
  • +
  • +

    Your Purchase

    + +

    Plan: {{ plan.name }}

    +

    Price: {{ plan.price }}€

    +
  • +
  • +

    + Your purchase will be effective immediately. You will be charged + for the price of the plan. +

    +
  • +
  • + {% csrf_token %} + + You will be able to review your order before finalizing your purchase. +
  • +
+
+ +{% endblock %} + +{% block sidebar %} +{% include 'menu_payments.html' %} +{% endblock %} diff --git a/rowers/templates/confirm_trainingplan.html b/rowers/templates/confirm_trainingplan.html new file mode 100644 index 00000000..af325e53 --- /dev/null +++ b/rowers/templates/confirm_trainingplan.html @@ -0,0 +1,114 @@ +{% extends "newbase.html" %} +{% block title %}Rowsandall Purchase Training Plan{% endblock title %} +{% load rowerfilters %} +{% block main %} + +
+
+ +

Confirm Your Payment

+ +

Order Overview

+ +

+ Please refer to our terms and conditions for our + payments and refunds policy. Accepted payment methods are the payment methods offered + by + Braintree + through us. If you have any questions about our payments and refunds policy, please contact + us by email at support@rowsandall.com. +

+ +

+ Payments will be processed by Braintree (A PayPal service): +

+

+ + + +

+ + + +{% include 'braintreedropin.html' %} + + + +{% endblock %} + +{% block sidebar %} +{% include 'menu_payments.html' %} +{% endblock %} diff --git a/rowers/templates/instantplan.html b/rowers/templates/instantplan.html index 2883a0c9..1f4a07e4 100644 --- a/rowers/templates/instantplan.html +++ b/rowers/templates/instantplan.html @@ -27,6 +27,11 @@

What the plan will achieve: {{ plan.target }}

{% endif %}

Weekly volume: {{ plan.hoursperweek }} hours per week over {{ plan.sessionsperweek }} sessions.

+ {% if plan.price == 0 %} +

Price: Free

+ {% else %} +

Price: {{ plan.price }}€

+ {% endif %}
  • @@ -37,12 +42,20 @@ You can select the end date manually or use the training target (if you have any), and the plan will start at the date it needs to complete in time.

    + {% if plan.price == 0 %}
    + {% else %} + + {% endif %} {{ form.as_table }}
    {% csrf_token %} + {% if plan.price == 0 %}

    + {% else %} +

    + {% endif %}
  • diff --git a/rowers/templates/instantplans.html b/rowers/templates/instantplans.html index 38a8146b..4d31c7ac 100644 --- a/rowers/templates/instantplans.html +++ b/rowers/templates/instantplans.html @@ -22,6 +22,11 @@

    Goal: {{ plan.goal }}

    {% endif %}

    {{ plan.hoursperweek }} hours per week over {{ plan.sessionsperweek }} sessions

    + {% if plan.price == 0 %} +

    Price: Free

    + {% else %} +

    Price: {{ plan.price }}€

    + {% endif %}
  • {% endfor %} diff --git a/rowers/urls.py b/rowers/urls.py index a67cc0ee..45d75a9f 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -719,7 +719,8 @@ urlpatterns = [ re_path(r'^me/cancelsubscription/(?P[\w\ ]+.*)/$',views.plan_tobasic_view,name='plan_tobasic_view'), re_path(r'^checkouts/$',views.checkouts_view,name='checkouts'), re_path(r'^upgradecheckouts/$',views.upgrade_checkouts_view,name='upgrade_checkouts'), - re_path(r'^downgradecheckouts/$',views.downgrade_checkouts_view,name='downgrade_checkouts'), + re_path(r'^upgradecheckouts/$',views.upgrade_checkouts_view,name='upgrade_checkouts'), + re_path(r'^purchasecheckouts/$',views.purchase_checkouts_view,name='purchase_checkouts_view'), re_path(r'^planrequired/',views.planrequired_view,name='planrequired_view'), re_path(r'^starttrial/$',views.start_trial_view,name='start_trial_view'), re_path(r'^startplantrial/$',views.start_plantrial_view,name='start_plantrial_view'), @@ -744,6 +745,8 @@ urlpatterns = [ re_path(r'^plans/$', views.rower_select_instantplan, name='rower_select_instantplan'), re_path(r'^plans/(?P[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})/$', views.rower_view_instantplan, name='rower_view_instantplan'), + re_path(r'^buyplan/(?P\d+)/$',views.buy_trainingplan_view,name='buy_trainingplan_view'), + re_path(r'^confirmpurchaseplan/(?P\d+)/$',views.confirm_trainingplan_purchase_view,name='confirm_trainingplan_purchase_view'), re_path(r'^addinstantplan/$', views.add_instantplan_view, name='add_instantplan_view'), re_path(r'^deleteplan/(?P\d+)/$',login_required( views.TrainingPlanDelete.as_view()),name='trainingplan_delete_view'), diff --git a/rowers/views/paymentviews.py b/rowers/views/paymentviews.py index be456413..3d3435d5 100644 --- a/rowers/views/paymentviews.py +++ b/rowers/views/paymentviews.py @@ -84,6 +84,212 @@ def billing_view(request): 'planselectform':planselectform, }) +@login_required() +def buy_trainingplan_view(request,id=0): + if not PAYMENT_PROCESSING_ON: + url = reverse('promembership') + return HttpResponseRedirect(url) + + r = request.user.rower + + plan = get_object_or_404(InstantPlan,pk=id) + + if r.paymentprocessor != 'braintree': + messages.error(request,"This purchase is currently only available through BrainTree (by PayPal)") + + if id == 0 or id is None: + messages.error(request,"There was an error accessing this plan") + url = reverse('rower_view_instantplan',kwargs={ + 'id':plan.uuid, + }) + + return HttpResponseRedirect(url) + + if request.method == 'POST': + billingaddressform = RowerBillingAddressForm(instance=r) + form = TrainingPlanForm(request.POST,user=request.user) + if billingaddressform.is_valid(): + cd = billingaddressform.cleaned_data + for attr, value in cd.items(): + setattr(r, attr, value) + r.save() + + # redirect to payment confirmation view + if form.is_valid(): + cd = form.cleaned_data + + enddate = cd['enddate'] + rowers = cd['rowers'] + notes = cd['notes'] + status = cd['status'] + + # get target and set enddate + try: + target = cd['target'] + except KeyError: + try: + targetid = request.POST['target'] + + if targetid != '': + target = TrainingTarget.objects.get(id=int(targetid)) + else: + target = None + except KeyError: + target = None + + if target: + enddate = target.date + + pars = { + 'name':cd['name'], + 'enddate':enddate, + 'notes':notes, + 'status':status, + 'rower':rowers[0].id, + } + params = urllib.parse.urlencode(pars) + url = reverse('confirm_trainingplan_purchase_view',kwargs={'id':plan.id}) + url = url + "?%s" % params + return HttpResponseRedirect(url) + + else: + form = TrainingPlanForm(user=request.user) + billingaddressform = RowerBillingAddressForm(instance=r) + + return render(request, + 'buy_trainingplan.html', + { + 'rower':r, + 'plan':plan, + 'billingaddressform':billingaddressform, + 'form':form, + }) + +@login_required() +def purchase_checkouts_view(request): + if not PAYMENT_PROCESSING_ON: + url = reverse('promembership') + return HttpResponseRedirect(url) + + r = request.user.rower + + if request.method != 'POST': + url = reverse('rower_view_instantplan',kwargs={ + 'id':plan.uuid, + }) + return HttpResponseRedirect(url) + + form = TrainingPlanBillingForm(request.POST) + if form.is_valid(): + + data = form.cleaned_data + + plan = InstantPlan.objects.get(id=data['plan']) + authorizationstring = 'Bearer '+settings.WORKOUTS_FIT_TOKEN + url = settings.WORKOUTS_FIT_URL+"/trainingplan/"+str(plan.uuid) + headers = {'Authorization':authorizationstring} + response = requests.get(url=url,headers=headers) + if response.status_code != 200: + messages.error(request,"Could not connect to the training plan server") + return HttpResponseRedirect(reverse('rower_select_instantplan')) + + amount, success = braintreestuff.make_payment(r,data) + + if success: + messages.info(request,"Your payment was completed and the sessions are copied to your calendar") + plansteps = response.json() + name = data['name'] + enddate = data['enddate'] + notes = data['notes'] + status = data['status'] + startdate = enddate-datetime.timedelta(days=plan.duration) + p = TrainingPlan( + name=name, + #target=target, + manager=r, + startdate=startdate, + enddate=enddate,status=status, + notes=notes, + ) + + p.save() + + p.rowers.add(r) + + create_sessions_from_json(plansteps,[r],startdate,r.user) + + url = reverse('plannedsessions_view') + timeperiod = startdate.strftime('%Y-%m-%d')+'/'+enddate.strftime('%Y-%m-%d') + url = url+'?when='+timeperiod + + return HttpResponseRedirect(url) + else: + messages.error(request,"There was a problem with your payment") + url = reverse('rower_view_instantplan',kwargs={ + 'id':plan.uuid, + }) + return HttpResponseRedirect(url) + elif 'tac' not in request.POST: + try: + planid=int(request.POST['plan']) + enddate = request.POST['enddate'] + rower = r.id + # incomplete + except IndexError: + messages.error(request,"There was an error in the payment form") + url = reverse("purchase_checkouts_view") + return HttpResponseRedirect(url) + + url = reverse('rower_view_instantplan',kwargs={ + 'id':plan.uuid, + }) + return HttpResponseRedirect(url) + +@login_required() +def confirm_trainingplan_purchase_view(request,id = 0): + if not PAYMENT_PROCESSING_ON: + url = reverse('promembership') + return HttpResponseRedirect(url) + + r = request.user.rower + + plan = get_object_or_404(InstantPlan,pk=id) + + if r.paymentprocessor != 'braintree': + messages.error(request,"This purchase is currently only available through BrainTree (by PayPal)") + + if id == 0 or id is None: + messages.error(request,"There was an error accessing this plan") + url = reverse('rower_view_instantplan',kwargs={ + 'id':plan.uuid, + }) + + return HttpResponseRedirect(url) + + client_token = braintreestuff.get_client_token(r) + + enddate = request.GET.get('enddate',None) + name = request.GET.get('name','') + status = request.GET.get('status',True) + notes = request.GET.get('notes','') + if enddate is None: + messages.error(request,"There was an error accessing this plan") + url = reverse('rower_view_instantplan',kwargs={ + 'id':plan.uuid, + }) + + return render(request, + 'confirm_trainingplan.html', + { + 'plan':plan, + 'client_token':client_token, + 'rower':r, + 'enddate':enddate, + 'status':status, + 'name':name, + 'notes':notes, + }) + @login_required() def upgrade_view(request): if not PAYMENT_PROCESSING_ON: diff --git a/rowers/views/planviews.py b/rowers/views/planviews.py index 4017c547..f97dc7f6 100644 --- a/rowers/views/planviews.py +++ b/rowers/views/planviews.py @@ -2473,9 +2473,6 @@ class PlannedSessionDelete(DeleteView): return obj -@user_passes_test(can_plan,login_url="/rowers/paidplans", - message="This functionality requires a Coach or Self-Coach plan", - redirect_field_name=None) def rower_view_instantplan(request,id='',userid=0): r = getrequestrower(request,userid=userid) if not id: @@ -2521,6 +2518,12 @@ def rower_view_instantplan(request,id='',userid=0): ).order_by("-date") if request.method == 'POST': + if not can_plan(request.user): + messages.error(request,'You must be on a paid plan to use this functionality') + url = reverse('rower_view_instantplan',kwargs={ + 'id':id, + }) + return HttpResponseRedirect(url) form = TrainingPlanForm(request.POST,user=request.user) if form.is_valid(): diff --git a/rowers/views/statements.py b/rowers/views/statements.py index 5881d15d..28430fae 100644 --- a/rowers/views/statements.py +++ b/rowers/views/statements.py @@ -77,6 +77,7 @@ from rowers.forms import ( VideoAnalysisCreateForm,WorkoutSingleSelectForm, VideoAnalysisMetricsForm,SurveyForm,HistorySelectForm, StravaChartForm,FitnessFitForm,PerformanceManagerForm, + TrainingPlanBillingForm, ) from django.urls import reverse, reverse_lazy From 3f7d10f2bf23527c4d50cb161554dd935e24c158 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 24 Mar 2021 06:58:12 +0100 Subject: [PATCH 3/4] fix --- rowers/urls.py | 2 +- rowers/views/paymentviews.py | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/rowers/urls.py b/rowers/urls.py index 45d75a9f..99959d6c 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -719,7 +719,7 @@ urlpatterns = [ re_path(r'^me/cancelsubscription/(?P[\w\ ]+.*)/$',views.plan_tobasic_view,name='plan_tobasic_view'), re_path(r'^checkouts/$',views.checkouts_view,name='checkouts'), re_path(r'^upgradecheckouts/$',views.upgrade_checkouts_view,name='upgrade_checkouts'), - re_path(r'^upgradecheckouts/$',views.upgrade_checkouts_view,name='upgrade_checkouts'), + re_path(r'^downgradecheckouts/$',views.downgrade_checkouts_view,name='downgrade_checkouts'), re_path(r'^purchasecheckouts/$',views.purchase_checkouts_view,name='purchase_checkouts_view'), re_path(r'^planrequired/',views.planrequired_view,name='planrequired_view'), re_path(r'^starttrial/$',views.start_trial_view,name='start_trial_view'), diff --git a/rowers/views/paymentviews.py b/rowers/views/paymentviews.py index 3d3435d5..95dc56c1 100644 --- a/rowers/views/paymentviews.py +++ b/rowers/views/paymentviews.py @@ -84,7 +84,9 @@ def billing_view(request): 'planselectform':planselectform, }) -@login_required() +@user_passes_test(can_plan,login_url="/rowers/paidplans", + message="This functionality requires a Coach or Self-Coach plan", + redirect_field_name=None) def buy_trainingplan_view(request,id=0): if not PAYMENT_PROCESSING_ON: url = reverse('promembership') @@ -119,7 +121,6 @@ def buy_trainingplan_view(request,id=0): cd = form.cleaned_data enddate = cd['enddate'] - rowers = cd['rowers'] notes = cd['notes'] status = cd['status'] @@ -145,7 +146,7 @@ def buy_trainingplan_view(request,id=0): 'enddate':enddate, 'notes':notes, 'status':status, - 'rower':rowers[0].id, + 'rower':r.id, } params = urllib.parse.urlencode(pars) url = reverse('confirm_trainingplan_purchase_view',kwargs={'id':plan.id}) @@ -165,7 +166,9 @@ def buy_trainingplan_view(request,id=0): 'form':form, }) -@login_required() +@user_passes_test(can_plan,login_url="/rowers/paidplans", + message="This functionality requires a Coach or Self-Coach plan", + redirect_field_name=None) def purchase_checkouts_view(request): if not PAYMENT_PROCESSING_ON: url = reverse('promembership') @@ -245,7 +248,9 @@ def purchase_checkouts_view(request): }) return HttpResponseRedirect(url) -@login_required() +@user_passes_test(can_plan,login_url="/rowers/paidplans", + message="This functionality requires a Coach or Self-Coach plan", + redirect_field_name=None) def confirm_trainingplan_purchase_view(request,id = 0): if not PAYMENT_PROCESSING_ON: url = reverse('promembership') @@ -359,6 +364,7 @@ def downgrade_view(request): setattr(r, attr, value) r.save() + if planselectform.is_valid(): plan = planselectform.cleaned_data['plan'] @@ -376,6 +382,7 @@ def downgrade_view(request): kwargs={ 'planid':plan.id }) + return HttpResponseRedirect(url) else: From 1bdd497a0ef1a712527c536dc5107005d1f60b81 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 24 Mar 2021 07:08:35 +0100 Subject: [PATCH 4/4] fix - downgrade button still not working need a check --- rowers/views/paymentviews.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rowers/views/paymentviews.py b/rowers/views/paymentviews.py index 95dc56c1..d5640c89 100644 --- a/rowers/views/paymentviews.py +++ b/rowers/views/paymentviews.py @@ -626,6 +626,7 @@ def upgrade_checkouts_view(request): @login_required() def downgrade_checkouts_view(request): + print('aap') if not PAYMENT_PROCESSING_ON: url = reverse('promembership') return HttpResponseRedirect(url)